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:

  1. Module Code Compilation: Ansible Core on the control node takes the relevant Python module source file (for example apt.py or template.py) and dynamically combines it with the argument parameters you wrote in the task.
  2. Payload Packaging: The combined code result is compressed into a self-contained Python archive package in Zip format.
  3. 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/).
  4. 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.
  5. Result Reporting: The module returns its work report in clean structured JSON text format through the active SSH connection channel to the control node.
  6. 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).
  7. 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)"]
    end

register: 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 (like command or shell). 0 means 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 the stdout text 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 the when conditional.
# 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 /tmp folder, 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.cfg to speed up parallel batch processing on large-scale infrastructure according to the control node machine’s capacity.
  • Free Strategy Utilization — Use strategy: free if 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: false to turn off false change statuses on pure read tasks so execution statistics stay accurate.
  • Custom Failure Conditions — Use failed_when to define your own operational failure thresholds, independent of the module’s default exit code.
  • Database Port Waiting — Combine the until, retries, and delay parameters to guide the target service port readiness pooling process after a restart.
  • Atomic Deployment Protection — Enable any_errors_fatal: true on application release deployment plays to prevent inconsistent system conditions caused by partial server failures.

← Previous: Structure Next: Handler →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact