Module 08 — Execution Strategies & Performance at Scale
Updated 20 August 2026
Making Ansible fast and safe on hundreds of hosts instead of five. Everything here is invisible on a three-host lab and decisive on a 500-host fleet.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Modules 01–07. You met serial, forks and the task barrier in Module 03 — this module goes into the mechanics and the tuning.
Part A · Where the time actually goes
A1 · The four costs
"My playbook is slow" almost never means "my tasks are slow". It means SSH connection setup (the parking), fact gathering (a full check of the car at every stop), and only five hosts moving at once (one car). Those are the three things this module actually tunes, and the running of the modules themselves barely appears in the total.
Diagram source
flowchart TD
A["Total run time"] --> B["1 · SSH connection setup<br>one TCP + TLS handshake per host<br>FIX: ControlPersist multiplexing"]
A --> C["2 · Fact gathering<br>a full extra module execution per host<br>FIX: gather_subset, caching, or off"]
A --> D["3 · Per-task round trips<br>copy payload, execute, read JSON, delete<br>FIX: pipelining, fewer tasks, list arguments"]
A --> E["4 · Serialisation<br>only 'forks' hosts progress at once<br>FIX: raise forks, or strategy: free"]
style B fill:#FEF3C7,stroke:#D97706
style C fill:#FEE2E2,stroke:#DC2626
style D fill:#DDD6FE,stroke:#7C3AED
style E fill:#D1FAE5,stroke:#059669That is why the first three fixes are all about round trips, and why "my playbook is slow" almost never means "my tasks are slow".
A2 · Measure before you tune
profile_tasks is the examination. It costs you nothing, and it prints exactly which task consumed the time. Raising forks before you have measured is prescribing before examining — and it is the single most common way people spend an afternoon tuning the wrong thing.
# ansible.cfg
[defaults]
callbacks_enabled = profile_tasks, timerANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer ansible-playbook site.yml| Callback | What it reports |
|---|---|
| profile_tasks | ⭐ Wall-clock per task, plus a slowest-tasks summary |
| timer | Total playbook duration |
| profile_roles | Time aggregated per role |
| cgroup_perf_recap | CPU and memory used by the control node itself — for diagnosing fork limits |
🧪 Exercise A2.1 — Find out where your time actually goes
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer ansible-playbook site.yml 2>&1 | tail -25✅ Expected result — click to reveal
Tuesday 17 August 2026 09:14:02 +0800 (0:00:06.812) 0:00:06.812 ******
===============================================================================
Gathering Facts --------------------------------------------------------- 6.81s
Install base packages --------------------------------------------------- 4.22s
Deploy nginx configuration ---------------------------------------------- 0.94s
Ensure nginx is running -------------------------------------------------- 0.61s
Create application user -------------------------------------------------- 0.44s
Set sysctl values -------------------------------------------------------- 0.38s
...
Playbook run took 0 days, 0 hours, 0 minutes, 19 secondsGathering Facts is the single most expensive item, and you did not write it. That is the pattern on almost every real playbook, and it is why Part C exists.
Read it as a ratio, not a number. Here 6.81s of 19s — 36% of the run — went on collecting data before doing anything. On 200 hosts with forks: 5 that ratio holds and the absolute number becomes minutes, on every run, including every CI-triggered one.
Do this before changing any setting. Raising forks when the bottleneck is a single slow task achieves nothing; disabling fact gathering in a play that genuinely needs facts breaks it. profile_tasks costs nothing and tells you which lever to pull.
🎯 Interview questions — Profiling
Q. A playbook is slow. How do you find out why?
Enable profile_tasks and timer callbacks, which give per-task wall clock and a slowest-tasks summary, then read the ratio rather than the raw numbers.
On most real playbooks the largest single item is Gathering Facts, followed by connection setup — not the tasks. So the first questions are whether the play needs facts at all, whether forks is still at the default 5, and whether pipelining is on.
Measure first. Raising forks when one slow task is the bottleneck changes nothing.
Part B · Connection-level tuning
B1 · forks — how many hosts at once
forks is how many tills are open, and the default is five. The thing that limits how high you can push it is your control machine's CPU and memory, not the servers you are managing — the shop runs out of cashiers long before it runs out of customers. That is why the honest answer to "what should forks be?" is "measure your control node", not a number.
[defaults]
forks = 25 # default is 5ansible-playbook site.yml -f 50 # for this run onlyWhat limits how high you can go is the control node, not the targets: each fork is a Python process holding an SSH connection, so you are bounded by CPU, RAM and file descriptors. 25–50 is normal; several hundred needs a deliberately sized control node and a raised ulimit -n.
B2 · Pipelining
Pipelining is carrying it all in one trip — roughly five SSH operations per task cut down to two. And here is why it is worth knowing separately from forks: opening more tills does nothing for a single table, but a waiter who walks less is faster even when you have exactly one host. Pipelining helps at any scale.
[ssh_connection]
pipelining = TrueWithout pipelining each task is roughly: create a temp dir, copy the module file, execute it, delete the temp dir — several SSH operations. With pipelining the module is fed to the remote Python over the already-open session, cutting that to one.
Modern RHEL and Debian images generally have it off already. Verify before enabling fleet-wide:
ansible all -m ansible.builtin.shell -a "grep -r requiretty /etc/sudoers /etc/sudoers.d/ 2>/dev/null || echo 'not set - pipelining OK'" -oB3 · SSH multiplexing — ControlPersist
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = ~/.ansible/cpThe first connection to a host opens a master socket; every later connection reuses it, skipping the TCP and TLS handshake entirely. ControlPersist=60s keeps it alive for a minute after the last use.
The fix: shorten the directory, or use a hashed path.
[ssh_connection]
control_path_dir = /tmp/.acp
control_path = %(directory)s/%%h-%%rThis is one of those errors that is baffling until you have seen it once, which is exactly why it gets asked.
B4 · Fewer round trips beats faster round trips
# ❌ THREE round trips
- ansible.builtin.package: name=nginx state=present
- ansible.builtin.package: name=git state=present
- ansible.builtin.package: name=htop state=present
# ❌ ALSO three - a loop is still one execution per item
- ansible.builtin.package:
name: "{{ item }}"
loop: [nginx, git, htop]
# ✅ ONE round trip, one package-manager transaction
- ansible.builtin.package:
name: [nginx, git, htop]
state: presentOn 200 hosts, collapsing three looped package tasks into one saves 400 round trips.
🧪 Exercise B4.1 — Measure forks and pipelining
# Baseline: defaults
time ANSIBLE_FORKS=5 ANSIBLE_PIPELINING=False ansible all -m ansible.builtin.ping
# More parallelism
time ANSIBLE_FORKS=50 ANSIBLE_PIPELINING=False ansible all -m ansible.builtin.ping
# Parallelism plus pipelining
time ANSIBLE_FORKS=50 ANSIBLE_PIPELINING=True ansible all -m ansible.builtin.ping
# Count the SSH operations each way
ANSIBLE_PIPELINING=False ansible web01 -m ansible.builtin.setup -vvv 2>&1 | grep -c 'EXEC'
ANSIBLE_PIPELINING=True ansible web01 -m ansible.builtin.setup -vvv 2>&1 | grep -c 'EXEC'✅ Expected result — click to reveal
forks=5, pipelining=off real 0m38.412s
forks=50, pipelining=off real 0m6.883s
forks=50, pipelining=on real 0m4.201s5 <- pipelining off: mkdir, copy, chmod, execute, cleanup
2 <- pipelining on: essentially just executeTwo separate effects, and it is worth keeping them distinct.
forks removed the queueing — 38s to 6.9s, roughly a 5.5x improvement, because hosts stopped waiting in line. This is pure parallelism and does nothing for a single host.
Pipelining removed work per host — a further ~40%, by collapsing five SSH operations per task into two. This helps even on one host, and it compounds with task count: a 50-task playbook saves 150 SSH operations per host.
Which to reach for: forks when you have many hosts and few tasks; pipelining when you have many tasks regardless of host count. In practice, both.
🎯 Interview questions — Connection tuning
Q. What is forks and what limits it?
The number of hosts worked on in parallel; default 5, which turns a 200-host run into 40 sequential rounds. 25–50 is normal.
The limit is the control node — each fork is a Python process holding an SSH connection, so CPU, RAM and the open-file-descriptor limit bound it. Going to several hundred needs a properly sized control node and a raised ulimit -n. The managed nodes are not the constraint.
Q. What does pipelining do and why is it off by default?
It feeds the module to the remote Python over the already-open SSH session instead of creating a temp directory, copying a file, executing and cleaning up — roughly five SSH operations per task down to two, about a 40% reduction.
It is off by default because it requires requiretty to be disabled in /etc/sudoers on the managed nodes, and not every distribution image ships that way. With become in use and requiretty on, tasks fail with sudo: sorry, you must have a tty to run sudo.
Q. You get unix socket path too long. What is happening?
The SSH ControlPersist socket path, built from user, host and port, has exceeded the operating system's Unix socket path limit — common with long cloud DNS hostnames.
Fix by shortening control_path_dir (for example /tmp/.acp) or using a shorter control_path template such as %(directory)s/%%h-%%r. The error message never mentions hostnames, which is what makes it memorable once you have hit it.
Q. Give three ways to reduce round trips.
Use modules that accept lists rather than looping — package: name: [a, b, c] is one transaction, a loop is three. Enable pipelining to collapse the per-task SSH operations. Keep ControlPersist multiplexing on so the TCP and TLS handshake happens once per host rather than per task.
And structurally: fewer, larger tasks beat many small ones, because the round trip is the cost, not the work.
Part C · Fact performance
C1 · Three levers
Fact caching is writing it down — the doctor's check-up from Module 02, done once and kept on file instead of repeated at every visit. And the risk is exactly the one you would expect from an address book: if they moved house and your book still has the old address, the card goes to the wrong place. That is what --flush-cache is for, and why a cache timeout is a judgement call rather than a setting you can copy from a blog post.
# 1. Turn it off entirely - for plays that reference no ansible_* variable
- hosts: web
gather_facts: false
# 2. Narrow it - collect only what you need
- hosts: web
gather_facts: true
gather_subset:
- "!all"
- "!min"
- network
# 3. Gather explicitly, later, only where needed
- hosts: web
gather_facts: false
tasks:
- ansible.builtin.setup:
gather_subset: ["!all", "network"]
when: needs_network_facts | default(false) | bool| gather_subset | Collects |
|---|---|
| all | Everything — the default |
| min | A small core set — always included unless you say !min |
| hardware | CPU, memory, devices, mounts — the slowest subset by far |
| network | Interfaces, addresses, routes |
| virtual | Virtualisation type and role |
| !all, !hardware | Exclusions — combine with inclusions |
There is also gather_timeout (default 10s) — worth raising rather than losing facts on a host with slow storage.
C2 · Fact caching
# ansible.cfg — jsonfile: no infrastructure, good for a single control node
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200# redis: shared between control nodes and CI runners
[defaults]
gathering = smart
fact_caching = redis
fact_caching_connection = redis.internal:6379:0
fact_caching_timeout = 86400| gathering | Behaviour |
|---|---|
| implicit | Default. Gather at every play start unless gather_facts: false |
| explicit | Never gather unless a play sets gather_facts: true |
| smart | ⭐ Gather only if this host has no cached facts. The setting that makes caching useful |
Mitigations: a fact_caching_timeout matched to how fast your estate actually changes; --flush-cache on runs where correctness matters more than speed; and never caching facts that feed a security-relevant decision.
The counter-question to expect: "how do you know your cached facts are current?" The honest answer is that you set a timeout and accept the window — which is exactly why caching suits large read-heavy fleets and not one-off provisioning.
🧪 Exercise C2.1 — Measure the three fact strategies
cat > factbench.yml <<'EOF'
---
- hosts: all
gather_facts: true
tasks:
- ansible.builtin.debug:
msg: "{{ inventory_hostname }}"
EOF
time ansible-playbook factbench.yml # full gather
time ANSIBLE_GATHER_SUBSET='!all,!min,network' ansible-playbook factbench.yml
time ansible-playbook factbench.yml -e '{"ansible_facts": {}}' --flush-cache
# now enable jsonfile caching and run twice
export ANSIBLE_GATHERING=smart
export ANSIBLE_CACHE_PLUGIN=jsonfile
export ANSIBLE_CACHE_PLUGIN_CONNECTION=/tmp/afacts
time ansible-playbook factbench.yml # cold cache
time ansible-playbook factbench.yml # warm cache
ls /tmp/afacts/✅ Expected result — click to reveal
full gather real 0m6.812s
gather_subset network real 0m2.944s
caching, cold real 0m6.903s
caching, warm real 0m1.402s/tmp/afacts/:
db01 web01 web02 web03Three distinct wins, and they stack.
gather_subset cut the gather itself roughly in half by skipping hardware, which probes block devices and mounts.
Caching removed the gather entirely on the second run — 6.9s to 1.4s. That is the setting that matters for a fleet you run against repeatedly, and gathering = smart is what makes it take effect: without it, Ansible gathers regardless and the cache is only written, never read.
Open one of the cache files — cat /tmp/afacts/web01 | python3 -m json.tool | head — and you will see the full fact dictionary as JSON. That is exactly what hostvars['web01'] reads from on a cached run, which is also why caching fixes the hostvars problem from Module 02 Exercise B4.2 without a preliminary gathering play.
⚠️ Now change something on a host — add an IP, for instance — and re-run. The playbook still reports the old value until fact_caching_timeout expires or you pass --flush-cache. That is the trade, and you should be able to state it before someone asks.
🎯 Interview questions — Facts and caching
Q. Give three ways to reduce fact-gathering cost, in order of impact.
Fact caching with gathering = smart — removes the gather entirely on repeat runs, which is the biggest win for a fleet you run against regularly.
gather_subset — collect only what you need; excluding hardware typically halves it, since that subset probes block devices and mounts.
gather_facts: false — for plays that reference no ansible_* variable at all.
And measure first with profile_tasks, because Gathering Facts is usually the single largest item but not always.
Q. What does gathering = smart do, and why does caching need it?
It gathers facts only for hosts that have no cached facts. Without it, gathering stays implicit and Ansible gathers at every play start regardless — so the cache is written but never read, and you get no speed-up at all while believing caching is enabled.
explicit is the third option: never gather unless a play asks.
Q. What is the risk of fact caching?
Stale data. A host gains memory or an interface and the playbook keeps using yesterday's values until the cache expires — and the playbook looks correct throughout, which makes it a genuinely unpleasant bug.
Mitigate with a fact_caching_timeout matched to how fast the estate changes, --flush-cache where correctness outweighs speed, and by not caching facts that drive security decisions.
It suits large, read-heavy fleets; it is wrong for provisioning runs where the machine is changing underneath you.
Q. How does fact caching help hostvars?
hostvars['db01']['ansible_facts'] is undefined unless db01's facts were gathered in this run — the Module 02 trap. With caching enabled, previously gathered facts are available, so a play targeting only web can read db01's address without a preliminary fact-gathering play.
The caveat is the same: those cached values may be stale.
Part D · Execution strategies
D1 · The four strategies
But you have given up the one thing the coach was providing: the guarantee that everybody finished stop one before anybody starts stop two. strategy: free is letting everyone make their own way. It is right for work where the hosts do not depend on each other, and wrong the moment "database first, then the app tier" is part of the plan.
Diagram source
flowchart TD
A["strategy:"] --> B["linear (default)<br>Task 1 on ALL hosts, barrier,<br>then task 2 on all hosts"]
A --> C["free<br>Each host races through the whole play<br>NO barrier between tasks"]
A --> D["host_pinned<br>A worker takes one host and finishes it<br>before picking up the next host"]
A --> E["debug<br>Interactive debugger on task failure"]
B --> B1["✅ Orchestration works<br>❌ Everyone waits for the slowest host"]
C --> C1["✅ Fastest wall clock<br>❌ NO cross-host ordering guarantees"]
D --> D1["✅ Good for many hosts, few forks<br>❌ Same ordering caveat as free"]
style B fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
style C1 fill:#FEE2E2,stroke:#DC2626Use free for genuinely independent, order-insensitive work: patching, log collection, fact gathering. Never for anything where one host's state depends on another's.
D2 · serial — batching the whole play
serial: 1 # one host at a time. Safest, slowest
serial: 10 # ten at a time
serial: "25%" # a quarter of the fleet per batch
serial: [1, 5, 10] # canary of 1, then 5, then 10, then 10...
serial: [1, "10%", "50%"] # mixed absolute and percentageTwo consequences people miss:
ansible_play_hosts contains only the current batch; for the full list use ansible_play_hosts_all.
run_once fires once per batch, not once per play — which surprises people writing database migrations.
D3 · throttle — limiting one task
- name: Register with the licence server
ansible.builtin.uri:
url: https://licence.internal/register
method: POST
throttle: 2 # at most 2 hosts run THIS task concurrentlythrottle can never exceed forks — it only ever reduces concurrency further.
D4 · async — long-running tasks
poll: 0 starts the wash and walks away; async_status is going back to check. Doing the first without the second is how a genuine failure gets silently thrown away — the playbook reports success because it never asked. If you use async, plan the check at the same moment you write it.
Diagram source
flowchart TD
A["async: 3600<br>poll: 10"] --> B["Ansible BLOCKS<br>polling every 10s<br>until done or 3600s elapse"]
C["async: 3600<br>poll: 0"] --> D["FIRE AND FORGET<br>task starts, Ansible moves on<br>immediately"]
D --> E["register the job id"]
E --> F["Other tasks run<br>in the meantime"]
F --> G["async_status with the jid<br>+ until: result.finished"]
G --> H["Collect the result"]
style D fill:#FEF3C7,stroke:#D97706,stroke-width:2px
style H fill:#D1FAE5,stroke:#059669# Pattern 1: avoid the SSH timeout on a genuinely long task
- name: Run a two-hour database migration
ansible.builtin.command: /opt/db/migrate.sh
async: 7200 # allow up to 2 hours
poll: 30 # check every 30 seconds
# Pattern 2: parallelise slow independent work
- name: Start the long job on every host
ansible.builtin.command: /opt/slow-job.sh
async: 3600
poll: 0 # fire and forget
register: job
- name: Do other useful work while it runs
ansible.builtin.debug:
msg: "not blocked"
- name: Now wait for completion
ansible.builtin.async_status:
jid: "{{ job.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 120
delay: 30- poll: 0 means Ansible never learns the outcome unless you check with async_status. Fire-and-forget with no follow-up is fine for "kick off a reboot", and silently loses failures for anything you care about.
- async does not work with action plugins that run on the control node — uri, debug, most cloud modules. It is for work executing on the target.
- The job status lives on the target in ~/.ansible_async/, so async_status must run against the same host, and a reboot in between destroys it.
🧪 Exercise D4.1 — Compare linear, free and async
cat > strat.yml <<'EOF'
---
- hosts: all
gather_facts: false
tasks:
- name: Uneven work
ansible.builtin.command: "sleep {{ 8 if inventory_hostname == 'web01' else 1 }}"
changed_when: false
- name: Announce
ansible.builtin.debug:
msg: "done on {{ inventory_hostname }}"
EOF
time ansible-playbook strat.yml # linear
time ANSIBLE_STRATEGY=free ansible-playbook strat.yml # free✅ Expected result — click to reveal
linear — the default:
TASK [Uneven work] ***********************
ok: [web02] <- 1s
ok: [web03] <- 1s
ok: [db01] <- 1s
ok: [web01] <- 8s
TASK [Announce] ************************** <- nothing starts until 8s
ok: [web02] => {"msg": "done on web02"}
...
real 0m9.2sfree:
TASK [Uneven work] ***********************
ok: [web02]
TASK [Announce] **************************
ok: [web02] => {"msg": "done on web02"} <- web02 finished the WHOLE play at 1s
TASK [Announce] **************************
ok: [web03] => {"msg": "done on web03"}
TASK [Uneven work] ***********************
ok: [web01] <- web01 still on task 1 at 8s
real 0m8.4sRead the interleaving in the free output. Task headers repeat out of order because each host is running its own copy of the play independently. Three hosts finished completely while web01 was still on task 1.
The wall-clock saving here is small (9.2s → 8.4s) and that is the honest lesson — free cannot beat the slowest host, it only stops the others waiting. The benefit grows with the number of tasks and the variance between hosts, and it is zero when all hosts take the same time.
What you gave up is larger than what you gained, unless the work is genuinely independent: there is now no point in the play where you can rely on all hosts having reached the same state.
🎯 Interview questions — Strategies
Q. Name the execution strategies and when you would use each.
linear — the default, with a barrier between tasks so every host completes task N before any starts N+1. Required for orchestration.
free — no barrier; each host races through the whole play independently. For independent, order-insensitive work such as patching or log collection.
host_pinned — a worker takes one host and finishes it before moving on; useful with many hosts and few forks.
debug — drops into an interactive debugger on failure.
The trade with free is not just speed: you lose every cross-host ordering guarantee, so "migrate the database then restart the app tier" stops holding.
Q. serial vs throttle vs forks — what is the difference?
forks is global parallelism — how many hosts Ansible works on at once, across the whole run.
serial batches the entire play — each batch completes every task before the next batch begins. This is how rolling deploys are built.
throttle limits concurrency for one specific task, for a rate-limited API or licence server, while the rest of the play runs at full speed. It can only ever reduce concurrency below forks, never raise it.
Q. How do you run a task that takes two hours without the SSH connection timing out?
async: 7200 with poll: 30 — Ansible starts the task in the background on the target and polls for completion, so no single SSH session has to stay open for two hours.
For parallelising independent long work, poll: 0 fires and forgets, and you collect results later with async_status plus until: result.finished.
Caveats worth volunteering: poll: 0 loses the outcome entirely unless you check; async does not work with control-node action plugins; and the job state lives in ~/.ansible_async/ on the target, so a reboot destroys it.
Q. Inside a serial play, what does run_once do?
It runs once per batch, not once per play — so with serial: 10 across 100 hosts it executes ten times.
That surprises people writing database migrations. If you need genuinely once, combine run_once with delegate_to a fixed host and guard it, or move it into a separate play that targets a single host before the rolling play begins.
Part E · Scaling beyond one control node
E1 · Sizing the control node
ulimit -n # open file descriptors - raise for high forks
ulimit -n 8192
# Rough guidance: each fork is a Python process holding an SSH connection
# forks 25 -> ~2 vCPU, 4 GB
# forks 50 -> ~4 vCPU, 8 GB
# forks 100+ -> 8+ vCPU, 16 GB, tuned ulimits, and measure with cgroup_perf_recapANSIBLE_CALLBACKS_ENABLED=cgroup_perf_recap ansible-playbook site.ymlE2 · ansible-pull — inverting the model
Normal Ansible is the postroom; ansible-pull is the noticeboard. It solves scale and it solves reaching machines you cannot SSH into, and it costs you orchestration and visibility. In an interview, that trade — not the command syntax — is the answer they are listening for.
# Runs ON the managed node: clone a repo and apply a playbook to itself
ansible-pull -U https://git.internal/ansible/config.git -C main local.yml# /etc/cron.d/ansible-pull
*/30 * * * * root ansible-pull -U https://git.internal/ansible/config.git local.yml >> /var/log/ansible-pull.log 2>&1| Push (normal) | Pull (ansible-pull) | |
|---|---|---|
| Initiated by | Control node | The managed node itself, on a timer |
| Scale limit | Control node capacity | Effectively unlimited — each host does its own work |
| Network | Control node needs SSH in to every host | Host needs HTTPS out to git only |
| Orchestration | ✅ Full — ordering across hosts | ❌ None — every host is independent |
| Visibility | ✅ One run, one report | ❌ You must ship logs somewhere yourself |
What you give up is orchestration and visibility, which is usually decisive. Most teams stay with push and scale the control node, or move to AWX for queueing and reporting.
Being able to name ansible-pull, say precisely when it makes sense, and say why you would usually not use it is a much better answer than either enthusiasm or ignorance.
E3 · When to move to AWX
That is Module 12. The performance connection is that AWX also gives you a consistent, correctly sized control node instead of whatever laptop happened to run the play.
Part F · Putting it together
F1 · Production practice
| Habit | Why |
|---|---|
| Profile with profile_tasks before tuning anything | Raising forks when one task is slow achieves nothing |
| forks 25–50, not the default 5 | 200 hosts at forks=5 is 40 sequential rounds per task |
| pipelining = True, after verifying requiretty is off | ~40% fewer SSH operations per task; compounds with task count |
| Short control_path_dir | Long cloud hostnames overflow the Unix socket path limit |
| Modules that accept lists rather than loop | One transaction and one round trip instead of N |
| gathering = smart plus fact caching on a repeat-run fleet | Removes the largest single cost entirely on subsequent runs |
| gather_subset excluding hardware where you only need network | hardware probes block devices and mounts — typically half the gather |
| --flush-cache on runs where correctness beats speed | Stale facts produce bugs that look like playbook bugs |
| strategy: free only for genuinely independent work | It removes orchestration, not just waiting |
| serial • max_fail_percentage: 0 for any fleet-wide change | Halts at the first failure instead of propagating it to 500 hosts |
| throttle on tasks hitting rate-limited APIs | Keeps the rest of the play at full parallelism |
| async • poll: 0 • async_status for long work — never poll: 0 alone | Fire-and-forget with no follow-up silently discards failures |
| Raise ulimit -n before raising forks past ~50 | Otherwise the run gets slower, with no error explaining why |
F2 · Capstone exercise
Brief. You have inherited a playbook that takes 41 minutes across 300 hosts. Requirements:
- Identify where the time goes, without guessing
- Reduce fact-gathering cost without losing the network facts the templates need
- Increase parallelism safely, and state what limits it
- Collapse three looped package tasks into one round trip
- A 20-minute security scan must not block the rest of the play
- One task calls a licence API that permits at most 5 concurrent clients
- The whole thing must still roll out safely — halting on the first failure
✅ Model answer — attempt it first, then click
Step 1 — measure. Never tune first.
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer,cgroup_perf_recap \
ansible-playbook site.yml 2>&1 | tail -30Suppose it shows: Gathering Facts 14m, Install packages 9m, Security scan 18m, everything else under a minute.
Step 2 — ansible.cfg:
[defaults]
forks = 50 # requirement 3
gathering = smart # requirement 2
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200
callbacks_enabled = profile_tasks, timer
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = /tmp/.acp # long cloud hostnamesulimit -n 8192 # requirement 3 - BEFORE raising forksStep 3 — the playbook:
---
- name: Tuned fleet playbook
hosts: all
serial: [1, 10, "25%"] # requirement 7
max_fail_percentage: 0 # requirement 7
gather_facts: true
gather_subset:
- "!all"
- "!min"
- network # requirement 2 - keep network, drop hardware
tasks:
- name: Start the security scan in the background # requirement 5
ansible.builtin.command: /opt/security/scan.sh
async: 1800
poll: 0
register: scan_job
changed_when: false
- name: Install packages in ONE transaction # requirement 4
ansible.builtin.package:
name:
- nginx
- git
- htop
state: present
- name: Register with the licence server # requirement 6
ansible.builtin.uri:
url: https://licence.internal/register
method: POST
throttle: 5
changed_when: false
- name: Now collect the scan result # requirement 5
ansible.builtin.async_status:
jid: "{{ scan_job.ansible_job_id }}"
register: scan_result
until: scan_result.finished
retries: 60
delay: 30The seven decisions:
- profile_tasks first — requirement 1. Without it you might have "optimised" the 40-second tasks and left the 18-minute scan blocking.
- gather_subset: ["!all", "!min", "network"] — requirement 2. Drops hardware, which is the expensive subset, while keeping the network facts templates use. Plus gathering = smart with caching, so repeat runs skip gathering entirely.
- forks: 50 with ulimit -n 8192 raised first — requirement 3. The limit is the control node's CPU, RAM and file descriptors, not the targets, and raising forks without raising the fd limit makes it slower with no error.
- package: name: [list] — requirement 4. One transaction, one round trip, replacing three.
- async: 1800, poll: 0 at the start, async_status at the end — requirement 5. The scan runs while the other tasks proceed, and the result is still collected, so a failure is not silently discarded.
- throttle: 5 — requirement 6. Only that task is limited; the rest of the play stays at forks: 50.
- serial: [1, 10, "25%"] with max_fail_percentage: 0 — requirement 7. Canary of one, then progressive batches, halting on the first failure.
The mistake to avoid: serial and forks interact. With serial: 10 only ten hosts are in the batch, so forks: 50 has nothing extra to do — the effective parallelism is min(forks, batch size). That is fine and expected here, since the batching is the safety requirement and the forks setting matters for the larger later batches.
Verify:
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer ansible-playbook site.yml --limit web01
ansible-playbook site.yml --list-hosts
ansible-playbook site.yml --check --diff --limit web01F3 · Command reference — everything from this module
Profiling
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer ansible-playbook site.yml # ⭐
ANSIBLE_CALLBACKS_ENABLED=profile_roles ansible-playbook site.yml
ANSIBLE_CALLBACKS_ENABLED=cgroup_perf_recap ansible-playbook site.yml # control node load
ansible-doc -t callback -l # available callbacks
time ansible-playbook site.yml # ⭐ crude but instantConnection tuning
ansible-playbook site.yml -f 50 # ⭐ forks for this run
ANSIBLE_FORKS=50 ansible-playbook site.yml
ANSIBLE_PIPELINING=True ansible-playbook site.yml # ⭐ test before committing to cfg
ulimit -n # ⭐ check before raising forks
ulimit -n 8192
# verify pipelining is safe across the fleet
ansible all -m ansible.builtin.shell \
-a "grep -r requiretty /etc/sudoers /etc/sudoers.d/ 2>/dev/null || echo OK" -o[defaults]
forks = 25 # ⭐
[ssh_connection]
pipelining = True # ⭐
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = /tmp/.acp # ⭐ fixes 'socket path too long'Facts
ansible-playbook site.yml --flush-cache # ⭐ force a re-gather
ANSIBLE_GATHER_SUBSET='!all,!min,network' ansible-playbook site.yml # ⭐
ansible web01 -m ansible.builtin.setup -a 'gather_subset=!all,network'
ls /tmp/ansible_facts/ # ⭐ what is cached
cat /tmp/ansible_facts/web01 | python3 -m json.tool | head -30
rm -rf /tmp/ansible_facts/ # ⭐ clear a stale cache[defaults]
gathering = smart # ⭐ REQUIRED for caching to help
fact_caching = jsonfile # or redis
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 7200 # ⭐ tune to how fast the estate changes
gather_timeout = 30 # raise for hosts with slow storageStrategy and batching keywords
strategy: linear # default - barrier between tasks
strategy: free # ⭐ no barrier - independent work only
strategy: host_pinned # a worker finishes one host before the next
ANSIBLE_STRATEGY=free ansible-playbook site.yml # ⭐ test without editing
serial: 1 # ⭐ one at a time
serial: "25%" # ⭐ percentage batches
serial: [1, 5, "25%"] # ⭐ THE canary pattern
max_fail_percentage: 0 # ⭐ only meaningful with serial
any_errors_fatal: true # one failure aborts everywhere
throttle: 5 # ⭐ per-task concurrency cap
run_once: true # ⭐ once per BATCH, not per playAsync
- ansible.builtin.command: /opt/long.sh
async: 7200 # ⭐ max runtime
poll: 30 # ⭐ block, checking every 30s
- ansible.builtin.command: /opt/long.sh
async: 3600
poll: 0 # ⭐ fire and forget
register: job
- ansible.builtin.async_status: # ⭐ ALWAYS pair with poll: 0
jid: "{{ job.ansible_job_id }}"
register: result
until: result.finished
retries: 120
delay: 30ssh web01 'ls ~/.ansible_async/' # ⭐ async job state lives on the TARGETPull mode
ansible-pull -U https://git.internal/ansible/config.git local.yml
ansible-pull -U <repo> -C main -i inventory local.yml --vault-password-file /etc/ansible/.vpass1. profile_tasks find the actual bottleneck
2. gather_facts / subset usually the biggest single item
3. fact caching + smart removes it entirely on repeat runs
4. forks (raise ulimit -n) removes queueing
5. pipelining removes per-task SSH work
6. list args, fewer tasks removes round trips
7. async / throttle for specific problem tasksSteps 2 and 3 typically deliver more than 4 and 5 combined, and almost everyone tries 4 first.
F4 · Official documentation
| Link | Covers |
|---|---|
| Controlling playbook execution: strategies and more | strategy, serial, throttle, run_once, forks |
| Asynchronous actions and polling | async, poll, async_status in full |
| Configuration settings | forks, pipelining, gathering, fact_caching, control_path_dir |
| Cache plugins | jsonfile, redis, memcached and their options |
| Callback plugins | profile_tasks, timer, cgroup_perf_recap |
| Strategy plugins | linear, free, host_pinned, debug |
| ansible.builtin.setup — gather_subset | Every subset name and what it collects |
| ansible-pull CLI | Pull-mode operation |
F5 · Self-assessment
1. On a typical fleet run, what is usually the single largest cost?
Gathering Facts — a full extra module execution per host, often 30%+ of the run — followed by connection setup. Rarely the tasks themselves.
That is why "my playbook is slow" almost never means "my tasks are slow", and why you profile before tuning.
2. What limits how high you can set forks?
The control node: CPU, RAM and open file descriptors, since each fork is a Python process holding an SSH connection. Raise ulimit -n before going past roughly 50.
The symptom of overshooting is the run getting slower with no error, which cgroup_perf_recap will show as control-node saturation.
3. Why is pipelining off by default, and how do you check it is safe?
It requires requiretty disabled in sudoers on the managed nodes, and not every image ships that way — with become in use it fails with sudo: sorry, you must have a tty to run sudo.
Check with an ad-hoc grep of /etc/sudoers and /etc/sudoers.d/ across the fleet before enabling it globally.
4. Why does fact caching need gathering = smart?
Without it, gathering stays implicit and Ansible gathers at every play start regardless — the cache is written but never read, so you get no speed-up while believing caching is on.
5. What is the risk of fact caching, and how do you manage it?
Stale facts, producing bugs where the playbook looks entirely correct. Manage with a fact_caching_timeout matched to how fast the estate changes, --flush-cache when correctness matters more than speed, and by not caching facts that drive security decisions.
6. What do you actually give up with strategy: free?
Every cross-host ordering guarantee. Without the barrier, "migrate the database then restart the app tier" no longer holds — a fast app server can restart against an unmigrated database.
And the wall-clock gain is bounded by the slowest host; free only stops the others waiting.
7. serial, throttle and forks — one sentence each.
forks is global parallelism across the run. serial batches the entire play so each batch completes every task before the next begins. throttle caps concurrency for one task, and can only reduce below forks.
8. What are the three traps with async?
poll: 0 never learns the outcome unless you follow up with async_status. async does not work with action plugins that run on the control node. And the job state lives in ~/.ansible_async/ on the target, so async_status must target the same host and a reboot destroys it.
9. Inside a serial: 10 play across 100 hosts, how many times does run_once fire?
Ten — once per batch, not once per play. For genuinely once, put it in a separate play targeting a single host before the rolling play, or combine run_once with delegate_to and a guard.
10. When is ansible-pull the right answer, and what do you lose?
Thousands of hosts where push saturates the control node; hosts unreachable inbound (NAT, customer sites, ephemeral instances); or when you want continuous enforcement.
You lose orchestration entirely — every host is independent — and centralised visibility, since you must ship logs yourself. That is usually decisive, which is why most teams scale the control node or move to AWX instead.
11. State the tuning order.
Profile → fact strategy → fact caching → forks (with ulimit) → pipelining → fewer round trips → async/throttle for specific tasks.
Facts and caching usually deliver more than forks and pipelining combined, and almost everyone tries forks first.
Everything so far has assumed a static inventory. Module 09 covers inventory plugins, aws_ec2, keyed_groups, constructed inventories, caching, and the Terraform-plus-Ansible pattern.
📚 Sources for the interview questions
Behaviour verified against the current official strategies documentation and configuration reference.
Question selection cross-referenced against publicly published 2026 Ansible interview question sets:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.