Skip to main content
RunBook Academy

← All runbooks in Ansible

high riskcluster affecting~90 min

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.

  1. 1Stop everything - confirm no automation will re-enter the rollout while you are assessing
  2. 2Classify every host in scope into one of five states, by measurement rather than by reading the log
  3. 3Find the hosts that are absent from the recap entirely - these are invisible in the usual reading
  4. 4Determine, per host, whether the change is fully applied, partly applied or not applied
  5. 5Assess service health independently of the change state; a converged host can still be broken
  6. 6Communicate the assessment before deciding, with numbers not impressions
  7. 7Decide forward, backward or hold - and decide once, for the whole affected set
  8. 8Execute the decision in batches with verification, treating it as a new rolling change
  9. 9Verify convergence: every host in scope is in one state, and that state is the intended one
  10. 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_percentage or any_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

Read-only / Safeconfirm nothing will re-enter
# 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

Read-only / Safewhat should have been touched
# 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.txt

This 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

Read-only / Saferecap versus intended
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.txt

Step 4: Classify every host by measurement

Five states. Every host in intended.txt goes into exactly one.

StateMeaningHow to establish it
1. Not attemptedThe run never reached itAbsent from the recap
2. Attempted, unchangedFailed before making any changechanged=0 failed=1 in the recap, confirmed by measurement
3. Partly appliedSome tasks ran, some did notThe dangerous one - requires per-host measurement
4. Fully appliedEvery task completedchanged=N failed=0, confirmed by measurement
5. UnreachableNever connectedunreachable=1; state is genuinely unknown
Read-only / Safemeasure, do not infer
# 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.txt

Measurement, 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.

Read-only / Safeis it actually serving
# 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' -o

A 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.

DecisionWhen it is rightWhat it requires
ForwardThe failure is understood, host-specific, and fixedConfidence that the remaining hosts will not hit it
BackwardThe change itself is bad, or the cause is unknownEvery applied host reversible - no irreversible step reached
HoldThe cause is unknown but the current state is stableA 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.

Read-only / Safebuild the target list from measurement
# 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.txt
Service impact possibleexecute, with the list confirmed first
LIMIT=$(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

Read-only / Safeone state, and the right one
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 drain

The 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

SymptomLikely causeResolution
Recap shows fewer hosts than the group hasUntouched hosts are absent from the recapDiff recap against --list-hosts
Re-running the rollout skips hostsThe --limit was derived from the recap, which omitted themBuild limits from measurement
A host behaves like neither versionState 3 - partly appliedPer-task measurement; converge or reverse it deliberately
Capacity is low with no failed hostsHosts left drained mid-batchReturn healthy hosts to rotation first
Rollback “succeeded” but the fault persistsAn irreversible step was reached on those hostsForward fix; escalate
The recovery run fails on its first hostThe cause was never host-specificStop; the diagnosis was wrong
Fleet stays split for weeks“Hold” with no end timeEvery hold needs an end time and an owner
State of one host cannot be determinedUnreachable during and afterBlocks 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.

References

  1. Error handling in playbooks
  2. Controlling playbook execution: strategies and more
  3. ansible.builtin.package_facts module