You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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; resetActorDirs wipes the bundle dir between runs).
Layers shared between images are downloaded, stored, and unpacked once per image, not once per node.
mutate.Extract itself 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).
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).
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.
Per actor start: read manifest record, assemble lowerdir= from layer paths (reversed — OCI lists bottom-first, overlayfs wants top-first), mkdir per-actor rootfs/, upper/, work/ under the existing bundle path, one mount syscall. Milliseconds regardless of image size.
microVM path:ReconstructSharedDirFromImage mounts a lowerdir-only (implicitly read-only) overlay for virtiofsd; the guest keeps building its own tmpfs upper in StartOverlayWorkload. Single-layer images: RO bind mount.
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 — never du at 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.
A layer is deleted only when its last retained referencing image is gone.
Control-plane integration (aligned with the #276 NodeInventory direction)
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 in pins/) 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):
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):mutate.Extract, destroying layer identity.prepareOCIDirectory→untar;resetActorDirswipes the bundle dir between runs).Measured impact from existing issues:
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).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).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
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
/runtrap).Pull path (replaces
MemoryPullCache.Fetch+mutate.Extract)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).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.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.Rootfs composition (replaces per-run untar)
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.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).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.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.rootfs/before the bundle-dir wipe; decrement layer pins on unmount.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
--image-cache-max-bytes. Sizes recorded at unpack time — neverduat eviction./proc/mounts) — never evicted;Control-plane integration (aligned with the #276 NodeInventory direction)
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):
Phase 1 — core cache (the performance win):
refs/) + version marker; cache-root flag + sizing docsarchive.Apply, atomic rename; injectable authenticatorprepareOCIDirectory; unmount-before-wipe ordering vsresetActorDirs/proc/mountspin rebuild)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)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:
Phase 3 — control-plane integration:
PreloadImageRPC with expiring pins + admission check + download prioritizationLater / 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
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).