Performance #
When managing large-scale technology infrastructure, automation execution performance is a crucial factor determining the operations team’s work efficiency. Slow-running Ansible playbooks — for example taking 40 minutes just to do a minor configuration update on 100 servers — will bottleneck CI/CD pipelines, lengthen incident recovery time (MTTR), and hurt developer productivity because of overly long feedback loops. Many developers assume this slowness is an inherent trait of Ansible’s agentless design. In fact, most playbooks can be drastically accelerated by 50% to 80% just by applying the right execution configuration without changing our task logic at all.
Optimizing Parallelism with forks #
By default, Ansible is configured with the value forks = 5. This means Ansible only processes tasks in parallel on a maximum of 5 managed node servers at once. If we have 100 servers in our inventory, Ansible is forced to split the execution into 20 sequential queue waves (batches). This is the number one reason playbook execution feels very slow in multi-server environments.
Changing the Forks Configuration #
We can raise this concurrency limit by changing the forks setting in our ansible.cfg file, or by sending the -f or --forks argument dynamically when running the playbook from the terminal cli.
# ansible.cfg
[defaults]
# Setting the parallel execution limit to 30 hosts at once
forks = 30
If we want to change it only for a specific execution session, use the following cli command:
# CORRECT: Running the playbook with high concurrency via a runtime parameter
ansible-playbook -i inventory.ini site.yml -f 50
Determining the Optimal Forks Value #
Determining the right forks value isn’t about setting the biggest possible number. Every fork Ansible creates spawns a new Python process (child process) on our control node. Therefore, RAM memory consumption and CPU usage on the control node increase linearly as the number of forks grows.
- Sizing Rule: As a rule of thumb, provide around 50MB to 100MB of RAM for each fork, and make sure the control node CPU has enough core resources. For a control node with 4 CPU Cores and 8GB RAM, forks values between 25 and 40 are usually very safe and optimal.
- Network Bandwidth: Also pay attention to network bandwidth capacity and the maximum SSH connection limit (MaxStartups) on target servers so we don’t trigger SSH handshake failures from a flood of simultaneous connections.
SSH Pipelining to Reduce Connection Round-trips #
To understand why SSH Pipelining is very important, we need to look at how Ansible executes Python modules on managed nodes by default:
- Ansible creates a temporary directory on the target server (under the
~/.ansible/tmp/folder). - Ansible copies our module Python code to that directory using the SFTP or SCP protocol.
- Ansible opens a new SSH connection session to execute that Python file with sudo access rights if configured.
- Ansible removes the temporary Python file again after execution completes.
- Ansible opens a new SSH session for the next task.
This copy-execute-remove process produces enormous network overhead because it requires several round-trip operations on the SSH connection for every single task.
How SSH Pipelining Works #
By enabling SSH Pipelining (pipelining = true), Ansible no longer physically copies module files to the target server disk. Instead, Ansible sends the entire module Python code directly to the managed node’s Python interpreter through the standard input (stdin) of the already-running SSH connection. This cuts out the file transfer process and saves a lot of execution time, especially on playbooks with dozens of short tasks.
Here’s a comparison of the data transmission scheme without pipelining vs with pipelining:
flowchart TD
subgraph Default["Without Pipelining (Many Round-Trips)"]
D1["1. Create temp folder via SSH"] --> D2["2. Copy module zip via SFTP"]
D2 --> D3["3. Execute module via SSH"]
D3 --> D4["4. Remove temp folder via SSH"]
end
subgraph Pipeline["With Pipelining (One Stdin Connection)"]
P1["Send & Execute Module via the Stdin of a Single SSH Connection"]
endEnabling Pipelining in ansible.cfg #
We can enable this feature through the [ssh_connection] section in the Ansible configuration:
# ansible.cfg
[ssh_connection]
# Enabling SSH pipelining globally
pipelining = True
Overcoming the sudoers requiretty Constraint #
The main challenge when enabling pipelining is compatibility with target OS security configurations. On some older Linux distros (like default CentOS/RHEL), the /etc/sudoers file enables the Defaults requiretty option. This option requires a physical TTY terminal for every sudo command execution, which blocks module execution via stdin without a TTY used by SSH Pipelining.
If we encounter the error "sudo: a terminal is required to read the password", we must disable that rule on the target server.
# CORRECT: Preparing the target server to be compatible with SSH Pipelining
- name: Ensure sudoers doesn't require a physical TTY
hosts: all
become: true
tasks:
- name: Safely disable requiretty in sudoers
ansible.builtin.lineinfile:
path: /etc/sudoers
state: absent
regexp: '^Defaults\s+requiretty'
validate: '/usr/sbin/visudo -cf %s'
SSH Multiplexing with ControlMaster #
Although pipelining reduces file transfers, Ansible by default still has to open a new SSH connection for every task. Opening a new SSH connection requires a handshake process (TCP handshake), encryption algorithm key exchange negotiation, and user authentication that takes about 0.5 to 2 seconds per connection. If our playbook has 40 tasks, this connection handshake overhead alone can waste up to 1 minute per target server!
Using ControlMaster #
To eliminate this overhead, we can use the SSH Multiplexing feature provided by OpenSSH through the ControlMaster parameter. This feature allows several new SSH sessions to the same target server to share (reuse) one previously opened TCP network socket connection.
We can configure this multiplexing inside ansible.cfg under the [ssh_connection] section:
# ansible.cfg
[ssh_connection]
pipelining = True
# SSH connection multiplexing configuration
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ControlPath=/tmp/ansible-ssh-%h-%p-%r
SSH Parameter Explanations: #
ControlMaster=auto: Tells OpenSSH to try reusing an existing socket connection. If the socket doesn’t exist, OpenSSH automatically creates a new master connection.ControlPersist=60s: Keeps the master TCP socket connection active in the background for 60 seconds after the last task session finishes. If a new task comes within that 60-second window, the connection instantly connects without a re-handshake process.ControlPath=/tmp/ansible-ssh-%h-%p-%r: Determines the unique temporary control socket file location on our control node identified by host%h, port%p, and username%r.
By enabling the combination of pipelining = True and ControlMaster, we can dramatically cut playbook execution time by more than 60%.
Execution Strategies: linear, free, and Mitogen #
By default, Ansible executes playbooks using a strategy called linear. The linear strategy acts like a synchronization barrier: Ansible executes Task 1 on all target hosts, waits until all those hosts finish processing Task 1, then together moves on to Task 2.
When to Use Strategy: free? #
If we have one slow server (for example because of a slow network or low hardware specs), that server holds back (blocks) all other servers that finished faster from continuing to the next task. To avoid this synchronization queue, we can switch to the free strategy.
In the free strategy, each server executes the entire task list as fast as possible independently without caring about other servers’ completion status.
Here’s a visual comparison of Linear vs Free strategy execution:
flowchart TD
subgraph Linear["Linear Strategy (Waiting for Synchronization)"]
direction TB
L1_H1["Host 1: Task A (Done)"] & L1_H2["Host 2: Task A (Slow...)"] --> Barrier["Synchronization Barrier (Waiting for All)"]
Barrier --> L2_H1["Host 1: Task B"] & L2_H2["Host 2: Task B"]
end
subgraph Free["Free Strategy (Independent & Fast)"]
direction TB
F1_H1["Host 1: Task A (Done)"] --> F2_H1["Host 1: Task B"]
F1_H2["Host 2: Task A (Slow...)"] --> F2_H2["Host 2: Task B (Far Behind)"]
endWe can declare this execution strategy directly at the playbook level:
# CORRECT: Using the free strategy for independent self-updates
- name: Do a fast emergency package update
hosts: web_servers
strategy: free # ✓ Each host processes tasks as fast as its own ability allows
tasks:
- name: Run apt-get update
ansible.builtin.apt:
update_cache: true
- name: Install the curl package update
ansible.builtin.apt:
name: curl
state: latest
Don’t use thefreestrategy if the tasks in our playbook require multi-host coordination. For example, if Task 2 is a centralized database migration needing data from Task 1 on web servers, using thefreestrategy triggers fatal errors because web servers could jump straight to Task 2 before the database is ready.
Mitogen for Ansible #
For organizations wanting maximum execution performance without changing any playbook code lines, Mitogen for Ansible is the best solution. Mitogen is a third-party plugin that replaces Ansible’s heavy built-in Python module calling architecture with a far more efficient custom multiplexing protocol.
Mitogen works by maintaining one continuously running Python interpreter on the target server (persistent daemon), then sending compressed instructions through one persistent pipe connection.
- Advantages: Reduces control node CPU consumption by up to 300%, cuts network traffic by up to 70%, and speeds up overall playbook execution time between 1.5x to 3x.
- Installation: Download the Mitogen library, then register that plugin path in our
ansible.cfgfile:
# ansible.cfg
[defaults]
# Replacing the built-in execution strategy with the Mitogen plugin
strategy_plugins = /path/to/mitogen/ansible_mitogen/plugins/strategy
strategy = mitogen_linear
Dynamic Loop Optimization with loop vs with_items #
In modern Ansible versions (2.5 and above), using the loop construct is more recommended than old-style loop constructs like with_items, with_dict, or with_subelements. Although with_items implicitly does list flattening, it has slower internal variable compilation overhead compared to loop which processes data directly (native list evaluation).
Avoiding Repeated Loop Tasks #
The biggest performance problem related to loops isn’t about choosing between loop or with_items, but developers’ tendency to create loop tasks calling system modules many times for operations that could actually be sent in bulk (bulk/batch operations).
Let’s study the anti-pattern example of calling packages one by one vs the efficient mass installation solution:
# ANTI-PATTERN: Installing packages one by one inside a loop.
# This forces the apt module to run 4 times, opening the apt database lock repeatedly.
- name: Basic tools installation (Slow)
ansible.builtin.apt:
name: "{{ item }}"
state: present
with_items:
- git
- tmux
- htop
- vim
# DON'T: This operation takes 4x longer because the apt initialization overhead runs 4x.
# CORRECT: Sending the entire list directly to the apt module in one go.
- name: Basic tools mass installation (Fast)
ansible.builtin.apt:
name:
- git
- tmux
- htop
- vim
state: present
# ✓ The apt module detects the list argument and does the installation in a single transaction.
This bulk sending pattern must also be applied to file copying operations or configuration creation by leveraging single templates or smart template modules instead of looping to copy files one by one.
Avoiding Overhead with Fact Caching and Smart Gathering #
The initial step of every Ansible playbook execution is collecting target system information called “Gathering Facts” using the setup module. This step collects complete hardware details, network IP addresses, OS information, and disk mounts. This process takes about 2 to 5 seconds per host.
Using gather_facts: false #
If our playbook only does simple tasks like restarting services or pulling Git repositories that don’t use Ansible built-in variables (ansible_*), turn off this fact gathering entirely to save time instantly:
# CORRECT: Disabling fact gathering for non-conditional tasks
- name: Restart the Nginx Cluster
hosts: web_servers
gather_facts: false # ✓ Instantly saves 2-5 seconds per server
tasks:
- name: Reload the nginx configuration
ansible.builtin.systemd:
name: nginx
state: reloaded
Enabling Fact Caching in ansible.cfg #
If our playbook still needs facts, we can use the Fact Caching technique. With fact caching, Ansible only fetches facts directly from target servers on the first execution, then stores that data to a local storage system (like JSON files or a Redis database) for a certain time limit (TTL). On subsequent executions, Ansible directly reads the data from the local cache without needing to do the SSH setup to target servers.
Here’s the JSON file-based cache configuration in ansible.cfg:
# ansible.cfg
[defaults]
# Setting smart fact gathering (only if the cache expires)
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
# Storing fact caches for 24 hours (86400 seconds)
fact_caching_timeout = 86400
Profiling Playbooks with Callback Plugins #
We can’t effectively optimize performance if we don’t know which task wastes the most execution time. To scientifically analyze our playbook performance, we must enable Ansible’s built-in callback plugin named profile_tasks.
Enabling the Profiling Plugin #
Add the following configuration to our ansible.cfg file to enable time recording statistics:
# ansible.cfg
[defaults]
# Enabling the task and play total time recording plugin
callbacks_enabled = profile_tasks, timer
Reading Profiling Results #
After the plugin is enabled, every time we run a playbook, Ansible records the duration for each task and presents a summary list of the 20 slowest tasks at the end of the terminal log:
Thursday 17 June 2026 16:20:00 +0700 (0:00:01.050) 0:05:30.120 ****
===============================================================================
ansible.builtin.apt (postgresql package installation) ------------------ 85.20s
ansible.builtin.git (pull source code) -------------------------- 32.40s
ansible.builtin.setup (Gathering Facts) -------------------------- 22.15s
ansible.builtin.template (generate config app) ------------------- 5.12s
...
By analyzing this report, we can immediately identify the main bottlenecks:
- If Gathering Facts takes too long, we need to enable Fact Caching or set
gathering = smart. - If ansible.builtin.apt is slow, we should combine installation tasks into bulk operations.
- If the git module is slow, we need to check internet connection latency or use the
depth: 1parameter for a lighter shallow clone.
Performance Strategy and Settings Comparison #
Here’s a summary matrix table of performance optimizations we can use as a quick reference for choosing the right technique according to our infrastructure scenario:
| Optimization Technique | Performance Impact | Difficulty Level | Trade-off / Risk |
|---|---|---|---|
| Raising forks | High (2x - 5x) | Very Easy | Consumes more control node CPU/RAM |
| SSH Pipelining | High (1.5x - 2x) | Easy | Requires disabling requiretty on targets |
| SSH ControlMaster | Medium (1.3x - 1.5x) | Easy | Requires OpenSSH on the control node |
| Fact Caching | Medium (Saves setup) | Easy | Fact data can expire (out of date) |
| Free Strategy | Very High | Medium | Removes inter-host flow synchronization |
| Mitogen Plugin | Very High (3x - 5x) | Medium | Some custom modules may be incompatible |
Summary #
forksdetermines the number of Ansible parallel execution concurrency; raise it from the default value of 5 to 25-50 according to our control node’s CPU and memory capacity.- SSH Pipelining minimizes network overhead by transmitting module code directly to the target Python interpreter’s stdin without copying temporary files to disk.
- SSH Multiplexing (
ControlMaster) eliminates repeated TCP/SSH connection handshake overhead by sharing one previously opened persistent socket connection.- The
freestrategy frees each host to finish tasks as fast as possible independently, ignoring the queue bottleneck of other slow servers.- Mitogen is a very powerful third-party plugin for replacing Ansible’s processing backend, offering instant multi-fold execution acceleration.
- Optimize our loops by sending data in bulk (bulk lists) to modules (like the apt module) instead of calling the module repeatedly inside a loop.
- Use Fact Caching (
gathering = smartwith ajsonfilebackend) to avoid repeated fact collection on every playbook run.- Enable the
profile_tasksplugin to get objective analytical data about which tasks are the main bottleneck of our playbook.