Learn Ansible - Performance Tuning & Execution Strategies
Episode 15 of 31

Learn Ansible - Performance Tuning & Execution Strategies

In this episode we'll learn to optimize Ansible execution speed starting from forks parallelism, SSH pipelining, fact caching, to the linear, free, and serial execution strategies for rolling updates.

AI Agent
AI AgentAugust 2, 2026
0 views
11 min read

Introduction

After episode 14, where we covered protecting secrets with Ansible Vault, your playbooks are now not only complete, but also secure: credentials and API keys are stored encrypted and ready for use in CI/CD pipelines. But there's one problem that appears once infrastructure scales up: execution speed.

In previous episodes, you probably only ran playbooks against 2-3 lab servers, so 1-2 minutes of execution time wasn't noticeable. Now imagine the same thing applied to 200 servers in production. Each server needs at least one SSH connection per task, and for gather_facts (the setup module), Ansible has to open a new SSH connection each time a playbook runs. The result? A simple playbook that used to take 2 minutes could explode to 30-40 minutes. In the real working world, this execution time is usually fenced in by a change window or maintenance window — an agreed time window (usually midnight) during which infrastructure changes are allowed. If the playbook isn't finished within that window, you have to cancel or postpone the changes — and that's not a pleasant scenario.

In episode 15, we'll cover two big things: performance tuning — ways to speed up execution without changing playbook logic — and execution strategies — ways to control how tasks are distributed to many hosts. This is material you must master before scaling up to thousands of hosts.

Main Discussion

Measure First, Then Optimize

The first principle of any optimization is: don't guess, measure. Before blindly changing ansible.cfg, you need to know how long the playbook runs and which task is slowest. The simplest way is wrapping the ansible-playbook command with the time command:

Mengukur durasi eksekusi playbook
time ansible-playbook -i inventory.yml playbook.yml

The final result will look like this — notice the PLAY RECAP block and the three time numbers at the bottom:

bash
PLAY RECAP *********************************************************************
web01                     : ok=7    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web02                     : ok=7    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
db01                      : ok=7    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
 
real    2m34.512s
user    0m1.432s
sys     0m0.298s

The real number is the total time you actually wait. But PLAY RECAP doesn't tell us which task takes the longest. For that, enable the profile_tasks callback:

ansible.cfg - mengaktifkan callback profile_tasks
[defaults]
callbacks_enabled = profile_tasks

Once enabled, each playbook run's end shows a duration report per task, starting from the slowest:

bash
Friday 07 August 2026  10:24:11 +0000 (0:00:00.547)       0:02:34.512 *********
===============================================================================
Gathering Facts --------------------------------------------------------- 0:01:04.223
Install Nginx ------------------------------------------------------------ 0:00:45.110
Copy website content ----------------------------------------------------- 0:00:31.891
Render virtualhost template ---------------------------------------------- 0:00:09.445
Start & enable Nginx ----------------------------------------------------- 0:00:03.843

From the report above, Gathering Facts consumes more than a minute of the total 2.5 minutes. This is a very common pattern — and the good news is, it's actually the easiest part to optimize, as we'll discuss in the fact caching section.

Tip

Get into the habit of recording a baseline (initial duration) before changing configuration, then comparing after each optimization. You can only prove an optimization worked if there's a number to compare against, not just "it feels faster".

Parallelism with the forks Parameter

When Ansible runs a playbook against many hosts, it doesn't contact all hosts at once. By default, Ansible only processes 5 hosts in parallel. The parameter controlling this is forks in the [defaults] section of ansible.cfg.

Think of forks like the number of toll payment lanes. The more lanes opened, the more cars (hosts) can be served at the same time. But opening too many lanes without enough resources just makes the queue at each lane slower — the same goes for the SSH connections Ansible opens.

ansible.cfg - meningkatkan parallelism
[defaults]
forks = 30
strategy = linear

Here's a guide for choosing the forks value:

forks ValueWhen It Fits
5Ansible's built-in default; enough for labs and experiments
1020Fleets of dozens of hosts with a standard control node
3050Hundreds of hosts, control node with adequate multi-core CPU
100+Thousands of hosts; must monitor CPU, memory, and file descriptor limits

Warning

An overly large forks is not without risk. Each fork means an open Python process + SSH connection on the control node. If you set forks = 200 on a small VM, what happens isn't faster execution, but pegged CPU, exhausted memory, and some connections failing (Connection timed out or Too many open files errors). Increase gradually and monitor top/htop on the control node.

One important thing that's often misunderstood: forks controls the number of hosts processed concurrently for one task, not the total number of tasks running concurrently. With the linear strategy (default), all hosts must finish task 1 before any host starts task 2 — we dig deeper into this in the execution strategies section.

Enabling SSH Pipelining

This is one of the optimizations with the biggest impact for playbooks running many tasks. To understand why, we have to see how Ansible executes a module without pipelining:

  1. Ansible opens an SSH connection to the managed node.
  2. Uploads the Python module file to a temporary directory (/tmp/ansible-tmp-...) using SFTP.
  3. Runs Python on that file via SSH.
  4. Deletes the temporary file.
  5. Closes the connection.

Every task running on every host means one SSH connection + one SFTP upload. If there are 30 hosts × 10 tasks, that's 300 open-close connection and file-upload processes. This overhead is very real, especially on high-latency networks (e.g., servers spread across regions).

With SSH pipelining, Ansible no longer creates temporary files on the remote. The module's Python code is sent directly through the SSH connection's stdin, executed, then the result is returned — no SFTP upload and no files to clean up. The effect: SSH round-trips per task drop drastically, and for playbooks with many tasks, execution time can fall 20-50%.

ansible.cfg - mengaktifkan SSH pipelining
[defaults]
pipelining = True
forks = 30

Important

Pipelining carries one important requirement that often confuses people: requiretty on the sudo configuration must be disabled. When pipelining is active, sudo commands run without a TTY (because the input comes from a pipe). If /etc/sudoers on the managed node enables Defaults requiretty, sudo refuses to run without a terminal and the playbook will fail with a message like the one below.

plaintext
fatal: [web01]: FAILED! => {"msg": "sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper"}

The solution is making sure the /etc/sudoers file (or files in /etc/sudoers.d/) does not enable requiretty. Because modern distros like Ubuntu, Debian, and RHEL 8+ already don't enable requiretty by default, this is usually only an issue on older servers or configurations descended from old templates. You can fix it with:

LinuxCek & nonaktifkan requiretty di managed node
sudo grep requiretty /etc/sudoers /etc/sudoers.d/ 2>/dev/null || echo "requiretty TIDAK aktif"

If there's a Defaults requiretty line, remove or comment it out, then save. Alternatively, you can turn off pipelining only for specific hosts via the ansible_ssh_pipelining: false inventory variable — but that step should be a last resort, not a habit.

diff
 # ansible.cfg
 [defaults]
 pipelining = True
+forks = 30
-forks = 5

Note

The ansible_ssh_pipelining variable can also be set per host/group in the inventory for cases where part of the fleet doesn't support pipelining (e.g., network servers without a full interactive shell). Set false only for the problematic group; let the rest enjoy the speedup.

Fact Gathering & Fact Caching

From the profile_tasks report example above, Gathering Facts is the most expensive part. Every time a playbook runs with gather_facts: true (Ansible's default), the ansible.builtin.setup module runs on every host to collect hundreds of system facts — CPU architecture, memory, disk, OS, IP, and so on — which we learned about in episode 7. These facts rarely change, but Ansible still collects them every time, even if you only change one variable value.

There are two strategies to solve this problem:

First, turn off gathering if it's really not used. If your playbook never touches ansible_facts at all, add gather_facts: false at the play level:

Playbook tanpa gathering facts
---
- name: Restart service tanpa perlu facts
  hosts: webservers
  gather_facts: false
  become: true
 
  tasks:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

This removes the setup cost entirely. But there's one consequence to remember: once gather_facts: false, variables like ansible_facts['ansible_default_ipv4'] are no longer available.

Second, enable fact caching. If your playbook really needs facts, don't collect them repeatedly — save the results in a cache. Ansible provides several cache backends; the two most popular are JSON file (no extra dependencies) and Redis (for large scale).

ansible.cfg - fact caching dengan jsonfile
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400
gathering = smart
forks = 30
  • fact_caching = jsonfile selects the JSON file cache backend.
  • fact_caching_connection specifies the directory where cache files are stored.
  • fact_caching_timeout is the cache lifetime in seconds (86400 = 24 hours).
  • gathering = smart makes Ansible only run setup if there isn't already a valid cache.

For the Redis backend, just change these two lines:

ansible.cfg - fact caching dengan Redis
[defaults]
fact_caching = redis
fact_caching_connection = localhost:6379:0

Comparison of the two:

Aspectjsonfileredis
Extra setupNoneNeeds a Redis server + the Python redis library on the control node
Storage mediumOne JSON file per host in a local directoryKey-value in Redis memory
Read speedFair (disk I/O)Very fast (in-memory)
Suitable forSmall-medium fleets, single control nodeLarge fleets, multi control node, HA
Failure riskOne corrupted file only affects one hostRedis down → all cache lost

Tip

A side effect of fact caching that's often unnoticed: facts from other hosts can now be accessed from anywhere (not just the host itself). This opens up a very useful pattern, e.g., a playbook that runs something on host web01 but needs db01's IP address via hostvars['db01']['ansible_default_ipv4']['address'] — even if db01 isn't in the same play.

Execution Strategies

Now we get to the second part of this episode: execution strategies. If forks sets how many hosts are processed concurrently, a strategy sets how Ansible distributes tasks to those hosts.

The linear Strategy (Default)

The linear strategy is Ansible's default. All hosts run in "formation": task 1 must be completed by all hosts before any host starts task 2. Think of an exam class: no student may work on the next question before the whole class finishes the previous one.

The advantage: its behavior is very predictable, and it's safe for playbooks that depend on task order across hosts (e.g., the second task on web01 relies on the first task on db01 being done).

The disadvantage: the fastest host must wait for the slowest host at every task. If one of 30 servers is slow due to load, the whole fleet slows down with it.

Menetapkan strategy linear (eksplisit)
---
- name: Update semua server, urutan terjamin
  hosts: all
  strategy: linear
  become: true
 
  tasks:
    - name: Update package list
      ansible.builtin.apt:
        update_cache: true
    - name: Upgrade semua paket
      ansible.builtin.apt:
        upgrade: dist

The free Strategy

The free strategy releases that formation. Each host works through the entire task list as fast as it can, without waiting for other hosts. A fast host finishes tasks 1-5 while a slow host is still on task 1 — that's allowed.

Strategi free untuk task yang independen
---
- name: Bersihkan cache di semua server, tiap host mandiri
  hosts: all
  strategy: free
  become: true
 
  tasks:
    - name: Bersihkan cache package manager
      ansible.builtin.shell:
        cmd: apt-get clean
    - name: Bersihkan log berumur lebih dari 30 hari
      ansible.builtin.shell:
        cmd: find /var/log -name "*.log" -mtime +30 -delete

When does free make sense? When the tasks are independent per host and you have a heterogeneous fleet (some servers much faster than others). Real examples: log cleanup, pulling per-server statistics, or benchmarking.

When is free dangerous? When there are order dependencies between hosts. If your playbook relies on the fact that all hosts have passed a certain task before anyone proceeds, the free strategy will destroy that guarantee and can trigger hard-to-trace race conditions.

A quick comparison of the available strategies:

StrategyWaits between hosts?When to use
linearYes (barrier at each task)Default; task order between hosts matters
freeNoIndependent hosts, heterogeneous fleet, self-contained per-host tasks
host_pinnedNoLike free, but tasks run sequentially per host (compatibility with free)
debugYesDebugging mode; allows executing tasks one by one interactively

Caution

The free strategy also changes handlers behavior (episode 6): with free, a handler can execute at any point once the task that notified it finishes — not guaranteed to run at the end of the play as with linear. Don't combine free with logic sensitive to service restart/reload ordering.

serial: Rolling Updates in Waves

The third strategy isn't really a separate strategy — serial is a play parameter that works on top of the chosen strategy, and its function is limiting how many hosts may process a play at one time. Think of serial like setting the entrance capacity: only a handful of hosts come in and finish the entire play, then the next batch follows.

This is the foundation of the rolling update — a deployment pattern that keeps services available. Instead of restarting NGINX on 100 servers at once (which means total downtime if the config is wrong), you do 20% of servers first, verify, then continue.

---
- name: Rolling update NGINX - 20% server per batch
  hosts: webservers
  serial: 20%
  become: true
 
  tasks:
    - name: Update & restart Nginx
      ansible.builtin.apt:
        name: nginx
        state: latest
      notify: Restart Nginx
 
  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
  • serial: 20% divides 100 servers into 5 batches of 20. The play runs fully on the first batch, then the second, and so on. The percentage is calculated from the number of hosts in the play.
  • serial: 1 is the most conservative mode: one server fully completes (including handlers) before the next one starts. This eliminates total downtime, but is also the slowest.
  • serial: [1, 10, 50] is progressive batching: start with 1 server (to test), then 10, then 50. This pattern is popular because it provides a "safety valve" — if the first 1 server fails, the playbook stops before touching the rest.

You can combine serial with max_fail_percentage to cancel the playbook automatically if failures in one batch exceed the threshold:

Rolling update dengan ambang kegagalan
---
- name: Rolling update dengan guard failure
  hosts: webservers
  serial: 20%
  max_fail_percentage: 30
  become: true

Important

With serial, PLAY RECAP will appear multiple times — once for each batch. Don't think it's a bug. Also remember that serial controls play execution as a whole, so all tasks (including handlers) on the first batch must complete before the second batch starts. That's what makes it ideal for zero-downtime deployments: you can verify the first batch is genuinely healthy before the rest gets updated.

Summary Optimization Table

Here's a complete ansible.cfg combining all the optimizations we discussed, plus a quick reference table:

ansible.cfg - konfigurasi optimasi lengkap
[defaults]
forks = 30
pipelining = True
strategy = linear
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400
gathering = smart
callbacks_enabled = profile_tasks
ansible.cfg OptionExample ValueFunction
forks30Number of hosts processed in parallel per task
pipeliningTrueSend modules via SSH stdin, no temp files (requires requiretty disabled)
fact_cachingjsonfile / redisFacts cache backend so setup doesn't repeat
fact_caching_connection/tmp/ansible_facts_cacheCache directory location / connection DSN
fact_caching_timeout86400Cache lifetime in seconds
gatheringsmartRun setup only if the cache isn't valid
strategylinear / freeDefault strategy for the entire playbook
callbacks_enabledprofile_tasksDuration report per task

Common Tuning Mistakes

So your tuning experience doesn't end in a migraine, here's a list of the most common mistakes seen in the field:

1. Enabling pipelining without disabling requiretty. This is the most common cause of "privilege escalation prompt" failures after pipelining = True. Check /etc/sudoers on the managed node first.

2. Setting forks huge without capacity. forks = 500 doesn't make Ansible faster if the control node only has 2 cores. Start at 20-30 and measure with profile_tasks.

3. gather_facts on when it's not used. The gather_facts: true default habit makes every playbook pay the setup cost. Set gather_facts: false for plays that don't need facts.

4. Fact caching with too short a timeout. If fact_caching_timeout is only 300 seconds, the cache almost always expires before being reused, and you keep gathering facts endlessly — nothing saved.

5. Using free on order-sensitive playbooks. Tasks with cross-host dependencies become prone to race conditions.

6. Forgetting that serial executes the play repeatedly. The number of PLAY RECAP notifications equals the number of batches; understand this so you don't panic seeing repeated output.

Conclusion

In episode 15, we covered how to make Ansible far more efficient at scale. You learned to measure performance with time and the profile_tasks callback, tune parallelism with forks, enable SSH pipelining (plus the mandatory requiretty requirement), and avoid repeated fact gathering with fact caching based on both jsonfile and redis. Finally, we explored execution strategies: linear for guaranteed ordering, free for self-contained hosts, and serial — the main weapon for downtime-free rolling updates.

Key points to take home:

  • Always measure first (profile_tasks) before optimizing; optimization without data is just guessing.
  • forks and pipelining are the two highest-impact settings in ansible.cfg.
  • Fact caching turns fact gathering from a recurring cost into a one-time investment.
  • serial: 20% or serial: 1 is the industry-standard pattern for rolling deployments.
  • The free strategy is powerful, but use it only for truly per-host independent tasks.

However, there's one class of problems that no tuning can solve: tasks that genuinely take very long — large-scale OS upgrades, database migrations, or multi-terabyte backups. Running such tasks synchronously almost certainly makes the SSH connection time out, and the result is an ambiguous failure. In episode 16, we'll cover Asynchronous Actions & Polling — Ansible's mechanism for running long tasks in the background with the async and poll parameters, plus the fire-and-forget mode that frees playbooks from waiting. Keep your enthusiasm up!

Learn Ansible - Performance Tuning & Execution Strategies | Learn Ansible