Skip to content

atelet: MemoryPullCache retains full image tarballs indefinitely (LRU bounded by entry count; the 100 MiB guard measures manifest size) #437

Closed

Description

Summary

Two bugs in cmd/atelet/internal/memorypullcache. Together they let atelet's heap grow without bound — it permanently retains one fully-extracted copy of every distinct image digest it has ever pulled — until the kernel OOM-kills it.

This is distinct from #120, which is a transient spike inside a single mutate.Extract. What follows is permanent retention across pulls.

Bug 1: the size guard measures the wrong thing

size, err := img.Size()
if size > 100*1024*1024 {
    slog.InfoContext(ctx, "Image is too large to cache", ...)
    return mutate.Extract(img), &imageCfg, err   // not cached
}

v1.Image.Size() returns the size of the manifest (a few KB), not the size of the image content. The guard therefore never fires: Image is too large to cache appears 0 times in our logs, while a ~740 MiB (extracted) image is cached on every pull.

Bug 2: the LRU is bounded by entry count, not by bytes

cache: lru.New(256)

type cachedImage struct {
    tar []byte
    cfg v1.Config
}

Each entry holds the entire flattened, uncompressed image tarball. Bounded at 256 entries, atelet will retain 256 × image-size before evicting anything. The existing TODO already anticipates this:

// TODO: Need a smarter cache with bounds on total consumed size, not just number of entries.

Impact (measured)

A Node-based coding-agent image: 222 MiB compressed (4 layers) → ~740 MiB extracted.

  • go_memstats_heap_inuse_bytes = 1.455 GiB with exactly 2 distinct image digests cached (2 × ~0.74 GiB). go_goroutines = 42 and container_memory_cache stays flat at 0.02–0.08 GiB, so this is live, reachable heap — not garbage, not page cache.
  • Over ~4 days atelet RSS climbed monotonically from 0.8 GiB to 15.3 GiB and was OOMKilled (exit 137). The sawtooth then repeats.

Growth is per distinct digest, not per actor — cache hits cost nothing. But since each actor pins the image digest it was created with, digest diversity accumulates as older actors are resumed, so any cluster that ships new image builds keeps introducing new permanently-retained entries.

Collateral: actors stranded in STATUS_RESUMING

When atelet dies mid-restore, the in-flight RestoreWorkload is cancelled — while creating pause container: while running 'runsc create': context canceled — and the actor is left in STATUS_RESUMING indefinitely. An explicit resume recovers it, but it does not self-heal.

Suggested fix

Make the cache size-aware: account len(tar) and bound total bytes (configurable budget), evicting LRU. Hot digests stay cached, so resume latency does not regress, while the heap is capped.

Fixing only Bug 1 would also stop the growth, but it would disable caching for exactly the images that matter and add a registry pull to every resume — which makes #228 worse. Bounding by bytes seems strictly better.

Happy to send a PR if this direction sounds right.

Activity

  1. dberkov commented on Jul 19, 2026

    @dberkov
    Collaborator

    #463 proposes replacing memorypullcache entirely with an on-disk, size-bounded layer store, which removes both bugs described here (and the restart-strands-actors collateral).

  2. added 3 commits that reference this issue on Jul 20, 2026
    8933927
    a51b7f2
    048792d
  3. added a commit that references this issue on Jul 23, 2026
    610e391
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions