Skip to main content
RunBook Academy

LinuxLXIV · Rolling MaintenanceOrchestration

Orchestrating the rolling loop - automation that stops on evidence

Advanced⏱ ~15 minansible-playbook

What you'll learn

  • Express a rolling maintenance as a serial Ansible play with a ramped batch size
  • Write a health gate that blocks the next batch on evidence rather than on elapsed time
  • Choose between max_fail_percentage and any_errors_fatal for the abort condition
  • Recognise validation that passes because a different node answered it

Prerequisites

Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-11

Not yet marked complete on this device.

Doing rolling maintenance by hand works and does not scale. Two nodes is a pleasant afternoon; forty is a week of windows, and somewhere in that week somebody skips the observation period because the window is running late — which is the exact failure the workflow lesson warns about.

Automating it is the right answer and it is also how the failure gets faster. A script that patches forty nodes in sequence with no gate between them does not remove the risk of a bad change; it removes the human who would have noticed after the first one.

The whole design problem is therefore: what stops the loop?

The shape

Ansible expresses the loop directly. serial splits the play into batches and runs the entire play — including handlers — once per batch, which is what makes a per-batch gate possible.

# rolling-patch.yml
- name: Rolling patch of the web tier
  hosts: webservers
  # Canary first, then widen. Never start at the ceiling.
  serial:
    - 1
    - 2
    - "25%"
  # Any host failing in a batch aborts the play before the next one
  max_fail_percentage: 0
  order: sorted

  pre_tasks:
    - name: Record the kernel we are rolling back to
      ansible.builtin.command: uname -r
      changed_when: false
      register: pre_kernel

    - name: Signal the load balancer to drain this node
      ansible.builtin.file:
        path: /etc/myapp/draining
        state: touch
        mode: '0644'
      become: true

    - name: Wait out the load balancer detection window
      ansible.builtin.wait_for:
        timeout: 20

    - name: Confirm no established connections remain
      ansible.builtin.shell: >
        set -o pipefail;
        ss -Htn state established '( sport = :8080 )' | wc -l
      args:
        executable: /bin/bash
      register: conns
      changed_when: false
      retries: 12
      delay: 10
      until: conns.stdout | int == 0

  tasks:
    - name: Apply the change
      ansible.builtin.apt:
        upgrade: dist
        update_cache: true
      become: true

    - name: Does this host need a reboot?
      ansible.builtin.stat:
        path: /var/run/reboot-required
      register: reboot_flag

    - name: Reboot and wait for the host to answer again
      ansible.builtin.reboot:
        reboot_timeout: 900
        post_reboot_delay: 15
      become: true
      when: reboot_flag.stat.exists

  post_tasks:
    - name: No failed units
      ansible.builtin.command: systemctl is-system-running
      register: sysstate
      changed_when: false
      failed_when: sysstate.stdout not in ['running', 'starting']

    - name: The application answers on this node, not through the VIP
      ansible.builtin.uri:
        url: "http://{{ ansible_default_ipv4.address }}:8080/healthz"
        status_code: 200
      register: health
      retries: 30
      delay: 10
      until: health.status == 200

    - name: Return the node to the pool
      ansible.builtin.file:
        path: /etc/myapp/draining
        state: absent
      become: true

    - name: Observation period - let the node carry real traffic
      ansible.builtin.wait_for:
        timeout: 600

    - name: Assert the node logged no server errors while serving
      ansible.builtin.shell: >
        set -o pipefail;
        journalctl -u myapp --since '10 min ago' --no-pager
        | grep -c ' 5[0-9][0-9] ' || true
      args:
        executable: /bin/bash
      become: true
      register: errs
      changed_when: false
      failed_when: errs.stdout | int > 0

The last two tasks are the only place a fixed wait belongs. The observation period is genuinely a duration — the node has to serve real traffic for a while — but it is followed by an assertion, so the wait is setting up evidence rather than standing in for it.

Read that play as four gates, not as a list of tasks. The drain gate blocks until connections have actually fallen. The reboot gate blocks until the host answers again. The health gate blocks until the application responds on that host. The observation gate holds for a fixed period and then asserts that the node served that traffic without errors. Any one of them failing stops the batch, and max_fail_percentage: 0 stops the play.

until and retries are the whole mechanism

The difference between automation that gates on evidence and automation that gates on a timer is one keyword.

# A TIMER. This is not a gate. It waits and then proceeds
# regardless of what it finds.
- name: Wait for the service to settle
  ansible.builtin.pause:
    minutes: 5

# A GATE. It re-evaluates a condition and fails if the condition
# is never met within retries x delay.
- name: Wait for the service to be healthy
  ansible.builtin.uri:
    url: "http://{{ ansible_default_ipv4.address }}:8080/healthz"
    status_code: 200
  register: health
  retries: 30
  delay: 10
  until: health.status == 200

Both take five minutes on a healthy node. On an unhealthy node, the first one takes five minutes and then patches the next node anyway. The second one fails the host after five minutes and, with max_fail_percentage: 0, stops the play.

retries multiplied by delay is your patience budget, and it should be generous. A gate that gives up after 30 seconds converts every slow start into an aborted maintenance, and a team that has seen that twice starts running the playbook with the gate removed.

Choosing the abort condition

Ansible’s default is generous in a way that is wrong for maintenance. A host that fails a task is removed from the play, and the play carries on with the survivors. On a rolling patch that means node 3 breaks, gets quietly dropped, and nodes 4 through 40 are patched with the same broken change.

Two directives fix it, and they are not the same.

DirectiveEffectUse when
max_fail_percentage: 0After a batch completes, abort the play if any host in it failedThe normal choice for rolling maintenance
any_errors_fatal: trueAbort the play immediately when any host fails a task, without finishing the batchThe batch is larger than one and you want the other hosts in it left alone

With serial: 1 the two are nearly equivalent, because a batch is one host. With a larger batch they differ: max_fail_percentage lets the rest of the batch finish and then stops, while any_errors_fatal halts the moment the first host fails.

Neither of them helps if a task cannot fail. ignore_errors: true and a command task without failed_when both produce gates that always pass, and both are usually added during a frustrating debugging session and never removed.

Rerunning safely

Rolling maintenance automation gets rerun constantly — after an abort, after a fix, after adding hosts. Three properties make that safe.

Idempotent tasks. Covered in the configuration-management part, and it is what lets you rerun the whole play against a fleet that is half patched. The already-patched hosts report ok and take seconds.

A drain step that tolerates an already-drained node. file: state=touch on a file that exists is fine; command: touch wrapped in a creates guard is fine; a task that fails when the flag is already there turns your recovery rerun into another abort.

--limit for surgical reruns. After an abort you usually want to work on one host, not resume the fleet:

# Just the node that failed
ansible-playbook -i /etc/ansible/prod rolling-patch.yml --limit web03

# Resume the campaign, excluding the one you are still debugging
ansible-playbook -i /etc/ansible/prod rolling-patch.yml --limit 'webservers:!web03'

Knowledge check

Knowledge check · 4 questions

  1. Q1. What is the practical difference between a pause task and a uri task with retries, delay and until?

  2. Q2. By default, a host that fails a task in an Ansible play is removed from the play and the remaining hosts continue.

  3. Q3. Which of these make a rolling-maintenance playbook safe to rerun after an abort? Select all that apply.

  4. Q4. A rolling patch playbook runs serial: 1 across twelve nodes with a post_tasks check: uri against https://app.example.com/healthz expecting 200, where app.example.com is the load balancer VIP. Every node reports green and the site goes down at the end of the run. Explain the mechanism and give the fix.

Passing score: 75%. Answers are checked in this browser.