Module 08 — Execution Strategies & Performance at Scale

Updated 20 August 2026

Module 08 · Execution Strategies & Performance at Scale

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

The analogy. Think of a day of errands that took three hours when you expected one. The instinct is to blame the driving, and it is almost never the driving — it was twenty minutes hunting for parking at every stop, plus the fact that you visited them strictly one at a time instead of splitting the list between two cars.

"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:#059669
The distribution most people get wrong. On a typical fleet run, fact gathering and connection setup dominate — not the tasks themselves. A playbook of 20 tasks against 200 hosts with forks: 5 spends far more time waiting on SSH and setup than doing work.

That 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

The analogy. Think of a doctor prescribing for a pain without examining you first. Treating the most common cause is a guess — sometimes it happens to work, and when it does not you have lost a week and learned nothing about what is actually wrong. The examination takes ten minutes and tells you where to look.

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.

plain text
# ansible.cfg
[defaults]
callbacks_enabled = profile_tasks, timer
bash
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer ansible-playbook site.yml
CallbackWhat it reports
profile_tasks⭐ Wall-clock per task, plus a slowest-tasks summary
timerTotal playbook duration
profile_rolesTime aggregated per role
cgroup_perf_recapCPU and memory used by the control node itself — for diagnosing fork limits
🧪 Exercise A2.1 — Find out where your time actually goes
bash
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer ansible-playbook site.yml 2>&1 | tail -25
Expected result — click to reveal
plain text
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 seconds

Gathering 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

The analogy. Think of a supermarket with twenty tills, five of them open. The queue is not long because the cashiers are slow — it is long because fifteen tills are closed. Opening more fixes it immediately, right up to the point where you run out of staff to operate them.

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.

plain text
[defaults]
forks = 25          # default is 5
bash
ansible-playbook site.yml -f 50          # for this run only
The default of 5 is the single biggest easy win. With 200 hosts and forks: 5, every task becomes 40 sequential rounds. At forks: 50 it is 4.

What 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

The analogy. Think of a waiter serving one table. A poor waiter walks to the kitchen for the starter, back again for the cutlery, again for the drinks, again for the bread — four trips for one course. A good waiter loads a tray and does it in one. The food that arrives is identical; the walking is not.

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.

plain text
[ssh_connection]
pipelining = True

Without 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.

The requirement, and the reason it is off by default: pipelining needs requiretty disabled in /etc/sudoers on the managed nodes. Some distributions still ship it enabled, and with become in use the task fails with sudo: sorry, you must have a tty to run sudo.

Modern RHEL and Debian images generally have it off already. Verify before enabling fleet-wide:

bash
ansible all -m ansible.builtin.shell -a "grep -r requiretty /etc/sudoers /etc/sudoers.d/ 2>/dev/null || echo 'not set - pipelining OK'" -o

B3 · SSH multiplexing — ControlPersist

plain text
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = ~/.ansible/cp

The 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 unix socket path too long error. The control socket path is built from the user, host and port. With long hostnames — typical of cloud DNS names — it can exceed the ~100-character limit the OS imposes on Unix socket paths, and every connection fails with an error that says nothing about hostnames.

The fix: shorten the directory, or use a hashed path.

plain text
[ssh_connection]
control_path_dir = /tmp/.acp
control_path = %(directory)s/%%h-%%r

This 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

yaml
# ❌ 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: present
The general principle from Module 03, restated as a performance rule: check whether the module accepts a list before reaching for loop. package, apt, yum, dnf, pip, file (via path lists in some cases) and several others do. Ansible even warns you about looping package modules.

On 200 hosts, collapsing three looped package tasks into one saves 400 round trips.

🧪 Exercise B4.1 — Measure forks and pipelining
bash
# 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
plain text
forks=5,  pipelining=off   real  0m38.412s
forks=50, pipelining=off   real  0m6.883s
forks=50, pipelining=on    real  0m4.201s
plain text
5      <- pipelining off: mkdir, copy, chmod, execute, cleanup
2      <- pipelining on: essentially just execute

Two 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

The analogy. Think of asking a friend their birthday every single time you meet them. It is not rude exactly, but it burns the first minute of every conversation on something that has not changed since the last time you asked. What people actually do is ask once and write it down.

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.

yaml
# 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_subsetCollects
allEverything — the default
minA small core set — always included unless you say !min
hardwareCPU, memory, devices, mounts — the slowest subset by far
networkInterfaces, addresses, routes
virtualVirtualisation type and role
!all, !hardwareExclusions — combine with inclusions
hardware is usually the expensive one. It probes block devices and mounts, which on a host with many volumes or a slow SAN can take seconds by itself. gather_subset: ["!all", "!min", "network"] is a common and dramatic improvement for plays that only need IP addresses.

There is also gather_timeout (default 10s) — worth raising rather than losing facts on a host with slow storage.

C2 · Fact caching

plain text
# 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
plain text
# 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
gatheringBehaviour
implicitDefault. Gather at every play start unless gather_facts: false
explicitNever gather unless a play sets gather_facts: true
smart⭐ Gather only if this host has no cached facts. The setting that makes caching useful
Cached facts go stale, and a stale fact is a nasty bug because the playbook looks correct. A host gets more memory, or a new interface, and your playbook keeps templating yesterday's values until the cache expires.

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
bash
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
plain text
full gather              real  0m6.812s
gather_subset network    real  0m2.944s
caching, cold            real  0m6.903s
caching, warm            real  0m1.402s
plain text
/tmp/afacts/:
db01  web01  web02  web03

Three 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

The analogy. Think of the coach tour from Module 01 — and now imagine telling everyone to make their own way between the stops. Nobody stands around waiting at the museum, and the fast walkers reach lunch early. That genuinely does save time, and on a day of independent errands it is obviously the right call.

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:#DC2626
strategy: free removes orchestration, not just the wait. With no barrier, "migrate the database, then restart the app tier" stops being guaranteed — hosts proceed independently and a fast app server can restart against an unmigrated database.

Use 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

yaml
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 percentage
serial batches the entire play, not a task. Each batch runs every task in the play before the next batch starts — which is exactly what makes drain → deploy → verify → re-enable work as a unit.

Two 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

yaml
- 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 concurrently
throttle is per-task; forks and serial are global. It is the right tool when one specific task hits a rate-limited API or a licence server that cannot take 50 simultaneous requests, while the rest of the play runs at full parallelism.

throttle can never exceed forks — it only ever reduces concurrency further.

D4 · async — long-running tasks

The analogy. Think of putting a load of washing on. You start the machine and go and cook dinner — nobody stands in front of it for ninety minutes watching the drum turn. But you do have to come back, because clothes left in there overnight are their own problem, and because you never actually found out whether the cycle finished or the machine stopped with an error light on.

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
yaml
# 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
Three things about async that catch people out:
  1. 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.
  2. async does not work with action plugins that run on the control nodeuri, debug, most cloud modules. It is for work executing on the target.
  3. 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
bash
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:

plain text
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.2s

free:

plain text
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.4s

Read 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 lessonfree 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

bash
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_recap
bash
ANSIBLE_CALLBACKS_ENABLED=cgroup_perf_recap ansible-playbook site.yml
The symptom of an under-sized control node is not an error message — it is the run getting slower as you raise forks, because the machine is swapping or starved of file descriptors. cgroup_perf_recap shows the control node's own CPU and memory during the run, which is how you tell "raise forks further" from "the control node is the bottleneck".

E2 · ansible-pull — inverting the model

The analogy. Think of a company noticeboard versus posting a letter to every member of staff. Individual letters mean you know exactly who received what and when — but the postroom does all the work, and at three thousand staff the postroom is the bottleneck. A noticeboard scales to any number of people, because everybody reads it themselves. What you lose is knowing who has read it, and any ability to say "finance must read this before sales does".

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.

bash
# 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
plain text
# /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 byControl nodeThe managed node itself, on a timer
Scale limitControl node capacityEffectively unlimited — each host does its own work
NetworkControl node needs SSH in to every hostHost 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
When pull genuinely wins: thousands of hosts where a push model saturates the control node; hosts you cannot reach inbound (behind NAT, in customer sites, ephemeral autoscaled instances); or a continuous-enforcement model closer to how Puppet works.

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

The signals that a team has outgrown running ansible-playbook from laptops and CI: needing RBAC over who can run what against which inventory; needing an audit trail of every run; needing scheduling and queueing so runs do not collide; needing to hand a self-service form to people who should not touch YAML; and needing credential management so secrets are not on individual machines.

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

HabitWhy
Profile with profile_tasks before tuning anythingRaising forks when one task is slow achieves nothing
forks 25–50, not the default 5200 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_dirLong cloud hostnames overflow the Unix socket path limit
Modules that accept lists rather than loopOne transaction and one round trip instead of N
gathering = smart plus fact caching on a repeat-run fleetRemoves the largest single cost entirely on subsequent runs
gather_subset excluding hardware where you only need networkhardware probes block devices and mounts — typically half the gather
--flush-cache on runs where correctness beats speedStale facts produce bugs that look like playbook bugs
strategy: free only for genuinely independent workIt removes orchestration, not just waiting
serialmax_fail_percentage: 0 for any fleet-wide changeHalts at the first failure instead of propagating it to 500 hosts
throttle on tasks hitting rate-limited APIsKeeps the rest of the play at full parallelism
asyncpoll: 0async_status for long work — never poll: 0 aloneFire-and-forget with no follow-up silently discards failures
Raise ulimit -n before raising forks past ~50Otherwise the run gets slower, with no error explaining why

F2 · Capstone exercise

Attempt this without looking anything up. It exercises profiling, connection tuning, fact strategy, batching and async together.

Brief. You have inherited a playbook that takes 41 minutes across 300 hosts. Requirements:

  1. Identify where the time goes, without guessing
  2. Reduce fact-gathering cost without losing the network facts the templates need
  3. Increase parallelism safely, and state what limits it
  4. Collapse three looped package tasks into one round trip
  5. A 20-minute security scan must not block the rest of the play
  6. One task calls a licence API that permits at most 5 concurrent clients
  7. 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.

bash
ANSIBLE_CALLBACKS_ENABLED=profile_tasks,timer,cgroup_perf_recap \
  ansible-playbook site.yml 2>&1 | tail -30

Suppose it shows: Gathering Facts 14m, Install packages 9m, Security scan 18m, everything else under a minute.

Step 2 — ansible.cfg:

plain text
[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 hostnames
bash
ulimit -n 8192                            # requirement 3 - BEFORE raising forks

Step 3 — the playbook:

yaml
---
- 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: 30

The seven decisions:

  1. profile_tasks first — requirement 1. Without it you might have "optimised" the 40-second tasks and left the 18-minute scan blocking.
  2. 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.
  3. 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.
  4. package: name: [list] — requirement 4. One transaction, one round trip, replacing three.
  5. 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.
  6. throttle: 5 — requirement 6. Only that task is limited; the rest of the play stays at forks: 50.
  7. 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:

bash
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 web01

F3 · Command reference — everything from this module

Commands and settings from Module 08. ⭐ marks genuinely daily-use.

Profiling

bash
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 instant

Connection tuning

bash
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
plain text
[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

bash
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
plain text
[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 storage

Strategy and batching keywords

yaml
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 play

Async

yaml
- 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: 30
bash
ssh web01 'ls ~/.ansible_async/'   # ⭐ async job state lives on the TARGET

Pull mode

bash
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/.vpass
The performance tuning order — do it in this sequence, not in reverse:
plain text
1. 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 tasks

Steps 2 and 3 typically deliver more than 4 and 5 combined, and almost everyone tries 4 first.


F4 · Official documentation

LinkCovers
Controlling playbook execution: strategies and morestrategy, serial, throttle, run_once, forks
Asynchronous actions and pollingasync, poll, async_status in full
Configuration settingsforks, pipelining, gathering, fact_caching, control_path_dir
Cache pluginsjsonfile, redis, memcached and their options
Callback pluginsprofile_tasks, timer, cgroup_perf_recap
Strategy pluginslinear, free, host_pinned, debug
ansible.builtin.setupgather_subsetEvery subset name and what it collects
ansible-pull CLIPull-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.


Next — Module 09 · Dynamic Inventory & Cloud.

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:

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.

Spotted a mistake or want something added? Send me a note.