AnsibleXXXV · Large Fleet ArchitectureLarge fleet architecture
Estimating runtime before you commit to a window
What you'll learn
- Build a runtime estimate from host count, task count, per-task cost and effective concurrency
- Explain why the slowest host in a batch sets the pace under the linear strategy
- Measure per-task cost with the tooling that ships in ansible-core
- Decide what to cut when the estimate does not fit the window
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
“How long will this take?” is asked at every change advisory board and answered, almost universally, with a guess. The guess is usually derived from a run against a handful of hosts, multiplied by nothing in particular, and rounded up to a number that sounds cautious.
Then the run is still going when the window closes, and the choice is between an unapproved change and an interrupted one.
The estimate is not difficult. It is four numbers, one correction that most people miss, and a check against reality.
The model
wall-clock ~= (hosts / effective concurrency) * per-host play time
+ fixed overhead
Written out as quantities you can actually obtain:
| Quantity | How you get it |
|---|---|
| Host count | --list-hosts, per wave |
| Effective concurrency | min(forks, serial batch size) — and lower if any task carries a throttle |
| Per-host play time | Measured: sum of per-task cost over the play |
| Fixed overhead | Inventory parse, fact gathering, connection setup, handler flush |
Two of those are commonly got wrong, and both make the estimate too optimistic.
Effective concurrency is not forks
forks is a ceiling. The number that governs is the smallest of:
forkson the controller,- the
serialbatch size, if set, - the
throttlevalue on the task currently executing, - and, in practice, whatever the shared dependency will tolerate.
A play with forks: 100, serial: 50 and a throttle: 10 on the
package task runs that task ten hosts at a time. If that task is most of
the play’s duration, the effective concurrency for planning purposes is
ten, not a hundred.
Batches are also strictly sequential. serial: 50 over 2,000 hosts is
forty batches, one after another, each paying its own connection setup
and fact gathering.
Sum of maxima, not maximum of sums
Here is the correction that matters, and it follows directly from how the default strategy works.
linear synchronises at every task boundary: the play does not move to
task n+1 on any host until task n has finished on every host in
the batch. So the batch’s duration is not the slowest host’s total
time — it is the sum, over tasks, of the slowest host for that task.
An illustration with three hosts and three tasks, in seconds:
| Task | host A | host B | host C | Batch pays |
|---|---|---|---|---|
| 1 | 2 | 2 | 9 | 9 |
| 2 | 7 | 1 | 1 | 7 |
| 3 | 1 | 6 | 1 | 6 |
| Total | 10 | 9 | 11 | 22 |
The slowest host takes 11 seconds. The batch takes 22. Estimating from “the slowest host we measured” understates it by a factor of two, and the factor grows with batch size, because a larger batch is more likely to contain an outlier for any given task.
Measuring per-task cost with what core ships
profile_tasks is the usual recommendation and it is not in
ansible-core — it lives in the ansible.posix collection. The
callbacks that ship in core are exactly five: default, junit,
minimal, oneline and tree.
If you cannot install collections on the controller, junit gives you
per-task, per-host timings:
ANSIBLE_CALLBACKS_ENABLED=ansible.builtin.junit \
JUNIT_OUTPUT_DIR=./timings \
ansible-playbook -i inventory/ site.yml --limit measurement_group$ head -6 timings/*.xml<?xml version="1.0" ?>
<testsuites disabled="0" errors="0" failures="0" tests="10" time="24.97292804718017">
<testsuite disabled="0" errors="0" failures="0" name="wf" skipped="0" tests="10" time="24.97292804718017">
<testcase classname="/path/to/site.yml:5" name="[web03] web: per-host jitter" time="0.2070465087890625">The time on testsuites is the sum of every host-task duration,
not wall-clock: that run finished in about three and a half seconds of
real time. Which is a useful reminder in itself — the aggregate you want
for a budget is per-task maxima, and you have to compute it.
python3 - <<'PY'
import glob, collections, xml.etree.ElementTree as ET
worst = collections.defaultdict(float)
for path in glob.glob('timings/*.xml'):
for tc in ET.parse(path).iter('testcase'):
task = tc.get('name').split(': ', 1)[-1]
worst[task] = max(worst[task], float(tc.get('time')))
for task, t in sorted(worst.items(), key=lambda kv: -kv[1]):
print(f'{t:8.2f}s {task}')
print(f'{sum(worst.values()):8.2f}s TOTAL per batch')
PYA worked budget
A patching change over wave 3: 1,600 hosts, forks: 60, serial: 200,
one throttle: 20 task.
| Step | Working | Value |
|---|---|---|
| Batches | 1600 / 200 | 8 |
| Effective concurrency, normal tasks | min(60, 200) | 60 |
| Per-batch time, measured non-throttled tasks | sum of per-task maxima | 210 s |
| Throttled download task | 200 hosts / 20 at a time × 25 s each | 250 s |
| Fact gathering per batch | measured | 40 s |
| Per batch | 210 + 250 + 40 | 500 s |
| Run time | 8 × 500 s | 66 min |
| Inventory parse, once | measured | 1 min |
| Estimated wall-clock | ~67 min |
Then the part that is usually left out entirely:
| Programme step | Value |
|---|---|
| Wave 1 canary run and verification | 20 min |
| Gate: human review of canary evidence | 30 min |
| Wave 2 run and soak | 24 h |
| Wave 3 run (above) | 67 min |
| Contingency at 30% | 20 min |
A change advisory board asking “how long?” usually means the whole programme, and the answer is dominated by the soak, not by Ansible.
Checking the extrapolation
An estimate built from twenty hosts and applied to two thousand is a linear model of a system that is not linear. Check it at an intermediate size — 200 hosts — before committing.
The places the extrapolation breaks:
- Controller saturation. Beyond the controller’s fork ceiling, throughput drops and failures appear that look target-side.
- Shared dependency saturation. The mirror is fine at 20 concurrent clients and refuses at 200.
- Tail growth. A 200-host batch contains outliers a 20-host sample did not.
- Fact-gathering cost, which scales with host count and is often the single largest line.
If the 200-host check lands within about 20% of the model, the model is good enough for a window. If it does not, the difference is telling you something specific, and it is worth finding out what before running against 2,000.
Knowledge check
Knowledge check · 4 questions
Q1. Three hosts run a three-task play under the default strategy. Host totals are 10s, 9s and 11s, but each task has a different slowest host: 9s, 7s and 6s. How long does the batch take?
Q2. A play sets forks: 100 and serial: 50, and its longest task carries throttle: 10. What effective concurrency should the runtime estimate use for that task?
Q3. Which of these are true about measuring per-task cost with what ansible-core 2.21.3 ships? Select all that apply.
Q4. Switching a play from the linear strategy to free in order to make a runtime estimate fit is a correctness decision, not just a performance one.
Passing score: 75%. Answers are checked in this browser.