Skip to content

microvm-rootfs: build from Alpine, drop the glibc closure - #534

Merged
norrietaylor merged 4 commits into
mainfrom
slim-microvm-rootfs-alpine
Jul 29, 2026
Merged

microvm-rootfs: build from Alpine, drop the glibc closure#534
norrietaylor merged 4 commits into
mainfrom
slim-microvm-rootfs-alpine

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 27, 2026

Copy link
Copy Markdown
Member

Closes #531.

What changed

microvm-rootfs built its image by snapshotting its own runtime closure out of the build sandbox — base + git + iproute2 + e2fsprogs + util-linux, all glibc-linked. That closure was the weight.

It now assembles from pinned upstream Alpine artifacts:

  • Base: the alpine-minirootfs-3.24.1 tarball (musl libc, busybox, alpine-baselayout's /etc and mountpoints), sha256-pinned per arch.
  • Overlay: 33 sha256-pinned .apk files, resolved as a closure against the v3.24 main APKINDEX. Six are the tools busybox does not cover — bash (the in-guest minimald chroots in and runs /bin/bash), git (session bring-up shells out to it), e2fsprogs (mkfs.ext4 for the per-VM /dev/vdb), iproute2-minimal (ip), util-linux-misc (nsenter) and fstrim. The other 27 are their shared-library closure.
  • runtime_deps = []. Runtime deps get hardlinked into the build sandbox, which is what would drag glibc back in. base and e2fsprogs stay as build-only deps — bash/coreutils/tar/findutils for build.sh, mke2fs to pack the image.

The .apk files are unpacked with tar rather than apk: the guest root is read-only and nothing in the closure has an install script to run (the busybox applet symlinks and CA bundle that do need one are already applied in the minirootfs).

Two chunks of build.sh deleted themselves as a result:

  • The libblkid/libuuid soname canonicalization — that existed because our e2fsprogs and util-linux each shipped a fork with the same soname. Alpine has one provider for each.
  • The /bin/sh fallback — the minirootfs ships it.

New in its place: build.sh asserts every guest tool is present at its expected path and that the only ELF interpreter in the tree is musl's, so a leaked glibc dep or an upstream path move fails the build instead of the guest boot.

Size

staged tree image allocated image blocks used
arm64 33.7 MiB 45.0 MiB 30.1 MiB
x86_64 28.1 MiB 38.9 MiB 29.7 MiB

Against ~180 MB before. The gap between allocated and used is the pre-existing tree + 10% + 8 MiB headroom formula, left untouched — it is empty space that compresses away in the distributed artifact, and tightening it risks mke2fs -d failing to fit the tree.

Only real prune left is usr/lib/bash (~2.7 MB of loadable builtins reachable only via enable -f). Alpine splits headers, static libs, man pages and docs into -dev/-doc subpackages that are not in the closure at all, so the old prune list had nothing else to remove.

How this was verified

CI is green — minimal-check and minimal build both pass on the current head. Everything below was done in addition, outside the sandbox against the real artifacts, on Linux with GNU tar 1.35 + e2fsprogs 1.47.4 (the version this repo pins):

  • Digests: all 33 x 2 .apk digests and both minirootfs digests re-derived from freshly downloaded artifacts and diffed against the file — no transcription drift.
  • Build: build.sh run end to end for both arches. Both produce a byte-identical image across two runs separated in time, and both e2fsck -fn clean.
  • glibc: every ELF interpreter reference in the staged tree (113 of them) is /lib/ld-musl-<arch>.so.1. Zero libc.so.6 or GLIBC_2.* references.
  • Tools: chrooted into the staged tree — with no host libraries reachable — and ran each one: bash 5.3.9 (aarch64-alpine-linux-musl), git 2.54.0 (real git init + commit), iproute2-v7.0.0, nsenter 2.42.1, mke2fs 1.47.4, fstrim 2.42.1, /bin/sh. Same on x86_64.
  • tar -xof: required by minimal-check. Every entry in all 33 archives is uid 0 / gid 0, so --no-same-owner is inert on the payload; the minirootfs extraction is untouched, so /etc/shadow keeps root:shadow. The image is byte-for-byte what it was before the flag.

Risk

Per the issue: land it if integ tests are clean, defer if not. Deliberate behavioural deltas to watch, none of which a unit check would catch:

  • musl, not glibc. Anything the initramfs execs inside the chroot must be musl-linked or static. This is the reason the issue calls for minimald's static musl build if it ever moves into the rootfs; it stays in the initramfs here, so nothing was added for it.
  • No /etc/resolv.conf. The minirootfs does not ship one and the root is read-only, so guest DNS has to come from the initramfs (bind-mount or equivalent). Whether the old sandbox-snapshot image happened to carry one is not something I could determine from this repo.
  • busybox for the rest of the userland. coreutils/grep/awk/sed are now busybox applets, not GNU. ip, nsenter and fstrim are the real util-linux/iproute2 binaries precisely because they are load-bearing, but anything shelling out with GNU-only flags elsewhere would surface here.

Source mirroring

The pinned artifacts are fetched from gs://minimal-staging-archives/alpine/<branch>/..., not dl-cdn.alpinelinux.org. CodeRabbit flagged the reason and it is right: a branch's /main/<arch>/ directory holds only each package's current build, so the moment Alpine rebuilds one of these (bash-5.3.9-r1 -> -r2) the pinned filename 404s and this package stops building. 245 packages in this repo already fetch from the bucket for the same reason (see #511 for the sqlite case).

All 68 objects (33 .apk files x 2 arches, plus both minirootfs tarballs) are mirrored under alpine/v3.24/, preserving the upstream path layout one-to-one — so every object's mirror key is also its provenance, and an Alpine bump only changes the branch segment. Each was re-downloaded from dl-cdn, checked against the sha256 in build.ncl before upload, and then re-verified by reading it back out of the bucket: 68/68 match.

Follow-up

Bumping Alpine means re-resolving the whole closure against the new branch's APKINDEX, not editing entries one at a time. Both build.ncl comments say so, but a small resolver script in the repo would make that a one-command job — happy to add it here or separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

Summary by CodeRabbit

  • Improvements
    • Updated the microVM guest filesystem build to assemble the ext4 userland from pinned Alpine Linux 3.24.1 artifacts for more reproducible results.
    • Improved multi-architecture support by selecting the correct Alpine packages per target.
    • Added stricter build-time checks to confirm required guest binaries and musl loader presence, and to fail if glibc artifacts are detected.
    • Reduced unnecessary content in the guest image (including trimming bash libraries) to keep it leaner.
    • Enhanced build logs by including the staged filesystem size.

The image was assembled by snapshotting this package's own runtime closure
out of the build sandbox (base + git + iproute2 + e2fsprogs + util-linux).
Those packages carry glibc, and that closure was the bulk of the ~180 MB
image — enough to show up as download lag on the install path and as slower
VM boots.

Assemble it from pinned upstream Alpine artifacts instead: the minirootfs
tarball (musl libc + busybox + baselayout) overlaid with a sha256-pinned
closure of 33 .apk files, resolved against the v3.24 main APKINDEX. Six of
those are the tools busybox does not cover — bash, git, e2fsprogs
(mkfs.ext4), iproute2-minimal (ip), util-linux-misc (nsenter) and fstrim —
and the rest is their shared-library closure. runtime_deps is now empty, so
no package from this repo, and no glibc, reaches the image; base and
e2fsprogs stay as build-only deps for build.sh and mke2fs.

The staged tree is 33.7 MiB on arm64 and 28.1 MiB on x86_64, with ~30 MiB of
blocks used in the packed image on both. Two side effects of dropping our
packages: the libblkid/libuuid soname canonicalization is gone (Alpine has a
single provider for each rather than two forks), and so is the /bin/sh
fallback (the minirootfs ships it). build.sh now asserts each guest tool is
present and that the only ELF interpreter in the tree is musl's, so a
leaked glibc dep or an upstream path move fails the build rather than the
guest boot.

Closes #531

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 43071975-2f63-416a-9b02-b7d2f9f5a069

📥 Commits

Reviewing files that changed from the base of the PR and between 01629b4 and 375ba75.

📒 Files selected for processing (1)
  • packages/microvm-rootfs/build.sh
📝 Walkthrough

Walkthrough

The microVM rootfs build now assembles the guest filesystem from pinned Alpine minirootfs and APK artifacts instead of repository-built runtime dependencies. The build script overlays the APK closure, validates required musl-based contents, and produces the existing ext4 rootfs output.

Changes

Alpine rootfs assembly

Layer / File(s) Summary
Pin Alpine artifacts and dependency closure
packages/microvm-rootfs/build.ncl
Pins Alpine versions, maps targets to architectures, selects per-architecture APK checksums, and declares the minirootfs plus package closure as build dependencies.
Assemble and validate the guest filesystem
packages/microvm-rootfs/build.sh
Unpacks the minirootfs, overlays APK payloads, removes usr/lib/bash, validates required binaries and musl-only loader contents, and reports staged-tree size with the final image size.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BuildSpec
  participant AlpineArtifacts
  participant BuildScript
  participant RootfsImage
  BuildSpec->>AlpineArtifacts: fetch pinned minirootfs and APK closure
  BuildScript->>AlpineArtifacts: unpack and extract payloads
  BuildScript->>RootfsImage: build ext4 rootfs
  RootfsImage-->>BuildScript: report image and staged-tree sizes
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR matches #531 by replacing the glibc-linked runtime closure with pinned Alpine artifacts, removing runtime deps, and cutting rootfs size to ~30 MB.
Out of Scope Changes check ✅ Passed The changes stay focused on rebuilding the microVM rootfs from Alpine artifacts and related validation, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: switching microvm-rootfs to Alpine artifacts and removing the glibc-linked closure.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slim-microvm-rootfs-alpine

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/microvm-rootfs/build.ncl (1)

102-187: 🗄️ Data Integrity & Integration | 🔵 Trivial

Mirror these Alpine APKs or expect branch drift. packages/microvm-rootfs/build.ncl:102-187 uses v3.24/main/... URLs, which are current-branch artifacts rather than an archive. When Alpine rebuilds a package, older filenames can disappear and later fetches can 404; a local mirror/cache or documented re-resolution path would make this less brittle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/microvm-rootfs/build.ncl` around lines 102 - 187, Make the Alpine
APK inputs in the shared-library closure reproducible without relying on mutable
v3.24/main URLs. Update the apk declarations around the listed packages to use a
local mirror/cache or an archived, immutable repository source, and document the
re-resolution process required when the Alpine version changes. Preserve the
package versions and checksums as the validated closure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/microvm-rootfs/build.sh`:
- Around line 55-63: Update the archive merge command in the APK loop to avoid
applying the scratch directory’s own metadata to $STAGE, while still extracting
all payload contents and preserving existing destination-directory metadata. Use
tar’s no-overwrite-directory behavior for this extraction, keeping the
surrounding cleanup and per-APK merge flow unchanged.

---

Nitpick comments:
In `@packages/microvm-rootfs/build.ncl`:
- Around line 102-187: Make the Alpine APK inputs in the shared-library closure
reproducible without relying on mutable v3.24/main URLs. Update the apk
declarations around the listed packages to use a local mirror/cache or an
archived, immutable repository source, and document the re-resolution process
required when the Alpine version changes. Preserve the package versions and
checksums as the validated closure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fc2fac4d-e2dd-4200-8333-37030e2e61c2

📥 Commits

Reviewing files that changed from the base of the PR and between b3f3c91 and e166e7b.

📒 Files selected for processing (2)
  • packages/microvm-rootfs/build.ncl
  • packages/microvm-rootfs/build.sh

Comment thread packages/microvm-rootfs/build.sh
Two CI checkers failed on the previous commit:

- build script disallowed-patterns: build.sh:62 used `tar -xf` for the .apk
  payload merge; the repo requires `tar -xof`. Every entry in all 33 .apk
  archives is uid 0 / gid 0, so --no-same-owner is inert on the payload, and
  the minirootfs extraction is untouched, so /etc/shadow keeps root:shadow.
  Rebuilt both arches: the image is byte-for-byte what it was before.
- fmt: build.ncl reformatted with the repo's Nickel formatter. Whitespace only.
@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

A branch's /main/ directory on dl-cdn.alpinelinux.org only ever holds each
package's current build, so the moment Alpine rebuilds one of these
(bash-5.3.9-r1 -> -r2) the pinned filename 404s and this package stops
building. 245 packages in this repo already point at
gs://minimal-staging-archives for the same reason.

Mirror the 68 objects (33 .apk files x 2 arches, plus both minirootfs
tarballs) under alpine/<branch>/, preserving the upstream path layout so
every object's mirror key is also its provenance. The bump procedure in the
header comment now names the mirroring step.
@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The guest minimald points DNS at the switch's server by writing
/run/resolv.conf and bind-mounting it over /etc/resolv.conf
(minimald guest.rs, install_resolv_conf). A bind only rewrites the
mount tree, so it works on the read-only root — but the target path
has to exist first.

The old sandbox-snapshot image inherited a resolv.conf from the build
root. The Alpine minirootfs ships none, so on the assembled image the
mount fails:

  WARN installing /etc/resolv.conf for guest egress (DNS may fail)
       error=No such file or directory (os error 2)

leaving the guest with no resolver at all — anything that resolves a
name, including the in-guest `pkgs` clone during session mint, dies
with "Could not resolve host".

Stage the path as an empty file; the contents are written at runtime.

Verified on aarch64/KVM: booting the image built from this commit
emits READY and brings egress up with the warning gone, matching the
pre-Alpine image. Booting the parent commit's image reproduces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@norrietaylor
norrietaylor added this pull request to the merge queue Jul 29, 2026
Merged via the queue into main with commit 2f5e086 Jul 29, 2026
10 checks passed
@norrietaylor
norrietaylor deleted the slim-microvm-rootfs-alpine branch July 29, 2026 07:17
norrietaylor added a commit to gominimal/minimal that referenced this pull request Jul 29, 2026
)

The pin was 9 days and 62 commits stale (c854d6b1, 2026-07-20). The
motivating change is gominimal/pkgs#534, which rebuilds microvm-rootfs
from Alpine and drops the glibc closure — but the pin is linear, so this
necessarily carries everything before it too.

Materially in range:

- microvm-rootfs from Alpine (#534) — 186 MB -> 45 MB
- two libkrun vsock fixes: RX descriptor fill (#506) and packet-count
  backpressure (#512), both on the path minvmd depends on
- rust 1.97.1 (#502) and glibc 2.44 (#529) in the build stack

The rest is routine package version bumps.

Pinned at the branch tip rather than at #534's commit: pinning
mid-history buys nothing here, and the three commits after it are a
bottom bump, a graphviz bump, and a license-metadata fix.

Verified on this host: `mip materialize --arch aarch64 minvmd-rootfs`
resolves from cache and yields a 47,212,544-byte ext4 image whose only
interpreter is /lib/ld-musl-aarch64.so.1 — no glibc. The same image has
already been booted and driven through the session e2e (cold activate
4296 ms, warm ls 16 ms, sandbox proof 8030 ms), and A/B'd for cold-boot
latency against the outgoing rootfs: median 129 ms vs 154 ms, n=10 each,
non-overlapping.

This changes the guest for macOS as well, which already ships the
payload; the VM lanes are the gate.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

Slim Alpine microvm rootfs from ~180MB to ~30MB (drop glibc-linked packages)

2 participants