Skip to main content
RunBook Academy

AnsibleXXXV · Large Fleet ArchitectureLarge fleet architecture

Estimating runtime before you commit to a window

Advanced⏱ ~22 minansible-playbook

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

Not yet marked complete on this device.

“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:

QuantityHow you get it
Host count--list-hosts, per wave
Effective concurrencymin(forks, serial batch size) — and lower if any task carries a throttle
Per-host play timeMeasured: sum of per-task cost over the play
Fixed overheadInventory 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:

  • forks on the controller,
  • the serial batch size, if set,
  • the throttle value 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:

Taskhost Ahost Bhost CBatch pays
12299
27117
31616
Total1091122

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:

Configuration changecapture per-task timings with a core callback
ANSIBLE_CALLBACKS_ENABLED=ansible.builtin.junit \
JUNIT_OUTPUT_DIR=./timings \
ansible-playbook -i inventory/ site.yml --limit measurement_group
Read-only / Safewhat the file contains
$ 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.

Read-only / Safeextract the per-task maximum from the JUnit output
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')
PY

A worked budget

A patching change over wave 3: 1,600 hosts, forks: 60, serial: 200, one throttle: 20 task.

StepWorkingValue
Batches1600 / 2008
Effective concurrency, normal tasksmin(60, 200)60
Per-batch time, measured non-throttled taskssum of per-task maxima210 s
Throttled download task200 hosts / 20 at a time × 25 s each250 s
Fact gathering per batchmeasured40 s
Per batch210 + 250 + 40500 s
Run time8 × 500 s66 min
Inventory parse, oncemeasured1 min
Estimated wall-clock~67 min

Then the part that is usually left out entirely:

Programme stepValue
Wave 1 canary run and verification20 min
Gate: human review of canary evidence30 min
Wave 2 run and soak24 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

  1. 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?

  2. 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?

  3. Q3. Which of these are true about measuring per-task cost with what ansible-core 2.21.3 ships? Select all that apply.

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