AnsibleXXXI · Serial Execution and Failure ToleranceSerial Execution
Handlers flush per batch
What you'll learn
- State how many times handlers flush in a batched play and why
- Explain why per-batch flushing is the mechanism a rolling restart depends on
- Identify handlers that are unsafe to fire once per batch
- Reason about which hosts are left inconsistent when one batch fails
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
In an unbatched play, handlers run once, at the end, after every host
has finished every task. That is the behaviour the handlers part
established, and on a fleet it is a problem: a template task that
changes configuration on 400 hosts queues 400 notifications, and the
flush at the end of the play restarts 400 services at the same moment.
The handler is doing exactly what it was told. The play just never gave it a reason to stagger.
serial is that reason, and the mechanism is worth stating precisely
because everything in the rolling-deployment part is built on it.
The observed behaviour
$ ansible-playbook -i inv6.ini handlers.ymlPLAY [Handlers per batch] ******************************************************
TASK [Deploy config] ***********************************************************
changed: [h01]
changed: [h02]
RUNNING HANDLER [Restart service] **********************************************
ok: [h01] => { "msg": "RESTARTED h01" }
ok: [h02] => { "msg": "RESTARTED h02" }
PLAY [Handlers per batch] ******************************************************
TASK [Deploy config] ***********************************************************
changed: [h03]
changed: [h04]
RUNNING HANDLER [Restart service] **********************************************
ok: [h03] => { "msg": "RESTARTED h03" }
ok: [h04] => { "msg": "RESTARTED h04" }
PLAY [Handlers per batch] ******************************************************
TASK [Deploy config] ***********************************************************
changed: [h05]
changed: [h06]
RUNNING HANDLER [Restart service] **********************************************
ok: [h05] => { "msg": "RESTARTED h05" }
ok: [h06] => { "msg": "RESTARTED h06" }Three batches, three flushes, two hosts restarted per flush. Never more than two instances of the service down at once.
This follows directly from the previous lesson: a batched play is the
same play executed once per batch, and the implicit handler flush at
the end of a play therefore happens at the end of each batch. There is
no separate rule for handlers under serial. There is one rule — the
play restarts — and this is a consequence of it.
Why this is the whole mechanism
Take the per-batch flush away and a rolling restart is not expressible.
Consider what a rolling deploy needs: for each batch, change the configuration, restart the service, confirm it came back, then move on. The “restart the service” step must happen inside the batch, between the change and the health check. If handlers only flushed at the end of the play, the sequence would be:
- Batch 1 writes config. Notifies.
- Batch 1 health-checks — against a service still running the old config.
- Batch 2 writes config. Notifies.
- Batch 2 health-checks — same problem.
- …
- End of play: every service restarts simultaneously.
Which is the fleet-wide simultaneous restart you used serial to avoid,
with the health checks rendered meaningless because they all ran before
any restart happened.
Per-batch flushing is what makes the health check land after the restart of the hosts it is checking, and before the next batch is touched.
The handler that fires once per batch
The same property that makes rolling restarts work makes a certain class
of handler wrong under serial, and it is wrong quietly.
A handler is per-host. Under serial, it runs once per batch for the
hosts in that batch. If what the handler touches is not per-host — if
it is a shared dependency, a central cache, a load balancer, an external
API — then it fires once per batch against the same shared thing.
- name: Deploy backend configuration
hosts: appservers
become: true
serial:
- 1
- 5
- 25%
tasks:
- name: Write the backend definition
ansible.builtin.template:
src: backend.conf.j2
dest: /etc/app/backend.conf
mode: '0644'
notify: Reload the shared proxy
handlers:
# WRONG under serial: fires once per batch, against the same proxy.
- name: Reload the shared proxy
ansible.builtin.systemd_service:
name: haproxy
state: reloaded
delegate_to: proxy01.example.comWithout serial this handler is fine — it fires once, at the end of the
play, however many hosts notified it. Add serial and it fires once per
batch. A 200-host fleet with serial: [1, 5, 25%] runs six batches, so
the shared proxy is reloaded six times during a single deploy.
Six reloads of HAProxy is survivable. Six restarts of a shared message broker, six cache flushes, or six calls to an API with a rate limit are not, and the failure surfaces as something unrelated: the fourth batch fails its health check because the proxy was mid-reload when it was probed.
The fix is to move the shared work out of the batched play. Anything
that touches a single shared resource once belongs in a separate play
with its own hosts: — usually hosts: localhost or the resource
itself — that runs after the rolling play completes.
- name: Roll the backend configuration across the fleet
hosts: appservers
become: true
serial:
- 1
- 5
- 25%
tasks:
- name: Write the backend definition
ansible.builtin.template:
src: backend.conf.j2
dest: /etc/app/backend.conf
mode: '0644'
notify: Restart the local app
handlers:
- name: Restart the local app
ansible.builtin.systemd_service:
name: app
state: restarted
- name: Reload the shared proxy once, after the rollout
hosts: proxy01.example.com
become: true
tasks:
- name: Reload haproxy
ansible.builtin.systemd_service:
name: haproxy
state: reloadedWhere the failure boundary falls
Per-batch flushing has a second consequence, and it is the useful one during an incident.
Each batch has its own flush points. A batch that fails takes only its own queued handlers down with it. Batches that already completed have already flushed.
That partitions the fleet into three states after a failed rollout:
| Group | State |
|---|---|
| Hosts in completed batches | configuration written and applied — consistent |
| Hosts in the failing batch | configuration possibly written, handler discarded — mixed |
| Hosts in later batches | never touched — consistent, on the old version |
The middle row is the only one you have to investigate, and it is
bounded by the batch size. On a 200-host fleet with serial: 25%, a
failed rollout leaves at most 50 hosts in an uncertain state, not 200.
This is a real argument for smaller batches that has nothing to do with capacity: the batch size is also the size of the search you will have to run afterwards.
Knowledge check
Knowledge check · 4 questions
Q1. A play over 6 hosts with serial: 2 has one task that notifies one handler, and every host reports changed. How many RUNNING HANDLER banners appear?
Q2. Which handlers become incorrect when serial is added to a play that previously had none? Select all that apply.
Q3. After a rolling deploy fails in batch three of eight, hosts in batches one and two have already had their handlers flushed and are in a consistent state.
Q4. Inside a batched rolling play, why is meta: flush_handlers usually needed before the health check task?
Passing score: 75%. Answers are checked in this browser.