Skip to main content
RunBook Academy

← All break/fix scenarios in Ansible

advancedrollout~35 min

Break/Fix: the rollout stopped after two of six nodes and two more are missing from the report entirely

Reported symptoms

  • A rolling deployment across six application nodes aborted after the second batch
  • One node failed its post-deploy check; the play stopped and reported NO MORE HOSTS LEFT
  • The play recap lists only four of the six nodes, and nobody can say what state the other two are in
  • Capacity is reduced and nobody is sure by how much, because at least one node was drained and never returned
  • The fleet is now running two different application versions simultaneously
  • Rerunning the play is the obvious next step and is also the most dangerous available option

Evidence

  • · The run output shows the PLAY header repeated once per batch, which is how batching appears in a log
  • · The recap lists four hosts; two hosts that exist in the inventory are absent from it entirely
  • · `max_fail_percentage` is set to 25 and `serial` to 2, so one failure in a two-host batch is 50 percent
  • · The load balancer shows one node still administratively drained
  • · The application version endpoint reports the new version on two nodes and the old version on three
  • · The sixth node is drained, running the new version, and receiving no traffic
  • · `ansible-playbook ... --list-hosts` shows six hosts, which is the number the change record approved
Diagnosis and resolutionclick to reveal

Root cause

Nothing malfunctioned. `serial: 2` splits six hosts into three batches of two, and `max_fail_percentage: 25` instructs Ansible to abandon the play when more than 25 percent of a batch fails. One failure in a batch of two is 50 percent, so the threshold was crossed by the first failure that could possibly occur - the setting was copied from a play that runs against a much larger batch, where 25 percent means something quite different. The play therefore stopped, correctly, leaving the fleet split across two versions. The damaging part is what the report does not say: hosts in batches that never started are absent from the play recap altogether, so the summary shows four hosts and the operator has to know that six were expected in order to notice. Worse, the node that failed mid-batch had already been drained from the load balancer and the return-to-service step never ran, so capacity was silently reduced by a node that is neither in service nor obviously out of it. The obvious response - rerun the play - is the worst available option, because it starts from batch one and re-deploys nodes that are already correct while the drained node remains drained.

Remediation

Do not rerun. Establish the true state of every node first - version, service health, and load balancer membership, gathered from the nodes and the load balancer rather than from the Ansible log - and write it down, because the log does not contain it. Return the orphaned drained node to service or take it out deliberately, so capacity is a known quantity. Then decide between rolling forward and rolling back on the evidence, not on momentum, and execute that decision with a play limited to the nodes that need it. Only afterwards fix the policy: `max_fail_percentage` must be expressed in terms of the batch size it will actually be evaluated against, and the drain step must be paired with a return-to-service step that runs even when the deployment fails.

Verification

Every node in the inventory must appear in your state table with a version, a health result and a load balancer status - the check is that the table has six rows, not that the run was green. Confirm capacity from the load balancer, which is the only component that knows how many nodes are actually serving. Prove the failure policy behaves as intended by rehearsing it: on a staging fleet, force a failure in one node of a batch and confirm the play stops or continues as you now intend, and that the drained node is returned either way. Finally, confirm every node reports the same version before declaring the rollout complete.

Prevention

Compute `max_fail_percentage` against the batch size, not against the fleet, and write the arithmetic in a comment beside it - one failure in a batch of two is fifty percent whatever the fleet size. Never let a drain step exist without a paired return-to-service step that runs on failure, using a block with an always section so the node cannot be orphaned. Treat the play recap as incomplete by construction: compare the number of hosts in the recap against the number the play targeted, and alert when they differ. Make the state table a required artefact of any aborted rollout, before any decision about what to do next, because reconstructing it later from logs is not possible. And rehearse the failure path in staging, since the batch policy is the one setting whose behaviour you only ever see on a bad day.

Reported symptoms

A rolling deployment of the application across six nodes stops after the second batch. The last thing in the output is NO MORE HOSTS LEFT, twice, and then a recap.

It is 22:40. The deployment window closes at 23:30. The questions in the room, in the order they were asked:

  • Which nodes have the new version?
  • Which nodes are serving traffic?
  • Why does the recap list four nodes when there are six?
  • Should we just run it again?

Nobody can answer the first three from the run output, and the fourth is about to be answered with “yes” because it is 22:40.

Evidence provided

Read-only / Safethe PLAY header repeats per batch - that is what batching looks like
$ grep -E 'PLAY \[|TASK \[|ok:|changed:|fatal:|NO MORE' logs/rollout-2226.log
PLAY [Deploy the application] **************************************************
TASK [Drain from the load balancer] ********************************************
changed: [node01]
changed: [node02]
TASK [Deploy and verify] *******************************************************
ok: [node01]
ok: [node02]
PLAY [Deploy the application] **************************************************
TASK [Drain from the load balancer] ********************************************
changed: [node03]
changed: [node04]
TASK [Deploy and verify] *******************************************************
fatal: [node03]: FAILED! => {"msg": "health check returned 503 after 6 attempts"}
ok: [node04]
NO MORE HOSTS LEFT *************************************************************
NO MORE HOSTS LEFT *************************************************************
Read-only / Safefour rows - node05 and node06 are not here at all
$ tail -6 logs/rollout-2226.log
PLAY RECAP *********************************************************************
node01                     : ok=6    changed=3    unreachable=0    failed=0
node02                     : ok=6    changed=3    unreachable=0    failed=0
node03                     : ok=3    changed=1    unreachable=0    failed=1
node04                     : ok=4    changed=1    unreachable=0    failed=0
Read-only / Safethe failure policy
$ grep -n -A4 'hosts: app' deploy.yml
3:  hosts: app
4:  serial: 2
5:  max_fail_percentage: 25
6:  gather_facts: true
Read-only / Safethree on the new version, three on the old
$ for n in 01 02 03 04 05 06; do printf 'node%s ' $n; curl -s --max-time 3 http://192.0.2.1$n:8080/version || echo unreachable; done
node01 2.4.0
node02 2.4.0
node03 2.3.1
node04 2.4.0
node05 2.3.1
node06 2.3.1
Read-only / Safetwo nodes drained; only one of them failed
$ curl -s http://lb.example.com:8404/stats;csv | awk -F, '$1=="app_pool" {print $2, $18}'
node01 UP
node02 UP
node03 MAINT
node04 MAINT
node05 UP
node06 UP

Work the evidence before reading on

Nothing here is a malfunction. Every component did what it was configured to do.

  1. Count the hosts in the recap. Count the hosts in the inventory. The difference is the most important number in the incident.
  2. Work out what fraction of a two-host batch one failure represents, then compare it with max_fail_percentage.
  3. node04 reports the new version and is in MAINT at the load balancer. Which step ran and which step did not?

Before continuing: if you rerun the play right now, what happens to node04, and what happens first?

Root cause

1. The failure policy triggered on the first possible failure

serial: 2 divides six hosts into three batches of two. The play runs completely for batch one, then completely for batch two, and so on - which is why the PLAY header appears once per batch in the log.

max_fail_percentage: 25 tells Ansible to abandon the play when more than 25 percent of the hosts in the current batch have failed. In a batch of two, one failure is 50 percent. There is no failure smaller than that available, so the setting means “stop on the first failure”, and it was almost certainly copied from a play with much larger batches where 25 percent tolerates a genuine minority.

The play stopped. That is the configured behaviour, correctly executed.

2. Hosts in unstarted batches are absent, not reported as skipped

node05 and node06 were in batch three. Batch three never began.

They are not listed as skipped, or as unreachable, or as anything else - they are simply not in the recap. Reading the recap tells you about four hosts and says nothing whatever about the other two, and the only way to notice is to already know how many there should have been.

That is the mechanism behind the question nobody could answer at 22:40. The report is not wrong; it is incomplete by construction, and it does not say so.

3. The drain step has no counterpart on failure

node03 was drained, failed its check, and stopped. node04 was drained, deployed successfully, and then the play abandoned it before the return-to-service step.

So capacity is not reduced by one node. It is reduced by two, one of which is perfectly healthy and running the new version. Nothing in the Ansible output says this; the load balancer says it, and only if somebody asks.

An orphaned drained node is the worst state in the incident because it is invisible from every angle except the load balancer: the host is up, the service is running, the version is correct, and it is serving nothing.

Resolution

  1. Do not rerun. Announce that explicitly, because the instinct is strong and somebody is already typing it.
  2. Build the state table before doing anything else. One row per node in the inventory, with columns for version, service health, and load balancer status, gathered from the nodes and the load balancer rather than from the Ansible log.
  3. Deal with the orphaned drained node immediately. node04 is healthy and running the new version; return it to service or take it out deliberately, but make capacity a known number rather than an unknown one.
  4. Find out why node03 failed its health check. This is the only genuinely unknown thing in the incident, and it decides whether rolling forward is safe.
  5. Decide between hold, roll forward and roll back on the evidence and the time remaining. Two versions running simultaneously is only acceptable if the versions are compatible, which is a question about the application, not about Ansible.
  6. Execute the decision with a play limited to the nodes that need it. Nodes already on the target version and in service must not be touched again.
  7. Fix the failure policy afterwards, not during the incident. max_fail_percentage has to be computed against the batch size, and the value should carry a comment showing the arithmetic.
  8. Wrap the drain in a block with an always section that returns the node to service, so a failure between drain and return cannot orphan a node again.

Verification

  1. The state table has one row per inventory host. Six rows for six nodes; a table with four rows is the original failure repeating itself.
  2. Capacity is a known number, confirmed at the load balancer. No node is in a maintenance state that nobody chose.
  3. Every node reports the same version, if the decision was to roll forward or back. Query each node directly rather than inferring from the run.
  4. Traffic is actually being served by every node that is supposed to be serving. A node marked UP that receives no requests is a different fault, and the request counters at the load balancer are what distinguish them.
  5. The failure policy behaves as intended. Rehearse it on staging: force one node in a batch to fail and confirm the play stops or continues as you now intend. This is the check that can fail and it is the one nobody had run.
  6. A drained node is always returned. In that same rehearsal, confirm the always section returns the healthy node in the failing batch to service.
  7. The recap-versus-target comparison is automated. The pipeline compares the number of hosts in the recap against the number reported by --list-hosts and fails when they differ.

Prevention

  • Compute max_fail_percentage against the batch, and write the arithmetic beside it. One failure in a batch of two is fifty percent regardless of how large the fleet is, and a value copied from a larger-batch play means something entirely different here.
  • Never let a drain exist without a paired return. A block whose always section returns the node to service is the difference between a failed deployment and a silently degraded fleet:
- name: Deploy with a guaranteed return to service
  block:
    - name: Drain from the load balancer
      ansible.builtin.include_tasks: drain.yml
    - name: Deploy and health-check
      ansible.builtin.include_tasks: deploy.yml
  always:
    - name: Return to service
      ansible.builtin.include_tasks: undrain.yml
  • Treat the play recap as incomplete. Compare its host count against the targeted count on every batched run, and alert on any difference.
  • Make the state table a required step after any aborted rollout, before any decision. It cannot be reconstructed from the log afterwards.
  • Rehearse the failure path. The batch policy is the one setting whose behaviour you only observe on a bad day, which makes staging the only place to observe it cheaply.
  • Remember that run_once means once per batch. Anything that must happen exactly once in a batched play needs a different mechanism.
  • Decide in advance what a mixed-version fleet means for this application. If the two versions cannot coexist, a rolling deployment is the wrong pattern and the play should say so.