Task & Module Execution #
Writing task flows inside a playbook file is the initial declarative step. However, understanding how Ansible processes, transfers, executes, and cleans up those task scripts behind the scenes is an advanced skill level that every production-grade automation developer must master. Without a deep understanding of this execution cycle, you’ll struggle to analyze mid-flow network failures, trace slow execution performance, or handle multi-host atomic application deployment scenarios. This article dissects the module execution lifecycle, compares Linear vs Free strategies, tunes concurrency capacity (forks), stores output variables (register), manipulates end statuses (changed_when and failed_when), and forces global process stops (any_errors_fatal).
Module Execution Lifecycle #
Every time you trigger one task line in a playbook, Ansible doesn’t send raw text commands through the SSH terminal. Ansible executes a very orderly file preparation and transfer process chain.
Here are the 7 stages of a single task’s execution lifecycle on a target server:
- Module Code Compilation: Ansible Core on the control node takes the relevant Python module source file (for example
apt.pyortemplate.py) and dynamically combines it with the argument parameters you wrote in the task. - Payload Packaging: The combined code result is compressed into a self-contained Python archive package in Zip format.
- File Transmission: Ansible opens an SSH connection and transfers the Zip file to the target managed node using the SFTP or SCP protocol. By default, the file is placed in a hidden temporary directory inside the login user’s home folder (for example
~/.ansible/tmp/). - Extraction & Execution: Ansible instructs the target Python interpreter on the managed node to extract the Zip file into memory, run it locally, and collect its execution output.
- Result Reporting: The module returns its work report in clean structured JSON text format through the active SSH connection channel to the control node.
- Status Parsing: Ansible on the control node parses that JSON response to determine the task’s final status: green (
ok), yellow (changed), or red (failed). - Cleanup: Ansible sends a final SSH command to delete the temporary Zip file in the managed node’s
~/.ansible/tmp/folder so it doesn’t clutter the server’s hard drive storage capacity.
The preparation, transfer, and execution process above repeats for every task on every target host. SSH Pipelining (if enabled in ansible.cfg) combines steps 3, 4, and 5 directly through SSH standard input without physical SFTP file transfer, speeding up the process up to 3 times.
Linear Strategy (Default Mechanism) #
By default, Ansible applies a task alignment method called the Linear Strategy. Under this strategy, Ansible acts as a strict orchestra conductor: the first task must finish executing on all eligible target hosts before the second task may begin.
Linear strategy illustration on three target servers:
Task 1: Install Nginx
├── Execute on web-01.example.com ✓ (Success)
├── Execute on web-02.example.com ✓ (Success)
└── Execute on web-03.example.com ✓ (Success)
↓ (All hosts finished processing Task 1)
Task 2: Copy the Nginx Configuration File
├── Execute on web-01.example.com ✓ (Success)
├── Execute on web-02.example.com ✓ (Success)
└── Execute on web-03.example.com ✓ (Success)
↓ (All hosts finished processing Task 2)
Task 3: Start the Nginx Service
├── Execute on web-01.example.com ✓
├── Execute on web-02.example.com ✓
└── Execute on web-03.example.com ✓
If one host (for example web-02) fails Task 1, Ansible automatically removes web-02 from the next task execution queue. However, other hosts (web-01 and web-03) that succeeded are still allowed to continue to Task 2. The main advantage of this linear strategy is consistency: you’re guaranteed that all target servers are at the same state stage at any given time.
forks and Batch Parallelism #
Although it uses a linear strategy that synchronizes task steps, Ansible doesn’t process target servers one by one sequentially. Ansible runs tasks in parallel on groups of target servers using a connection socket capacity called Forks.
The default forks value in Ansible is 5. This parameter determines the parallel batch size limit:
Scenario: forks = 3, target = 6 servers
Task 1: Install Nginx
- Batch 1 (Parallel): web-01, web-02, web-03 ← Running simultaneously
- Batch 2 (Parallel): web-04, web-05, web-06 ← Runs after Batch 1 finishes
Tuning the forks value can be adjusted in your project’s ansible.cfg file:
# File: ansible.cfg
[defaults]
# Increase forks to raise parallelism on large networks
forks = 20
Increasing forks speeds up total playbook duration across hundreds of servers, but requires larger control node CPU and RAM capacity to manage parallel payload compilation processes.
Free Strategy (Host Independence) #
If you have a scenario where the tasks on each server have absolutely no dependency on each other, and you want each server to finish all tasks as fast as possible without waiting for slower servers (for example due to different regional network latency), you can enable the Free Strategy.
# File: playbooks/deploy-free.yml
---
- name: Deploy Application Code Freely and Independently
hosts: webservers
# Change from Linear (Default) to Free Strategy
strategy: free
tasks:
- name: Pull the latest Git repository
git:
repo: https://github.com/org/app.git
dest: /opt/app
- name: Run npm dependency installation
npm:
path: /opt/app
Under the Free Strategy, task flow isn’t synchronized. Servers with faster internet connections immediately finish Task 1, Task 2, all the way to the end, while slower servers are still processing Task 1.
The visual comparison between Linear and Free strategies is shown in the diagram below:
flowchart TD
subgraph "Linear Strategy (Default - Synchronized)"
direction TB
L1["Task 1 (Host A, B, C)"] -->|"Wait for All Hosts to Finish"| L2["Task 2 (Host A, B, C)"]
L2 -->|"Wait for All Hosts to Finish"| L3["Task 3 (Host A, B, C)"]
end
subgraph "Free Strategy (Asynchronous / Independent)"
direction TB
F1["Host A: Task 1 -> Task 2 -> Task 3"]
F2["Host B: Task 1 -> Task 2 -> Task 3"]
F3["Host C: Task 1 -> Task 2 -> Task 3"]
F1 & F2 & F3 -->|"Independent Execution as Fast as Possible"| F4["Done (Different Finish Times)"]
endregister: Storing Output State #
Every time a task runs, the Ansible module returns JSON data (like shell command output, file change status, error messages). You can capture and store this response data into a memory variable using the register keyword so subsequent tasks can use it for logic branching.
The JSON object structure stored by register generally has the following key properties:
changed: A boolean value (true/false) indicating whether the task successfully modified the target server state.failed: A boolean indicating whether the task experienced an execution failure.rc: The return code (exit status) specific to modules running CLI commands (likecommandorshell).0means success, and any non-zero number means failure.stdout: A raw text string containing the standard output from the terminal command run.stderr: A raw text string containing the error report (standard error) if the command had problems.stdout_lines: The result of splitting thestdouttext into a line-by-line array, making it easier to iterate with Jinja2 loops in the next task.skipped: A boolean indicating whether the task was skipped because it didn’t meet thewhenconditional.
# File: playbooks/conditional-restart.yml
---
- name: Manage the Web Service Based on Status
hosts: webservers
become: true
tasks:
- name: Get the current actual status of the Nginx service
systemd:
name: nginx
register: nginx_runtime_status # Store the JSON response in a variable
- name: Display the registered variable contents (Debugging)
debug:
var: nginx_runtime_status
- name: Restart Nginx ONLY IF its current status is active
systemd:
name: nginx
state: restarted
# Evaluate the JSON properties inside the register variable
when: nginx_runtime_status.status.ActiveState == "active"
changed_when and failed_when #
By default, Ansible determines a task’s changed status (yellow) if it detects a modification on the managed node, and failed (red) if the command returns an error code (non-zero exit code). However, there are cases where these default criteria produce wrong reports. You can manipulate those criteria using control parameters.
1. changed_when (Change Status Manipulation) #
Many command execution modules (like command or shell) will always return a changed = true status every time they run because Ansible doesn’t know whether the shell command modified the system or not.
If the command only reads information (like checking a version or disk capacity status), you must force it to false so it doesn’t pollute your playbook’s idempotency statistics:
# Example of turning off false changed status
- name: Get the installed PostgreSQL version
command: psql --version
register: postgres_version
# ANTI-PATTERN: Letting the yellow changed status fire for a pure read command
# (Without changed_when)
# CORRECT: Force the status to stay green (ok) because there's no system modification
changed_when: false
You can also use conditional expressions. Mark changed only if a certain output line exists:
- name: Run the database migration
command: bundle exec rake db:migrate
register: db_migrate_output
# Only mark changed if the output doesn't contain the phrase 'no migrations'
changed_when: "'No migrations to apply' not in db_migrate_output.stdout"
2. failed_when (Failure Status Manipulation) #
Sometimes a command returns a non-zero error code that’s actually normal and not a system failure. Conversely, sometimes a command runs successfully (exit code 0) but its output contains critical error text. You can manipulate this failure criteria:
- name: Check the root partition storage capacity utility
command: df -h /
register: root_disk_usage
# Define your own failure criteria:
# Fail if the exit code is non-zero OR if usage capacity reaches 95%
failed_when:
- root_disk_usage.rc != 0
- "'95%' in root_disk_usage.stdout"
State Waiting Patterns (until, retries, delay) #
In system automation, sometimes you must trigger a process and wait until the service is truly ready to accept connections before continuing to the next task (for example, after restarting a database server, you must wait until database port 5432 responds to TCP connections).
Ansible provides a conditional looping mechanism using the combination of the until, retries, and delay parameters:
# Database port readiness waiting flow
- name: Wait for the PostgreSQL database to be ready to accept connections
wait_for:
port: 5432
state: started
timeout: 5
register: port_status
# Repeat this task...
until: port_status is succeeded
# Try a maximum of 12 times (Total waiting: 12 * 5s = 60 seconds)
retries: 12
# Pause 5 seconds between each port query attempt
delay: 5
any_errors_fatal #
By default, if you run a playbook on 10 servers and 2 servers experience execution failures (failed tasks), Ansible stops execution only for those 2 servers, while the other 8 servers continue with subsequent tasks.
However, in atomic multi-host application release deployment scenarios (like behind a Load Balancer cluster), letting some servers update the application while others fail can trigger data inconsistency conditions that break the system (split-brain scenario).
You can set the any_errors_fatal: true parameter at the Play level to ensure that if even one target server fails to process a task, the entire playbook execution for all other servers is immediately stopped.
# File: playbooks/deploy-atomic.yml
---
- name: Large-Scale Application Update Release (Atomic)
hosts: webservers
become: true
# Critical Safety: One server failure stops the entire process globally
any_errors_fatal: true
tasks:
- name: Download the latest application release package
get_url:
url: "https://artifacts.unisbadri.com/apps/release-v2.tar.gz"
dest: /tmp/release-v2.tar.gz
- name: Extract the application code to the production directory
unarchive:
src: /tmp/release-v2.tar.gz
dest: /var/www/html/
remote_src: true
Summary #
- Zip Payload Cycle — Ansible module execution works by wrapping Python scripts into a Zip archive, sending it via SSH to the
/tmpfolder, extracting it locally, and returning a JSON response report before cleanup.- Linear Strategy Consistency — The default method guaranteeing state synchronization where one task must complete on all target hosts before moving to the next task.
- Forks Concurrency Tuning — Adjust the forks value in
ansible.cfgto speed up parallel batch processing on large-scale infrastructure according to the control node machine’s capacity.- Free Strategy Utilization — Use
strategy: freeif tasks between hosts have no work order dependency to minimize total execution duration.- Logic Branching via register — Capture and store module execution responses into register variables to evaluate JSON property data in subsequent conditional tasks.
- Idempotency Enforcement — Use
changed_when: falseto turn off false change statuses on pure read tasks so execution statistics stay accurate.- Custom Failure Conditions — Use
failed_whento define your own operational failure thresholds, independent of the module’s default exit code.- Database Port Waiting — Combine the
until,retries, anddelayparameters to guide the target service port readiness pooling process after a restart.- Atomic Deployment Protection — Enable
any_errors_fatal: trueon application release deployment plays to prevent inconsistent system conditions caused by partial server failures.