[core][sandbox] Bound the image cache: LRU eviction and opt-in tarball - #65748
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces an image cache eviction mechanism for Ray's sandbox environment to prevent disk space exhaustion, alongside making the retention of uncompressed image tarballs opt-in. Feedback on these changes highlights critical robustness and safety concerns: a race condition and resource leak in sandbox creation could lead to failed container starts or permanent image locking, concurrent file deletions during eviction could prematurely abort the cleanup process, and invalid environment variable parsing for the cache size cap could crash the sandbox creation. Addressing these issues with more granular exception handling and proper ordering of the image usage marking will ensure a more resilient and stable implementation.
2d8b9bb to
1b06549
Compare
Nodes cache the extracted rootfs of every image they ever ran plus an uncompressed re-tar of it, and nothing reclaims the space: a long-lived node fills its disk, which takes down far more than sandboxes (observed as opaque 500s from the whole Ray stack during Terminal-Bench sweeps of 89 distinct multi-GB images). - RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES caps the cache: before each new pull, least-recently-extracted images are evicted until the cache fits. Images backing live sandboxes are protected by refcount markers (their extracted rootfs is the overlay lower layer), mid-pull directories by the .extracted marker, and concurrent pulls by taking each candidate's per-image lock non-blocking. - The uncompressed tarball becomes opt-in (RAY_SANDBOX_KEEP_IMAGE_TARBALL): it doubled every image's footprint for a rarely-exercised restore path. Signed-off-by: xyuzh <xinyzng@gmail.com>
Review feedback, three parts: - Tolerate concurrent deletions per entry while scanning the cache instead of aborting the whole eviction on the first OSError. - Never evict images extracted within a grace period (10 minutes): it covers the window between a pull returning and its sandbox registering as a user. - Release the in-use marker on every sandbox-creation failure path (init, bundle preparation, boot): a failed creation never reaches delete_sandbox, and the marker would have blocked eviction of that image forever. Signed-off-by: xyuzh <xinyzng@gmail.com>
test_pull_and_extract_remote_image pinned the old always-write-a-tar behavior; assert the new default (no tarball) and cover the opt-in env explicitly. Signed-off-by: xyuzh <xinyzng@gmail.com>
1b06549 to
77fc569
Compare
There was a problem hiding this comment.
I have a few comments:
- The default is not to evict anything, right? We should think about whether that's the right default, and also document this flag (especially if we don't evict anything by default, since that will use up the disk which will be very disruptive for anybody using it)
- Ideally this would be part of
ImageManagerso every backend will use it automatically without having to make too many API calls. In that case, the image manager should be able to callmark_image_in_useautomatically when the image is used, and we probably just need an explicitreleasecall to be called at the right time. Not necessary to address before merging, but if there is a natural API as part of the image manager, we should do it (for sure once we implement more backends).
| pass | ||
|
|
||
|
|
||
| def release_image_use(image_dir: str, instance_id: str) -> None: |
There was a problem hiding this comment.
Renamed in 0ccea5d: pinning moved into ImageManager, so the public API is pull_image(..., instance_id=) plus release_image(image, instance_id), and the file-level helpers are private (_mark_image_in_use / _release_image_use).
|
|
||
| _IMAGE_CACHE_MAX_BYTES_ENV = "RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES" | ||
| _KEEP_IMAGE_TARBALL_ENV = "RAY_SANDBOX_KEEP_IMAGE_TARBALL" | ||
| _USERS_DIRNAME = ".users" |
There was a problem hiding this comment.
should this be an absolute path?
There was a problem hiding this comment.
It's a directory name, not a path: each cached image gets a .users/ subdirectory with one marker file per live sandbox, joined onto that image's cache dir. Renamed to _USERS_SUBDIR with a comment in 0ccea5d to make that clearer.
| return total | ||
|
|
||
|
|
||
| def evict_images_over_cap(images_dir: str, max_bytes: int) -> None: |
There was a problem hiding this comment.
nit: evict_least_recently_used_images?
There was a problem hiding this comment.
Renamed to evict_least_recently_used_images in 0ccea5d.
…default - `ImageManager.pull_image(..., instance_id=)` registers the sandbox as a user of the image while holding the image's lock, and `ImageManager.release_image` drops it; the gVisor backend no longer touches marker files directly. Eviction re-checks users under the same lock, so a pull can never race an eviction. The mtime grace period, which never covered cache hits, is gone. - The cache cap now defaults to half of the filesystem holding the cache; `RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES` overrides it and `0` disables eviction. Invalid values log a warning and fall back to the default. - Eviction counts sibling and orphan `<name>.tar` archives toward the cap, never evicts the image being pulled, and runs after teardown in `delete_sandbox` so the overlay lower layer is unused when released. - Rename to `evict_least_recently_used_images`; document the cache settings.
|
Addressed the review comments in 0ccea5d:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Reviewed by Cursor Bugbot for commit 0ccea5d. Configure here.
|
|
||
| shutil.rmtree(root_dir, ignore_errors=True) | ||
| # Only now is the overlay's lower layer unused. | ||
| self._image_manager.release_image(config.image, sandbox_id) |
There was a problem hiding this comment.
Delete can leak image pin forever
Medium Severity
delete_sandbox pops metadata first and only calls release_image after kill, runsc delete, and rmtree. An exception in that stretch (for example FileNotFoundError from subprocess.run) skips the release, and a retry finds no metadata. The .users marker stays on disk, so that image is never evicted again.
Reviewed by Cursor Bugbot for commit 0ccea5d. Configure here.
The archive's only reader was the restore branch in the same function, which re-extracted it when the extracted directory had gone missing, a path nothing exercised. Drop the archive, the restore branch, and the RAY_SANDBOX_KEEP_IMAGE_TARBALL opt-in. Eviction still removes archives left by earlier versions and counts them toward the cap. Docs: call out RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES in the troubleshooting section as well.
|
Good call on the tarball: its only reader was the restore branch in the same function (re-extract from the archive when the extracted directory had gone missing), which nothing exercised. Removed it entirely in 51cdda2, along with |
Signed-off-by: Philipp Moritz <pcmoritz@gmail.com>
Signed-off-by: Philipp Moritz <pcmoritz@gmail.com>


Why are these changes needed?
Nodes cache the extracted rootfs of every image they ever ran plus an uncompressed re-tar of it, and nothing reclaims the space. A long-lived node eventually fills its disk, which takes down far more than sandboxes: during Terminal-Bench 2.1 sweeps (89 distinct images, many multi-GB) under Harbor, aged nodes degraded into opaque 500s from the whole Ray stack while fresh nodes ran the same images fine.
Changes:
ImageManagerevicts the least recently extracted images until the cache fits the cap. The cap defaults to half of the filesystem that holds the cache;RAY_SANDBOX_IMAGE_CACHE_MAX_BYTESsets it explicitly and0disables eviction. Sibling and orphan<name>.tararchives count toward the cap.ImageManager.pull_image(..., instance_id=)registers the sandbox as a user of the image while holding the image's lock, andrelease_image(image, instance_id)drops the pin. Eviction re-checks users under the same lock, so a pull can never race an eviction, and mid-pull images (no.extractedmarker) and images whose lock is held are skipped. The gVisor backend pins on pull, releases on every creation-failure path, and releases after teardown indelete_sandbox, once the overlay's lower layer is unused. Backends built onBaseImageManagerget this for free.RAY_SANDBOX_IMAGE_CACHE_MAX_BYTESis documented in the sandboxes guide under "Container images" and in the troubleshooting section.Related issue number
Follow-up to #65570; sibling of #65737 / #65744 / #65745 (the Terminal-Bench fix series).
Checks
ruff,black, andpydoclintpass with the pre-commit versions and flags.test_image_cache_eviction(LRU order, in-use and kept images protected, unextracted skipped, orphan tarballs counted),test_pull_pins_image_until_release(pin on pull and on cache hit, eviction blocked until release),test_image_cache_max_bytes_default_and_env. The gVisor-gated remote-pull test asserts no archive is written.