Skip to content

feat: use node-local overlayfs rootfs cache to eliminate per-restore untar (#228)#283

Open
chenggui53 (chenggui53) wants to merge 9 commits into
agent-substrate:mainfrom
chenggui53:worktree-overlayfs-rootfs-cache
Open

feat: use node-local overlayfs rootfs cache to eliminate per-restore untar (#228)#283
chenggui53 (chenggui53) wants to merge 9 commits into
agent-substrate:mainfrom
chenggui53:worktree-overlayfs-rootfs-cache

Conversation

@chenggui53

Copy link
Copy Markdown

Summary

Implements #228: cache one extracted, read-only rootfs per immutable image digest on each node, and materialize each actor's bundle as a thin overlayfs mount instead of re-untarring the whole image on every restore.

Problem

Every Restore call in atelet fully reconstructs the rootfs by pulling and untarring the OCI image — even when the same image digest has already been extracted on the same node many times before. Observed cost:

  • prepareOCIDirectory (untar rootfs): ~15–20s
  • runsc restore (checkpoint restore): ~268ms

Rootfs extraction dominates resume latency by ~99%.

Solution

Change the scaling behavior from "every restore pays extraction cost" to "first restore of a digest on a node pays extraction; later restores pay only an overlay mount."

On first use of an image digest on a node:

  • Pull + extract the flattened rootfs once into a node-local, read-only cache directory keyed by the image digest: /var/lib/ateom-gvisor/rootfs-cache/<sha256>/lower/

On every restore using the same digest:

  • Instead of RemoveAll + untar, set up an overlayfs mount for the actor bundle's rootfs:
    • lowerdir = the cached, read-only extracted rootfs (shared, never mutated)
    • upperdir + workdir = per-actor, actor-private writable layers

Changes

File Operation Description
internal/ateompath/ateompath.go Modified Added RootfsCacheDir and RootfsCacheLowerDir()
cmd/atelet/internal/rootfscache/rootfscache.go New Core cache module: EnsureRootfs, Untar, ValidateTarName, LRU eviction, concurrent dedup
cmd/atelet/internal/rootfscache/rootfscache_test.go New Unit tests: cache miss/hit, concurrent safety, partial cleanup, eviction, digest validation
cmd/atelet/overlay.go New overlayfs mount/unmount helpers + isOverlayfsAvailable()
cmd/atelet/oci.go Modified prepareOCIDirectory integrates overlayfs path with untar fallback; extractDigestFromRef; unmountActorRootfs
cmd/atelet/main.go Modified Creates rootfs cache, wires into AteomHerder, resetActorDirs adds unmount before cleanup

Key Design Decisions

  1. Fallback safety: tag-only refs (no digest) automatically fall back to the existing untar path
  2. Concurrent safety: per-digest inflightEntry dedup — N goroutines requesting the same digest only trigger 1 untar
  3. Crash safety: .ready sentinel file; loadIndex auto-cleans partial entries from previous crashes
  4. Eviction: LRU by .last_access timestamp, async trigger, 20GB default cap
  5. Unmount cleanup: resetActorDirs does MNT_DETACH unmount on overlayfs rootfs before RemoveAll

Expected Impact

Scenario Before After
First restore (same node + digest) ~15-20s ~15-20s (populates cache)
Subsequent restore (cache hit) ~15-20s <1s (overlayfs mount)
Checkpoint restore ~268ms ~268ms (unchanged)
Total resume latency (cache hit) ~15-20s <1.3s

Testing

  • ✅ All unit tests pass (go test ./cmd/atelet/...)
  • ✅ Rootfscache tests: cache miss, cache hit, concurrent misses, partial entry cleanup, LRU eviction, digest validation
  • ✅ Existing oci_test.go tests continue to pass (untar, path traversal, symlink escape, hardlink escape)
  • ✅ Built and deployed to kind cluster — atelet running, rootfs-cache directory initialized, overlayfs kernel module available

Open Questions (for follow-up)

  • Eviction policy tuning (size cap, reference counting)
  • Tag-based images without digest — should we resolve digest via HEAD request?
  • Observability: cache hit/miss rate metrics (counters already added, need Prometheus dashboard)

@google-cla

google-cla Bot commented Jun 22, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@chenggui53 chenggui53 (chenggui53) force-pushed the worktree-overlayfs-rootfs-cache branch from 2b2efc3 to 970a309 Compare June 22, 2026 07:40
@BenTheElder

Copy link
Copy Markdown
Collaborator

I'd like to hold this one momentarily while we land #123 (one of our POC2 / alpha milestone requirements), as we need to make it work there too and there's already a lot in flight.

I agree that we should do something roughly like this though, and appreciate the PR.

Comment thread cmd/atelet/internal/rootfscache/rootfscache.go
Comment thread cmd/atelet/internal/rootfscache/rootfscache.go
Comment thread cmd/atelet/overlay.go Outdated
Comment thread cmd/atelet/oci.go Outdated
Comment thread cmd/atelet/oci.go Outdated
Comment thread cmd/atelet/oci.go
@BenTheElder

Copy link
Copy Markdown
Collaborator

Note: if you rebase on main we have uVM now (including e2e tests), also FYI #346.

uVM is already mounting the image rootfs ro with a tmpfs upper to handle writes, similar to gVisor. I'm not sure we need the overlay on the atelet side vs just caching the unpacked images. We would need overlay if we started handling image layer sharing but that has other complexity.

Implements issue agent-substrate#228: cache extracted rootfs per image digest on each
node, and materialize per-actor bundles as overlayfs mounts instead of
re-untarring on every restore.

Changes:
- New rootfscache package (cmd/atelet/internal/rootfscache)
- New overlay.go with mount/unmount helpers
- Modified prepareOCIDirectory for overlayfs integration
- Updated resetActorDirs to unmount before cleanup
- Added rootfs cache paths to ateompath
- Unit tests for cache hit/miss, concurrent access, eviction
EnsureRootfs took an io.Reader that oci.go always populated via
pullCache.Fetch before the call, so every resume pulled and extracted
the image into the memory pull cache even when the on-disk rootfs cache
already had it. Change the parameter to a lazy tar provider closure that
EnsureRootfs invokes only on a cache miss; on a hit it returns the
on-disk lowerDir without pulling or extracting anything.
@chenggui53 chenggui53 (chenggui53) force-pushed the worktree-overlayfs-rootfs-cache branch from d44ee35 to db0e31b Compare July 6, 2026 07:00
atelet runs with all capabilities dropped and can no longer call
mount(2), so it cannot perform the overlayfs rootfs mount itself. On a
rootfs-cache hit atelet now writes a per-bundle "overlay-lower" marker
recording the read-only lowerdir; the privileged ateom worker reads the
marker and mounts the overlay just before `runsc create`, in ateom's
mount namespace (shared by the runsc child, invisible to atelet).

- ateompath: add ContainerRootfsDir/OverlayUpperDir/OverlayWorkDir/
  OverlayLowerMarkerFile so both sides agree on paths by convention.
- atelet: write the marker instead of mounting; drop unmountActorRootfs
  and the reset-time unmount loop (mounts live in ateom's ns).
- ateom: mount overlays in Run/Restore (with failure-path unmount) and
  unmount in Checkpoint so a long-lived ateom does not leak mounts.
Two leak/idempotency gaps in the overlay rootfs lifecycle:

- CheckpointWorkload unmounted only after the checkpoint calls, so an
  early-return checkpoint failure skipped the unmount and leaked the
  mount into a long-lived ateom. Move the unmount to a defer registered
  before the checkpoint so it runs on every exit path.
- mountOverlayRootfsIfRequested now best-effort unmounts the target
  before mounting, mirroring setupActorNetwork's stale-network cleanup.
  This makes re-mount idempotent (no stacked overlays on actor reuse)
  and closes the window from any prior leaked mount at the same target.
LRU eviction could os.RemoveAll a lowerdir currently backing a live
actor's overlayfs mount, which the kernel forbids modifying and would
corrupt the running actor. atelet restarts independently of the actors
whose overlays live in ateom's mount namespace, so an in-memory refcount
is not restart-safe.

Add a restart-safe, on-disk in-use signal: overlayLowerInUse scans the
per-container overlay-lower bundle markers (written before ateom mounts,
removed only at teardown) into the set of lowerdirs in use, injected into
the cache via the new WithInUseFunc functional option. evictIfNeeded and
EvictLRU skip pinned entries and, on provider error, skip the pass
entirely (exceeding the disk budget is safe; deleting a live lowerdir is
not).
Only the untar path needs to pre-create the identity bind target: runsc
resolves the destination against the extracted image there. On the overlay
path the pre-created dir is shadowed by ateom's later overlay mount, and
runsc auto-creates the missing target into the upperdir anyway.
Two rootfs-cache improvements:

- Trigger one eviction pass in New (after loadIndex) so a node that boots
  already over budget reclaims disk immediately, rather than waiting for the
  next cache miss. Fixes a steady-state, all-hits node never shrinking.

- Untar now returns the total regular-file bytes written, and extract uses it
  to size the cache entry, dropping the extra full-tree dirSize walk on the
  miss path. Hardlinks are no longer double-counted.
…ailure

The overlay rootfs mount is performed by the privileged ateom worker
after atelet has already returned from bundle preparation, so atelet
cannot fall back in-band; it only learns of a mount failure through the
RPC error. Introduce internal/overlayfallback as the cross-process
contract: ateom wraps mount failures as a FailedPrecondition status
carrying a marker that survives the server interceptor's status rebuild,
and atelet recognizes it, re-prepares the bundles with a plain untar
(clearing the overlay-lower marker), and retries the RPC once.

This guarantees a working rootfs even when the overlay mount fails,
addressing R5 (ensure fallback to untar on mount failure).
Add --rootfs-cache-max-bytes to atelet, defaulting to
rootfscache.DefaultMaxCacheBytes (20 GiB), replacing the hardcoded 0.
Behavior is unchanged by default; operators can now tune the node-local
overlayfs rootfs cache budget without a rebuild.
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.

3 participants