AnsibleXLVIII · Maintenance Windows and RollbackMaintenance windows and rollback
Canary first, with criteria written down
What you'll learn
- Size a canary and its follow-on batches for a change rather than copying a number
- Express abort criteria as conditions a play can evaluate, not conditions a person must judge
- Predict which hosts are changed, which are unchanged and which are absent after an abort
- Explain why max_fail_percentage is evaluated per batch and what that implies for batch sizing
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
Progressive exposure is one host, then a batch, then the fleet, with a
decision between each. The decision is the point. Without it, serial: 5
is a slower way of changing everything.
This lesson is about making the decision mechanical, because the alternative — an operator watching output and deciding when to stop — is a control that works during a demonstration and fails at 03:00 on the third hour of a window.
Sizing the exposure
serial accepts a list, and the list is the exposure schedule.
- name: Roll out the release
hosts: appservers
serial: [1, 3, 6]
max_fail_percentage: 10
Read it as: one host, then three, then six at a time until the fleet is done. The first entry is the canary.
The numbers are not universal, and copying them from another team’s playbook is the usual mistake. Three questions size them:
| Question | Effect on the canary | Effect on later batches |
|---|---|---|
| How long until a problem is visible? | Canary must stay live at least that long | Batches must be spaced by it |
| What fraction of capacity can be down? | Usually irrelevant — one host | This is the hard ceiling |
| How long does the whole window allow? | — | Batch size is fleet size ÷ available rounds |
The second one is the constraint people skip. A fleet of ten behind a
load balancer with serial: 6 takes 60% of capacity out at once. If the
service needs 50% to serve peak traffic, serial: 6 is an outage that
the play will report as a success.
Criteria a play can evaluate
The change plan holds criteria in prose. The play needs them as conditions. Translating between the two is the work.
| Criterion in the plan | Expressed in the play |
|---|---|
| The service must answer after the change | uri with status_code: 200, retries, until |
| Error rate must stay below 1% | uri against the metrics endpoint plus an assert on the parsed value |
| Latency must stay under 400 ms | uri with timeout: 0.4, or an assert on a measured value |
| The version must actually be the new one | assert comparing a reported version to the intended one |
| No more than 10% of hosts may fail | max_fail_percentage: 10 |
| The window ends at 03:30 | An assert on the current time, evaluated per batch |
- name: Wait for the service to answer after the change
ansible.builtin.uri:
url: "http://{{ ansible_host }}:8080/healthz"
status_code: 200
timeout: 5
register: health
retries: 6
delay: 10
until: health is succeeded
- name: Confirm the running version is the one we deployed
ansible.builtin.assert:
that:
- health.json.version == target_version
fail_msg: >-
{{ inventory_hostname }} reports {{ health.json.version | default('unknown') }},
expected {{ target_version }}
quiet: true
- name: Refuse to start another batch after the window closes
ansible.builtin.assert:
that:
- ansible_date_time.time < window_end_time
fail_msg: "Window closed at {{ window_end_time }}; not starting another batch"
quiet: true
run_once: trueThe third task is the one almost nobody writes, and time is the abort criterion that fires most often.
What an abort actually looks like
Executed, rather than described. Ten hosts, serial: [1, 3, 6],
max_fail_percentage: 10, with the health check failing on web02,
web03 and web09.
$ ansible-playbook -i inventories/prod rollout.yml; echo "exit=$?"PLAY [Canary then batches, with an abort criterion] ****************************
TASK [Post-change health check] ************************************************
skipping: [web01]
TASK [Mark host as validated] **************************************************
ok: [web01] => {
"msg": "web01 validated"
}
PLAY [Canary then batches, with an abort criterion] ****************************
TASK [Post-change health check] ************************************************
fatal: [web02]: FAILED! => {"changed": false, "msg": "health check failed"}
fatal: [web03]: FAILED! => {"changed": false, "msg": "health check failed"}
skipping: [web04]
NO MORE HOSTS LEFT *************************************************************
NO MORE HOSTS LEFT *************************************************************
PLAY RECAP *********************************************************************
web01 : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web02 : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
web03 : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
web04 : ok=0 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
exit=2Four things in that output are worth reading carefully.
The play header appears once per batch. PLAY [...] printed twice
because serial runs the play once per batch. This is why run_once
means once per batch rather than once per play, and it is a common
source of surprise.
Two failures out of three is 66%, which exceeds 10%, so the play ended. The evaluation is per batch, not against the fleet. Two hosts out of ten is 20% of the fleet, and the fleet number is never computed.
web04 shows skipped=1 and never reached the second task. It was
in the failing batch, evaluated the health check, and the play ended
before the next task. Its state is “partially through the batch”, which
is the population lesson 7 is about.
web05 through web10 have no recap line at all. They were never
entered. The recap has four lines for a ten-host fleet, and the six
missing hosts are still on the old release — invisible in the output
that the operator is reading.
Canary size and the percentage floor
With serial: 1, a single failure is 100% of the batch. Any
max_fail_percentage below 100 therefore aborts on a failed canary,
which is the intent.
Verified by execution: the same play with the failure induced on web01
instead stopped immediately, produced a one-line recap, and exited 2. The
canary is a hard gate by construction, and no arithmetic is needed to
make it one.
The arithmetic does matter for later batches, and it bites in a direction people do not expect.
| Batch size | max_fail_percentage: 10 tolerates | Effective behaviour |
|---|---|---|
| 1 | 0 failures | Any failure aborts |
| 3 | 0 failures | 1 failure is 33%, aborts |
| 6 | 0 failures | 1 failure is 16%, aborts |
| 11 | 1 failure | 1 failure is 9%, continues |
| 20 | 2 failures | 2 failures is 10% — see below |
Knowledge check
Knowledge check · 4 questions
Q1. A play against 100 hosts uses serial: 10 and max_fail_percentage: 10. Exactly one host fails in each of the ten batches. What happens?
Q2. A rollout with serial: [1, 3, 6] against ten hosts aborts during the second batch, leaving a four-line PLAY RECAP. Which statements about the fleet are correct? Select all that apply.
Q3. Setting serial: 1 for the first batch is sufficient to make that host a canary.
Q4. A change plan states the abort criterion as no more than 10% of hosts may fail, and the play sets max_fail_percentage: 10 with batches of 20. Two hosts fail in a batch. What does the play do, and does it match the plan?
Passing score: 75%. Answers are checked in this browser.