Repository navigation
fix(ateom-gvisor): isolate and evict checkpoint page cache to prevent actor cgroup OOM - #1917
Conversation
8997600 to
e946bf7
Compare
9f717b2 to
53bf150
Compare
5f3a786 to
c683e25
Compare
|
/cc |
|
There is an interaction with merged #1876: the direct-local restore path sets ActorDirs.RestoreDir to the checkpoint directory and sends PreserveRestoreDir=true. In this PR, that call has restoreDir == actorDirs.GetRestoreDir(), so the preserved-source guard does not match; a single-link checkpoint then reaches the truncate-and-remove branch. That destroys the local checkpoint needed for another resume. Could this cleanup receive and honor PreserveRestoreDir? #1876's body calls out this follow-up explicitly. |
…agent-substrate#1876) ### Summary For a local pause resume (`CHECKPOINT_TYPE_LOCAL`), atelet no longer stages the snapshot into `restore-state/`. It verifies the files in `local-checkpoint/<snapshotName>/` and passes that directory to ateom as `ActorDirs.restore_dir` with `preserve_restore_dir = true`. The external/GCS `restore-state/` flow is unchanged. ### Why `main` currently hard-links the snapshot into `restore-state/`, and the only thing stopping ateom from mutating the actor's local restore point is the `Nlink > 1` side effect of that hard-link. This PR replaces that implicit guard with an explicit `preserve_restore_dir` flag: when set, ateom never deletes or rewrites files in `restore_dir` (no post-eager-restore drop of `memory-ranges`, copy-merge instead of in-place merge on the next OnDemand checkpoint, no `config.json` rewrite on a same-actor resume). This also removes the staging step and its cross-filesystem copy fallback, and gives lifecycle v2, where local snapshots are kept and reused as a cache after upload, a clear read-only contract. Resume latency and disk usage are on par with the current hard-link path. Follow-ups: - With `DATA_ON_GOLDEN` gone (agent-substrate#2105), `copyLocalCheckpoint` has no remaining callers and will be removed in a separate PR. - agent-substrate#1917 / agent-substrate#2030 should key their restore-file cleanup off `preserve_restore_dir` rather than `Nlink`, since a local restore now has a single link. ### Original benchmark vs the pre-hardlink full-copy baseline Setup: glutton, 1 user, 512Mi working set, pause/resume lifecycle. gVisor actors use 1536Mi so `pages.img` stays ~100% cached after pause (same as MicroVM); MicroVM actors use 768Mi. | | ResumeActor P50 | `download` (copy) | `ateom_restore` | |---|---|---|---| | gVisor 1536Mi, full-copy baseline | 740ms | ~310ms | ~370ms | | gVisor 1536Mi, this PR | **430ms** | 0 | ~370ms | | MicroVM 768Mi, full-copy baseline | 2900ms | ~1.8s (1.4–2.1s) | ~0.97s (0.78–1.1s) | | MicroVM 768Mi, this PR | **600ms** | 0 | ~0.57s (0.54–0.82s) | ResumeActor P50 is client-side (locust). `download` and `ateom_restore` are server-side medians from atelet's "Restore timing breakdown" log (6–8 resumes per run). - [x] Tests pass - [ ] Appropriate changes to documentation are included in the PR
c683e25 to
683e5b4
Compare
Yeah, rebased on #1876: staged-file cleanup now keys off preserve_restore_dir instead of Nlink, since a local restore now has a single link. Please review again. Thanks. |
|
Thanks for the rebase and the preserve_restore_dir fix — looks good. |
ae52c9a to
2a8e61d
Compare
2a8e61d to
808216f
Compare
| } | ||
|
|
||
| switch fidelity { | ||
| case ateompb.SnapshotFidelity_SNAPSHOT_FIDELITY_VOLUMES: |
There was a problem hiding this comment.
Can we wrap the SNAPSHOT_FIDELITY_VOLUMES branch in checkpoint cgroup isolation as well? Runsc will write pages.img and multitar.img from sentry process, large durable-dir tmpfs will still charge page cache to _pause.
There was a problem hiding this comment.
Done — generalized checkpointFull to checkpointIsolated and wrapped both SNAPSHOT_FIDELITY_VOLUMES (cmdFsCheckpoint) and SNAPSHOT_FIDELITY_MEMORY (cmdCheckpoint) in checkpoint cgroup isolation.
7148904 to
74f48ae
Compare
- checkpointIsolated: if removing <uid>-_ckpt fails after teardown (the sentry is still alive in it, e.g. a failed terminate after a VOLUMES checkpoint), move it back into <uid>-_pause and retry the removal so a live sandbox never stays without its memory.max. - isolateCheckpointCache: refuse to move a pid that is not in the pause leaf, so a reused pid is never moved into the checkpoint cgroup. - dropActorCheckpointCacheAsync: bound the preserved-dir restore wait by the hosted actor's lifetime (new hostedActor ctx, cancelled by unhostActor) instead of a fixed 30s timeout. - ateworker-microvm: scope the pre-Resume drop comment to the non-preserved case. - Tests for the reused-pid rejection, the failed-terminate rollback, and the preserved-dir eviction path (wait, wait error, actor unhosted).
74f48ae to
ad6ee0a
Compare
Benjamin Elder (BenTheElder)
left a comment
There was a problem hiding this comment.
This is a pretty complex change. Closer now I think.
How should we be handling VOLUMES mode for actor lifecycle v2?
| return nil | ||
| } | ||
| err := movePID(c.leaf, c.pid) | ||
| if errors.Is(err, fs.ErrNotExist) || errors.Is(err, unix.ESRCH) { |
There was a problem hiding this comment.
🤖 should-fix 🟡 – cgroupHasPID(c.dir, c.pid) just confirmed the sentry is still alive in c.dir, so fs.ErrNotExist from movePID(c.leaf, c.pid) means the pause leaf is missing and the sentry was not moved out of <uid>-_ckpt. Returning nil here makes checkpointIsolated set terminateOnExit = false and skip terminate(), leaving the live sentry running in the unlimited <uid>-_ckpt cgroup. Only unix.ESRCH should be ignored here, and TestCheckpointCgroupRestoreAfterLeafGone (cmd/ateworker-gvisor/ckptcgroup_test.go:161) should expect an error.
There was a problem hiding this comment.
Done — restore() now only ignores ESRCH, so a missing pause leaf makes checkpointIsolated terminate the sandbox. Updated TestCheckpointCgroupRestoreAfterLeafGone to expect the error and added an end-to-end case for it.
|
Ah, I see we discussed that in #1917 (comment) (lots of comment threads on this one). I think #1917 (comment) is a bug, reasoning about if it can be a follow-up ... This seems kinda bad though. |
…n checkpoint failure checkpointCgroup.restore() treated fs.ErrNotExist like ESRCH, but after cgroupHasPID confirmed the sentry is alive in <uid>-_ckpt, ENOENT means the pause leaf is missing and the sentry was not moved. Returning nil made checkpointIsolated skip terminate() and leave the live sentry in the unlimited cgroup. Only ignore ESRCH, so the caller terminates the workload instead.
…agent-substrate#2796) Replaces agent-substrate#1551, which was four commits from September that no longer applied: the restore source message, the download path and the module path all changed underneath it. Same design, ported onto today's main, after agent-substrate#2756 and agent-substrate#2889, in four commits, plus three from review: the sharing derived from the snapshot's owner, the gVisor worker evicting a shared file's pages before unlinking it, and the manifest read through the cache. Milestone M2 of the node-local artifact cache from agent-substrate#690: an actor restoring from a shared snapshot, the one a tag holds and every actor created from it starts on, gets its files from a node-level cache instead of its own download. **Sharing is the control plane's knowledge.** `ObjectStorage` gains `SnapshotSharing`, the property rather than the snapshot's kind, so new shared snapshot kinds need no atelet change. ate-api-server sets it on Restore from the snapshot's recorded owner: SHARED for a tag's snapshot, borrowed at creation and immutable, PRIVATE for the actor's own, which its next suspend replaces, and for an unrecorded owner; the local-copy fallback object carries it too. Neither side infers it from the URI. **The cache.** A `filecache.Store` under `/var/lib/ate/snapshot-cache`, behind `--snapshot-cache-dir` (empty disables), `--snapshot-cache-min-age` and `--snapshot-cache-fetch-timeout`, opened at startup with crash debris swept before atelet serves. The directory must be under the node state dir: the object-store plugin writes only there, so a cache elsewhere could never be filled, and startup refuses it. Every flag is validated whether or not the cache is enabled, and the help text says which are read only while it is. The image cache's base-path check moved to `nodepath.UnderBasePath`, which both caches use. **Eviction.** The cache runs on the eviction loop every file cache on the node shares, `cmd/atelet/internal/filecachegc` from agent-substrate#2889: `--snapshot-cache-gc-period`, `--snapshot-cache-max-bytes` and `--snapshot-cache-gc-dry-run`, a budget with a 90% low water, least recently used first, never an entry a restore still links. Its size, evictions and passes appear under the existing `ate.filecache.*` instruments as a second value of `ate.filecache.name`, `snapshot`; no new instrument and no new loop. Every flag is validated whether or not the cache is enabled, as the other flags in atelet are, and the help text says which ones are read only while it is; a disabled cache starts no loop and reports no size. **The restore path.** A restore whose durable snapshot is declared SHARED stages each file through the cache; concurrent restores of one snapshot share a single download, later ones read disk. How a hit is served depends on the sandbox class the request's runtime resolved to. ateworker-gvisor gets hard links. Once the restore's page loading is done the worker evicts the memory and filesystem images from the page cache and unlinks them from its restore dir, keeping only the small descriptors linked: a file with another link is treated like a preserved restore dir, since unlinking it would free nothing and would leave its pages charged to the actor's cgroup, the case agent-substrate#1917 fixed. So a link protects an image's entry through the restore and the min age, not for the actor's life; an entry evicted later is still read to the end through the descriptors runsc holds open, and the next restore reads the images from local disk. ateworker-microvm gets private hole-preserving copies, not because a link would corrupt the cache, it replaces `config.json` by rename and its delta merge copies when the inode is shared, but because a link would cost more than it saves: an on-demand restore keeps `memory-ranges` open for the VM's life, which would pin the entry that long and turn every suspend's in-place merge into a full copy, and the worker's page-cache eviction would drop the cached pages for every other restore; agent-substrate#2948 tracks what links would take. EXDEV on a link, from a cache bind-mounted apart from the restore dirs, degrades to a copy, warned once per process. The manifest of a shared snapshot is read through the cache too, so a restore that finds the snapshot on the node makes no request to object storage; a private snapshot's manifest is read fresh, as the commit marker it is. The cache key is the object's address without its query, so a signed URL's credential reaches neither the key, the cache's errors, `meta.json` nor the logs; and a restore that fails after staging removes its restore-state dir, so the links do not hold the cache's entries until the next lifecycle call. The fetch goes through the object-store plugin as the direct path does, one file into a scratch dir on the cache's filesystem; because the cache then writes the restore dir by path rather than through the plugin's `os.Root`, a symlink waiting there fails the restore instead of being followed. **Not here.** Private snapshots stay uncached by design. Links for ateworker-microvm are agent-substrate#2948, a worker change. The open-and-sweep code the two file caches share in shape is still duplicated, since the snapshot cache can be disabled and degrades to copies across mounts where the asset cache refuses to start. **Tests.** ate-api functional: a golden restore declares SHARED, the actor's own snapshot PRIVATE. ate-api unit: the detach-and-resume test from agent-substrate#2852 compares the whole Restore snapshot, so its fixture now puts the snapshot under the actor's own UID, and it expects PRIVATE for the downloaded copy and nothing for a local one. atelet: flag validation, enabled or not, including the eviction flags, the fetch timeout, and a directory outside the node state dir; two shared manifest reads download once and a private one reads fresh; open disabled, creating its root, sweeping debris; the sharing gate and the class modes; through the plugin-backed test atelet, two link restores share one download and one inode and the file is read-only, copy mode gives private inodes and survives a consumer's mutation, a nil cache downloads, a missing file's error reaches the caller with nothing left behind, a plugin error through the cached modes keeps its code and leaks no credential from a signed URI, a restore that fails at the worker after staging leaves no file in restore-state and no link on the cache's entry, four concurrent restores download once, a file in the checkpoint subdirectory lands there, and symlinks in place of the file or the subdirectory fail; on Linux, a restore dir on another mount gets a copy with no second download and one warning for two restores. ateworker-gvisor, on Linux: staged images hard-linked elsewhere are evicted after the restore wait and only then unlinked, with the other links intact. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
When a gVisor (
runsc) sandbox is paused or suspended under a tight memory limit (ActorMemory), writing the snapshot (pages.img) inside the actor's own cgroup doubles memory pressure (guest RAM + snapshot page cache), causing severe swap thrashing or cgroup OOM-kills.This PR decouples snapshot I/O from the actor's memory cgroup and deterministically reclaims snapshot page cache after restore:
runsc checkpointin a dedicated sibling cgroup(-_ckpt) so snapshot page cache is not charged against the actor's memory limit during pause or suspend.runsc wait -restorecompletes, evict snapshot page cache (guarded bycachestat(2)soFADV_DONTNEEDnever forces writeback). Staged files inrestore_dirare removed only whenpreserve_restore_diris unset (GCSrestore-state/); for local resume (atelet,ateom: restore local pause checkpoints directly from LocalSnap #1876) the files are kept and only their cache is evicted.Notes
Rebased on #1876: file cleanup now keys off
preserve_restore_dirinstead ofNlink, since a local restore no longer has a hard-link.DATA_ON_GOLDENcase dropped after #2105.Benchmark
1. Memory-Constrained Local Pause/Resume (
glutton_1user_500mi_gvisor_pause)1 user, 500 MiB active guest RAM (64 MiB dirtied before each pause), 600 MiB actor memory cgroup limit (
ActorMemory), 1m duration.main)PauseActorFailure Raterunsc checkpointOOM)PauseActorP50 / Avg / P95ResumeActorP50 / Avg / P952. Memory-Constrained GCS Suspend/Resume (
glutton_1user_500mi_gvisor_suspend)1 user, 500 MiB active guest RAM (64 MiB dirtied before each suspend), 600 MiB actor memory cgroup limit (
ActorMemory), 1m duration.main)SuspendActorFailure Raterunsc checkpointOOM)SuspendActorLatency (successful)ResumeActorP50 / Avg / P953. Multi-User Local Pause/Resume (
glutton_mem_512mi_4_user_gvisor_pause)4 concurrent users, 512 MiB active guest RAM (64 MiB dirtied before each pause), 768 MiB actor memory cgroup limit (
ActorMemory), 1m duration.main)#1917)20(2 actors OOM-crashed)27PauseActorFailure Rate15.4%(4/26 failed,runsc checkpointOOM)0.0%(0/30)PauseActorP50 / Avg / P953,100 ms / 3,272 ms / 8,600 ms1,000 ms / 1,008 ms / 1,400 msResumeActorP50 / Avg / P953,800 ms / 3,698 ms / 6,000 ms(5,000 ms / 4,365 ms / 5,600 ms(