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_LEN fix here still stands; the machinery around it has moved. That rework is a story for another post.