Conversation
… rootfs read-only Firecracker freezes the drive's path and `is_read_only` into the vmstate and cannot re-point either at restore, so every child restored from a tag necessarily opens the same ext4. When a bake opened it read-write, concurrent children were separate guest kernels writing one filesystem with no coordinator: `/usr/bin/uname` picks up another file's bytes (still a plausible ELF, so an "it executes" check passes), `/var/lib/dpkg` directory entries return EBADMSG, and the damage reads as a random build failure rather than a sandbox error. The host cannot give a child its own rootfs, so the only property it can enforce is that the shared one is never written. Bakes now open the rootfs read-only (`BootConfig::ext4_overlay`) and the guest kernel mounts it `ro`; `/forkd-init.sh` supplies everything writable from guest RAM — tmpfs for /run, /dev/shm and /tmp, and an overlayfs (image content as the lower layer, tmpfs upper) for /etc, /root, /home, /opt, /srv, /usr/local and /var. The lower layer is the point: a bare tmpfs would mask the image's preinstalled toolchains and the caches the bake warmed. Writable state is guest memory, so `memory.bin` carries it into every child and it survives a BRANCH — the property the existing /tmp tmpfs already relied on, generalised. The same fact retires the concurrency hazard: children can be restored in parallel again. Two consequences, both deliberate: the writable tmpfs is capped (2 GiB default, `forkd.rw_size=` cmdline hint via `with_rw_size`) so a job that outgrows it fails its own write with ENOSPC rather than OOMing the guest; and a write outside the provided set fails with EROFS instead of silently mutating the shared base. Guest RAM must cover the writable footprint. `BootConfig::ext4_rw` and the CLI's `--rw` remain for single-writer use. Signed-off-by: jrimmer <jason@rimmer.net>
Boot-level evidence, from our KVM hostBaked a throwaway tag from a small image carrying the new Reading it:
Guest RAM was 2 GiB with the default 2 GiB writable cap, so this run wrote 20 MB twice into RAM comfortably; the cap is what a job hits first if it outgrows the budget, and The tag and the loom-in image are throwaway ( |
Gap in this PR: the writable set is a fixed path list, and the build workspace is not in itFound while writing up what CI jobs need to know, with a live restore of a tag baked by this branch: The workspace is That generalises: a fixed list of seven trees only covers write paths we happened to think of, and images differ — the Elixir image we use also carries kaniko for image pushes, which writes Three ways out, ranked:
I would like your read on 1 before I spend the effort: it is the version that makes a read-only root invisible to jobs, which seems like the property we actually want. Until then this PR should not merge, and I would not re-bake any tag with it. |
|
One more gap this PR does not cover: volumes.
The init would need the same treatment for volumes: mount read-only and put the writable layer for |
|
Withdrawing this. The approach is wrong at the platform level, not just incomplete, and I would rather say so than keep patching it. What we learned by building and booting it:
The model that fits every consumer is: immutable shared rootfs + a per-sandbox writable volume. forkd already has the second half — I am filing a follow-up issue with the full evaluation and the ordered plan. The guest-init work here is not wasted: the overlay-upper mechanism becomes the writable layer with the volume as its upper instead of tmpfs, and stays as the no-volume fallback. |
This is option A from #317, implemented.
Why A, and not the role signal
The constraints I confirmed while investigating leave only one enforceable property:
path_on_hostandis_read_onlyfrom the binary vmstate, and there is nodrive_overridesto override either. The host cannot give a child its own rootfs.Since a child cannot be handed a different rootfs, the only thing the host can guarantee is that the shared one is never written. That is what this does, and it needs no role signal — which matters, because the alternative is a boot-time protocol on the path where a wrong answer means a child writes the shared ext4, i.e. exactly the corruption being fixed. A's failure mode is bounded writable space; B's is silent corruption.
I also corrected my own first framing of A: it does not lose the warm caches. The writable upper is guest memory, and
memory.binis what a snapshot captures — the property the existing/tmptmpfs comment already relies on ("tmpfs state survives BRANCH because it lives in guest RAM, which is what memory.bin captures"). This change is that comment generalised to the rest of the filesystem, so the writable state a bake warms is present in every child.What changes
Host.
BootConfig::ext4_overlayopens the drive read-only (rootfs_read_only: true) and bootsroot=/dev/vda ro init=/forkd-init.sh. Both halves are load-bearing: the host fd and the guest mount.forkd snapshotand the daemon'screate_snapshotuse it;ext4_rwand the CLI's--rwstay for single-writer use.Guest.
/forkd-init.shbuilds the writable layer in RAM:Overlay rather than bare tmpfs for those trees, so a preinstalled toolchain or a cache warmed at bake time stays visible instead of being masked.
/etcis on the list because the existing DNS fix writesresolv.conf. Paths the image does not have are skipped (a read-only root cannot be mkdir'd into), and a write outside the set fails with EROFS — loud, rather than silently mutating the shared base.Side effect worth noting: because the writable state is memory, the rootfs a tag ships is now the pristine base, so tags baked from the same image can share a rootfs sidecar.
The two trade-offs, deliberately chosen
forkd.rw_size=<size>on the cmdline viaBootConfig::with_rw_size), so a job that outgrows it fails its own write with ENOSPC rather than OOMing the guest. Counterpart:mem_size_mibnow has to cover the job's writable footprint, not just its working set./usr/binduring a build step.Evidence, and where it is thin
cargo fmt --checkandcargo clippy --all-targets --all-features -D warningsclean;cargo test -p forkd-vmm57,-p forkd-controller114,-p forkd-cli53, all passing. Two unit tests cover the config (read-only on both sides, norwtoken,init=present, cmdline size hint).The gap is boot-level, and I want to be straight about it: the unit tests prove the arguments are what I intend, not that a guest boots, mounts
ro, and keeps the base byte-identical under two concurrent children. That needs a real bake on a KVM host, and I am running it on ours now — I will post the transcript here (guest mounts, base hash before/after, two children writing the same paths) rather than leave it asserted. Until that lands, treat the guest-side change as unverified.Existing tags keep the read-write drive their vmstate froze and need re-baking to get the layer.