AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management
Rolling kernel upgrades across a fleet
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
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.
- 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: trueany_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.
- 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: trueuname -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:
| Outcome | reboot module | Health checks | Kernel assertion |
|---|---|---|---|
| new kernel running | pass | pass | pass |
| rebooted into the old kernel | pass | pass | fail |
| new kernel panicked, host fell back | pass | pass | fail |
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.
- 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.
$ ansible-playbook -i inv10.ini batchstop.ymlPLAY [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.
# 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
Q1. Beyond the downtime itself, what is the strongest argument against rebooting an entire fleet simultaneously for a kernel upgrade?
Q2. A rolling kernel play reboots each host and then asserts ansible_facts.kernel != kernel_before, without re-gathering facts. What happens?
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.
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.