Skip to main content
RunBook Academy

AnsibleXXXI · Serial Execution and Failure ToleranceFailure Tolerance

Stopping a rollout at 02:00

Advanced⏱ ~25 minansible-playbook

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

Not yet marked complete on this device.

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 completedstay applied — there is no rollback
The task in flightmay have completed on some hosts and not others
Queued handlersdiscarded, unflushed
block/always sectionsdo not run
PLAY RECAPnot printed for the interrupted batch
Hosts already changedno 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:

Read-only / Safea stop file checked at every batch boundary
    - 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.exists

Stopping 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:

Read-only / Safere-deriving the host list the run used
ansible-playbook site.yml --limit appservers --list-hosts
Read-only / Safereconstructing the slices
$ 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 app20

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

Read-only / Safethe version census
- 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 diskServingMeaning
newnewdeployed and restarted — done
newoldfile written, handler never flushed — the interrupted case
oldoldnever reached
newunreachabledeployed 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:

Service impact possibleresuming a rollout against the remainder
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.txt

Reverse — 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

  1. 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?

  2. Q2. After an interrupted rollout, which of these are true? Select all that apply.

  3. Q3. Re-running the same playbook command from the top is a reasonable way to finish an interrupted rollout.

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