-
Notifications
You must be signed in to change notification settings - Fork 290
Support durable directories and combined restore for gVisor #1379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| //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 main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
|
|
||
| "github.com/agent-substrate/substrate/internal/ateompath" | ||
| "github.com/agent-substrate/substrate/internal/proto/ateompb" | ||
| "github.com/agent-substrate/substrate/internal/tarutil" | ||
| ) | ||
|
|
||
| // durableTarFile is the snapshot file holding the tar of the actor's durable-dir | ||
| // volumes. Its entries are <volumeName>/... relative to | ||
| // ateompath.DurableDirVolumeMountsDir, so extraction restores the same layout. | ||
| // The name is shared with atelet, which uses it to carve durable data out of a | ||
| // FULL snapshot's file set when uploading a paused checkpoint as DATA. | ||
| const durableTarFile = ateompath.DurableDirTarFile | ||
|
|
||
| // hasDurableVolumes reports whether any container mounts a durable-dir volume. | ||
| func hasDurableVolumes(containers []*ateompb.Container) bool { | ||
| for _, c := range containers { | ||
| if len(c.GetDurableDirVolumeMounts()) > 0 { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // tarDurableVolumes archives the actor's durable-dir volumes (dir) into the | ||
| // checkpoint directory. The caller must have paused the guest first. | ||
| // | ||
| // Sockets the workload left behind and gVisor internal files (.gvisor.*) are | ||
| // skipped rather than archived. | ||
| func tarDurableVolumes(ctx context.Context, dir, checkpointDir string) error { | ||
| skip := func(rel string) bool { | ||
| base := filepath.Base(rel) | ||
| return strings.HasPrefix(base, ".gvisor.") | ||
| } | ||
| if err := tarutil.CreateFiltered(ctx, filepath.Join(checkpointDir, durableTarFile), dir, skip); err != nil { | ||
| return fmt.Errorf("while archiving durable-dir volumes from %q: %w", dir, err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // untarDurableVolumes restores the durable-dir volumes from a snapshot into the | ||
| // actor's host directory (dir, which atelet has already created, empty). | ||
| func untarDurableVolumes(dir, snapshotDir string) error { | ||
| if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| return fmt.Errorf("while creating durable-dir volumes dir %q: %w", dir, err) | ||
| } | ||
| if err := tarutil.Extract(filepath.Join(snapshotDir, durableTarFile), dir); err != nil { | ||
| return fmt.Errorf("while restoring durable-dir volumes into %q: %w", dir, err) | ||
| } | ||
| _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { | ||
| if err == nil && !info.IsDir() && strings.HasPrefix(info.Name(), ".gvisor.") { | ||
| _ = os.Remove(p) | ||
| } | ||
| return nil | ||
| }) | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,9 @@ const actorHTTPUpstream = "http://" + ateomnet.ActorVethIP + ":80" | |
| // termination grace period for the ateom. | ||
| const workloadGracePeriod = 1 * time.Minute | ||
|
|
||
| // resumeTimeout is the conservative ceiling for unpausing a paused sandbox. | ||
| const resumeTimeout = 30 * time.Second | ||
|
|
||
| func main() { | ||
| pflag.Parse() | ||
| if *showVersion { | ||
|
|
@@ -750,23 +753,35 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec | |
| // TODO(dberkov): this is a temporary workaround until gVisor supports taking durable-dir snapshots in a single request with the process snapshot. | ||
| switch req.GetScope() { | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA: | ||
| var ddv []string | ||
| for _, ctr := range req.GetSpec().GetContainers() { | ||
| for _, m := range ctr.GetDurableDirVolumeMounts() { | ||
| ddv = append(ddv, m.GetMountPath()) | ||
| } | ||
| } | ||
| if len(ddv) == 0 { | ||
| if !hasDurableVolumes(req.GetSpec().GetContainers()) { | ||
| return nil, fmt.Errorf("no durable-dir volumes found for DATA snapshot") | ||
| } | ||
| if err := rcmd.cmdFsCheckpoint(ctx, "pause", checkpointPath, ddv); err != nil { | ||
| return nil, fmt.Errorf("while fscheckpointing durable-dir %q: %w", ddv[0], err) | ||
| if err := rcmd.cmdPause(ctx, "pause"); err != nil { | ||
| return nil, fmt.Errorf("while pausing pause container: %w", err) | ||
| } | ||
| tarErr := tarDurableVolumes(ctx, ateompath.DurableDirVolumeMountsDir(req.GetActorUid()), checkpointPath) | ||
| // Undoing our own pause must not depend on the caller's context: | ||
| // tarutil does not check ctx, so a deadline expiring mid-tar would | ||
| // fail the resume instantly and leave the sandbox paused forever. | ||
| resumeCtx, cancelResume := context.WithTimeout(context.WithoutCancel(ctx), resumeTimeout) | ||
| defer cancelResume() | ||
| if err := rcmd.cmdResume(resumeCtx, "pause"); err != nil { | ||
| return nil, fmt.Errorf("while resuming pause container: %w", err) | ||
| } | ||
| if tarErr != nil { | ||
| return nil, fmt.Errorf("while archiving durable-dir volumes: %w", tarErr) | ||
| } | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: | ||
| // Checkpoint pause container (root of the sandbox) | ||
| // TODO: Consider pause -> tar -> resume -> checkpoint order for better failure handling. | ||
| if err := rcmd.cmdCheckpoint(ctx, "pause", checkpointPath); err != nil { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be pause -> tar -> checkpoint instead of checkpoint -> tar? Or IOW is this the right failure handling for just resuming if the disk is transiently full or something?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Left a TODO |
||
| return nil, fmt.Errorf("while checkpointing pause: %w", err) | ||
| } | ||
| if hasDurableVolumes(req.GetSpec().GetContainers()) { | ||
| if err := tarDurableVolumes(ctx, ateompath.DurableDirVolumeMountsDir(req.GetActorUid()), checkpointPath); err != nil { | ||
| return nil, fmt.Errorf("while archiving durable-dir volumes: %w", err) | ||
| } | ||
| } | ||
| default: | ||
| return nil, fmt.Errorf("unsupported snapshot scope: %v", req.GetScope()) | ||
| } | ||
|
|
@@ -927,6 +942,11 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore | |
| }() | ||
| checkpointDir := ateompath.RestoreStateDir(req.GetActorUid()) | ||
|
|
||
| if hasDurableVolumes(req.GetSpec().GetContainers()) { | ||
| if err := untarDurableVolumes(ateompath.DurableDirVolumeMountsDir(req.GetActorUid()), checkpointDir); err != nil { | ||
| return nil, fmt.Errorf("while restoring durable-dir volumes: %w", err) | ||
| } | ||
| } | ||
| // Compose the pause rootfs before create (see RunWorkload). runsc restore | ||
| // only needs the rootfs to hold the correct content; whether it came from | ||
| // an untar or an overlay of cached layers is transparent to it. | ||
|
|
@@ -936,22 +956,22 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore | |
|
|
||
| switch req.GetScope() { | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA: | ||
| // Create and restore pause container | ||
| // Create and start pause container (cold boot with durable-dir volumes restored) | ||
| containersToDelete = append(containersToDelete, "pause") | ||
| if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", []string{"--fs-restore-image-path", checkpointDir}); err != nil { | ||
| if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", nil); err != nil { | ||
| return nil, fmt.Errorf("while creating pause container: %w", err) | ||
| } | ||
| if err := rcmd.cmdStart(ctx, os.Stdout, "pause"); err != nil { | ||
| return nil, fmt.Errorf("while starting pause container: %w", err) | ||
| } | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL, ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA_ON_GOLDEN: | ||
| // Create and restore pause container | ||
| containersToDelete = append(containersToDelete, "pause") | ||
| if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", nil); err != nil { | ||
| return nil, fmt.Errorf("while creating pause container: %w", err) | ||
| } | ||
| if err := rcmd.cmdRestore(ctx, os.Stdout, "pause", checkpointDir); err != nil { | ||
| return nil, fmt.Errorf("while starting pause container: %w", err) | ||
| return nil, fmt.Errorf("while restoring pause container: %w", err) | ||
| } | ||
| default: | ||
| return nil, fmt.Errorf("unexpected snapshot scope: %v", req.GetScope()) | ||
|
|
@@ -977,13 +997,13 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore | |
| if err := rcmd.cmdStart(ctx, pw, ac.GetName()); err != nil { | ||
| return nil, fmt.Errorf("while starting %q application container: %w", ac.GetName(), err) | ||
| } | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: | ||
| case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL, ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA_ON_GOLDEN: | ||
| containersToDelete = append(containersToDelete, ac.GetName()) | ||
| if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { | ||
| return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) | ||
| } | ||
| if err := rcmd.cmdRestore(ctx, pw, ac.GetName(), checkpointDir); err != nil { | ||
| return nil, fmt.Errorf("while starting %q application container: %w", ac.GetName(), err) | ||
| return nil, fmt.Errorf("while restoring %q application container: %w", ac.GetName(), err) | ||
| } | ||
| default: | ||
| return nil, fmt.Errorf("unexpected snapshot scope: %v", req.GetScope()) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
claude surfaced:
I think that's right, or some variation on it, the alternative is to make tarutil context aware, but that seems like a bigger lift for this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, updated the changes.