Skip to main content
RunBook Academy

AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management

Rolling kernel upgrades across a fleet

Expert⏱ ~28 min🧪 Lab requiredansible-playbook

What you'll learn

  • Argue concretely why a simultaneous fleet reboot is unacceptable, not merely inconvenient
  • Assemble the drain, patch, reboot, validate, undrain loop as one play
  • Prove the new kernel is running rather than installed, using refreshed facts
  • Avoid the run_once trap in a batched play and use the one-shot bootloader entry correctly

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.

A kernel upgrade is the one change on a Linux host that cannot be validated before it takes effect and cannot be undone without a second reboot. Across a fleet, that property compounds.

Rebooting a production fleet simultaneously is not acceptable. Not “slower than ideal”, not “worth avoiding if you have time” — not acceptable, and the reasons are worth stating in full, because “it will be down for four minutes” is the argument people expect and it is the weakest one available.

Why simultaneous is different in kind

Quorum does not survive it. Anything consensus-based — etcd, a database replica set, Ceph monitors, a Consul or ZooKeeper ensemble — requires a majority of members to remain up. Reboot all of them at once and there is no majority; the cluster does not degrade, it stops. Some of these recover on their own when members return. Some require manual intervention to re-establish a quorum they lost cleanly, which is a 2 a.m. procedure nobody has practised.

Every cache starts cold at once. A service whose steady state depends on a warm cache does not return to steady state when it comes back — it returns to a cold-start state, under full production load, with no warmed peer to take the pressure. The classic outcome is a thundering herd against the database behind it, which then falls over, which prevents any host from warming. The fleet is up and the service is down.

The dependencies the fleet needs to come back are in the fleet. Hosts need DNS to resolve, LDAP or Kerberos to authenticate, NTP to agree on time, a package mirror, an artifact store, a log collector. If those run on machines you have just rebooted alongside everything else, the fleet is trying to boot into an environment that is also booting. Ordering matters and a simultaneous reboot has no ordering.

There is no healthy host left to compare against. This is the one that hurts most during the incident. Diagnosis of “is this host behaving oddly” is almost always differential: you look at a host that works and a host that does not. Reboot everything at once into a bad kernel and every host is equally broken, with nothing to diff against, and the fastest diagnostic tool you have is gone.

The loop

Per batch: drain, patch, reboot if required, reconnect, prove the new kernel is running, validate, return to service, soak. Then the next batch.

Service impact possiblethe fleet-wide preconditions, unbatched, before anything changes
- name: Pre-flight the whole fleet before any batch starts
hosts: '{{ patch_target }}'
gather_facts: true
any_errors_fatal: true
tasks:
  - name: Refuse to start with insufficient space on /boot
    ansible.builtin.assert:
      that: >-
        (ansible_facts.mounts | selectattr('mount', 'equalto', '/boot')
         | map(attribute='size_available') | first | default(0)) > 314572800
      fail_msg: >-
        {{ inventory_hostname }} has under 300 MB free on /boot. A kernel
        install here will write a truncated initramfs and the host will
        not boot.

  - name: Record the fleet-wide kernel baseline
    ansible.builtin.debug:
      msg: >-
        {{ ansible_play_hosts_all | length }} hosts, running kernels:
        {{ ansible_play_hosts_all
           | map('extract', hostvars, ['ansible_facts', 'kernel'])
           | unique | list }}
    run_once: true

any_errors_fatal: true on the pre-flight play means one host failing its disk check stops the whole run before anything is patched. That is the correct severity for a pre-flight: the point of running it is to find out now.

The run_once in that play is safe because the play is not batched. Under serial it would run once per batch — the trap the next section deals with.

Service impact possiblethe rolling play
- name: Rolling kernel upgrade
hosts: '{{ patch_target }}'
become: true
serial: '{{ patch_serial }}'
max_fail_percentage: 0
tasks:
  - name: Record the kernel this host is running before we touch it
    ansible.builtin.set_fact:
      kernel_before: '{{ ansible_facts.kernel }}'

  - name: Take this host out of service and put it back whatever happens
    block:
      - name: Drain from the load balancer
        ansible.builtin.include_tasks: drain.yml

      - name: Install the new kernel
        ansible.builtin.include_tasks: kernel-install.yml

      - name: Reboot into it
        ansible.builtin.reboot:
          msg: 'Kernel upgrade, window {{ patch_window_id }}'
          reboot_timeout: 900
          post_reboot_delay: 30
        register: reboot_result

      - name: Re-gather facts so we are describing the host that came back
        ansible.builtin.setup:
          gather_subset: ['!all', '!min', 'kernel', 'hardware']

      - name: Prove the running kernel actually changed
        ansible.builtin.assert:
          that: ansible_facts.kernel != kernel_before
          fail_msg: >-
            {{ inventory_hostname }} rebooted after
            {{ reboot_result.elapsed }}s but is still running
            {{ kernel_before }}. The bootloader default was not updated,
            or the new kernel failed and the host fell back.
          success_msg: >-
            {{ inventory_hostname }}: {{ kernel_before }} ->
            {{ ansible_facts.kernel }}

      - name: Validate the host is doing its job
        ansible.builtin.include_tasks: validate.yml

    always:
      - name: Return healthy hosts to service, announce the rest
        ansible.builtin.include_tasks: undrain.yml

  - name: Soak before the next batch
    ansible.builtin.pause:
      seconds: '{{ soak_seconds | default(120) }}'
    run_once: true

uname -r after the reboot is the proof

ansible_facts.kernel is the running kernel — confirmed on ansible-core 2.21.3, where a setup run reported ansible_kernel: 7.0.0-29-generic, the same string uname -r prints.

The assertion above compares it against the value recorded before the reboot, and it is the single most important task in the play, because it is the only one that distinguishes these three outcomes:

Outcomereboot moduleHealth checksKernel assertion
new kernel runningpasspasspass
rebooted into the old kernelpasspassfail
new kernel panicked, host fell backpasspassfail

The middle row is the quiet one. Everything works. The service is healthy. The host is running the kernel it was running yesterday, and the vulnerability the window existed to close is still there — on a machine your patch report lists as done.

The installed kernel is not the booting kernel

A host can be running 6.6.0, have both 6.6.0 and 6.6.9 installed, and be set to boot 6.6.9 next time. That is the normal state between the package install and the reboot, and it is why “the kernel package is current” is not a claim about what is executing.

The Linux course covers the bootloader side in detail. Two facts from it matter to a rolling play:

grub2-reboot and grub-reboot set a one-shot entry. The next boot consumes it and then forgets it, so the boot after that returns to the default. That is exactly the right tool for testing a suspect kernel — if it does not come back, a power cycle returns you to the known-good default with nothing left in a strange state.

It is the wrong tool for a rollback you intend to keep. A one-shot boot that fixes production is a reprieve, not a rollback, and the next reboot — weeks later, during an unrelated incident — silently returns to the kernel that broke.

A persistent change is grubby --set-default, verified with grubby --default-kernel before rebooting. On Debian-family hosts without grubby, that means editing GRUB_DEFAULT and running update-grub.

Configuration changea canary that tests a kernel without committing to it
- name: Set a one-shot boot into the new kernel on the canary only
ansible.builtin.command:
  argv: [grub2-reboot, '{{ candidate_kernel_entry }}']
changed_when: true
when:
  - ansible_facts.os_family == 'RedHat'
  - inventory_hostname == ansible_play_hosts_all[0]

That when condition is the subject of the next section, and it is not run_once.

run_once is per batch, and that breaks fleet-wide steps

Verified on ansible-core 2.21.3: in a play with serial: [1, 3, 6] over ten hosts, a task marked run_once: true executed three times — once for each batch, on the first host of that batch.

Read-only / Saferun_once under serial, executed on 2.21.3
$ ansible-playbook -i inv10.ini batchstop.yml
PLAY [Validation failure stops the next batch] **********************************
"msg": "BATCH h01 of 10 total"

PLAY [Validation failure stops the next batch] **********************************
"msg": "BATCH h02,h03,h04 of 10 total"

PLAY [Validation failure stops the next batch] **********************************
"msg": "BATCH h05,h06,h07,h08,h09,h10 of 10 total"

Three PLAY banners, three executions of a task that says “run once”.

For a soak pause, that is exactly right — one pause per batch is the intent. For anything that must genuinely happen once across the whole run, it is a bug, and in a kernel-upgrade context the candidates are real: taking a fleet-wide snapshot, opening a change ticket, silencing monitoring for the window, setting the one-shot boot entry on the canary.

Read-only / Safethe two idioms, side by side
# Once per batch — correct for a soak, a batch banner, a per-batch pause.
- name: Soak before the next batch
ansible.builtin.pause:
  seconds: 120
run_once: true

# Once for the whole run — correct for anything fleet-wide.
- name: Silence monitoring for the whole window
ansible.builtin.uri:
  url: 'https://monitoring.example.com/api/v2/silences'
  method: POST
  body_format: json
  body:
    matchers: [{ name: 'group', value: '{{ patch_target }}' }]
    comment: 'Kernel window {{ patch_window_id }}'
  headers:
    Authorization: 'Bearer {{ monitoring_token }}'
delegate_to: localhost
when: inventory_hostname == ansible_play_hosts_all[0]

ansible_play_hosts_all holds every host the play targets, unaffected by batching. Testing inventory_hostname == ansible_play_hosts_all[0] is true for exactly one host in exactly one batch — the first host of the first batch — which is genuinely once per run.

Knowledge check

Knowledge check · 4 questions

  1. Q1. Beyond the downtime itself, what is the strongest argument against rebooting an entire fleet simultaneously for a kernel upgrade?

  2. Q2. A rolling kernel play reboots each host and then asserts ansible_facts.kernel != kernel_before, without re-gathering facts. What happens?

  3. Q3. A rolling kernel play with serial: [1, 3, 6] over ten hosts includes a run_once task that opens a change ticket. Which statements are correct? Select all that apply.

  4. Q4. grub2-reboot and grub-reboot set a one-shot boot entry that the next boot consumes and forgets, which makes them right for testing a kernel and wrong for a rollback you intend to keep.

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