There is one failure mode an A/B update system is not supposed to have, because avoiding it is the entire reason you pay for A/B: you ship an update, the device installs it, every status check returns green, yet the device quietly goes on running the old version. Not a visible rollback triggered by a crash loop. A fallback so clean that nothing upstream of the board ever changes color.
I hit exactly that on a Raspberry Pi 5 gateway running RAUC1. The distance between “the pipeline reported success” and “the new kernel never executed once” came down to a single number in the bootloader’s configuration, one that sits one layer below everything the update pipeline knows how to verify.
A kernel that grew
The change that set it off was routine. I wanted portable eBPF
tracing2 on the gateway, and portable tracing means BTF3, and
BTF is generated by pahole4 from DWARF, so the kernel has to be
built with debug info even though the running system never reads it.
Yocto’s KERNEL_DEBUG5 keeps that DWARF in vmlinux, but
vmlinux is not what ships. What lands in the FIT is linux.bin: vmlinux
run through objcopy -O binary -S, and the -S strips the non-allocated
sections, DWARF among them. The debug info never reaches the device. What
survives is the .BTF section. The kernel’s BTF generator marks it
allocable on purpose, so it lives through that strip and is present at
runtime for CO-RE to read. That surviving metadata is the weight that came
along: small, but enough to matter here. In the bundle the compressed kernel
grew from about 18 MiB to 19.9 MiB. Unremarkable, and not the number that
mattered.
The number that mattered is the one nobody watches: the decompressed size the bootloader has to land in RAM.
$ dumpimage -T flat_dt -p 0 -o /tmp/k.gz fitImage
$ gunzip -c /tmp/k.gz | wc -c
69048328
65.85 MiB, decompressed. Hold onto it.
The symptom, and where it wasn’t
The bundle built, signed, and installed without complaint. rauc status
marked slot B good, the hashes matched, the boot order flipped to B A.
The device rebooted onto slot A. Three more reboots, still A. On paper the
update had landed; in practice the new slot had never run.
The reflex is to suspect whatever changed, and what changed was the RAUC
bundle. That reflex is wrong here, and the serial console is where it
becomes obvious. The FIT loaded from the boot partition, its signature
verified against the baked-in key, the kernel subimage hash verified. Then
bootm began decompressing the kernel into its load address and stopped:
Uncompressing Kernel Image to 1314c40
Error: inflate() returned -5
Image too large: increase CONFIG_SYS_BOOTM_LEN
inflate() returning -5 is zlib’s Z_BUF_ERROR6: the output
buffer filled before the stream was done. Every integrity check upstream of
this line had already passed. The failure is downstream of all of them, in
the one operation none of them model: decompression into a fixed buffer.
One number, one layer down
That buffer is CONFIG_SYS_BOOTM_LEN. The upstream rpi_arm64_defconfig
never sets it, so the build inherits the Kconfig default7 of
0x4000000, 64 MiB. The decompressed kernel is 65.85 MiB. The arithmetic
isn’t subtle, and the fix isn’t either: set the ceiling explicitly, with
headroom.
CONFIG_SYS_BOOTM_LEN=0x8000000
128 MiB leaves the kernel sitting at half the ceiling, room for drift before this returns. One line. The interesting question is not how to fix it; it’s why nothing between the build server and the board caught a kernel that had outgrown the bootloader shipped alongside it.
Why the pipeline couldn’t see it
The bundle carries two things that have to agree: a kernel and a bootloader. They are built independently, and nothing enforces the one assumption they share: that the decompressed kernel fits the buffer the bootloader was compiled with. The kernel side assumed 128 MiB of headroom; the bootloader side shipped 64. Both halves were internally correct. Their contract was violated, and no stage in the pipeline is written to check a contract that spans them.
It couldn’t have been caught by the checks that exist, because every one of
them operates on bytes at rest. The bundle signature, the FAT-side SHA, the
FIT config signature, the kernel hash all verify that the right bytes
arrived intact. None of them can answer “will these bytes decompress into
the buffer this particular bootloader has.” That property is only
observable at bootm time, on the device, on the first boot of the new
slot, the last place you want to discover it.
And A/B did its job, which is the part that stings. Redundancy exists to
make a bad install survivable, so A/B treated a good install with a latent
incompatibility identically: slot B failed its three attempts, and slot A,
whose kernel and bootloader were last written as a mutually consistent pair,
won the fallback and booted normally. From the fleet’s point of view the
device was healthy. The only two places the truth surfaced were
/proc/cmdline reporting rauc.slot=A after an install that targeted B,
and the serial log holding the diagnostic. Neither is wired into anything
that pages you.
It surfaced on a dev image, on the bench, where the cost was an evening and a serial cable. The same silent fallback on a fielded production unit is a different bill: a device that reports a successful update and quietly keeps running the old build is the kind of thing you rediscover as a commissioning engineer’s flight to a remote site, to reflash a board that swore it was fine. Catching it here was luck of timing, not design, which is most of why it’s worth writing down.
What I’d actually change
The Kconfig line closes this instance and nothing more. A fix that relies on remembering to raise a buffer every time the kernel gains weight isn’t a fix; it’s a scheduled repeat. Three things generalize out of this, in ascending order of how much they’d have helped.
The smallest is that the CO-RE debug metadata, the DWARF and BTF info that produced the growth, has no business in the image the fleet builds by default. It’s bench equipment: needed for portable tracing while you’re developing against the kernel, dead weight in every image that isn’t.
The larger one is the coupling between kernel and bootloader, currently implicit and enforced by nothing. Make it explicit where it’s cheap to check: a build step that decompresses the kernel out of the FIT and fails the build if it wouldn’t clear the bootloader’s configured ceiling, with margin. That turns this class of bug from a field fallback into a red build. The build server is where it costs minutes; the device in the field is where it costs a debugging session and a serial cable.
The uncomfortable one is the monitoring. A pipeline that reports success at every layer while the device fails to boot the thing it just installed is not observability, it’s decoration. The signal exists: “installed to B, booted from A” is a computable fact the moment the device comes back up. It simply isn’t treated as one. Surfacing that mismatch as a first-class alert is more work than a Kconfig line, and it’s the change that would actually have caught this without anyone reading a serial log.
The first of those is the part I actually changed. The BTF/CO-RE debug
metadata now lives in its own opt-in kernel fragment, gated off by default
and kept deliberately separate from the eBPF tracing runtime, so you can
turn on bpf, kprobes and ftrace for everyday tracing without dragging
in the DWARF that inflates the image, and you only pay the size when you
explicitly ask for a CO-RE lab build. The default profile, and every
production image, never carries it and never comes near the ceiling. The
BOOTM_LEN bump stays regardless, as the floor under the dev builds that do
opt in.
The other two haven’t shipped, and the gap between them is the point. The build-time fit-check still doesn’t exist, though the bundle did grow an adjacent safeguard since: it now self-heals a set of boot-critical U-Boot environment variables from a canonical file carried inside the signed bundle. That closes a real failure mode, a stale boot script stranding an OTA’d kernel, but not this one: it corrects an environment, not a kernel that won’t decompress. And the monitoring is still exactly where it was: “installed to B, booted from A” remains a fact the system could compute on every boot and doesn’t. Which is the uncomfortable payoff of listing these in ascending order of leverage: the change that would have caught this earliest is the one still not built.
Until that changes, the only thing keeping a big kernel and a small buffer out of the same bundle is a convention. Conventions are exactly what failed here the first time.
Update (v0.5.0, July 2026). This was the boot chain as it stood in May 2026. A later change, shipped as v0.5.0, re-architected it: the signed-FIT flow was split and the non-FIT boot path removed. The
CONFIG_SYS_BOOTM_LENfix here still stands; the machinery around it has moved. That rework is a story for another post.
Gateway Yocto BSP (open source): github.com/umair-as/rpi5-iot-gateway — U-Boot config, RAUC hooks, and kernel fragments under
meta-iot-gateway/. ↩︎