AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution
Making "exactly once" actually mean once
What you'll learn
- Write a once-per-play condition that is unaffected by serial batching
- Distinguish ansible_play_hosts_all, ansible_play_hosts and ansible_play_batch by scope and by failure behaviour
- Use throttle to serialise a task without designating a single host
- Choose between the constructs from the stated intent of the task
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
The previous lesson established that run_once means once per batch. This
one is about what to write instead, and the useful move is to stop asking
“how do I make run_once behave” and start asking what the task actually
needs.
Three intents hide behind the same keyword:
- “This work happens one time per run.” A schema migration. A release tag. A change-ticket transition.
- “Only one host may do this at a time.” Anything competing for a shared lock, a licence seat, or an API rate limit.
- “This is not about any host.” Fetching a manifest, validating input, notifying a channel.
Each has a construct that expresses it, and each construct survives
serial because it does not depend on the batch.
Intent one: once per run
The play host lists are the tool, and choosing between them is the whole decision.
$ ansible-playbook -i hosts.ini playhosts-serial.yml"msg": "web1: play_hosts=['web1', 'web2', 'web3', 'web4', 'web5', 'web6'] batch=['web1', 'web2'] all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
"msg": "web3: play_hosts=['web1', 'web2', 'web3', 'web4', 'web5', 'web6'] batch=['web3', 'web4'] all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"
"msg": "web5: play_hosts=['web1', 'web2', 'web3', 'web4', 'web5', 'web6'] batch=['web5', 'web6'] all=['web1', 'web2', 'web3', 'web4', 'web5', 'web6']"| Variable | Scope | Shrinks when a host fails? |
|---|---|---|
ansible_play_hosts_all | every host the play started with | no |
ansible_play_hosts | the whole play, active hosts only | yes |
ansible_play_batch | the current serial batch, active hosts | yes |
Only the third is batch-scoped, and that is precisely why run_once — which
works in batch terms — repeats. A condition written against either of the
first two is evaluated identically in every batch, so exactly one host in
the whole play satisfies it.
- name: Apply pending schema migrations
ansible.builtin.command: /opt/app/bin/migrate --apply
args:
chdir: /opt/app
when: inventory_hostname == ansible_play_hosts[0]
delegate_to: "{{ migration_runner_host }}"Verified with serial: 2 over six hosts: the task ran once, on web1, in
the first batch, and was skipped for every host in batches two and three.
Note that this is a plain when: with no run_once anywhere. That is the
point — the conditional is evaluated on every host individually, so none of
the run_once hazards from the previous lesson apply. It composes with
other conditions normally, and a reader can work out what it does without
knowing the keyword’s edge cases.
Which list: ansible_play_hosts or ansible_play_hosts_all?
They differ in one situation, and it is the situation that matters: when the host they name has already dropped out.
$ ansible-playbook -i hosts.ini allzero-fail.ymlTASK [web1 drops out] **********************************************************
fatal: [web1]: FAILED! => {"changed": false, "msg": "web1 is out"}
skipping: [web2]
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]
TASK [Once per play via ansible_play_hosts_all[0]] *****************************
skipping: [web2]
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]
TASK [Compare - ansible_play_hosts[0] instead] *********************************
ok: [web2] => {
"msg": "PLAY_HOSTS[0] on web2 (hosts[0]=web2)"
}
skipping: [web3]
skipping: [web4]
skipping: [web5]
skipping: [web6]ansible_play_hosts_all[0] is still web1. web1 is gone, so no
surviving host matches and the task ran nowhere at all — silently, with
a clean recap.
ansible_play_hosts[0] had already become web2, so the task ran.
Both behaviours are defensible and they encode different requirements:
ansible_play_hosts[0]— “this must happen once, on some host”. The default choice. Resilient, and the failure of one host does not silently cancel the shared work.ansible_play_hosts_all[0]— “this must happen once, on a host chosen deterministically”. Stable across runs while the inventory is stable, and it stops rather than substituting when that host is unavailable.
If the answer is “it must happen once, on a specific host”, neither is
right — that is delegate_to with a named host, and the condition is only
deciding how often.
Intent two: one at a time, but everyone
throttle is the keyword for “this task must not run concurrently”, and it
is a different requirement from “this task must run once”.
The distinction is worth stating plainly, because teams reach for
run_once when they mean throttle: 1: run_once means five hosts get
the work done by one of them; throttle: 1 means all five do the work,
one after another.
$ /usr/bin/time -f 'elapsed %e s' ansible-playbook -i thr.ini thr.yml -f 5-- forks 5, no throttle --
elapsed 2.48 s
-- forks 5, throttle 1 --
elapsed 8.91 s
-- forks 1, no throttle --
elapsed 8.92 s
-- forks 1, throttle 4 (cannot raise above forks) --
elapsed 8.91 sFour hosts at two seconds each: 2.5 seconds in parallel, 8.9 seconds
serialised. The last measurement is the one worth keeping — throttle: 4
with forks: 1 still took 8.9 seconds, because throttle can only lower
concurrency, never raise it. The keyword documentation says so directly:
“This is independent of the forks and serial settings, but cannot be set
higher than those limits.”
Where this replaces a misused run_once:
- name: Register this host with the service catalogue
ansible.builtin.uri:
url: "https://catalogue.example.com/api/v1/nodes"
method: POST
body_format: json
body:
name: "{{ inventory_hostname }}"
role: "{{ node_role }}"
headers:
Authorization: "Bearer {{ catalogue_token }}"
delegate_to: localhost
throttle: 1
no_log: trueEvery host needs its own registration, so run_once would be wrong in the
strongest sense — five of six hosts would go unregistered. throttle: 1
gets all six done without ever having two writers at the catalogue at once.
throttle applies to Play, Role, Block, Task and Handler, so a whole block
of tasks that share one lock can be serialised together rather than
task-by-task. The performance side of it — protecting package mirrors,
licence servers and rate-limited APIs — is the subject of a lesson in the
next part.
Intent three: not about any host
When the work is not per-host at all, the cleanest construct is the one that does not need a keyword: a play that targets one thing.
- name: Pre-flight
hosts: localhost
gather_facts: false
connection: local
tasks:
- name: Fetch the release manifest
ansible.builtin.uri:
url: "https://artifacts.example.com/releases/{{ release_version }}.json"
return_content: true
register: manifest
- name: Refuse to proceed without a signature
ansible.builtin.assert:
that:
- manifest.json.signature is defined
fail_msg: "Release {{ release_version }} is unsigned; aborting."
- name: Roll the web tier
hosts: web
serial: 4
tasks:
- name: The actual rollout
ansible.builtin.debug:
msg: "deploying {{ hostvars['localhost']['manifest']['json']['version'] }}"Three properties make this the strongest of the three constructs where it fits:
- Once is structural. No keyword to misread, nothing for a future
serial:to change. - It fails before anything is touched. An unsigned manifest stops the run while the fleet is untouched, which is a better place to stop than batch two of five.
- The value is available to every host, through
hostvars['localhost'], and it is the same value for all of them — unlike arun_onceregistration, which differs per batch.
The cost is that variables do not flow implicitly between plays. Reaching
back through hostvars['localhost'] is the price, and it is a fair one for
work that genuinely belongs to the run rather than to a host.
Knowledge check
Knowledge check · 4 questions
Q1. A play over 6 hosts with serial: 2 needs a schema migration applied exactly once per run. Which construct achieves that?
Q2. What is the practical difference between run_once: true and throttle: 1 on the same task?
Q3. A must-happen-once task is gated by when: inventory_hostname == ansible_play_hosts_all[0], and the host it names failed earlier in the play. Which statements are true? Select all that apply.
Q4. A separate hosts: localhost play makes "runs once" structural, so no future serial: change can turn it into several executions.
Passing score: 75%. Answers are checked in this browser.