Repository navigation
Description
Consolidated design addressing #437, #120, #166, #228, and unblocking #223/#276/#235/#220. Supersedes the flattened-per-digest cache sketched in #228 with a per-layer store (same overlayfs consumption model, adds cross-image layer dedup).
Problem
Image handling in the atelet has no persistent cache and duplicates work at every level. Current behavior (cmd/atelet/internal/memorypullcache/memorypullcache.go, cmd/atelet/oci.go):
- Images are pulled with go-containerregistry and immediately flattened via
mutate.Extract, destroying layer identity. - The only cache is an in-memory LRU of whole flattened tarballs: digest-keyed only (tag refs are never cached), bounded by entry count with no byte accounting, lost on atelet restart, shared with nothing.
- The rootfs is fully re-untarred on every actor run/resume (
prepareOCIDirectory→untar;resetActorDirswipes the bundle dir between runs). - Layers shared between images are downloaded, stored, and unpacked once per image, not once per node.
Measured impact from existing issues:
- Resume latency is ~99% rootfs extraction: untar ~15–20 s vs.
runsc restore~268 ms (Avoid re-untarring actor image rootfs on every restore #166, Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228). - The in-memory cache's 100 MiB guard never fires —
v1.Image.Size()returns manifest size — and the count-bounded LRU retains one flattened copy of every digest ever pulled: atelet RSS grew 0.8 → 15.3 GiB over 4 days, OOMKilled, stranding actors inSTATUS_RESUMING(atelet: MemoryPullCache retains full image tarballs indefinitely (LRU bounded by entry count; the 100 MiB guard measures manifest size) #437). mutate.Extractitself spikes memory on multi-GB images (all layer decompressors/response bodies held open); observed OOM at a 64 GiB pod limit (atelet OOMs on multi-layer multi-GB images #120).- Cold pulls of large images death-loop against the 28 s workflow deadline (Allow large image pulls to succeed by decoupling workflow deadline from lock TTL #233).
At target scale (up to hundreds of images per node, ~1.5 GB compressed each, ~40% shared layers), flattened per-image storage needs ~1 TB where layer dedup needs ~650 GB — and every shared base layer is re-downloaded per image.
Related TODOs: memorypullcache.go:51; roadmap "Shared image cache", "Peer-to-peer OCI and snapshot sharing".
Proposed design
A content-addressed, on-disk layer pool owned solely by the atelet (single-writer — no daemon, no leases DB; precedent: the digest-named asset cache in cmd/atelet/sandbox_assets.go).
On-disk layout
<cache-root>/ # flag, NOT /run (see #30); default under /var/lib/atelet
layers/sha256/<diffid>/ # unpacked layer trees (overlayfs lowerdirs) — stored ONCE per node
manifests/sha256/<digest>.json # manifest + config + ordered diffid list + recorded sizes
refs/<escaped-tag-ref> # tag → digest resolution cache (TTL)
pins/<digest> # expiring preload leases
version # layout version marker
Layers shared by N images exist once; an "image" is only a manifest record listing its layers in order. Compressed blobs are not retained after unpack (revisit with p2p sharing). Everything survives atelet restart and node reboot. Startup recovery: sweep orphaned *.tmp-* dirs, load manifest index, rebuild mount pins from /proc/mounts, drop expired preload pins. Running actors are unaffected by atelet restarts (overlay mounts are kernel state) — this also removes the #437 collateral where a restart mid-restore strands actors.
Sizing note for operators: the cache root must live on a volume sized for both capacity and IOPS — on GKE, disk size gates IOPS, and an undersized volume throttles unpack throughput (cf. #30's /run trap).
Pull path (replaces MemoryPullCache.Fetch + mutate.Extract)
- Resolve ref → manifest digest (
remote.Headfor tags — fixes tag refs bypassing the cache, and provides the resolved-digest return value Allow tag-based ActorTemplate images by persisting resolved digests with snapshots #223 needs). Record multi-arch index→platform mapping under both digests (fixes the keying caveat atmemorypullcache.go:183). - For each layer missing from the pool: download → verify digest → decompress → untar in one stream into
layers/sha256/<diffid>.tmp-<rand>, atomic rename. Parallel layer downloads (bounded, ~4). Memory use is O(stream buffers), independent of image size — closing atelet OOMs on multi-layer multi-GB images #120's failure mode structurally. - Singleflight by diffid and by manifest digest: concurrent actor starts of overlapping images never duplicate a download or unpack.
- Layer unpack via
containerd/containerd/archive.Apply(correct OCI whiteout → overlayfs conversion, opaque xattrs, path safety) — as suggested in the atelet OOMs on multi-layer multi-GB images #120 comments. Do not hand-roll. - Auth is an injected authenticator interface, not the current hardcoded GCP special case — leaves the door open for Feature request: Support 3rd party private container registries + authentication like EKS #432/cache credential bundle instead of reading from file path on each request #459 without expanding scope here.
Rootfs composition (replaces per-run untar)
- Per actor start: read manifest record, assemble
lowerdir=from layer paths (reversed — OCI lists bottom-first, overlayfs wants top-first),mkdirper-actorrootfs/,upper/,work/under the existing bundle path, onemountsyscall. Milliseconds regardless of image size. - gVisor path: bundle
rootfs/inprepareOCIDirectorybecomes the overlay mountpoint; runsc unchanged. Hardlink/reflink alternatives are rejected per Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228's analysis (rootfs is writable; reflink is fs-dependent). - microVM path:
ReconstructSharedDirFromImagemounts a lowerdir-only (implicitly read-only) overlay for virtiofsd; the guest keeps building its own tmpfs upper inStartOverlayWorkload. Single-layer images: RO bind mount. - Semantics preserved: per-actor writes land in
upper/, wiped byresetActorDirsbetween runs — identical "pristine rootfs per run" contract as today. Image content becomes physically immutable (shared RO lowers) while the actor keeps a writable rootfs — the exact "image mount ro + writable overlay" outcome the Should actor rootfs be read-only? #235 thread converged on, and the mechanical lower/upper split [Feature] Distinct lifecycle for “rootfs” and memory snapshots vs. “working” space. Needs API surface of where to mount. #220 needs. - Teardown: unmount
rootfs/before the bundle-dir wipe; decrement layer pins on unmount. - Known limits (document only): mount-option page-size cap (~60–80 layers with long paths; mitigate via short dir names or
lowerdir+=), ~500-lowerdir kernel cap.
Layer materializer interface (future-proofing for image streaming)
Defined from day one: EnsureLayer(diffid, descriptor) → path, Release(diffid), SizeOf(diffid); untar backend first. A future lazy-pull backend (eStargz/SOCI-style FUSE serving the layer path, cf. stargz-store) plugs in without redesign; full pull remains the fallback for non-streamable images. Invariants: no code outside the materializer walks/mutates/deletes layer dirs or assumes plain directories; all mounts centralized in the atelet.
GC / eviction
- Watermark-driven (kubelet-style: evict at high watermark, stop at low) plus
--image-cache-max-bytes. Sizes recorded at unpack time — neverduat eviction. - Protection hierarchy, then LRU by image last-use:
- layers of actively mounted images (in-memory refcounts, rebuilt from
/proc/mounts) — never evicted; - layers of images with an unexpired preload pin — never evicted;
- everything else — LRU candidates.
- layers of actively mounted images (in-memory refcounts, rebuilt from
- A layer is deleted only when its last retained referencing image is gone.
Control-plane integration (aligned with the #276 NodeInventory direction)
- Inventory reporting: atelet periodically reports cached image digests (and later rootfs/layer state) through the node-bound, TTL-backed NodeInventory mechanism proposed in the Prefer workers with cached actor images during scheduling #276 discussion — soft preference only; stale data costs latency, never correctness. No standalone one-off RPC/store key (per the Phase 0 consensus there; rebase target for PR Prefer workers with cached actor images during scheduling #298).
PreloadImage(ref, ttl)— async; runs the normal pull path with no actor, for when the control plane knows actors will land on a node 1–2 min ahead. Creates an expiring pin (default ~10 min, persisted inpins/) so GC can't evict before actors arrive; converts to a mount pin on first actor start, lapses to normal LRU on expiry (mispredictions can't leak disk). Actors arriving mid-preload join in-flight downloads via singleflight. Admission check: fail loudly if unique size can't fit even after maximal eviction. On-demand pulls take download-slot priority over preloads. Contract: best-effort; verify via inventory. Also mitigates Allow large image pulls to succeed by decoupling workflow deadline from lock TTL #233's cold-pull deadline exposure (though the lock-TTL/deadline decoupling in Allow large image pulls to succeed by decoupling workflow deadline from lock TTL #233 is still needed independently).
Implementation phases
Phase 0 — immediate mitigation (independent of the cache):
- Bump go-containerregistry to ≥ v0.21.6 (upstream fix for the atelet OOMs on multi-layer multi-GB images #120 extract memory spike)
Phase 1 — core cache (the performance win):
- Layer pool + manifest records + tag resolution (
refs/) + version marker; cache-root flag + sizing docs - Streaming pull path: parallel downloads, singleflight,
archive.Apply, atomic rename; injectable authenticator - Materializer interface with untar backend
- Overlay composition in
prepareOCIDirectory; unmount-before-wipe ordering vsresetActorDirs - microVM path: RO overlay into virtiofsd shared dir
- Startup recovery (tmp sweep, index load,
/proc/mountspin rebuild) - Delete
memorypullcache(closes atelet: MemoryPullCache retains full image tarballs indefinitely (LRU bounded by entry count; the 100 MiB guard measures manifest size) #437's bug class) - Spike: validate overlayfs-backed rootfs under
runsc restoreend-to-end (Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228 flagged this; expected orthogonal since restore only needs correct rootfs content)
Phase 2 — GC + observability:
- Size accounting, watermark eviction, layer refcounts, preload-pin persistence
- Metrics/traces: cache hit/miss, bytes downloaded vs reused, rootfs-materialization time (asked for in Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228), cache size, evictions
Phase 3 — control-plane integration:
- Report cached digests via NodeInventory (Prefer workers with cached actor images during scheduling #276 Phase 1/2 shape)
-
PreloadImageRPC with expiring pins + admission check + download prioritization
Later / out of scope: lazy-pull backend (eStargz/SOCI), blob retention + p2p/GCS sharing (roadmap), generic registry credentials (#432), credential-bundle caching (#459), snapshot-manifest persistence for tag-based ActorTemplates (#223's control-plane half — enabled by this pull path).
Rejected alternatives
- containerd daemon: operational weight, no shim story for the microVM path, against the in-process design.
- Flattened per-image disk cache (Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228's initial shape, or extending memorypullcache to disk): loses cross-image layer dedup (download + disk + page cache), loses delta-efficient image updates, no per-layer seam for a streaming backend.
- Hardlink/reflink rootfs cloning: unsafe (writable rootfs truncates shared inodes) / fs-dependent, per Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228's analysis.
- Persisting per-actor upperdirs: would silently change the "pristine rootfs per run" actor contract; out of scope.
Dependencies
Reuse: go-containerregistry (already vendored; bump ≥ 0.21.6), containerd/archive (new), image-spec/go-digest (promote from indirect), x/sync/singleflight. Nothing transplantable from kubernetes/kubernetes (kubelet delegates image management to the CRI runtime); its watermark GC policy shape is reimplemented trivially.
Related issues
Closes #437, #166, #228; closes #120 together with the Phase 0 bump. Enables #223, #276 (NodeInventory consumer), #235, #220. Complements #233 (deadline decoupling still required for first-ever cold pulls).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn Progress
Activity
Bump go-containerregistry to v0.21.7 to fix OOM during image extract
dberkov commentedon Jul 20, 2026
Phase 1 implementation is up: #467 (stacked on #464). Two deliberate deviations from the design above, called out in the PR description: mounting/whiteout materialization lives in ateom rather than atelet (atelet drops all capabilities), and layer unpack extends the in-repo hardened untar instead of vendoring containerd/archive (whose Apply assumes CAP_DAC_OVERRIDE). Validated on kind + GKE (both runtimes) and against 411 SWE-bench-scale images with 0 failures.
Bump go-containerregistry to v0.21.7 to fix OOM during image extract (#…
Add node-local OCI image layer cache (imagecache), replacing memorypu…
Add node-local OCI image layer cache (imagecache), replacing memorypu…
Add node-local OCI image layer cache (imagecache), replacing memorypu…
Add node-local OCI image layer cache (imagecache), replacing memorypu…
igooch commentedon Jul 30, 2026
Picking up Phase 2 (GC + observability). Before writing the POC I compared the design against
kubelet's image GC, containerd's GC, Go's allocate-black rule, and the kernel (overlayfs, mount
namespaces, ext4 orphan recovery), then built a POC (
phase2-gc-record-first) and ran validationon Kind and GKE. Requesting sign-off on seven amendments to #463's Phase 2 section before the
engine PR goes up.
Proposed Amendments
/proc/mounts. Since Add node-local OCI image layer cache: overlay-composed actor rootfs from shared unpacked layers (#463 Phase 1) #467, mounts live in the ateoms'namespaces — atelet can't see them. Instead, scan the per-bundle
rootfs-overlay.jsonspecs(written before mount, deleted after unmount) and add an
imageDigestfield to the spec; itswrite becomes atomic.
atelet is stateless and ateom unmount is best-effort, so there is no reliable decrement point.
(kubelet's
MinAge).unpack loop. Every layer is then referenced before it can exist, so a pull's protection is
ordinary reachability — durable across restarts, no extra mechanism. This is the unanimous
pattern in the sources (Go allocates black, containerd creates the ingest record in the same
transaction, ext4 journals the orphan entry with the allocation). Record-last appears to be
incidental in Phase 1 (Design: on-disk, layer-deduplicated OCI image cache for atelet #463 doesn't specify it; every reader already tolerates partial records)
— Dmitry Berkovich (@dberkov) please contradict if there was intent.
.rm-*(with non-blocking pre-checks) → async remove. If any layer must be kept (min-age,in-flight, rooted), atomically restore the record. Invariant: no layer is ever left
unreferenced by a live process. The kernel offers no safety net here — deleting a live lowerdir
is silently undefined.
raw formula can demand far more than the cache holds (measured: 18.9 GiB asked of an 11 MiB
cache), evicting everything every tick.
unreferenced layers, so recovery is bounded and runs once in
New, before any pull exists. (Anearlier per-pass sweep was an online fsck — review 2 showed it deleting layers under in-flight
pulls — and was withdrawn, along with per-pull pins, which rooted digests via records that didn't
exist mid-pull. Phase 2 has no pins; they return in Phase 3 for preloads, where a record
exists to root.)
Unchanged from #463: high/low watermarks,
--image-cache-max-bytes, LRU by image last-use, sizesrecorded at unpack (never
du). All on-disk changes are additive; layoutversionstays"1".Validation
startup; hostile loop (min-age 0, GC every 1 s, cold cache) repeatedly clean.
Full-scope process memory survived six evict/resume cycles (Use a node-local overlayfs rootfs cache to eliminate per-restore untar during actor resume #228's flagged risk).
-racegreen throughout; net −309 lines versus using an alternative approach ofpins/in-flight/sweep.
Decisions needed
--image-cache-pinned-images? Leaning accept.enabled;
--image-cache-gc-dry-runcovers the soak.Filed #641 separately: actor deletion never cleans the actor's node directory (atelet has no delete
RPC). Deleting a crashed actor — no Checkpoint after its last Run — leaks its bundle dirs, whose
specs then root its image forever. Pre-existing, outside GC, hit twice during validation; noting so
conservative over-retention isn't mistaken for a GC bug.
If the amendments look right, I'll start posting the foundation PRs while the engine PR is under
discussion.
imagecache: record layer sizes at unpack and image last-use on cache hit
imagecache: record image digest in bundle overlay specs; atomic Write…
imagecache: drop issue/phase references from comments
serverboot: dynamic --log-level flag for the server binaries
imagecache: record layer sizes at unpack and image last-use on cache …
imagecache: two-phase layer retirement primitives + .rm-* startup swe…
imagecache: record image digest in bundle overlay specs; atomic Write…
serverboot: dynamic --log-level flag for the server binaries (#677)
imagecache: write the image record before unpacking layers (#717)
imagecache: eviction engine — root set, EvictUnused, startup orphan r…
atelet: watermark-driven image cache GC loop (#836)
validate-image-cache: drive eviction through Store.EvictUnused + GC l…
tomergee commentedon Aug 18, 2026
Review of CUJs to look into igooch sandeep-gh-acc Tomer Glottmann (@tomergee) :