Runbook: Recover from a partial fleet failure
1 · Prerequisites
Confirm every item is in place before any state change.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The rollout is actually stopped - no scheduled job, no CI pipeline and no colleague is about to run it again
- · The full run log is preserved, not just the recap; the recap alone cannot reconstruct the state
- · The --list-hosts output taken before the run is available, or can be regenerated from the same command
- · The change being rolled out is understood well enough to say whether a half-applied host is safe to leave
- · It is known whether any step in the change was irreversible
- · The service owner has been told the rollout stopped, before any recovery action is taken
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Stop everything - confirm no automation will re-enter the rollout while you are assessing
- 2Classify every host in scope into one of five states, by measurement rather than by reading the log
- 3Find the hosts that are absent from the recap entirely - these are invisible in the usual reading
- 4Determine, per host, whether the change is fully applied, partly applied or not applied
- 5Assess service health independently of the change state; a converged host can still be broken
- 6Communicate the assessment before deciding, with numbers not impressions
- 7Decide forward, backward or hold - and decide once, for the whole affected set
- 8Execute the decision in batches with verification, treating it as a new rolling change
- 9Verify convergence: every host in scope is in one state, and that state is the intended one
- 10Write the timeline: when the rollout stopped, what state each group was left in, and for how long
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓Every host in the original scope is accounted for in exactly one state - the counts sum to the --list-hosts total
- ✓No host is left partly applied; each is either fully changed or fully at the previous state
- ✓The service health check passes on every host that is carrying traffic
- ✓A subsequent converge run reports changed=0 across the whole scope
- ✓Load balancer or cluster membership matches the host count - nothing is left drained
- ✓The timeline records the window and the host counts, not just the outcome
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Rolling back a partial failure is itself a rolling change and needs the same batching and verification
- ↶Roll back only the hosts that received the change; rolling back a host that never got it can move it away from the intended state
- ↶Work from the measured host list, not from the run log - the log tells you what was attempted, measurement tells you what is true
- ↶POINT OF NO RETURN: if any part of the change was irreversible - a data migration, an external call, a credential rotation - rollback is not available for hosts that reached that step, and the decision is forward-only for them
- ↶A fleet split between two states is a worse position than either, so do not stop the rollback partway either
- ↶Restore capacity before investigating: return every healthy drained host to rotation first
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the service owner before choosing forward or backward - the choice has service consequences and is not the change operators to make alone
- · Escalate immediately if any host reached an irreversible step and the change is bad
- · Escalate if the state of any host cannot be determined; an unknown host blocks the decision for everyone
- · Escalate if the same failure occurs on the first host of the recovery attempt - the cause is not host-specific and repeating the rollout will repeat the failure
- · Escalate to the platform owner if the failure was caused by the rollout mechanism rather than by the change
A rollout that stops halfway leaves a fleet in several states at once, and the instinct - re-run it and see how far it gets - is the one action that makes the situation harder to reason about. The fleet is not broken; it is unknown, and the work is turning unknown into known before doing anything else.
The single most important fact in this runbook: hosts that were never attempted do not appear in the recap at all. Not as skipped, not as unreachable. They are simply absent, and a recovery driven by reading the recap will not know they exist.
When to use this runbook
- A rolling change stopped on
max_fail_percentageorany_errors_fatal. - A run was interrupted - cancelled, controller lost, session dropped.
- A batch failed and the operator stopped rather than continuing.
- Any run where the question “what state is the fleet in?” does not have an immediate answer.
Blast radius
The whole scope of the original run, in an unknown mixture of states. Everything in Steps 1 to 5 is read-only; the decision in Step 7 is where you start changing things again.
Step 1: Stop everything
# Is the run still going?
pgrep -af 'ansible-playbook' || echo 'no ansible-playbook running'
# Scheduled invocations
systemctl list-timers --all --no-pager | grep -i ansible
crontab -l 2>/dev/null | grep -i ansible
sudo crontab -l -u ansible 2>/dev/null
# CI pipelines
echo 'Check the pipeline UI and pause the job that runs this playbook'A recovery conducted while a scheduled converge is running against the same hosts is not a recovery. Confirm quiet before assessing, and tell the team the rollout is stopped so nobody helpfully re-runs it.
Step 2: Reconstruct the intended scope
# The same command, with --list-hosts, gives the original scope
ansible-playbook -i inventories/production deploy.yml \
--limit web -e app_version=2.4.0 --list-hosts \
| grep -oE '[a-z0-9.-]+\.example\.com' | sort > intended.txt
wc -l intended.txtThis is the denominator. Every host in this file must end up in exactly one of the five states in Step 3, and the counts must sum to this total.
Step 3: Find the invisible hosts
LOG=deploy-2026-08-11.log
# Hosts that appear in the recap - i.e. hosts the run actually attempted
awk '/PLAY RECAP/,0' "$LOG" | grep -oE '^[a-z0-9.-]+\.example\.com' \
| sort -u > attempted.txt
# Hosts in scope that the run never reached
comm -23 intended.txt attempted.txt | tee never-attempted.txt
wc -l never-attempted.txtStep 4: Classify every host by measurement
Five states. Every host in intended.txt goes into exactly one.
| State | Meaning | How to establish it |
|---|---|---|
| 1. Not attempted | The run never reached it | Absent from the recap |
| 2. Attempted, unchanged | Failed before making any change | changed=0 failed=1 in the recap, confirmed by measurement |
| 3. Partly applied | Some tasks ran, some did not | The dangerous one - requires per-host measurement |
| 4. Fully applied | Every task completed | changed=N failed=0, confirmed by measurement |
| 5. Unreachable | Never connected | unreachable=1; state is genuinely unknown |
# What version is each host actually running?
ansible web -m uri -o \
-a 'url=http://{{ ansible_host }}:8080/version return_content=true' \
| tee measured-versions.txt
# What package versions are installed?
ansible web -m package_facts -o >/dev/null
ansible-playbook -i inventories/production report-versions.yml | tee measured-packages.txt
# What is on disk?
ansible web -b -m stat -a 'path=/etc/app/app.conf' -o | tee measured-config.txtMeasurement, not the log. The log records what Ansible attempted and what it believed; the host records what is true. Those diverge in exactly the situation you are in - a run that stopped partway, possibly with handlers pending, possibly with a task that succeeded and a task that did not.
Step 5: Assess service health separately
Change state and service health are different questions and both need answering.
# A real request per host, not a port check
for h in $(cat intended.txt); do
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "http://$h:8080/healthz" || echo 000)
printf '%-30s %s\n' "$h" "$code"
done | tee measured-health.txt
# Anything left drained from the load balancer
curl -sS https://lb.example.com/api/pool/web | python3 -m json.tool | grep -c drain
# Anything in a failed state
ansible web -b -m command -a 'systemctl --failed --no-pager --plain' -oA host in State 4 can still be unhealthy, and a host in State 1 is usually perfectly healthy on the old version. Draining is the item most often overlooked: a rollout that stopped mid-batch leaves hosts out of rotation, and those are capacity you are not receiving.
Step 6: Communicate before deciding
Produce a short, numeric assessment and send it before you act:
Rollout of app 2.4.0 to group web stopped at 14:22.
Scope 40 hosts
Fully applied 12 (on 2.4.0, healthy)
Partly applied 1 (web13 - package updated, service not restarted)
Attempted, no change 2 (web14, web15 - failed the pre-check)
Not attempted 24 (still on 2.3.7, healthy, unaffected)
Unreachable 1 (web27 - state unknown)
Drained and not returned: 3 hosts (web13, web14, web15)
Service currently healthy on 37 of 40; capacity at 92%.
Recommendation: hold. Root cause of the web14/web15 failure is not yet
known and applies to the 24 untouched hosts equally.
The point is not the format. It is that “we are 30% through and something failed” is not a basis for a decision and this is. The recipient can act on numbers; they cannot act on an impression.
Step 7: Forward, backward, or hold
One decision, for the whole affected set, made with the service owner.
| Decision | When it is right | What it requires |
|---|---|---|
| Forward | The failure is understood, host-specific, and fixed | Confidence that the remaining hosts will not hit it |
| Backward | The change itself is bad, or the cause is unknown | Every applied host reversible - no irreversible step reached |
| Hold | The cause is unknown but the current state is stable | A stated end time, and someone owning the investigation |
Step 8: Execute as a rolling change
Whichever direction, it is a new rolling change against a measured host list - with batching, health checks and a stop rule of its own.
# Forward: the hosts not yet on the new version
grep -v '2.4.0' measured-versions.txt \
| grep -oE '[a-z0-9.-]+\.example\.com' | sort -u > to-forward.txt
# Backward: the hosts that DID get the new version
grep '2.4.0' measured-versions.txt \
| grep -oE '[a-z0-9.-]+\.example\.com' | sort -u > to-rollback.txt
wc -l to-forward.txt to-rollback.txtLIMIT=$(paste -sd, to-rollback.txt)
ansible-playbook -i inventories/production deploy.yml \
--limit "$LIMIT" -e app_version=2.3.7 --list-hosts
ansible-playbook -i inventories/production deploy.yml \
--limit "$LIMIT" -e app_version=2.3.7 --diff | tee "recovery.log"Note the target list is built from measurement, not from the original log. The log says what was attempted; the measurement says what is true, and after a partial failure those are different documents.
Restore capacity first. Before investigating anything, return every healthy drained host to rotation - the service does not benefit from your investigation and it does benefit from its capacity back.
Step 9: Verify convergence
ansible web -m uri -o \
-a 'url=http://{{ ansible_host }}:8080/version return_content=true' \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort | uniq -c
# Every host in scope accounted for
ansible web --list-hosts | grep -c 'example.com'
# A converge run is now clean
ansible-playbook -i inventories/production deploy.yml --limit web --diff \
| grep -E 'changed=[1-9]'
# Nothing left drained
curl -sS https://lb.example.com/api/pool/web | grep -c drainThe uniq -c should show a single version with a count equal to the
host count. Two lines means the fleet is still split, and that is the
condition you set out to remove.
Step 10: Write the timeline
- When the rollout started, and when it stopped.
- The host counts in each of the five states at assessment time.
- The decision, who made it, and on what evidence.
- When capacity was restored.
- How long the fleet was split, and across which versions.
The duration of the split is the number that gets asked for later, when someone is investigating an intermittent fault that occurred during it.
Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
| Recap shows fewer hosts than the group has | Untouched hosts are absent from the recap | Diff recap against --list-hosts |
| Re-running the rollout skips hosts | The --limit was derived from the recap, which omitted them | Build limits from measurement |
| A host behaves like neither version | State 3 - partly applied | Per-task measurement; converge or reverse it deliberately |
| Capacity is low with no failed hosts | Hosts left drained mid-batch | Return healthy hosts to rotation first |
| Rollback “succeeded” but the fault persists | An irreversible step was reached on those hosts | Forward fix; escalate |
| The recovery run fails on its first host | The cause was never host-specific | Stop; the diagnosis was wrong |
| Fleet stays split for weeks | “Hold” with no end time | Every hold needs an end time and an owner |
| State of one host cannot be determined | Unreachable during and after | Blocks the decision - escalate rather than guess |
Escalation
Escalate when:
- The forward/backward choice needs making. That is the service owner’s.
- Any host reached an irreversible step and the change is bad.
- The state of any host cannot be determined.
- The recovery attempt fails the same way on its first host.
- The failure was in the rollout mechanism rather than the change.