Repository navigation
Resuming a microVM actor on the same node no longer reads its rootfs upper back from disk - #3485
Conversation
|
I'm going ahead and pursuing the density-aware-write-behind to scope out if we can reasonably include that as well, just to bring the performance back inline to before OOMkiller on workers that aren't packed to the gills, but for now doing that in another branch as a POC. |
this is not too bad actually, and it has a similarly bad regression without. EDIT: added |
Chenyi Wang (chw120)
left a comment
There was a problem hiding this comment.
LGTM
One note: #3353 added StartWriteback (fdatasync) for the VM memory snapshot, and this commit adds the fast-path tars to it too. Since fdatasync locks the shared ext4 journal (#1954) while returning borrowed memory only needs the pages to become clean, replacing fdatasync in StartWriteback avoids disk contention during concurrent suspends and immediate wakes.
I've updated #3477 to keep just that StartWriteback fix and a small mergeOnDemandDelta ordering fix, which brings local suspend + wake down further (630ms -> 393ms) on top of this PR.
| size, err := dirSize(srcDir) | ||
| var borrowed int64 | ||
| if err == nil { | ||
| borrowed = s.borrow.Take(size) |
There was a problem hiding this comment.
Minor question: since writeSnapshot waits for a snapshotWrites slot before calling Take while archiveDir calls Take immediately, could smaller tars grab the remaining pod headroom first during a burst and leave the larger VM memory snapshot throttled?
There was a problem hiding this comment.
Yes, it can. In that case the snapshot falls back to the growth limit and CH writes at disk speed, so it's slower but still safe. I think ordering the loans is worth a follow-up, but I'd rather not add it here.
GKE burn-in: rc.1 vs rc.1 + #3485 + #3476Tested this PR together with #3476 on top of Result: with both PRs applied, the #3353 suspend/resume regression is gone on microVM, and the gVisor suspend tail shrinks. microVM Setup
microVM
gVisor
Samples per build: microVM ~215 → ~260, gVisor ~270 → ~325 restores and checkpoints. The patched window was 23 s longer; the rest of the increase comes from faster cycles. Regression checks (patched): 0 locust failures (baseline 0). 0 ERROR-level lines in atelet or worker logs. 0 worker or ate-system restarts. No OOMKilled or Evicted events. No actors stuck in RESUMING/SUSPENDING. No new warning types: the only WARN in either run is the same pre-existing Caveats
Commits and image digestsCommits
Cherry-picks (patched ← original): Only Control plane (identical in both runs):
Workers
The patched worker binaries embed Benchmark images (not published; built from each build's commit): glutton |
Grant McCloskey (MushuEE)
left a comment
There was a problem hiding this comment.
Validated changes against burn-in tests
Correction: the micro-VM checkpoint tail is not back to pre-break yetMy comment above used only the first ~4.5 minutes of the 6-user step, and that overstated the micro-VM result. Over a longer window (same patched build, same clusters, still 6 users), the micro-VM checkpoint tail grows with time. gVisor stays fixed. Patched, first 22 minutes of the 6-user step (exact percentiles, ms; 0 failures)
micro-VM
Compared with the original burn-in: Round 1 =
Where the slow micro-VM checkpoints spend their time (the worker's own "Checkpoint timing breakdown"):
So the remaining tail is in the guest-memory |
Tim Hockin (thockin)
left a comment
There was a problem hiding this comment.
I'm afraid this whole subsystem is turning into spaghetti. We're plugging gaps here and there but I don't think we fully comprehend the implications, and it's now complicated enough that I doubt I am following it correctly. Gemini has a lot to say about this, and it's a real challenge to tell if it is right or if not, why.
I will send the naked comments now and do a deeper read of them in a bit
Second GKE burn-in confirms Grant McCloskey (@MushuEE)'s results, including a node-disk regression to fix before mergeGrant McCloskey (@MushuEE)'s runs above (v1.0.0-rc.1 + #3485 + #3476) and an independent run on our side (main @ 34cce1e + this PR's 4 commits; #3476 doesn't apply on that base) agree. Setup for ours: GKE 1.37, 5 × c3-standard-8, microVM, 4 unsized benchmark workers (no pod memory limit), agentsession load with 1Gi actors, 30-minute high-churn ladder. Latency medians are fixed 👍 Regression: node disk fills on microVM nodes.
Cause (confirmed on a node in our run):
Suggestions:
Happy to re-run either change on the same setups. Drafted and posted by an AI assistant on behalf of Anna Pendleton (@annapendleton). |
Candidate fix under test: StartWriteback pinning deleted snapshot filesWhat we saw. In the burn-in with Suspected mechanism (not yet confirmed). What we're testing. The patch below, on top of rc.1 + #3485 + #3476:
Diff under test ( 2 files changed, 235 insertions(+), 10 deletions(-))diff --git a/internal/snapshotcache/snapshotcache.go b/internal/snapshotcache/snapshotcache.go
index c3812a96..7302ad07 100644
--- a/internal/snapshotcache/snapshotcache.go
+++ b/internal/snapshotcache/snapshotcache.go
@@ -46,6 +46,8 @@ var (
// tmpfs, overlayfs) or kernel version.
cachestatFn = unix.Cachestat
fadviseFn = unix.Fadvise
+ // fdatasyncFn is a seam for tests of StartWriteback.
+ fdatasyncFn = unix.Fdatasync
// RetryDelays are the backoff intervals between post-restore eviction
// passes when dirty pages are waiting for background kernel writeback.
@@ -111,25 +113,39 @@ func DropFiles(files []File) bool {
return dropped
}
+// writebackSlots bounds how many StartWriteback fdatasync calls run at once in
+// this process. Concurrent fdatasync calls contend on the filesystem journal,
+// so each takes longer, and a file pinned by a slow sync can't be freed.
+var writebackSlots = make(chan struct{}, 2)
+
+// pendingWriteback is a file StartWriteback will sync, pinned to the inode the
+// path named when the write finished.
+type pendingWriteback struct {
+ path string
+ dev, ino uint64
+}
+
// StartWriteback writes each path's dirty pages to disk in the background and
// then calls done, if set. Writing can block on a busy disk, and callers don't
// wait.
+//
+// A file is opened only when its turn comes, and skipped if by then it has been
+// unlinked or its path names another file: the kernel drops a deleted file's
+// dirty pages without writing them, so syncing it would only pin its blocks and
+// slow the other writes. Either way its pages no longer count against the pod,
+// so done still means every file is on disk or gone.
func StartWriteback(paths []string, done func()) error {
- var files []*os.File
+ pending := make([]pendingWriteback, 0, len(paths))
for _, p := range paths {
- f, err := os.Open(p)
- if err != nil {
- for _, f := range files {
- _ = f.Close()
- }
+ var st unix.Stat_t
+ if err := unix.Stat(p, &st); err != nil {
return err
}
- files = append(files, f)
+ pending = append(pending, pendingWriteback{path: p, dev: uint64(st.Dev), ino: st.Ino})
}
go func() {
- for _, f := range files {
- _ = unix.Fdatasync(int(f.Fd()))
- _ = f.Close()
+ for _, w := range pending {
+ writeback(w)
}
if done != nil {
done()
@@ -138,6 +154,23 @@ func StartWriteback(paths []string, done func()) error {
return nil
}
+// writeback syncs w if the path still names the same, linked file.
+func writeback(w pendingWriteback) {
+ writebackSlots <- struct{}{}
+ defer func() { <-writebackSlots }()
+ f, err := os.Open(w.path)
+ if err != nil {
+ return // removed: nothing left to write back
+ }
+ defer f.Close()
+ var st unix.Stat_t
+ if err := unix.Fstat(int(f.Fd()), &st); err != nil || st.Nlink == 0 ||
+ uint64(st.Dev) != w.dev || st.Ino != w.ino {
+ return
+ }
+ _ = fdatasyncFn(int(f.Fd()))
+}
+
// SyncAndEvict waits for targets to reach disk and then evicts them, so pages
// still dirty from a recent snapshot get dropped too.
func SyncAndEvict(targets []File) {
diff --git a/internal/snapshotcache/writeback_test.go b/internal/snapshotcache/writeback_test.go
new file mode 100644
index 00000000..234c706a
--- /dev/null
+++ b/internal/snapshotcache/writeback_test.go
@@ -0,0 +1,192 @@
+//go:build linux
+
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package snapshotcache
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "sync"
+ "testing"
+ "time"
+)
+
+// syncRecorder replaces fdatasyncFn and records which files were synced.
+type syncRecorder struct {
+ mu sync.Mutex
+ paths []string
+}
+
+func (r *syncRecorder) install(t *testing.T) {
+ t.Helper()
+ prev := fdatasyncFn
+ fdatasyncFn = func(fd int) error {
+ p, err := os.Readlink("/proc/self/fd/" + strconv.Itoa(fd))
+ if err != nil {
+ p = "?"
+ }
+ r.mu.Lock()
+ r.paths = append(r.paths, p)
+ r.mu.Unlock()
+ return nil
+ }
+ t.Cleanup(func() { fdatasyncFn = prev })
+}
+
+func (r *syncRecorder) synced() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]string(nil), r.paths...)
+}
+
+// holdSlots occupies every writeback slot until the returned func runs, so a
+// StartWriteback started meanwhile waits before opening anything.
+func holdSlots(t *testing.T) func() {
+ t.Helper()
+ for range cap(writebackSlots) {
+ writebackSlots <- struct{}{}
+ }
+ var once sync.Once
+ release := func() {
+ once.Do(func() {
+ for range cap(writebackSlots) {
+ <-writebackSlots
+ }
+ })
+ }
+ t.Cleanup(release)
+ return release
+}
+
+func writeFile(t *testing.T, path, data string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func waitDone(t *testing.T, done <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("done was not called")
+ }
+}
+
+func TestStartWritebackSyncsLinkedFile(t *testing.T) {
+ var rec syncRecorder
+ rec.install(t)
+ p := filepath.Join(t.TempDir(), "memory-ranges")
+ writeFile(t, p, "pages")
+
+ done := make(chan struct{})
+ if err := StartWriteback([]string{p}, func() { close(done) }); err != nil {
+ t.Fatal(err)
+ }
+ waitDone(t, done)
+ if got := rec.synced(); len(got) != 1 || got[0] != p {
+ t.Errorf("synced %v, want [%s]", got, p)
+ }
+}
+
+// A file unlinked while its writeback is queued is never synced (its dirty
+// pages are dropped, not written), and done still runs.
+func TestStartWritebackSkipsFileUnlinkedWhileQueued(t *testing.T) {
+ var rec syncRecorder
+ rec.install(t)
+ dir := t.TempDir()
+ gone, kept := filepath.Join(dir, "rootfs-upper.tar"), filepath.Join(dir, "state.json")
+ writeFile(t, gone, "tar")
+ writeFile(t, kept, "state")
+
+ release := holdSlots(t)
+ done := make(chan struct{})
+ if err := StartWriteback([]string{gone, kept}, func() { close(done) }); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(gone); err != nil {
+ t.Fatal(err)
+ }
+ release()
+ waitDone(t, done)
+ if got := rec.synced(); len(got) != 1 || got[0] != kept {
+ t.Errorf("synced %v, want only [%s]", got, kept)
+ }
+}
+
+// A path that names a different file by its turn is skipped: the file that was
+// written is gone, and the new one isn't StartWriteback's to sync.
+func TestStartWritebackSkipsReplacedFile(t *testing.T) {
+ var rec syncRecorder
+ rec.install(t)
+ p := filepath.Join(t.TempDir(), "memory-ranges")
+ writeFile(t, p, "old")
+
+ release := holdSlots(t)
+ done := make(chan struct{})
+ if err := StartWriteback([]string{p}, func() { close(done) }); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, p, "new")
+ release()
+ waitDone(t, done)
+ if got := rec.synced(); len(got) != 0 {
+ t.Errorf("synced %v, want nothing", got)
+ }
+}
+
+// Queued writebacks hold no open file: an open descriptor on a deleted file
+// pins its blocks, which is how unsized micro-VM workers filled node disks.
+func TestStartWritebackOpensNothingWhileQueued(t *testing.T) {
+ var rec syncRecorder
+ rec.install(t)
+ p := filepath.Join(t.TempDir(), "memory-ranges")
+ writeFile(t, p, "pages")
+
+ release := holdSlots(t)
+ done := make(chan struct{})
+ if err := StartWriteback([]string{p}, func() { close(done) }); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(50 * time.Millisecond)
+ ents, err := os.ReadDir("/proc/self/fd")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range ents {
+ if target, err := os.Readlink(filepath.Join("/proc/self/fd", e.Name())); err == nil && target == p {
+ t.Errorf("%s is open while its writeback is queued", p)
+ }
+ }
+ release()
+ waitDone(t, done)
+}
+
+func TestStartWritebackMissingPathFailsUpFront(t *testing.T) {
+ called := false
+ err := StartWriteback([]string{filepath.Join(t.TempDir(), "missing")}, func() { called = true })
+ if err == nil {
+ t.Fatal("StartWriteback succeeded for a missing path")
+ }
+ if called {
+ t.Error("done was called for a writeback that never started")
+ }
+} |
|
Anna Pendleton (@annapendleton) Thanks, great find, that's a big miss. Pulled #3477's writeback rework in (Chenyi Wang (@chw120)): it closes each file as soon as it's clean or unlinked instead of holding all of them through fdatasync. On a 15 round soak of 3 filled actors in an unsized pool, /var stayed flat and deleted files held open went to 0 within 30s. Could you rerun your burn-in on the new head? Tim Hockin (@thockin) fixed the should-fix items in separate commits. Agreed this needs a holistic redesign after 1.0.0, these are stopgaps for the regression, and frankly the whole PR is less curated than I'd generally aim for given the time constraints. I also had an epoll cleanup / optimization for all this polling logic I'm ~happy with prior to this PR, but I'm keeping it out of this PR to focus on the regressions. Similarly #3431 is something we should take a hard look at, but it's not a regression and can wait. |
Results: #3485 + #3476 + the StartWriteback fix (micro-VM, first 22 minutes)The fix is the patch in my previous comment (#3485 (comment)). It changes only
Same setup as the other builds:
Only the worker image differs. v0.6.0 is the whole v0.6.0 stack, pre-#3353; its benchmark client needed #3089 to reach the router. micro-VM, p50 / p90 / p99 (ms)
0 request failures in all four. What changed
Disk / page cache
There's a second path the fix doesn't cover. At one point a single worker held 7 deleted Caveats
|
|
Thanks for the results
That should be in HEAD already, unless I'm missing something, no fdatasync left anywhere (133dcf5 + #3476) |
|
Burn-in update: #3485 @ gVisor looks healthy: worker checkpoint p99 is 314 ms and node disk is flat. Micro-VM still leaks disk. One node climbed to 79 GiB, and the kubelet evicted its worker about an hour in. The worker was holding 15–30 open handles to deleted 1 GiB Micro-VM median latency is back to v0.6.0 levels (239 ms), but the tail is worse than the earlier #3485 run: p90 2,863 ms, p99 6,133 ms, with nearly all of it in The new teardown timing shows the teardown tail is almost all rootfs upper removal (p99 2.2 s), which fits disk contention. The full ladder follows tonight. Micro-VM latency, first 22 minutes (6 users), worker checkpoint ms
Locust: Suspend 250 / 3,000 / 6,100 ms, Resume 360 / 650 / 1,500 ms, 0 failures. Disk leak evidence
Teardown breakdown, p99 ms
Setup and build
|
|
I can't repro the leak so far, fc9ece2 may be the fix though. Pushed some fixes based on Dmitry Berkovich (@dberkov) AI scans + more adversarial review locally. |
9f90899 to
32432e1
Compare
|
1183627 fixes the leak and the checkpoint tail. The writeback goroutine was blocking in sync_file_range while holding deleted snapshot files, see https://github.com/torvalds/linux/blob/v6.12/fs/sync.c#L315-L317. It now starts write-out once, 64MiB at a time, and closes a file as soon as it's unlinked. A/B on 4 identical clusters matching the burn-in setup from Grant McCloskey (@MushuEE) (5x c3-standard-8, 4 unsized workers, 1Gi actors, agentsession at 6 users for 45m, started together), 32432e1 vs 1183627:
The base matches the 9f90899 burn-in on both runtimes, including the slow busiest node, and with the fix the busiest node looks like the others. |
|
Second run on the same clusters and builds. The base didn't fall behind this time, load spread more evenly across the workers, so checkpoint latency came out about the same on both. The base still held ~6x more deleted micro-VM data though.
So the slowdown needs one node to fall behind. The base did that in one of two runs, the fix in neither. Actor placement is uneven in every run, often one worker gets no actors at all. That's separate from this PR, I'll file an issue after some further debugging. I also added a cleanup for the global test seam kludge introduced by this PR. I resisted the urge to do this for nearby code we didn't touch, but we really need a cleanup pass over all of this. |
d6d3b1c to
3452015
Compare
|
Squashed into 6 commits and rebased on main for easier review, one change per commit. Same code as d6d3b1c earlier with the burn-in results, the only diff in the PR's files is the comments now stating pre- and post-conditions.
|
3452015 to
68a88d6
Compare
| // poll instead of waiting on write-out, so a file unlinked meanwhile is | ||
| // closed and its dirty pages dropped instead of written back | ||
| for i := 0; pollWritebackFiles(pc, files, i == 1); i++ { | ||
| time.Sleep(delays[min(i, len(delays)-1)]) |
There was a problem hiding this comment.
🤖 nit 🟢 – Nothing bounds this loop. On tmpfs pages stay dirty forever, so the goroutine polls every 3 s indefinitely and the loan is never returned. Once loans exceed what is unbooked, Wait blocks placement. The old fdatasync returned at once there. A cap on passes, after which the loan is returned and a warning logged, would keep that failure contained.
There was a problem hiding this comment.
tmpfs pages never get the dirty tag cachestat counts, shmem uses noop_dirty_folio (https://github.com/torvalds/linux/blob/v6.12/mm/shmem.c#L4809), so done runs right away there.
A file whose pages can never be written (e.g. EIO on a broken filesystem) does poll forever though.
Capping it means giving the loan back while the pages are still dirty, I think that's probably a follow-up with a warning log, the node is in trouble at that point anyway.
Again, will fold in if the other comments are blocking.
| } | ||
| l.mu.Lock() | ||
| defer l.mu.Unlock() | ||
| l.bytes -= n |
There was a problem hiding this comment.
nit: l.bytes = max(0, l.bytes-n) to avoid negative number
There was a problem hiding this comment.
I'd rather not, Return only gets what Take handed out, so going negative would be a bug and a clamp would hide it.
|
Given we need N=2 reviews anyhow and it is late, we might as well stash the branch as-is, fix those, and qualify overnight in this case. nobody else has reviewed the current code. If it qualifies, I will push the update. Otherwise I will leave it as-is, because I know this code is thoroughly vetted to work and none of these are blocking in my opinion but some form of the fix is needed for GA. |
Signed-off-by: Benjamin Elder <[email protected]>
Signed-off-by: Benjamin Elder <[email protected]>
|
Realistically, this code is so subtle that nobody can read it and fully comprehend. The proof is in the testing. Dima previously approved, so I will enqueue this, |
…upper back from disk (agent-substrate#3485) Why: In the microVM burn-in (GKE downstream testing), wake p50 went from ~350ms to ~1.1s (!), almost all of it in upper_join. Reported to me by @annapendleton though I'm not sure who originally found it. agent-substrate#3353 started writing out snapshot tars as they're written, so their dirty pages can't run a tight worker pod out of memory. It also dropped the written pages from the page cache, so a resume on the same node read the rootfs upper tar back from disk ... which is slow. It also made every suspend write the tars at disk speed. Fixes by commit order: 1. Pull summing memory.stat keys out of memoryUsed, no behavior change. 2. Clean pages are reclaimable, so dropping them is irrelevant for OOM safety. - The tars now stay cached. Write-out waits on the previous chunk instead of the one just written, so dirty pages stay at about two chunks without stalling on each one. 4. Start snapshot writeback after the OnDemand delta merge, which replaces memory-ranges, so it tracks the merged file instead of the unlinked delta. 5. Write snapshot files back in the background instead of with a blocking fdatasync. - Starting write-out can block for a long time on a busy disk (https://github.com/torvalds/linux/blob/v6.12/fs/sync.c#L315-L317), and holding deleted snapshot files open meanwhile leaked disk under load, so it starts write-out once, 64MiB at a time, and closes a file as soon as it's unlinked, which drops its dirty pages. 7. What snapshot writes can borrow leaves clean page cache out, so cached tars don't make a pod look "full". - A worker pod without a memory limit (e.g. the deploy_locust.sh default) has nothing to protect, so it lends all of it instead of capping every suspend as if the pod were full. 9. When the pod can lend memory for the whole archive, the tar skips the write-behind and goes to the page cache at full speed, and the loan comes back once it's on disk. Otherwise the write-behind still bounds its dirty pages. That said, with respect to workers without limits: We should probably consider recommending memory limits on worker pods. I will think about it overnight and propose docs updates tomorrow. Tested with locust burn-in tests (see comments in the PR). AI use: AI-assisted, I reviewed and tested it. - [x] Tests pass - [x] Docs updated --------- Signed-off-by: Benjamin Elder <[email protected]> (cherry picked from commit a6442f9) Signed-off-by: Eitan Yarmush <[email protected]>
Bring `release-1.0` up to `main` at 5a5b192 with the 19 changes merged since the previous batch in #3536. The final tree matches that commit exactly. **Please use Rebase and merge to preserve the individual commits and author attribution.** All 19 applied cleanly in upstream order. Original authors, author dates, co-author and sign-off trailers, and `-x` source references are retained. Upgrade notes: - #3533: old gVisor MEMORY snapshots, including goldens, no longer restore. Recreate them after upgrading. MicroVM MEMORY snapshots still resume with their old mounts until recreated. `/tmp` is now always tmpfs; ROOTFS snapshots no longer preserve its contents, and image-provided files there are hidden. MicroVM `/tmp` counts against actor memory and is capped at half the guest's memory. gVisor `/dev/shm` is now a 64 MiB tmpfs. Mount a `durableDir` at either path when persistence or more space is needed. `/run` now comes from the image rootfs. - #3447: remove `--actor-jwt-jwks-file` from custom credential-exchange sidecar configs. Set `--actor-jwt-issuer-ca-file` only when the issuer uses a private CA other than the service DNS CA. - #3450: do not reuse a deleted atespace name. See the updated atespace name-reuse guidance. Includes #3505, which overlaps the open backport in #3547. GKE provisioning and teardown now live in [substrate-gke](https://github.com/ai-on-gke/substrate-gke) rather than this repository. <details> <summary>Included PRs, in cherry-pick order</summary> 1. #3500: demos/{sandbox,jupyter,multi-template}: fix tutorial prerequisites, walkthroughs, and counter volume 2. #3498: demos/counter: inline gVisor walkthrough and fix setup commands 3. #3511: e2e tests: Extend the existing tests to cover tag actor. 4. #2775: Generate API reference documentation and publish it on Substrate website 5. #3119: Document how people, pods and CI jobs should authenticate to ate-api 6. #3505: tools: remove setup-gcp and hack/teardown.sh 7. #3147: e2e: resume fidelity after SandboxConfig version edits 8. #3370: ate-setup: reconcile ate-api-server ConfigMaps and roll out on config changes 9. #1607: hack: reject breaking proto changes across each proto package 10. #3415: docs: update and add security diagrams 11. #3485: Resuming a microVM actor on the same node no longer reads its rootfs upper back from disk 12. #3447: Verify actor JWTs in the credential exchange through OIDC discovery 13. #3533: ocispec: mount tmpfs at /dev/shm, and /tmp on both runtimes 14. #3450: docs: Document that atespace names must not be reused 15. #3410: Document using more than one egress credential provider 16. #3486: atelet: register the device plugin on a new socket each time 17. #3553: ate-setup: wait for the demo namespace in delete demo 18. #3560: Fix multiple issues in claude-code-multiplex demo 19. #3517: docs: remove inaccurate early-stage architecture documentation </details> Validation: every intermediate tree matches its upstream source commit, all 19 authorship records and original attribution trailers are preserved, and `git diff --check` passes. The original 18 backport commits are unchanged. AI use: AI-assisted cherry-picking, attribution checks, and review. - [ ] Tests pass - [x] Docs updated --------- Signed-off-by: Mofi Rahman <[email protected]> Signed-off-by: Eitan Yarmush <[email protected]> Signed-off-by: Nayana Bidari <[email protected]> Signed-off-by: Julian Gutierrez Oschmann <[email protected]> Signed-off-by: Max Thompson <[email protected]> Signed-off-by: Steven Shriver <[email protected]> Signed-off-by: Grant McCloskey <[email protected]> Signed-off-by: Shrutiya <[email protected]> Signed-off-by: Haiyan Meng <[email protected]> Signed-off-by: Greg Castle <[email protected]> Signed-off-by: Benjamin Elder <[email protected]> Signed-off-by: Anish Ramasekar <[email protected]> Signed-off-by: Shruti Nair <[email protected]> Signed-off-by: Haven Xia <[email protected]> Co-authored-by: Mofi Rahman <[email protected]> Co-authored-by: nybidari <[email protected]> Co-authored-by: Julian Gutierrez Oschmann <[email protected]> Co-authored-by: Max Thompson <[email protected]> Co-authored-by: Grant McCloskey <[email protected]> Co-authored-by: Steven Shriver <[email protected]> Co-authored-by: shrutiyam-glitch <[email protected]> Co-authored-by: haiyanmeng <[email protected]> Co-authored-by: Greg Castle <[email protected]> Co-authored-by: Benjamin Elder <[email protected]> Co-authored-by: Anish Ramasekar <[email protected]> Co-authored-by: Shruti Nair <[email protected]> Co-authored-by: Haven Xia <[email protected]> Co-authored-by: Adam Wathieu <[email protected]>
…upper back from disk (#3485) Why: In the microVM burn-in (GKE downstream testing), wake p50 went from ~350ms to ~1.1s (!), almost all of it in upper_join. Reported to me by @annapendleton though I'm not sure who originally found it. #3353 started writing out snapshot tars as they're written, so their dirty pages can't run a tight worker pod out of memory. It also dropped the written pages from the page cache, so a resume on the same node read the rootfs upper tar back from disk ... which is slow. It also made every suspend write the tars at disk speed. Fixes by commit order: 1. Pull summing memory.stat keys out of memoryUsed, no behavior change. 2. Clean pages are reclaimable, so dropping them is irrelevant for OOM safety. - The tars now stay cached. Write-out waits on the previous chunk instead of the one just written, so dirty pages stay at about two chunks without stalling on each one. 4. Start snapshot writeback after the OnDemand delta merge, which replaces memory-ranges, so it tracks the merged file instead of the unlinked delta. 5. Write snapshot files back in the background instead of with a blocking fdatasync. - Starting write-out can block for a long time on a busy disk (https://github.com/torvalds/linux/blob/v6.12/fs/sync.c#L315-L317), and holding deleted snapshot files open meanwhile leaked disk under load, so it starts write-out once, 64MiB at a time, and closes a file as soon as it's unlinked, which drops its dirty pages. 7. What snapshot writes can borrow leaves clean page cache out, so cached tars don't make a pod look "full". - A worker pod without a memory limit (e.g. the deploy_locust.sh default) has nothing to protect, so it lends all of it instead of capping every suspend as if the pod were full. 9. When the pod can lend memory for the whole archive, the tar skips the write-behind and goes to the page cache at full speed, and the loan comes back once it's on disk. Otherwise the write-behind still bounds its dirty pages. That said, with respect to workers without limits: We should probably consider recommending memory limits on worker pods. I will think about it overnight and propose docs updates tomorrow. Tested with locust burn-in tests (see comments in the PR). AI use: AI-assisted, I reviewed and tested it. - [x] Tests pass - [x] Docs updated --------- Signed-off-by: Benjamin Elder <[email protected]> (cherry picked from commit a6442f9) Signed-off-by: Eitan Yarmush <[email protected]>
After agent-substrate#3485 (a6442f9), every snapshot checkpoint called snapshotcache.StartWriteback unconditionally, even on worker pods without a memory limit. In workloads that suspend and resume frequently (such as glutton_mem_512mi_1_user_gvisor_pause), atelet deletes the previous local snapshot as soon as the next checkpoint completes (~1.9s later). Without forced writeback, deleting that short-lived snapshot drops its unwritten pages in memory without touching the disk. Calling StartWriteback unconditionally forced all 520 MiB of every checkpoint onto the 175 MB/s disk and held the files open until writeback finished, saturating the disk and stalling gvisor_sentry for ~750ms per suspend (SuspendActorAsync p50 ~1,400ms vs ~410ms before agent-substrate#3353). Move the StartWriteback call into Ledger.Writeback(paths, n), which returns n immediately when the pod has no memory limit (or n <= 0) and only flushes to disk when a memory-limited pod has borrowed memory to reclaim.
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause), SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to ~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485). Each 512 MiB checkpoint writes ~520 MiB into the kernel page cache on a 175 MB/s persistent disk. Because atelet only deletes the previous checkpoint after the next checkpoint finishes (~1.8 s later), calling sync_file_range(SYNC_FILE_RANGE_WRITE) immediately on every checkpoint causes a 3.0 s disk flush that overlaps the next checkpoint at t = 1.4 s and stalls gvisor_sentry for ~750-1,000 ms in balance_dirty_pages. Move post-checkpoint writeback behind Ledger.Writeback(paths, n): - Without a pod memory limit, return n immediately without starting writeback. - With a pod memory limit, poll paths (every 25ms..500ms) so unlinked checkpoints close their FDs and drop dirty pages in RAM promptly, and only force write-out via sync_file_range when the pod has less than n of unbooked or free memory left.
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause), SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to ~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485). Each 512 MiB checkpoint writes ~520 MiB into the kernel page cache on a 175 MB/s persistent disk. Because atelet only deletes the previous checkpoint after the next checkpoint finishes (~1.8 s later), calling sync_file_range(SYNC_FILE_RANGE_WRITE) immediately on every checkpoint causes a 3.0 s disk flush that overlaps the next checkpoint at t = 1.4 s and stalls gvisor_sentry for ~750-1,000 ms in balance_dirty_pages. Move post-checkpoint writeback behind Ledger.Writeback(paths, n) so write-out is only started when the pod has less than n of unbooked or free memory left.
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause), SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to ~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485). Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away keeps the disk busy flushing files that are about to be deleted, and the next checkpoint stalls behind it in balance_dirty_pages. StartWriteback now takes a flush func, and only starts write-out on a pass when it returns true. The callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left. While the pod has room, dirty pages stay cached and are dropped when the file is unlinked; once it's tight, they're written out as before so placements waiting on the loan can boot.
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause), SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to ~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485). Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away keeps the disk busy flushing files that are about to be deleted, and the next checkpoint stalls behind it in balance_dirty_pages. StartWriteback now takes a flush func, and only starts write-out on a pass when it returns true. The callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left. While the pod has room, dirty pages stay cached and are dropped when the file is unlinked; once it's tight, they're written out as before so placements waiting on the loan can boot.
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause), SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to ~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485). Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away keeps the disk busy flushing files that are about to be deleted, and the next checkpoint stalls behind it in balance_dirty_pages. StartWriteback now takes a flush func, and only starts write-out on a pass when it returns true. The callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left. While the pod has room, dirty pages stay cached and are dropped when the file is unlinked; once it's tight, they're written out as before so placements waiting on the loan can boot.
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause), SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to ~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485). Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away keeps the disk busy flushing files that are about to be deleted, and the next checkpoint stalls behind it in balance_dirty_pages. StartWriteback now takes a flush func, and only starts write-out on a pass when it returns true. The callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left. While the pod has room, dirty pages stay cached and are dropped when the file is unlinked; once it's tight, they're written out as before so placements waiting on the loan can boot.
atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away (agent-substrate#3353 / agent-substrate#3485) keeps the disk busy writing ~520 MiB that is about to be unlinked, and the next gVisor checkpoint stalls behind it in balance_dirty_pages. StartWriteback now takes a flush func and only starts write-out on a pass when it returns true. Callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left. Signed-off-by: Anna Pendleton <[email protected]>
atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away (agent-substrate#3353 / agent-substrate#3485) keeps the disk busy writing ~520 MiB that is about to be unlinked, and the next gVisor checkpoint stalls behind it in balance_dirty_pages. StartWriteback now takes a flush func and only starts write-out on a pass when it returns true. Callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left. Signed-off-by: Anna Pendleton <[email protected]>
Why: In the microVM burn-in (GKE downstream testing), wake p50 went from ~350ms to ~1.1s (!), almost all of it in upper_join. Reported to me by Anna Pendleton (@annapendleton) though I'm not sure who originally found it.
#3353 started writing out snapshot tars as they're written, so their dirty pages can't run a tight worker pod out of memory. It also dropped the written pages from the page cache, so a resume on the same node read the rootfs upper tar back from disk ... which is slow. It also made every suspend write the tars at disk speed.
Fixes by commit order:
That said, with respect to workers without limits: We should probably consider recommending memory limits on worker pods. I will think about it overnight and propose docs updates tomorrow.
Tested with locust burn-in tests (see comments in the PR).
AI use: AI-assisted, I reviewed and tested it.