Skip to content

Compose VM rootfs from shared layer blobs - #457

Closed
chruffins wants to merge 1 commit into
mainfrom
hypeship/rootfs-composition
Closed

chruffins wants to merge 1 commit into
mainfrom
hypeship/rootfs-composition

Conversation

@chruffins

@chruffins chruffins commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

summary

replace the old OCI rootfs unpack path with explicit layer-by-layer composition from the shared OCI cache.

this keeps the guest and hypervisor contract unchanged: hypeman still produces one read-only rootfs disk for the VM.

pre-existing flow

The old path asked umoci to resolve the manifest and unpack the complete image directly from the OCI layout:

pull image into shared OCI layout
  → read manifest and config
  → umoci.UnpackRootfs(complete manifest, target directory)
  → export target directory as one rootfs disk
  → boot VM with rootfs disk

Pseudocode:

function pullAndExport(image):
    pull image into ociCache
    manifest = readManifest(ociCache)
    config = readConfig(ociCache)
    validateConfigForUnpack(manifest, config)

    umoci.unpackRootfs(
        manifest,
        ociCache,
        targetDir,
        mapContainerRootToCurrentUser = true,
    )

    exportRootfs(targetDir, rootfsDisk)

new flow

The new path reads the persisted manifest model and applies each layer blob explicitly, in manifest order:

pull image into shared OCI layout
  → extract and persist manifest model
  → validate model and layer/config pairings
  → create isolated staging directory
  → apply each layer blob in order
  → replace export directory with the composed tree
  → export tree as one rootfs disk
  → boot VM with rootfs disk

Pseudocode:

function pullAndExport(image):
    pull image into ociCache
    model = extractManifestModel(ociCache)

    composeRootfs(model, exportDir):
        validateManifestModel(model)
        stagingDir = createTempDirectory(nextTo(exportDir))

        for layer in model.layers in manifest order:
            verifyCompressedDigest(layer, ociCache)
            applyLayer(
                blob = ociCache[layer.digest],
                destination = stagingDir,
                onDiskFormat = DirRootfs,
            )
            verifyDiffID(layer, decompressedLayer)

        chmod(stagingDir, 0755)
        removePath(exportDir)
        rename(stagingDir, exportDir)

    exportRootfs(exportDir, rootfsDisk)

what changed

  • Added composeRootfs, which applies shared layer blobs with umoci's DirRootfs format.
  • Preserved OCI layer semantics: whiteouts, opaque directories, hardlinks, and file/directory replacement.
  • Added manifest-model validation for layered rootfs configs, config media types, layer media types, and one diff ID per layer.
  • Re-verify compressed blob digests and diff IDs during every composition, including cache hits.
  • Applied the existing per-layer 100 GiB decompressed-size limit to image pulls.
  • Preserved tar ownership when running as root instead of mapping container root to the current user.
  • Kept the layer_unpack metric label and existing pull-failure message shape for observability compatibility.
  • Keyed temporary build directories by resolved manifest digest so concurrent builds cannot share or delete each other's staging trees.
  • Removed the old unpackLayers implementation and its manifest conversion helpers.

why one disk remains

The guest currently mounts one read-only lower disk (/dev/vda) and one writable upper disk (/dev/vdb). That contract is shared by Cloud Hypervisor, Firecracker, QEMU, and vz, so this PR composes the layers into one rootfs rather than attaching one device per layer.

Layer blobs are shared in the host-side OCI cache; the VM still receives its existing single rootfs disk. Layer-artifact reuse is a separate follow-up path.

failure and cleanup behavior

Composition happens in an isolated .compose-* directory. A successful compose replaces the destination tree. A failure or crash can leave the destination absent or strand a staging directory, so GC/reconciliation must remove stale staging trees in a follow-up.

Callers currently use fresh temporary export directories and do not read the destination concurrently.

validation

  • Two-layer fixture covering whiteouts, opaque directories, file/directory replacement, ownership, and modes.
  • EROFS export followed by fsck.erofs --extract.
  • Captured corrupt-manifest fixture still fails cleanly with the existing diff-ID pairing error.
  • Targeted image, path, build, scope, and API tests pass.
  • Docker Hub pull tests are rate-limited in this environment; VM lifecycle tests require network-bridge privileges. Both failures reproduce on unmodified main.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

Comment thread lib/images/compose.go Outdated
Comment thread lib/images/compose.go Outdated
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 2b465b6 to fb40cc1 Compare August 26, 2026 18:44
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from 5675147 to 6bde47c Compare August 26, 2026 18:50
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 6bde47c to abdceaf Compare August 26, 2026 18:52
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from abdceaf to 0d9f270 Compare August 26, 2026 18:53
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 0d9f270 to de83eaa Compare August 26, 2026 18:54
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from 2093a74 to 92dd458 Compare August 26, 2026 18:56
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 92dd458 to 5ec53e4 Compare August 26, 2026 18:58
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 5ec53e4 to e290067 Compare August 26, 2026 19:26
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from e290067 to c69e90b Compare August 26, 2026 19:30
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 3 times, most recently from d4801d7 to f24fed3 Compare August 26, 2026 19:41
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from cb42b2d to 3a95ff6 Compare August 26, 2026 19:50
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 3a95ff6 to 3f9ef7f Compare August 26, 2026 22:22
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 4500276 to a5e47e3 Compare August 31, 2026 23:13
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from a5e47e3 to c323a22 Compare August 31, 2026 23:37
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from c323a22 to fb28360 Compare September 1, 2026 16:02
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from fb28360 to e48db8d Compare September 1, 2026 16:37
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from e48db8d to 0d729b3 Compare September 1, 2026 20:41
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 0d729b3 to dac88a2 Compare September 1, 2026 21:07
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 10 times, most recently from 41aad4a to f9d76cf Compare September 3, 2026 15:05
composeRootfs merges an image's layers in manifest order, applying each
layer blob from the shared OCI cache with umoci's DirRootfs format so
whiteouts, opaque directories, file/directory replacement and hardlinks
behave as the tar stream specifies. The merged tree is exported to the
single read-only disk the guest mounts, which is why layers are shared as
blobs and artifacts rather than mounted per layer: every hypervisor keeps
its existing vda/vdb contract.

The umoci unpack path this replaces is removed with its private helpers.
Manifest validation, the pull-failure message and the layer_unpack
build-phase metric label are preserved so existing dashboards and alerts
keep working.

@sjmiller609 sjmiller609 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • If we export a full disk per image, I think that still has the issue of duplicating storage for the case of many-VMs with mostly-the-same images or all shared base. So I think that means it doesn't have a significant benefit compared to the previous way where we just have the rootfs created for each VM. I think this would have to do something where we are sharing these blobs between different VMs without duplicating them somehow, in order to get the benefit
  • doing that could potential have disk IO or other performance consequences, so I'm curious about doing benchmarking when we are swapping out the rootfs, maybe try this skill /benchmark-hypeman-fork but also try just plain fresh create, or in parallel from partially shared blobs maybe

Let me know if I'm missing something here

@chruffins

chruffins commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor Author
  • If we export a full disk per image, I think that still has the issue of duplicating storage for the case of many-VMs with mostly-the-same images or all shared base. So I think that means it doesn't have a significant benefit compared to the previous way where we just have the rootfs created for each VM. I think this would have to do something where we are sharing these blobs between different VMs without duplicating them somehow, in order to get the benefit

    • doing that could potential have disk IO or other performance consequences, so I'm curious about doing benchmarking when we are swapping out the rootfs, maybe try this skill /benchmark-hypeman-fork but also try just plain fresh create, or in parallel from partially shared blobs maybe

Let me know if I'm missing something here

so looking through #458 made me realize that PR doesn't add de-duplication so I need to rework that PR, but this PR is necessary for per-layer de-duplication that will be added in the follow-up PR. will benchmark resource utilization before re-requesting

update: benchmarking shows that this PR shouldn't be a regression for performance.

@sjmiller609 sjmiller609 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We’re changing the production build path for the rootfs in this PR, but it seems like a subsequent PR would need to change it again, but we haven't worked out how that will happen yet so I think we shouldn't merge this in yet (because we would have to change how the rootfs works twice to get to the end goal of storage deduplication). I think that exporting separate disks containing overlapping layer data is the behavior we need to avoid to avoid duplication.

Looking forward into the next PR, it looks like the approach is solving for identical-prefix sharing, but not for general layer de-duplication.

#458 still merges all layers except the last into one exported base disk. your concern applies: that disk is shared only when the entire ordered base-layer prefix matches.

for example:

image A: [ubuntu, dependencies-A, app-A]
image B: [ubuntu, dependencies-B, app-B]
the exported bases are [ubuntu, dependencies-A] and [ubuntu, dependencies-B]. both contain their own copy of ubuntu, despite sharing that input layer.

In terms of how it should work, I think we would need to be extending the existing OverlayFS setup in guest init to mount multiple shared read-only layers. Here's what hypeship says:

the established pattern is immutable shared layers plus a private writable overlay—not a separately flattened copy of every image.

the straightforward design
convert each OCI layer once into a content-addressed, read-only EROFS artifact.
expose the required artifacts to each VM without copying them.
inside the guest, mount those layers and use OverlayFS to present their ordered contents as one root filesystem.
give each VM its own writable upper layer. image layers—including the final application layer—stay shared.
for your example, both VMs reference the same ubuntu artifact, even though their dependencies differ. whiteouts and overrides determine what each guest sees; they don’t require copying the base.

this is the architecture used by containerd’s EROFS snapshotter. adopting it in hypeman still requires validating device limits, guest support, snapshot/restore, and performance across hypervisors.

We need to measure disk I/O, fanout, cold boot, and physical storage before committing to the new direction.

@chruffins

Copy link
Copy Markdown
Contributor Author

closing as superseded. the next implementation will target content-addressed artifacts for every OCI layer and compose them as multiple read-only OverlayFS lower layers, rather than introducing an intermediate flattened rootfs production path. benchmark coverage will be added before enabling the new runtime path.

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.

2 participants