Skip to content

fix(oci): convert layer whiteouts into overlayfs whiteouts - #283

Merged
CMGS merged 3 commits into
masterfrom
fix/erofs-whiteouts
Oct 17, 2026
Merged

CMGS merged 3 commits into
masterfrom
fix/erofs-whiteouts

Conversation

@CMGS

@CMGS CMGS commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Every OCI layer tar is converted with mkfs.erofs --tar=f ... and the guest stacks the blobs as overlayfs lowerdirs. Without --aufs, mkfs.erofs keeps OCI whiteout entries (.wh.<name>, .wh..wh..opq) as plain empty regular files, which overlayfs does not treat as whiteouts. So any path deleted by a later layer reappears in the VM, along with the .wh.* files themselves. This hits both cocoon image pull and cocoon image import (both go through runErofsConversion).

Fix

  • Pass --aufs to mkfs.erofs. File whiteouts and dir whiteouts become 0:0 char devices, and opaque markers become overlay opaque directories. --aufs is available in the enforced erofs-utils >= 1.8 floor.

  • Cache invalidation: blobs are cached by layer digest, so already-converted (broken) blobs would keep being reused. The blob key is now sha256("aufs:" + layerDigestHex) (blobHex in images/oci/erofs.go), used by both pull and import:

    • pulls and imports miss stale blobs and convert again;
    • isUpToDate also requires the entry's layer keys to match the expected keys, so re-pulling an unchanged manifest converts again instead of reporting "Already up to date";
    • images, VMs and snapshots that still reference old blobs keep working. Blob content behind a path never changes, which matters for snapshots;
    • the existing image GC collects the old blobs once nothing references them.

    A new blob extension or path was not used: GC scans *.erofs stems and removes BlobPath(hex), and hypervisor.BlobHexFromPath trims filepath.Ext. Old blobs would leak, or VM pins would break.

  • Commit keeps an already published blob. cocoon image import derives the temporary filesystem UUID from the label, so two imports of the same tar produce different bytes. The second commit's rename replaced the blob the first had published, and a VM created from the first image later opened a different file. Under the digest lock, a valid blob at the destination now wins, and the temp blob is left to the work dir cleanup.

  • os-image/start.sh runs the same conversion, so it passes --aufs too.

Hot-path cost: one SHA-256 over ~70 bytes per layer during pull/import; nothing on VM create/start.

Upgrade notes

  • Existing images keep their pre-fix blobs, and the whiteout bug, until they are pulled or imported again.
  • Snapshots taken before the upgrade reference the old blob paths. They restore and clone only on hosts that still hold those blobs; on other hosts the clone fails up front in VerifyBaseFiles with a missing base layer. This is intended: the snapshot's guest memory was captured against the old layer content.
  • During a rolling upgrade, upgraded and non-upgraded hosts derive different blob paths for the same layer, so a snapshot taken on one side does not clone on the other.

Tests

  • TestRunErofsConversionWhiteouts converts a layer containing a file whiteout, a dir whiteout and an opaque marker, then checks the result with dump.erofs --ls.
    CI skips this test: ubuntu-latest has no erofs-utils, and Ubuntu 24.04's packaged 1.7.1 is below the 1.8 floor. The evidence for it is the Linux container run below.
  • TestBlobHex pins the blob key derivation with a golden value.
  • TestCommitBlobsKeepsPublishedBlob commits two different temp blobs under one key and checks that the first one stays published.
  • isUpToDate gets a case where the stored entry is keyed by raw layer digests (pre-fix image), which must not count as up to date.

Evidence

Unit test in a Linux container (golang:1.27-trixie + erofs-utils)

$ mkfs.erofs --version
mkfs.erofs (erofs-utils) 1.8.6
$ go test -race -count=1 -v -run 'TestRunErofsConversionWhiteouts|TestBlobHex|TestMoveBootFileAndIsUpToDate' ./images/oci/
--- PASS: TestBlobHex (0.00s)
--- PASS: TestRunErofsConversionWhiteouts (0.00s)
--- PASS: TestMoveBootFileAndIsUpToDate (0.00s)
ok  	github.com/cocoonstack/cocoon/images/oci	1.017s

Negative control, same container, with "--aufs" removed from the args:

--- FAIL: TestRunErofsConversionWhiteouts (0.02s)
    erofs_test.go:107: //b type = "", want "3" (entries map[.wh.b:1 a:2 c:2])
    erofs_test.go:107: /a/x type = "", want "3" (entries map[.wh.x:1])
    erofs_test.go:102: /c entries = map[.wh..wh..opq:1 keep:1], want map[keep:1]
FAIL

Whole package at the current head, same container image:

$ go test -race -count=1 -v ./images/oci/
--- PASS: TestScanBootFilesNamePrefixLengths (0.00s)
--- PASS: TestScanBootFilesSkipsNonBoot (0.00s)
--- PASS: TestScanBootFilesLeadingSlash (0.00s)
--- PASS: TestCommitBlobsKeepsPublishedBlob (0.00s)
--- PASS: TestErofsVersionAtLeast (0.00s)
--- PASS: TestBlobHex (0.00s)
--- PASS: TestRunErofsConversionWhiteouts (0.00s)
--- PASS: TestRetryLayer (0.01s)
--- PASS: TestCollectBootHexesAndBootFilesPresent (0.00s)
--- PASS: TestMoveBootFileAndIsUpToDate (0.00s)
PASS
ok  	github.com/cocoonstack/cocoon/images/oci	1.031s

start.sh flags on Ubuntu 24.04's packaged erofs-utils

ubuntu:24.04 with apt-get install erofs-utils. The layer has .wh.b, a/.wh.x, c/.wh..wh..opq and c/keep, and is converted with the exact start.sh flags (--tar=f --aufs -zlz4hc -C16384 -T0 -U <uuid>), through both the gzip pipe and the plain tar path:

mkfs.erofs 1.7.1
gz-path-ok
raw-path-ok
== /
       NID TYPE  FILENAME
        37    2  .
        37    2  ..
        42    2  a
        47    3  b
        48    2  c
== /a
       NID TYPE  FILENAME
        42    2  .
        37    2  ..
        46    3  x
== /c
       NID TYPE  FILENAME
        48    2  .
        37    2  ..
        52    1  keep

End to end on a Linux KVM host (erofs-utils 1.9.4, Cloud Hypervisor)

Isolated COCOON_ROOT_DIR / COCOON_RUN_DIR / COCOON_LOG_DIR. One binary is built from this branch, the other from master b3b40f3.

  • Layer 1: crane export ghcr.io/cocoonstack/cocoon/ubuntu:24.04 (flattened rootfs).
  • Layer 2 has these entries: usr/share/.wh.doc (dir whiteout), etc/.wh.issue.net (file whiteout), etc/update-motd.d/.wh..wh..opq (opaque) and etc/update-motd.d/99-whiteout-test (new file).
cocoon image import wh l1.tar l2.tar
cocoon vm run --name wh --nics 0 --cpu 1 --memory 1G wh
cocoon vm exec wh -- sh -c '...'   # test -e on each path, ls -A /etc/update-motd.d

This branch:

6.8.0-142-generic
absent /usr/share/doc
absent /etc/issue.net
absent /usr/share/.wh.doc
absent /etc/.wh.issue.net
absent /etc/update-motd.d/.wh..wh..opq
absent /etc/update-motd.d/00-header
present /etc/update-motd.d/99-whiteout-test
motd.d: 99-whiteout-test

master b3b40f3:

6.8.0-142-generic
present /usr/share/doc
present /etc/issue.net
present /usr/share/.wh.doc
present /etc/.wh.issue.net
present /etc/update-motd.d/.wh..wh..opq
present /etc/update-motd.d/00-header
present /etc/update-motd.d/99-whiteout-test
motd.d: .wh..wh..opq 00-header 10-help-text 50-motd-news 60-unminimize 99-whiteout-test

Upgrade path: the master binary converted the tars first, then this branch's binary ran against the same root.

== blobs written by the unfixed binary
17c5a790ffb69f45744d835f0b1e766414666e52395340715944e8e773e45b2b.erofs
5a5b7e44d9504a988cd8f9189e66a93821e565323d5b2fc80e1fef97569d07a9.erofs
== fixed binary re-imports the same tars into the same root
== blobs after re-import
17c5a790ffb69f45744d835f0b1e766414666e52395340715944e8e773e45b2b.erofs
54071e80f6742404f1ddc0870e52f3d947eccef2584ebf9239e6cfe7ce54e433.erofs
5a5b7e44d9504a988cd8f9189e66a93821e565323d5b2fc80e1fef97569d07a9.erofs
c4d87074b3f6a1ee2eab3401d6495718b64b891f4539ba5db9d179fdc88e2080.erofs
absent /usr/share/doc
absent /etc/issue.net
absent /usr/share/.wh.doc
motd.d: 99-whiteout-test
== remove the old VM and image, then gc
collected blob=17c5a790ffb69f45744d835f0b1e766414666e52395340715944e8e773e45b2b
collected blob=5a5b7e44d9504a988cd8f9189e66a93821e565323d5b2fc80e1fef97569d07a9
== blobs after gc
54071e80f6742404f1ddc0870e52f3d947eccef2584ebf9239e6cfe7ce54e433.erofs
c4d87074b3f6a1ee2eab3401d6495718b64b891f4539ba5db9d179fdc88e2080.erofs

Pull path (ghcr.io/cocoonstack/cocoon/ubuntu:24.04): master pulled first, then this branch pulled the same manifest digest.

== unfixed pull: erofs blobs=2
== fixed re-pull of the same digest: erofs blobs=4
Already up to date: ghcr.io/cocoonstack/cocoon/ubuntu:24.04 (digest: sha256:9a876fbd8caab1a1f8337d3ddcb795876770c70077de14f56810de9fc37c179b)
booted 6.8.0-142-generic
== erofs blobs after image rm + gc: 0

The third pull reports up to date, and the image boots.

Gates (GOWORK=off)

$ make fmt-check                       # exit 0
$ make lint
==> golangci-lint GOOS=linux
0 issues.
==> golangci-lint GOOS=darwin
0 issues.

make test (vet + race) in the Linux container with erofs-utils 1.8.6:

  • Every package passes except meta/json, which hit the Makefile's 120 s timeout in its storm test at the default worker count.
  • With the CI workflow's settings it passes:
$ COCOON_STORM_WORKERS=2 go test -race -timeout 20m -count=1 ./meta/json/
ok  	github.com/cocoonstack/cocoon/meta/json	6.482s

CMGS added 3 commits October 17, 2026 20:25
mkfs.erofs --tar=f without --aufs keeps OCI whiteout entries (.wh.<name>,
.wh..wh..opq) as plain empty files, so the guest overlayfs never hides the
paths a later layer deletes: they reappear in every VM built from such an
image, together with the .wh.* files themselves. Pass --aufs (available in
the enforced erofs-utils >= 1.8) so whiteouts become 0:0 char devices and
opaque markers become overlay opaque dirs.

Blobs converted before this change are cached by layer digest and would keep
being reused, so blob keys now hash the conversion format with the layer
digest: pulls and imports miss the stale blobs and convert again, a pull of
an unchanged manifest no longer counts as up to date, images and VMs still
referencing old blobs keep working, and the existing image GC collects the
old blobs once nothing references them.
Two imports of the same tar convert with different filesystem UUIDs, and
the second commit's rename replaced the blob the first had published, so a
VM created from the first image later opened a different file. Under the
digest lock, a valid blob at the destination now wins and the temp blob is
left to the work dir cleanup.
start.sh ran the same mkfs.erofs --tar=f conversion without --aufs, so
the local harness kept the whiteout bug; Ubuntu 24.04's erofs-utils 1.7.1
already supports --aufs. TestBlobHex checked sha256 properties instead
of the derivation, so a silent re-key of every blob passed; it now pins
a golden value.
@CMGS
CMGS merged commit 8860ed3 into master Oct 17, 2026
4 checks passed
@CMGS
CMGS deleted the fix/erofs-whiteouts branch October 17, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant