AnsibleXXXIV · Concurrency, Strategies and PerformanceConcurrency, strategies and performance
Measure first, tune second
What you'll learn
- Install and enable a timing callback, and confirm it is the right name for your installation
- Read a profile_tasks summary and identify where a run actually spends its time
- Explain why a single wall-clock reading is not evidence of an improvement
- Follow a change-one-thing investigation loop rather than tuning several settings at once
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
“Ansible is slow” is a complaint about wall-clock time, and it is almost
always answered with the same guess: raise forks. Sometimes that helps.
More often the run was spending eighty per cent of its time in one task
that parallelism does nothing for, and the fork change makes the graph
noisier without making the run shorter.
This part is about performance work, and it opens with measurement because every other lesson in it is a change you should not make without a baseline. The discipline is ordinary engineering practice and it is routinely skipped here, because Ansible’s settings are easy to change and its behaviour is hard to observe.
The timing callback is not in ansible-core
Start with the correction, because half the instructions on the internet predate it.
$ ansible-doc -t callback -lansible.builtin.default default Ansible screen output
ansible.builtin.junit write playbook output to a JUnit file
ansible.builtin.minimal minimal Ansible screen output
ansible.builtin.oneline oneline Ansible screen output
ansible.builtin.tree Save host events to filesFive callbacks, and no profile_tasks. It lives in the ansible.posix
collection, along with profile_roles, timer and cgroup_perf_recap. If
you installed the ansible community package you already have it; if you
installed ansible-core and pinned your collections — which is what this
course teaches — you have to ask for it.
# requirements.yml
collections:
- name: ansible.posix
version: "2.1.0"ansible-galaxy collection install -r requirements.yml
Then confirm — do not assume — that the name you are about to put in a config file resolves on your installation:
$ ansible-doc -t callback -l | grep -E 'profile|timer'ansible.posix.cgroup_perf_recap Profiles system activity of tasks an...
ansible.posix.profile_roles adds timing information to roles
ansible.posix.profile_tasks adds time information to tasks
ansible.posix.timer Adds time to play statsA misspelled or unavailable callback name in callbacks_enabled produces a
run with no timing output and no complaint, which is a slow way to discover
you have not been measuring anything.
Enabling it
profile_tasks is an aggregate callback — it adds output alongside your
normal stdout callback rather than replacing it, so enabling it does not
change how the rest of the run is displayed.
Upstream gives the configuration form:
[defaults]
callbacks_enabled = ansible.posix.profile_tasks
For an investigation, the environment variable is better, because it applies to the one run you are measuring and leaves the repository untouched:
ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks \
ansible-playbook -i inventory/staging site.yml --checkThe setting name changed in 2.11 — it was callback_whitelist before —
and the environment variable is ANSIBLE_CALLBACKS_ENABLED, which
ansible-config list confirms on your own controller if you are unsure.
What the output looks like
Two blocks are added. Each task banner gains a timestamp and two
durations, and a TASKS RECAP at the end sorts tasks by wall-clock time.
$ ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks ansible-playbook -i slow.ini slow.yml -f 5TASK [A second slow step] ******************************************************
Tuesday 11 August 2026 22:40:15 +0000 (0:00:00.016) 0:00:03.241 ********
ok: [web2]
ok: [web1]
ok: [web4]
ok: [web3]
TASKS RECAP ********************************************************************
Tuesday 11 August 2026 22:40:16 +0000 (0:00:01.149) 0:00:04.391 ********
===============================================================================
Wait for a slow dependency ---------------------------------------------- 3.20s
A second slow step ------------------------------------------------------ 1.15s
Another quick step ------------------------------------------------------ 0.02s
Quick check ------------------------------------------------------------- 0.02sThree numbers, and they are easy to confuse:
- The timestamp is when the task started.
- The first duration in brackets is how long the previous task took.
- The second duration is elapsed time since the play began.
The TASKS RECAP is where the answer usually is. In that run, two tasks
account for 4.35 of 4.39 seconds and the other two are rounding error. No
amount of forks tuning moves the 3.20-second task, because it was not
waiting on parallelism.
The finding you will usually get
Here is the same profiler on a play that does nothing but gather facts and print one value, across 24 hosts:
$ ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks ansible-playbook -i facts.ini facts.yml -f 12TASKS RECAP ********************************************************************
Tuesday 11 August 2026 22:42:02 +0000 (0:00:00.121) 0:00:02.558 ********
===============================================================================
Gathering Facts --------------------------------------------------------- 2.43s
Nothing else ------------------------------------------------------------ 0.12sFact gathering was 95% of the run. That result is not a quirk of this example — it is the single most common finding when someone profiles a real playbook for the first time, and it is the reason two later lessons in this part are about facts rather than about concurrency.
The second most common finding is one task: a package manager refreshing
metadata, a wait_for on something slow to start, a command invoking a
script that sleeps, or a module that makes a network call per host.
Neither of those is fixed by forks. Both are obvious in a TASKS RECAP
and invisible in a wall-clock number.
The investigation loop
1. BASELINE Profile the run as it is. Three times. Record the TASKS RECAP.
2. IDENTIFY Name the one task or phase that dominates. Write it down.
3. CHANGE Change exactly one thing, aimed at that finding.
4. RE-MEASURE Profile again, three times, same inventory, same conditions.
5. DECIDE Keep it if the improvement exceeds the run-to-run spread.
Revert it otherwise. "No measurable difference" is a result.
6. REPEAT From step 1, with the new baseline.The rule that makes this work is step 3, and the specific prohibition worth
writing into a team runbook is this: do not change forks and
pipelining in the same commit.
They fail differently and they interact. Raising forks increases
controller load; enabling pipelining changes what happens on each managed
node and requires a sudoers property to be true. Change both, get a
regression, and you cannot tell whether the controller is saturated or
half your fleet is rejecting a non-TTY sudo — and the second one presents
as intermittent task failures that look nothing like a performance
setting.
Knowledge check
Knowledge check · 4 questions
Q1. A team adds callbacks_enabled = profile_tasks to ansible.cfg on a controller running a pinned ansible-core with no collections installed. What happens?
Q2. A TASKS RECAP shows Gathering Facts at 2.43s out of a 2.56s run. What is the correct next move?
Q3. Which of these make a baseline misleading? Select all that apply.
Q4. A performance runbook should forbid changing forks and pipelining in the same commit.
Passing score: 75%. Answers are checked in this browser.