Skip to content

Design: on-disk, layer-deduplicated OCI image cache for atelet #463

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 (prepareOCIDirectoryuntar; resetActorDirs wipes 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:

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)

  1. Resolve ref → manifest digest (remote.Head for 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 at memorypullcache.go:183).
  2. 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.
  3. Singleflight by diffid and by manifest digest: concurrent actor starts of overlapping images never duplicate a download or unpack.
  4. 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.
  5. 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), mkdir per-actor rootfs/, upper/, work/ under the existing bundle path, one mount syscall. Milliseconds regardless of image size.
  • gVisor path: bundle rootfs/ in prepareOCIDirectory becomes 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: 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.
  • Semantics preserved: per-actor writes land in upper/, wiped by resetActorDirs between 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 — never du at eviction.
  • Protection hierarchy, then LRU by image last-use:
    1. layers of actively mounted images (in-memory refcounts, rebuilt from /proc/mounts) — never evicted;
    2. layers of images with an unexpired preload pin — never evicted;
    3. 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)

Implementation phases

Phase 0 — immediate mitigation (independent of the cache):

Phase 1 — core cache (the performance win):

Phase 2 — GC + observability:

Phase 3 — control-plane integration:

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

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

Labels

area/nodekind/featureAn enhancement / feature request or implementation

Fields

No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions