Reported symptoms
The monthly kernel patch runs across 40 application hosts at 01:00. It completes on 39. The fortieth times out.
TASK [Reboot into the new kernel] **********************************************
fatal: [app017]: FAILED! => {"changed": false, "msg": "Timed out waiting for last boot time check (timeout=600)"}
Twenty minutes later app017 still does not answer SSH or ICMP. The
on-call engineer power-cycles it through the management processor. It
comes up and stops in the same place.
What makes this confusing rather than routine:
- The play has a pre-flight check that asserts free space on
/boot. It passed on this host. - The package upgrade task on this host reported success.
- The other 39 hosts are running the new kernel and are perfectly healthy.
So the first theory is hardware, and twenty minutes go into the management processor logs, which are clean.
Evidence provided
$ grep -B2 -A6 'app017' logs/patch-0100.log | head -20TASK [Assert enough free space on /boot] ***************************************
ok: [app017]
TASK [Install the new kernel] **************************************************
changed: [app017]
TASK [Reboot into the new kernel] **********************************************
fatal: [app017]: FAILED! => {"changed": false, "msg": "Timed out waiting for last boot time check (timeout=600)"}$ grep -A4 'Install the new kernel' logs/patch-0100-verbose.log | grep -i 'warning\|space'update-initramfs: Generating /boot/initrd.img-6.8.0-52-generic
zstd: error 70 : Write error : cannot write block : No space left on device
E: mkinitramfs failure zstd 70
update-initramfs: failed for /boot/initrd.img-6.8.0-52-generic with 1.$ grep -n -A6 'Assert enough free space' patch.yml8:- name: Assert enough free space on /boot
9: ansible.builtin.assert:
10: that:
11: - (ansible_mounts | selectattr('mount','equalto','/boot') | first).size_available > 200000000
12: fail_msg: "Not enough free space on /boot to install a kernel"$ ansible-config dump | grep -i 'fact_caching'CACHE_PLUGIN(/srv/automation/ansible.cfg) = jsonfile
CACHE_PLUGIN_CONNECTION(/srv/automation/ansible.cfg) = /var/cache/ansible/facts
CACHE_PLUGIN_TIMEOUT(/srv/automation/ansible.cfg) = 604800$ stat -c '%y' /var/cache/ansible/facts/app017; jq '.ansible_mounts[] | select(.mount=="/boot") | .size_available' /var/cache/ansible/facts/app0172026-08-08 03:11:44.000 +0000
418906112$ ansible app018 -i inventory -b -m ansible.builtin.command -a 'df -h /boot'Filesystem Size Used Avail Use% Mounted on
/dev/sda2 512M 474M 11M 98% /bootWork the evidence before reading on
The guard passed and the host is unbootable. Both of those are facts, so the guard was answering a question about something other than the current state of the host.
- Compare the free-space figure the guard used against the figure a comparable host reports today. What is the difference, and what happened in between?
- The upgrade task reported
changedand success. Read the verbose output of that same task. Do the two agree? - The other 39 hosts returned. Does that tell you the guard worked on them, or only that they had more room?
Before continuing: when was the value the guard asserted on actually measured, and by what?
Root cause
1. The guard asserted on a three-day-old measurement
The assertion reads ansible_mounts, which is a fact. Facts on this
controller are cached, with a timeout of a week.
The cached entry for app017 was written three days earlier. At that
time /boot had roughly 400 MB free. Since then the previous month’s
kernel had been installed and had consumed most of it, so the real
figure on the night of the run was around 11 MB.
The guard compared 400 MB against its threshold, passed, and reported
ok. Everything about that is correct behaviour: the fact was in the
cache, the cache was inside its timeout, and the assertion evaluated a
true statement about a filesystem that had not existed for three days.
A cached fact is a claim about a host in the past. Using one for a safety decision converts the guard into a record of history.
2. The upgrade succeeded and the initramfs did not
The package manager installed the kernel package. Its post-installation step, which builds the initial ramdisk, ran out of space and failed.
Whether that failure propagates to a non-zero exit status depends on the
distribution and the specific trigger, and in this case it did not - the
package was configured, the upgrade reported success, and the module
reported changed. The failure text was in the module output, which
nobody reads on a task that reports success.
The host was left with a boot loader entry pointing at a new kernel and a truncated ramdisk.
3. The reboot task did its job and could not have helped
ansible.builtin.reboot issues the reboot, waits for the host to
respond, and runs a test command to confirm it is usable. The default
timeout is 600 seconds and the default test command is whoami.
None of that can detect a host that will never come back. The module waited the full timeout and reported it accurately. The failure message is correct and uninformative, because from the controller’s point of view a host that stops in its initramfs and a host whose power supply failed look identical.
Resolution
- Connect to the console or management processor. There is no network path to a host stopped in its initramfs, and every step below needs one.
- Confirm the diagnosis from the console. An initramfs prompt with a message about being unable to find the root device is this fault; a kernel panic or a failed hardware check is a different incident with a different fix.
- Boot the previous kernel from the boot loader menu to get a working system. Do not try to repair anything from the initramfs prompt if a working kernel is available.
- Reclaim space on /boot by removing superseded kernels through the package manager, so the boot loader configuration is regenerated correctly. Deleting files by hand leaves the boot loader referring to entries that no longer exist.
- Regenerate the initramfs for the new kernel and confirm it completes without warnings and produces a file comparable in size to the one for the previous kernel.
- Reboot once more and confirm the host returns unattended into the new kernel. Watching it come back is the only proof that matters.
- Fix the guard: measure free space during the run rather than reading a fact, and place the assertion immediately before the upgrade rather than at the top of the play.
- Fix the upgrade step so a failed initramfs generation fails the task, and add a post-upgrade check that the initramfs exists and is plausible before any reboot is attempted.
Verification
- The guard can fail. On a scratch host, fill /boot deliberately and confirm the pre-flight refuses to start the upgrade. This is the check that would have prevented the incident and it had never been exercised.
- The recovered host reboots unattended. Reboot it once more and confirm it returns without console intervention; a host that needed a person to come back is not recovered.
- The running kernel matches the installed kernel. Compare the running version against the newest installed version on every host in the fleet, not just the repaired one. This catches the silent variant where a host reboots successfully into the old kernel.
- The initramfs is intact. The file for the running kernel exists and is comparable in size to the one for the previous kernel; a truncated ramdisk is often the only visible difference.
- Free space is adequate, measured now. Run a df across the fleet in this run rather than reading any cached figure, and require headroom for at least one more kernel.
- A failed initramfs generation fails the task. Reproduce it on a scratch host and confirm the play stops before the reboot rather than after it.
- Out-of-band access works on every host that will be rebooted. Test it before the next fleet patch, not during it.
Prevention
- Guards measure, they do not remember. Any assertion about a resource that can change needs a value gathered in this run:
- name: Measure free space on /boot now
ansible.builtin.command: df --output=avail --block-size=1 /boot
changed_when: false
register: boot_free
- name: Refuse to install a kernel without room for its initramfs
ansible.builtin.assert:
that: boot_free.stdout_lines[1] | int > 300000000
fail_msg: "Only {{ boot_free.stdout_lines[1] | int }} bytes free on /boot; refusing to install a kernel."
- Put the guard immediately before the dangerous step. A check at the top of a play is a check about a state that several tasks have had a chance to change.
- Refresh or bypass the fact cache for plays that make safety decisions. Caching is worth having; caching a value a guard depends on is not.
- Treat a reboot as irreversible and give it an explicit precondition: the kernel installed, the initramfs generated and verified, the boot loader updated, and free space still adequate.
- Verify the running kernel after every patch reboot. It is the check that catches both the host that never returns and the host that returns into the wrong kernel.
- Size
/bootfor at least three kernels and prune superseded ones as part of the patch cycle. Almost every incident of this shape is a 512 MB/bootthat was adequate two kernel sizes ago. - Ensure out-of-band access exists and is tested before any fleet reboot. One host in forty not returning is the expected case, not the surprising one.