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 (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.

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).

Activity

  1. dberkov commented on Jul 20, 2026

    @dberkov
    CollaboratorAuthor

    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.

  2. igooch commented on Jul 30, 2026

    @igooch
    Collaborator

    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 validation
    on Kind and GKE. Requesting sign-off on seven amendments to #463's Phase 2 section before the
    engine PR goes up.

    Proposed Amendments

    1. Root set from bundle specs, not /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.json specs
      (written before mount, deleted after unmount) and add an imageDigest field to the spec; its
      write becomes atomic.
    2. Refcounts recomputed from disk each pass (kubelet's model), not maintained in memory —
      atelet is stateless and ateom unmount is best-effort, so there is no reliable decrement point.
    3. Add a min-age veto (default 2m, mtime-based) covering the pull → spec-write → mount window
      (kubelet's MinAge).
    4. Write the image record BEFORE unpacking; touch it per completed layer; rewrite it after the
      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.
    5. Two-phase delete with a restore protocol: delete the record → rename unreferenced layers to
      .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.
    6. Watermark target capped at the cache's own size. The cache shares the boot disk, so kubelet's
      raw formula can demand far more than the cache holds (measured: 18.9 GiB asked of an 11 MiB
      cache), evicting everything every tick.
    7. Startup-only orphan scan for crash debris. With 4+5, live operation cannot create
      unreferenced layers, so recovery is bounded and runs once in New, before any pull exists. (An
      earlier 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, sizes
    recorded at unpack (never du). All on-disk changes are additive; layout version stays "1".

    Validation

    • Kind: restore protocol observed live; planted orphans ignored by the periodic pass, reclaimed at
      startup; hostile loop (min-age 0, GC every 1 s, cold cache) repeatedly clean.
    • GKE: protection + target cap on real nodes; suspend → evict → cold resume with state intact;
      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).
    • Unit + -race green throughout; net −309 lines versus using an alternative approach of
      pins/in-flight/sweep.

    Decisions needed

    1. Amendment 4's ordering — the one that most wants an explicit yes.
    2. Pause image is evictable on idle nodes (cheap re-pull on next Run). Accept, or add
      --image-cache-pinned-images? Leaning accept.
    3. Ship GC enabled by default (5m/85/80) or dry-run-by-default for the first release? Leaning
      enabled; --image-cache-gc-dry-run covers 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.

  3. tomergee commented on Aug 18, 2026

    @tomergee
    Collaborator

    Review of CUJs to look into igooch sandeep-gh-acc Tomer Glottmann (@tomergee) :

    1. Total job image size may be bigger than the actual disk size, when downloading all layers, need to regard uncompressed size and manage and LRU and evict turn on GC processes
    2. GC and LRU sync.async process performance
    3. When does this process start? might have race conditions when there is a heard of similar layered OCIs
    4. Worker to pod, first time is cold, is there latency or blocking until the layers are cached? Value starts when multiple instances, same layers, per node.
    5. Cross-reference images with Image streaming or other technologies
    6. on Pause- its saves locally on the Node, restarts from the same node directly, does the reference stay? are the layers recopied in the snapshot?
    7. Supervisor runtime support - additional image or volume
  4. added
    kind/designDesign discussion, investigation, or research required before implementation
    on Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area/nodekind/designDesign discussion, investigation, or research required before implementationkind/featureAn enhancement / feature request or implementation

Projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions