08 · Delegation, Serial Batches and Strategies¶
Some tasks must run on a different machine from the one being configured (take a host out of a load balancer, register it in a monitoring system). Some must happen only once for the whole group. Some must proceed in small batches so a mistake does not take everything down. Ansible provides delegate_to, run_once, serial, and execution strategies for these.
What was run
Four local hosts n1..n4 (ansible_connection=local) and the playbooks serial.yml, deleg.yml and strat.yml in src/l2, run with ansible-core 2.21.5. Real load balancers, rolling restarts of services and SSH delegation were not exercised.
delegate_to¶
delegate_to: host runs the task on another host while keeping the variable context of the original one. A typical use:
- name: remove this host from the load balancer
ansible.builtin.uri: {url: "http://lb.example/api/drain/{{ inventory_hostname }}", method: POST}
delegate_to: lb1
Each web host triggers one call from lb1's side (or localhost to call an API from the controller), and {{ inventory_hostname }} is still the web host. The task's own connection settings come from the delegated host. In my test, a delegated command ran on n4 for each of four hosts; the output was the right messages with all four hosts' names.
Gotchas:
ansible_delegated_varsexists only inside the delegated task's context. I referencedansible_delegated_vars['n4']from a later, ordinarydebugtask and got'ansible_delegated_vars' is undefined. To use delegated host data later, register the result or usehostvars['n4'].- Facts gathered during a delegated task are assigned to the delegated host only with
delegate_facts: true; otherwise they attach to the original host. commandis not a shell. My delegated command used$(basename $PWD)and printed it literally (registering n1 from $(basename /Users/.../l2)): thecommandmodule does not run a shell, so$()is not evaluated. Useshellwhen you need substitution (and then own the idempotency and quoting).- Parallel delegated tasks against one host can race: all four hosts ran the delegated task on
n4at once. If the delegated action is not safe to run concurrently (editing one file), useserial: 1or athrottle.
run_once¶
Printed run_once ran for n1: the task ran on the first host of the play batch only. Its result is copied to the other hosts' variable context, so a set_fact with run_once is visible everywhere: my token variable showed the same value (tok12839) on n1..n4. Typical uses: database migrations (once per cluster), sending one notification, creating one shared resource. Warnings:
- With
serial,run_onceruns once per batch, not once overall. Combine carefully (when: inventory_hostname == ansible_play_hosts_all[0]is the explicit alternative). - It runs on the first host, so if you
--limitthe run, the first limited host does the work. - If the first host fails the task, the whole group is treated as failed for that task.
serial: rolling batches¶
serial: 2 on four hosts ran the entire play in two passes: first n1+n2 for all tasks (A and B), then n3+n4:
serial also accepts a percentage ("25%") or a list for a ramp ([1, 3, "100%"]): one host as a canary, then three, then everyone. Batches are the basic mechanism of a rolling update (Level 3 · 07).
Failure budgets¶
max_fail_percentage stops the play if too large a share of a batch fails. Measured on four hosts with a task that fails on chosen hosts:
| Setting | Failing hosts | Result |
|---|---|---|
serial: 1, default (0%) |
n1 |
n1 failed; n2..n4 never ran (the play aborted) |
serial: 2, max_fail_percentage: 50 |
n1 (1 of 2 = 50%) |
batch continued; n2 finished; n3 and n4 ran |
serial: 2, max_fail_percentage: 50 |
n1 and n2 (100% > 50%) |
play aborted; n3 and n4 never ran |
Two details to remember: the threshold is "strictly more than" (50% failures of a batch of two does not abort at 50), and with the default of 0 any failure in a batch stops later batches. With serial: 1 and a default budget, one bad host protects the rest of the fleet, which is the point: a broken change is stopped after one machine. any_errors_fatal: true is the stricter setting (stop everything on the first failure, even within a batch).
Strategies¶
The strategy: keyword selects how tasks are scheduled across hosts:
linear(default): every host finishes task N before any host starts N+1.free: each host runs through the tasks as fast as it can.host_pinnedanddebugexist for special cases (not run).
Measured: host n1 sleeps 2 seconds on task 1, the others 0; the second task prints the seconds of the clock.
linear: second task on n1, n2, n3, n4 all at :45 total ~2.96 s
free: n4, n2, n3 at :46; n1 at :48 total ~2.99 s
Under linear the fast hosts waited for n1 and all printed the same second. Under free they ran ahead by two seconds. Total time was the same here (the slow host dominates in both), but free helps when tasks take very different times per host and later tasks do not depend on other hosts. Output ordering under free is non-deterministic, and tasks that expect other hosts to be at the same point (a "wait until everyone is done" step) break.
throttle and forks¶
throttle: 1 on a task limits it to one host at a time without changing the rest of the play (useful for a step that hits a rate-limited API). forks (-f, default 5) caps how many hosts run a task concurrently across the whole run. A play on 100 hosts with forks=5 takes at least 20 rounds per task (Level 3 · 06 measures fork effects).
Orchestrating a rolling change¶
A common structure for a service update:
- hosts: web
serial: [1, "25%", "100%"]
max_fail_percentage: 0
tasks:
- ansible.builtin.include_tasks: drain.yml # delegate_to: lb
- ansible.builtin.include_role: {name: webapp}
- ansible.builtin.include_tasks: smoke_test.yml # fail the batch if unhealthy
- ansible.builtin.include_tasks: undrain.yml
One canary host, a quarter of the fleet, then everyone; any failure stops the rollout. This is the CI/CD course's rolling and canary ideas (Level 3 · 01) applied with Ansible primitives.
How It Actually Works¶
serial makes the play executor split the matching host list into batches and run the whole task list for each batch in turn, re-evaluating the play for each (including run_once and handlers per batch). delegate_to changes only the connection: the task's templated arguments use the original host's variables, but the executor opens (or reuses) a connection to the delegated target. run_once selects the first host in the current batch and runs the task there, then copies the result to the others through register/set_fact machinery. The linear strategy is a loop that sends task N to all hosts' workers and waits for all results before proceeding; free gives each host's worker its own iterator over the task list. max_fail_percentage is evaluated after each batch finishes a task, against the number of hosts in the batch.
Common mistakes¶
run_oncewithserialand expecting one run in total.- Delegated tasks racing on a shared target.
- Using
ansible_delegated_varsoutside the delegated task. - Assuming
commandevaluates shell syntax. - No failure budget on a rolling change, so a bad change reaches the whole fleet.
freestrategy with cross-host dependencies.- Draining and undraining in separate plays so a failure leaves a host out of rotation; wrap in
blockwithalways.
Exercise¶
- Reproduce the
serialandmax_fail_percentagetable on four local hosts and add a case withserial: [1, 3]. - Write a play that gets a token with
run_onceanddelegate_to: localhost, stores it withset_fact, and uses it on every host. Run it with--limitfor two hosts and note who performs therun_once. - Make a task slow on one host and compare the wall-clock time of
linearandfreewith 8 hosts andforks=3. - Add a drain/undrain pair to a rolling play so the host is always put back, even when the middle task fails.