AnsibleXXXI · Serial Execution and Failure ToleranceFailure Tolerance
Stopping a rollout at 02:00
What you'll learn
- Describe what an interrupted ansible-playbook leaves behind and what it does not record
- Reconstruct which batch was in flight from the run output
- Establish the real state of a fleet after an interrupted rollout
- Decide between holding, rolling forward and reversing, on stated criteria
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
It is two in the morning. A rolling deploy is on batch four of eleven, something on the dashboard looks wrong, and you have to decide in the next thirty seconds whether to let it continue.
Everything else in this part is about the run stopping itself. This is about you stopping it, and about the considerably harder problem that follows: working out what you just did.
Ctrl-C, precisely
ansible-playbook responds to an interrupt by terminating. What it does
not do is unwind.
| On interrupt | |
|---|---|
| Tasks already completed | stay applied — there is no rollback |
| The task in flight | may have completed on some hosts and not others |
| Queued handlers | discarded, unflushed |
block/always sections | do not run |
PLAY RECAP | not printed for the interrupted batch |
| Hosts already changed | no record of which, beyond the scrollback |
The always row is the one that catches people who did everything else
right. A carefully written rolling play that puts its return-to-service
step in an always block — the pattern this part has been building
toward — still strands the batch if you interrupt it, because the
process is gone before the always can run.
Stopping better than Ctrl-C
If you have a choice — and if the dashboard is merely worrying rather than on fire, you do — there are cheaper places to stop.
Between batches is the cheap place. A batch boundary is the only moment in a rolling deploy when no host is halfway through anything. Hosts before it are done, hosts after it are untouched, and nothing is drained.
The way to arrange that is a stop file the play checks at the top of each batch. Because a batched play re-executes its task list per batch, a check placed first runs before every batch:
- name: Look for an operator stop request
ansible.builtin.stat:
path: /var/run/ansible/stop-rollout
register: stop_request
delegate_to: localhost
run_once: true
- name: Report the stop before acting on it
ansible.builtin.debug:
msg: >-
STOP REQUESTED. Halting before batch
[{{ ansible_play_batch | join(', ') }}].
Hosts not yet deployed remain on the previous version.
run_once: true
when: stop_request.stat.exists
- name: Halt the rollout at this batch boundary
ansible.builtin.meta: end_play
when: stop_request.stat.existsStopping the rollout is then touch /var/run/ansible/stop-rollout in
another terminal, and the run stops at the next boundary having
completed every host it started.
The cost is latency: you wait for the current batch to finish. That is usually seconds to a couple of minutes, and it buys a clean stop instead of an unknown one. If the situation genuinely cannot wait for the current batch, Ctrl-C is correct — just know what you are buying.
Working out which batch was in flight
The first reconstruction question, and it is answerable from the output if you have it.
Count the PLAY banners. Each one is a batch. The last banner in
the scrollback is the batch that was running when you stopped it. This
is the single most useful reason to keep the run output.
Read the last task line. The tasks completed under that final banner tell you how far into the batch the change had progressed — whether the hosts had been drained, whether the template had been written, whether the restart had happened.
Recover the batch composition arithmetically if the output is gone. The batches are consecutive slices of the play host list in inventory order, so you can re-derive them:
ansible-playbook site.yml --limit appservers --list-hosts$ ansible-playbook site.yml --limit appservers --list-hosts hosts (20):
app01
app02
...
app20
batch 1: app01
batch 2: app02 app03 app04 app05 app06
batch 3: app07 app08 app09 app10 app11 <-- interrupted here
batch 4: app12 app13 app14 app15 app16
batch 5: app17 app18 app19 app20This works because slicing is positional and in inventory order. It
stops working if anything reordered the play — order: shuffle destroys
the reconstruction entirely, which is a good reason not to use it on
plays that change things.
Establishing what is actually true
Do not trust the reconstruction. Use it to narrow the search, then ask the hosts.
The reconstruction tells you which hosts were probably affected. The fleet tells you what is actually deployed, and the two differ whenever your assumption about where the run stopped is wrong — which is exactly the case you are worried about.
- name: Establish which version each host is actually running
hosts: appservers
gather_facts: false
tasks:
- name: Read the deployed-version marker
ansible.builtin.slurp:
src: /etc/app/deployed-version
register: marker
failed_when: false
- name: Ask the running process what it is serving
ansible.builtin.uri:
url: 'http://{{ inventory_hostname }}:8080/version'
return_content: true
register: live
failed_when: false
- name: Report disk version, live version and service state together
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}:
on-disk={{ (marker.content | default('') | b64decode | trim) or 'ABSENT' }}
serving={{ live.content | default('UNREACHABLE') | trim }}The two columns are the point. On-disk and serving are different questions, and an interrupted rollout is precisely the situation that makes them disagree:
| On disk | Serving | Meaning |
|---|---|---|
| new | new | deployed and restarted — done |
| new | old | file written, handler never flushed — the interrupted case |
| old | old | never reached |
| new | unreachable | deployed and did not come back — investigate first |
Row two is the state Ctrl-C creates and the one the recap would never have told you about. Row four is the urgent one.
Add a third question if the play drains from a load balancer: is this host receiving traffic? That comes from the load balancer, not from the host, and the next part treats it in full. An interrupted rolling deploy leaves drained hosts that nothing will return to service, and they are invisible to every check that runs on the host itself.
Hold, forward, or reverse
Three options. The criteria are worth having decided in advance, because at 02:00 they will not feel obvious.
Hold — leave the fleet split, fix nothing tonight.
Correct when the versions are compatible and the split is stable. Half the fleet on the new version and half on the old is a working system if and only if the two versions can coexist, which is the version-skew question the next part takes up. If they can, holding until business hours is often the best available decision: nothing is broken, and diagnosis is better in daylight with more people awake.
The precondition is that you have confirmed capacity is intact. A hold with three hosts drained and out of service is not a hold, it is an unnoticed capacity loss.
Forward — finish the rollout.
Correct when the new version is fine and the interruption was caused by
something unrelated, or when the split itself is the problem and the new
version is known good. The mechanism is --limit scoped to the hosts
that have not been done:
ansible-playbook site.yml --limit @/tmp/not-yet-deployed.txt --list-hosts
ansible-playbook site.yml --limit @/tmp/not-yet-deployed.txt --check --diff
ansible-playbook site.yml --limit @/tmp/not-yet-deployed.txtReverse — put the deployed hosts back on the old version.
Correct when the new version is the problem. It is also the option people overestimate: rolling back is a deployment, with the same risks as the one that just failed, run by a more tired person under more pressure. It is only fast if it was built and tested in advance.
Knowledge check
Knowledge check · 4 questions
Q1. A rolling play puts its return-to-service step in an always block so that a failure cannot strand a drained host. The operator presses Ctrl-C mid-batch. What happens to the drained hosts?
Q2. After an interrupted rollout, which of these are true? Select all that apply.
Q3. Re-running the same playbook command from the top is a reasonable way to finish an interrupted rollout.
Q4. A rollout is interrupted at batch four of eleven. The version census shows half the fleet on the new version and half on the old, all hosts serving traffic and capacity intact. What most determines whether holding until business hours is acceptable?
Passing score: 75%. Answers are checked in this browser.