Firecracker backend: verify on KVM that forks of one tag never share a writable rootfs
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 25/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- linux, rust
- Domain
- backend, infrastructure
Research direction
Start by reading crates/forkd-vmm/src/lib.rs around BootConfig::ext4_rw and ext4_ro, then trace how restore_many_with handles the rootfs and how Snapshot records metadata. The issue asks maintainers to decide on the fix design before implementation, so first seek agreement on an option and any Snapshot changes. Done means restored children cannot concurrently corrupt a shared writable rootfs; verify with a concurrent-restore test.
Written by the indexing model from the issue text.
Description
Maintainer update, 2026-10-29: what this issue tracks now. The original report is kept unchanged below.
The code this report is about has left the tree.
sandstorm-vmm,sandstorm-cliandrestore_many_withwere removed in #362, along with the rest of the v0.5 Firecracker runtime; they live on only in thev0.5.3tag. #321, which fixed it on that path, was closed unmerged.Firecracker is back as an optional node backend (
sandstormd --backend firecracker,crates/sandstorm-core/src/node/firecracker.rs, #370, ADR 11). It is built so this bug cannot happen:
- Every image carries its own
rootfs.ext4, reflinked while the VM is paused, so memory and disk always come from the same moment.- Each sandbox gets a private reflinked copy in
layers/<id>/disk/. The drive path in the vmstate is the relativerootfs.ext4, and Firecracker runs in that directory, so a restore opens its own copy and never the parent's.- A restore re-points the drive anyway before resuming, in the order #321 worked out:
snapshot/loadwithresume_vm: false, thenPATCH /drives/rootfs, then resume, and the VM is killed on any failure (testsa_load_repoints_the_disk_before_it_resumes,a_failed_repoint_never_resumes).- The backend refuses to start on a work directory that cannot reflink, rather than falling back to a shared or fully copied disk.
None of that has run on KVM yet. None of the project's machines has
/dev/kvm. So this stays open as the check that the design really holds.Done when, on a KVM host with
--backend firecracker:
- Fork one tag into N ≥ 2 children concurrently.
lsofshows each Firecracker holding a different inode under its ownlayers/<id>/disk/rootfs.ext4, and none holds the template's or the tag's image.- Have every child write to the same paths for a while, then checkpoint and restore each one. Each sees only its own writes, and
e2fsck -nis clean on every disk and on the tag's image.- The tag's
rootfs.ext4hashes the same before and after the run.
Original report (v0.5, forkd-vmm)
Restores are not safe to run concurrently against one snapshot, and the failure is silent corruption rather than an error. Filing with the evidence because it cost us ~12h to attribute, and because the fix is a design decision I would rather ask you about than assume.
Mechanism
- An rw bake boots the parent with
rootfs_read_only: false(BootConfig::ext4_rw,crates/forkd-vmm/src/lib.rs:159; the ro variantext4_roat:139setstrue). - Firecracker serialises the drive's
path_on_host+is_read_onlyinto the vmstate, and reopens that path verbatim on/snapshot/load. snapshot.jsonpins the rootfs as an absolute shared path, not per-snapshot or per-child:
{ "vmstate": ".../elixir-release/vmstate",
"memory": ".../elixir-release/memory.bin",
"rootfs": "/var/cache/forkd/elixir-release-tools-local.ext4" }
restore_many_withcopies/layers memory only — the v0.5 chain assembly stagesmemory-assembled.bin; the rootfs path is untouched.
So every restored child of a tag opens the same ext4 read-write. Two children, two guest kernels, one filesystem, no coordinator:
$ lsof | grep firecrack
firecrack 2588644 root 30u REG 252,1 12884901888 4628036 /var/cache/forkd/elixir-release-tools-local.ext4
firecrack 2600514 root 30u REG 252,1 12884901888 4628036 /var/cache/forkd/elixir-release-tools-local.ext4
Same inode, both 30u (read-write).
Observable damage
Random, different victims each run, and the base ext4 still hashes byte-identical to its source image in the same session — so nothing points at the image:
/usr/bin/unameholding another file's bytes — a valid ELF of plausible size, so an "it still executes" check passes; only an identity check catches it (uname --version→uniq (GNU coreutils) 9.1)./usr/bin/trlikewise.libapt-pkg.so.6.0: invalid ELF header,libidn2.so.0: cannot dynamically load position-independent executable.EBADMSGon/var/lib/apt/lists/*and/var/lib/dpkg/triggers/File— corrupt directory entries.- The ext4's mtime changing while no bake is running.
Two consequences worth separating:
- Re-baking "fixes" it until the next concurrent job, which is what makes it look like a bad image rather than a shared writer. Restarting the service does not help either: long-lived pooled sandboxes are separate from the runner process, and it re-attaches to them, so the pool is the stale-content holder and nothing invalidates it.
- ENOSPC is an amplifier, not a second cause. On a full filesystem the diff/CoW writes fail mid-block, which is why freeing disk appeared to fix it and why it returns the moment concurrency does.
On the documented non-goal
DESIGN-v0.5-diff-snapshot-chains.md (Non-goals) says chains apply to memory.bin only and that "the rootfs gets the apt / pip-installed bytes via the install step's writes to ext4; we keep copying the rootfs whole; filesystem CoW (overlayfs / btrfs) is a separate piece of work". The stated safety assumption — the rootfs is copied whole — is not what restore_many_with does; it copies nothing. Per-child FS CoW was deferred as an optimisation, but with n-way restore it is load-bearing for correctness, which moves it in priority rather than scope.
Worth noting the same root cause explains two other reports: from-image has no content identity to key a cache on (the tag string is all there is), and the warm pool has no key to evict against — so cache staleness and pool staleness are the same missing identity.
Options
1. Read-only base + per-child writable upper (my preference). Flip the bake to ext4_ro, give each child a private upper (tmpfs, or a small per-child ext4/erofs overlaid on the ro base), and let callers pass a capacity hint so a build can size its layer. No host-side per-spawn copy, so spawn latency is unchanged and disk stays bounded by the pool rather than by accumulated writes.
2. Host-side per-child reflink. reflink_copy the base per child (12.88 GiB apparent / ~5 GiB allocated here, so near-instant on XFS/btrfs), or a dm-snapshot/qcow2 diff. More general — writes land in a real file you can inspect and GC — but it materialises per spawn and costs a lot of disk at pool scale.
3. Interim invariant. Refuse to let a second live child of an rw-rootfs snapshot exist. I want to flag the cost honestly rather than present it as free: from-image prints fork --tag X -n N --per-child-netns with rw hardcoded, and restore_many_with is built to share the inode across n children, so this rejects n > 1 on rw snapshots — a behaviour break, not just a guard. It also needs the drive's ro/rw state at restore time, which Snapshot does not persist today (the vmstate carries it, but forkd never parses vmstate).
Asks
- Which option, or a different shape? I am happy to write the DESIGN amendment and implement, and I would rather have that decided before producing a diff you then have to redirect.
- Independently of the choice: should
Snapshotpersist the drive's read-only flag? Nothing in forkd metadata records it today, so any guard or diagnostic has to infer it from the.ext4extension — and the daemon's bake path records no rootfs at all (snapshot_toreturnsrootfs: None).
Until one of these lands the only workable mode on a shared-rw tag is to keep a single writer, and nothing enforces that today. The caps that approximate it (a runner-side concurrency limit, a warm pool of one) cover only runner-driven sandboxes: anything spawning the same tag through the backend API, a CLI, or an agent is an extra writer regardless, so the rule is discipline rather than enforcement and I would not call it safe.
- Dominant language
- Rust
- Stars
- 2.9k
- Forks
- 227
- PR merge metrics
- No merged PRs in 30d
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from deeplethe/forkd
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
backend:firecracker bug
Difficulty 5/5 Over a week Newbie friendliness 28/100
-
backend:firecracker help wanted optimization
Difficulty 4/5 3-5 days Newbie friendliness 38/100
-
backend:gvisor enhancement help wanted
Difficulty 5/5 Over a week Newbie friendliness 25/100
-
backend:firecracker enhancement help wanted needs-verification
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Similar issues
-
priority middle
Difficulty 1/5 Under an hour Newbie friendliness 62/100
KATO-Hiro/AtCoderClans#12940 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
Maintainers usually reply within 1 day
-
o8 next debug build aborts on Glass to Dark appearance switch (nil identifier messaged before check)Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
hurttlocker/o8#5112 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 79/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day