FreeBSD live ISO — implementation report & remaining options

From the plan to a working build. cd9660 + mkuzip-compressed UFS + gunion(8) writable overlay + init_chroot pivot. Revision 3: implemented, tested with KDE Plasma 6, with a clear-eyed view of what we'd change next.

Revision history: Rev 1 — preload-based architecture with reboot -r. Abandoned after kernel source review showed vfs_unmountall(MNT_FORCE) would orphan vnode-backed mds during reroot. Rev 2 — preload + gunion + reboot -r via loader md_image. Abandoned due to UEFI staging-area limits + heavy RAM cost (entire compressed rootfs in kernel memory forever). Rev 3 (current) — cd9660 stays as kernel root, vnode-mount uzip, gunion overlay, init_chroot pivot. Working in CI.

Status: working v0.1

1. Working architecture

1.1 Boot flow

┌─ cd9660 ISO (the carrier; kernel root forever, hidden from userland after pivot) ─┐ │ /boot/kernel/kernel.gz loader auto-decompresses gzipped kernel │ │ /boot/kernel/*.ko only ~8 modules loader needs at boot (geom_uzip, │ │ geom_union, virtio_*, ahci, acpi, mfi) │ │ /boot/loader.efi UEFI loader │ │ /boot/loader.conf modules + init_script kenv │ │ /sbin/init -> /rescue/init real FreeBSD init (statically linked via rescue) │ │ /rescue/ sh, mdconfig, mount, kldload, kenv, halt, ... │ │ /sbin/geom dynamic geom (rescue's static geom can't dlopen union) │ │ /lib/, /libexec/, /lib/geom/ libs for /sbin/geom (transitively via ldd) │ │ /init.sh the pivot script run by init via init_script kenv │ │ /rootfs.uzip compressed UFS rootfs (the real live system) │ │ /rootfs.bytes uncompressed-size sidecar │ │ /sysroot/ empty mountpoint for the gunion overlay │ │ /EFI/BOOT/BOOTX64.EFI UEFI loader at iso9660 root (OVMF discovery path) │ └────────────────────────────────────────────────────────────────────────────────────┘ │ boot: loader → kernel mounts cd9660 as / → /sbin/init runs from cd9660 │ │ ▼ /sbin/init reads init_script=/init.sh, forks, execs /rescue/sh /init.sh: mdconfig -t vnode -o readonly -f /rootfs.uzip -u 0 # md0 # geom_uzip auto-tastes # → /dev/md0.uzip mdconfig -t swap -s ${UPPER_MB}m -u 1 # md1 (writable upper, # 50% of host RAM, # page-allocated) /sbin/geom union create md1 md0.uzip # /dev/md1-md0.uzip.union mount /dev/md1-md0.uzip.union /sysroot mount -t devfs devfs /sysroot/dev kenv init_chroot=/sysroot kenv -u init_script init_shell exit 0 ── after script exits, init's main() reads init_chroot kenv at the very next line (sbin/init/init.c:333), chroots into /sysroot, then continues to runcom → /etc/rc → multi-user. cd9660 stays mounted at kernel-/, hidden from chroot. The /rootfs.uzip vnode reference stays valid forever because cd9660 is never unmounted.

1.2 Why this design (and not the obvious alternative)

The "obvious" plan was preload rootfs.uzip via the loader and pivot via reboot -r. Source review of sys/kern/vfs_subr.c:5087 and sys/kern/kern_shutdown.c:543-633 showed the failure modes:

init_chroot sidesteps both: kernel mount table is unchanged (cd9660 stays mounted), nothing goes through the loader's staging area beyond the kernel itself, and only accessed pages of the uzip decompress into the page cache. This matches what Linux livecds do (cd9660 stays mounted, squashfs is loop-mounted by initramfs scripts, switch_root is a mount-namespace op rather than a kernel reroot).

1.3 The init_chroot trick (the load-bearing piece)

From sbin/init/init.c:326-336:

if (init_script kenv set)
    run_script(...)             // runs synchronously, blocks until child exits
if (init_chroot kenv set)       // re-read AFTER the script returns
    chroot(...)

The script can kenv init_chroot=/sysroot before exiting, and init reads that kenv on the literal next line. Match-perfect with helloSystem's geom_rowr branch trick. This is the only in-kernel "switch root" mechanism on FreeBSD that's workable without going through reboot -r's destructive unmount path. (FreeBSD has no pivot_root or switch_root equivalent — verified by grep across sys/kern/vfs_mount.c and sys/sys/mount.h.)

2. Measured results

2.1 ISO sizes & compression

Build variantContentLower UFSrootfs.uzipISO totalBuild time
Minimal base (no pkglist)803 MiB1.8 GiB230 MiB396 MiB~7 min
+ xorg + KDE Plasma 610.3 GiB11.3 GiB3.7 GiB3.8 GiB~36 min

2.2 Compression ratios

2.3 cdroot breakdown (KDE build)

3.7G    rootfs.uzip          ← 99% of ISO size
 19M    rescue               ← static busybox-equivalent
 16M    boot                 ← kernel.gz + 8 essential modules
 3.0M    lib                 ← libs for dynamic /sbin/geom
121K    libexec              ← ld-elf.so.1 + helpers
 38K    sbin                 ← /sbin/geom (dynamic)
 ...                         ← init.sh, rootfs.bytes, sysroot/, EFI/

2.4 Build time per phase (KDE)

PhaseTimeWhat dominates
Runner + vmactions cold start~3 mindownloading + booting FreeBSD VM image
pkg install xorg + kde~4 mindownloading 705 packages, ~2 GiB
makefs UFS at 11.3 GiB~2 minwriting zeros to disk
mkuzip zstd-19 + dedup~18 mincompressing 10 GiB real bytes — the bottleneck
cdroot prep + ISO + upload~3 mincd9660 makefs + 4 GiB artifact upload
Boot smoke-test (KVM)~13 minlonger because more pages to fault in

3. Lessons learned

3.1 Architectural lessons

  1. Linux's switch_root ≠ FreeBSD's reboot -r. Linux switch_root is mount-namespace plumbing; FreeBSD reboot -r is destructive (vfs_unmountall+remount). For a livecd with file-level overlay you want mount-namespace semantics, not reroot. init_chroot is FreeBSD's closest equivalent and works for our use case.
  2. Linux livecds don't preload the rootfs. They ship vmlinuz (~12 MiB) + initrd.img (~50 MiB) on the iso9660. The big squashfs is loop-mounted by initramfs scripts after kernel boot. We do the equivalent: kernel + ~8 modules on cd9660, the big uzip is mounted via mdconfig vnode in init.sh.
  3. UEFI loader staging is a real ceiling for the preload path. Default 64 MiB initial, expandable up to 4 GiB but expansion fails under OVMF fragmentation. Anything >~250 MiB through the loader is risky. Avoid the preload path entirely by using the cd9660-vnode-mount pattern.
  4. FreeBSD has no pivot_root or switch_root equivalent. Verified by source grep. init_chroot + chroot is the workaround. cd9660 stays kernel-/ forever; the chroot just hides it from userland.
  5. Block-level overlay (gunion) cannot wrap a filesystem-level mount. tarfs, squashfs (if it existed), and unionfs are all VFS-level — there's no block device for gunion to consume. Block-level and file-level overlays compose only with their own kind.

3.2 Implementation gotchas (each cost a CI run to discover)

SymptomRoot causeFix
Loader: cannot open /boot/lua/loader.luaCherry-picked specific files into cdroot/boot; missed Lua loader scriptsCopy whole /boot tree (now: only what's loader-needed, see below)
qemu: iothread mutex assertion in TCGqemu 8.x bug with FreeBSD long-mode transition under TCG-SMPUse -accel kvm if /dev/kvm exists; -machine q35 as TCG fallback
OVMF: BdsDxe: failed to load Boot0001El Torito EFI image was FAT12; UEFI spec requires FAT16+. Also OVMF prefers iso9660 fallback pathFAT16 ESP at 32 MiB + stage /EFI/BOOT/BOOTX64.EFI on cd9660 root
Kernel: panic: no initForgot /sbin/init + /rescue/ on cd9660 root after switching to cd9660-as-kernel-rootStage /rescue (tar pipe — see below) and /sbin/init -> /rescue/init on cd9660
Kernel: Failed to load kernel 'kernel'Gzipped kernel saved as /boot/kernel/kernel without .gz extension; loader's gzipfs triggers on suffixSave as /boot/kernel/kernel.gz
Mountroot: Mounting from ufs:/dev/md0 failed with error 2mfsroot was gzipped without .gz extension; kernel saw raw gzip bytes as md0Don't gzip mfsroot (or rename properly)
init.sh: geom: Unknown command: create/rescue/geom is statically linked, can't dlopen the union class libraryShip the dynamic /sbin/geom + libs via ldd + /lib/geom/geom_union.so
init.sh: mdconfig: ioctl(/dev/mdctl): EDOMByte-precise size with ${BYTES}b suffix wasn't 4 KiB-aligned (modern md sectorsize)Round to MiB units: ${MB}m
geom union: Upper provider md1 size too small, needs Ngunion needs upper ≥ lower × ~1.06 for its bitmap/metadata headerSize upper at max(50% RAM, lower × 1.1 + 64 MiB)
/etc/rc: mount_cd9660: /dev/iso9660/LIVECD: Invalid argumentInside chroot, mount -uw / consults kernel's mount table which still has cd9660 at /, not the chroot's view of /root_rw_mount="NO" in rc.conf — gunion union is already r/w
Boot test: hangs after SMOKE_TEST_DONEexpect's send "\x01"; send "c" to enter qemu monitor; we use -serial stdio not -serial mon:stdioclose; wait; exit 0 — SIGHUPs qemu via closing spawn
3.1 GiB ISO with 2.8 GiB /rescuecp -aR on FreeBSD doesn't preserve hardlinks (unlike GNU cp). /rescue's ~200 hardlinks to one crunchgen binary all expanded to full copies.tar pipe: (cd src; tar cf - rescue) | (cd dst; tar xf -)
makefs: minsize rounded up to ffs bsize exceeds maxsizemakefs's default 32 KiB block size requires the requested image size to be bsize-alignedRound LOWER_BYTES up to 1 MiB boundary
Build fails at makefs: cannot fit content into requested sizeFixed-LIVE_DISK_SIZE doesn't scale with package additions — adding KDE blew past the 2 GiB ceilingSwitch to LIVE_HEADROOM model: lower = content + headroom, content auto-detected
Boot: init 16 - - login_getclass: unknown class 'daemon'base.txz ships /etc/login.conf but not the compiled /etc/login.conf.db; bsdinstall normally builds it during install, which we skipRun cap_mkdb /etc/login.conf + pwd_mkdb -p on the rootfs after extraction
Boot test never sees marker, even though the boot succeededUsed "Starting local daemons:" marker — that line only prints if /etc/rc.local exists. We deleted rc.local for a clean live boot, so the line vanished too.Switch to the login: getty prompt as the marker. It always prints on a successful boot and is the strongest "system is fully up" signal.

3.3 Compression / size lessons

3.4 KDE / package lessons

4. Open architectural options

Current architecture works. Three paths to optimize further. None are mandatory; all are real tradeoffs.

4.1 Status quo: gunion + LIVE_HEADROOM (current) working

Build: makefs -s "$content + LIVE_HEADROOM" bakes empty space into the lower UFS. Runtime: gunion overlay backs writes with swap-md upper. df reports the bake-in headroom as free space.

ProsCons
  • Proven in CI on every commit
  • gunion is small, well-written (Kirk McKusick), no man-page warnings
  • Block-level semantics are simple to reason about
  • Survives any reasonable workload we can throw at it
  • Apparent free space fixed at build time, doesn't scale to host RAM
  • 16 GiB-RAM user sees same 1 GiB free as 2 GiB-RAM user
  • Wastes ~50-100 MiB of ISO size on bake-in (small, but not zero)
  • Build pipeline ships dynamic /sbin/geom + libs (~3 MiB cdroot)

4.2 Path A: gunion -s LARGER + growfs at boot unverified

Tighten the lower (no LIVE_HEADROOM at build time). At boot, create gunion with an explicit larger size, then growfs to extend the UFS to fill it. The "extra" space lives in the swap-md upper.

build:  makefs -s "${content_size}"      # tight, no headroom
        mkuzip ...

boot:   mdconfig -t vnode -f /rootfs.uzip -u 0
        TARGET_MB=$(( $(sysctl -n hw.physmem) / 1048576 / 2 ))
        mdconfig -t swap -s "${TARGET_MB}m" -u 1
        geom union create -s "${TARGET_MB}m" md1 md0.uzip
        growfs /dev/md1-md0.uzip.union     # extend UFS to fill the union
        mount /dev/md1-md0.uzip.union /sysroot
ProsCons / risks
  • Apparent free space scales with host RAM at boot
  • Smaller ISO (~50-100 MiB saved; no bake-in)
  • Faster build (mkuzip has less zero space to chew)
  • Stays with gunion (proven, simple, no stability risk)
  • ~5 lines of init.sh changes
  • Combination unverified in production; both gunion -s LARGER and growfs on union work individually but together is untested
  • growfs can fail in rare cylinder-group edge cases
  • Reads to "beyond-lower" address range need to return zeros — gunion behavior here is documented but worth confirming

Recommended next experiment. Smallest-risk way to get RAM-scaled apparent disk size while keeping gunion's stability.

4.3 Path B: tmpfs over key writable directories low risk

Keep gunion tight (LIVE_HEADROOM=0 or small). In init.sh, before chroot, mount tmpfs over /sysroot/tmp, /sysroot/var/tmp, /sysroot/var/log, /sysroot/home, /sysroot/root. These dirs get RAM-scaled scratch space.

mount /dev/md1-md0.uzip.union /sysroot
mount -t tmpfs -o mode=1777 tmpfs /sysroot/tmp
mount -t tmpfs -o mode=1777 tmpfs /sysroot/var/tmp
mount -t tmpfs tmpfs /sysroot/var/log
mount -t tmpfs tmpfs /sysroot/home
mount -t tmpfs tmpfs /sysroot/root
ProsCons
  • Trivial, mature primitives only (gunion + tmpfs)
  • RAM-scaled scratch in the dirs that matter for normal workflows
  • Linux livecds also do this in addition to overlayfs (it's idiomatic)
  • No change to gunion or build pipeline beyond LIVE_HEADROOM=small
  • Doesn't help pkg install (writes to /usr/local) or system-config edits (writes to /etc) — those still go through the (small) gunion overlay
  • Partial fix; the fundamental "write space caps at LIVE_HEADROOM for non-listed paths" is still there

Easy add-on regardless of which other path we take. Worth doing.

4.4 Path C: in-kernel unionfs + tmpfs stability uncertain

Drop gunion entirely. Mount uzip-backed UFS read-only at /lower, tmpfs at /upper, unionfs at /sysroot. File-level overlay; df reports tmpfs's free space (RAM-scaled).

mdconfig -t vnode -o readonly -f /rootfs.uzip -u 0    # md0.uzip
mount -t ufs -o ro /dev/md0.uzip /lower
mount -t tmpfs tmpfs /upper
mount -t unionfs -o below /upper /lower
# /lower is now the merged view; mount it at /sysroot via nullfs or move
ProsCons / risks
  • File-level granularity (matches Linux overlayfs semantics exactly)
  • tmpfs upper means df reports RAM-scaled free space natively
  • Lower can be sized tightly to content (no bake-in needed)
  • /rescue ships mount_unionfs already — no extra binaries on cdroot
  • Drops dynamic /sbin/geom + libs from cdroot (~3 MiB)
  • Build simpler (no LIVE_HEADROOM, no growfs)
  • Man page literally says "MAY DESTROY DATA ON YOUR SYSTEM" — irrelevant for ephemeral livecd writes but signals project caution
  • 30+ year history of "almost works" with periodic breakages
  • Recent (2024) commits suggest active fixes for deadlocks and races, but coverage isn't exhaustive
  • NomadBSD abandoned in-kernel unionfs over reroot-time deadlocks (we don't reroot, so probably not us — but the deadlock risk is real)
  • Failure modes in production: deadlock during heavy file I/O, kernel panic on rare paths

The architecturally cleanest answer if it's stable enough. NomadBSD's specific deadlock trigger (reroot under load) doesn't apply to us. Worth a CI experiment on a branch.

4.5 Rejected: unionfs-fuse explicit no

FUSE in the boot path is unwanted. Closes the option even though NomadBSD ships it in production. Listed for completeness.

4.6 Rejected (for now): port squashfs to FreeBSD kernel months of work

The 2023 GSoC squashfs-on-FreeBSD effort exists as a branch but never merged. Reviving it is weeks-to-months of kernel work. Even if done, it doesn't solve the writable-overlay problem (squashfs is also block-vs-filesystem mismatched against gunion). Real win would be ~10% smaller ISO from squashfs's tail-packing — not worth it.

4.7 Two-repo strategy

Rather than picking one architecture in this repo and discarding the alternatives, the plan is to build them as parallel repos with the same build pipeline and let the architectures be compared head-to-head:

RepoArchitectureStatus
freebsd-livecd-gunioncd9660 + uzip + gunion (block-level overlay) + init_chroot. LIVE_HEADROOM baked into the lower UFS at build time.working, v0.1
freebsd-livecd-unionfs (planned)cd9660 + uzip + in-kernel unionfs + tmpfs upper + init_chroot. RAM-scaled apparent disk size. File-level overlay; matches Linux livecd semantics.planned

Same build script structure, same pkg pipeline, same boot-test harness. The two will diverge only in the overlay layer (init.sh + cdroot's overlay-tooling files). Real measurements from each will let us compare ISO size, build time, runtime memory, and stability rather than guessing.

4.8 Comparison summary

OptionApparent FS size scales with host RAM?RiskEffort
Status quo (gunion + LIVE_HEADROOM)Nonone0 (current)
A: gunion + growfsYesmedium (combination unverified)~5 lines init.sh
B: tmpfs overlays for /tmp /home /var/tmpPartial (those dirs only)low~5 lines init.sh
C: in-kernel unionfsYesmedium-high (stability)~15 lines init.sh + cdroot changes
A + B togetherYes (full + scaled scratch)medium~10 lines init.sh

5. Recommendations

5.1 For v0.1 (now)

  1. Ship what works. Tag the current commit as v0.1. CI is green; minimal ISO is 396 MiB; KDE ISO works.
  2. Add release.yml to attach the ISO + sha256 to GitHub Releases on tag push.
  3. Update README size table with the measured numbers (replace aspirational with actual).

5.2 For v0.2 (low-risk improvements)

  1. Add tmpfs mounts for /tmp, /home, /var/tmp, /var/log (Path B). Trivial change, mature primitives, real win for live-session workflows.
  2. Make LIVE_HEADROOM configurable per-build via env var (already done). Document the trade.

5.3 For v0.3 (architectural experiment)

Try Path A on a branch. Smallest delta from current architecture; biggest user-visible win (RAM-scaled apparent disk). If gunion -s LARGER + growfs compose cleanly in CI's boot smoke-test, merge. If not, the branch is throwaway.

Skip Path C unless Path A fails. The unionfs stability risk isn't justified by the marginal additional gain (file-level granularity vs block-level — practical difference is small).

5.4 What we wouldn't change

6. References

This project

FreeBSD source citations (current main, commit 045a9ef829fa)

External — what we cribbed and what we didn't

Tools


Generated 2026-05-05. Updates revision 2 with measurements from the working CI build (run 25349540268, minimal, 396 MiB ISO; run 25350809866, KDE, 3.8 GiB ISO) and the architectural options surfaced during implementation. The plan is now an "implementation report + remaining options" document; the architecture section is now a "what's actually working" rather than "what we plan to build". The open architectural questions (RAM-scaled apparent free space) have three concrete paths forward, none of which are required for v0.1.