diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97497329..e333de51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,6 +95,29 @@ jobs: - name: Build run: make build + - name: Install UFFD pager systemd template + run: | + sudo mkdir -p /run/hypeman/uffd + sudo tee /etc/systemd/system/hypeman-uffd@.service > /dev/null <<'EOF' + [Unit] + Description=Hypeman UFFD Pager (%i) + Documentation=https://github.com/kernel/hypeman + After=network.target + + [Service] + Type=simple + Environment="HYPEMAN_UFFD_BINARY=/opt/hypeman/bin/hypeman-uffd-pager" + Environment="HYPEMAN_UFFD_DATA_DIR=/var/lib/hypeman" + Environment="HYPEMAN_UFFD_VERSION_KEY=%i" + Environment="HYPEMAN_UFFD_CACHE_MAX_BYTES=4294967296" + EnvironmentFile=-/run/hypeman/uffd/%i.env + ExecStart=/bin/sh -c 'exec "$HYPEMAN_UFFD_BINARY" --data-dir "$HYPEMAN_UFFD_DATA_DIR" --version-key "$HYPEMAN_UFFD_VERSION_KEY" --cache-max-bytes "$HYPEMAN_UFFD_CACHE_MAX_BYTES"' + Restart=on-failure + RestartSec=5 + KillMode=process + EOF + sudo systemctl daemon-reload + - name: Prewarm test cache env: HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001 @@ -131,13 +154,25 @@ jobs: TLS_ALLOWED_DOMAINS: '*.hypeman-development.com' HYPEMAN_TEST_PREWARM_STRICT: "1" HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001 + HYPEMAN_UFFD_PAGER_BINARY: ${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }} + HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX: ci-${{ github.run_id }}-${{ github.run_attempt }} run: | + cp "$PWD/bin/hypeman-uffd-pager" "$HYPEMAN_UFFD_PAGER_BINARY" + chmod +x "$HYPEMAN_UFFD_PAGER_BINARY" export HYPEMAN_TEST_PREWARM_DIR="$HOME/.cache/hypeman-ci/linux-amd64" make test TEST_TIMEOUT=20m - name: Cleanup if: always() - run: sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}" + run: | + units="$(systemctl list-units --all --full --plain 'hypeman-uffd@ci-${{ github.run_id }}-${{ github.run_attempt }}-*.service' --no-legend | awk '{print $1}' || true)" + if [ -n "$units" ]; then + echo "$units" | xargs -r sudo systemctl stop || true + echo "$units" | xargs -r sudo systemctl reset-failed || true + fi + sudo rm -f /run/hypeman/uffd/ci-${{ github.run_id }}-${{ github.run_attempt }}-*.env + sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}" + rm -f "${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }}" test-darwin: runs-on: [self-hosted, macos, arm64] diff --git a/Makefile b/Makefile index a0b71091..d2d42997 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ SHELL := /bin/bash # Directory where local binaries will be installed BIN_DIR ?= $(CURDIR)/bin GO_TEST_TIMEOUT ?= 300s +UFFD_PAGER_BINARY ?= $(BIN_DIR)/hypeman-uffd-pager $(BIN_DIR): mkdir -p $(BIN_DIR) @@ -256,6 +257,9 @@ build-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binarie go build -tags containers_image_openpgp -o $(BIN_DIR)/hypeman ./cmd/api go build -o $(BIN_DIR)/hypeman-uffd-pager ./cmd/uffd-pager +$(BIN_DIR)/hypeman-uffd-pager: | $(BIN_DIR) + go build -o $@ ./cmd/uffd-pager + # Build all binaries build-all: build @@ -289,19 +293,23 @@ else endif # Linux tests (as root for network capabilities) -test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries build-embedded +test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries build-embedded $(BIN_DIR)/hypeman-uffd-pager @VERBOSE_FLAG=""; \ TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$$PATH"; \ if [ -n "$(VERBOSE)" ]; then VERBOSE_FLAG="-v"; fi; \ if [ -n "$(TEST)" ]; then \ echo "Running specific test: $(TEST)"; \ sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \ + "HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \ + "HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$${HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX:-}" \ "HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \ "HYPEMAN_TEST_PREWARM_STRICT=$${HYPEMAN_TEST_PREWARM_STRICT:-}" \ "HYPEMAN_TEST_REGISTRY=$${HYPEMAN_TEST_REGISTRY:-}" \ go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=$(TEST_TIMEOUT) ./...; \ else \ sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \ + "HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \ + "HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$${HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX:-}" \ "HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \ "HYPEMAN_TEST_PREWARM_STRICT=$${HYPEMAN_TEST_PREWARM_STRICT:-}" \ "HYPEMAN_TEST_REGISTRY=$${HYPEMAN_TEST_REGISTRY:-}" \ diff --git a/lib/forkvm/README.md b/lib/forkvm/README.md index bb6567c7..6e956fec 100644 --- a/lib/forkvm/README.md +++ b/lib/forkvm/README.md @@ -59,6 +59,20 @@ instead of reusing the source identity. - Network override fields are supplied at snapshot load to bind the fork to its own TAP device. - Vsock CID remains stable for snapshot-based flows. +- When the Firecracker snapshot memory backend is configured as UFFD, UFFD is + used as a one-shot acceleration for the first restore of a newly forked + standby snapshot. The fork initially reuses the source snapshot memory as the + pager backing file instead of cloning the large memory file during fanout. +- That deferred memory clone is paid when the fork later enters standby. Before + Firecracker writes the fork's diff snapshot, Hypeman materializes the fork's + own `snapshot-latest/memory` file from the original backing memory. After that + point the fork has a normal on-disk snapshot base, independent from the source. +- Subsequent direct restores of that same fork use Firecracker's normal + file-backed memory backend. If that standby fork is itself forked again, the + new child gets its own one-shot UFFD restore. +- This keeps UFFD on the high-fanout path where shared snapshot cache is most + useful, while preserving the normal Firecracker diff-snapshot lifecycle for + per-instance standby/resume cycles. ## VZ (Virtualization.framework) diff --git a/lib/forkvm/copy.go b/lib/forkvm/copy.go index fda4a48f..26a35802 100644 --- a/lib/forkvm/copy.go +++ b/lib/forkvm/copy.go @@ -33,12 +33,22 @@ type copyState struct { reflinkDead bool } +// CopyOptions controls which guest-directory files are copied. +type CopyOptions struct { + SkipRelativePaths map[string]struct{} +} + // CopyGuestDirectory recursively copies a guest directory to a new destination. // Regular files are cloned via reflink (FICLONE) when the underlying filesystem // supports it; otherwise we fall back to a sparse extent copy // (SEEK_DATA/SEEK_HOLE). Runtime sockets and logs are skipped because they are // host-runtime artifacts. func CopyGuestDirectory(srcDir, dstDir string) error { + return CopyGuestDirectoryWithOptions(srcDir, dstDir, CopyOptions{}) +} + +// CopyGuestDirectoryWithOptions is CopyGuestDirectory with optional path skips. +func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error { srcInfo, err := os.Stat(srcDir) if err != nil { return fmt.Errorf("stat source directory: %w", err) @@ -68,9 +78,15 @@ func CopyGuestDirectory(srcDir, dstDir string) error { if relPath == "." { return nil } + if _, ok := opts.SkipRelativePaths[filepath.Clean(relPath)]; ok { + return nil + } if d.IsDir() && shouldSkipDirectory(relPath) { return filepath.SkipDir } + if !d.IsDir() && shouldSkipRuntimeSocket(relPath) { + return nil + } if !d.IsDir() && shouldSkipRegularFile(relPath) { return nil } @@ -115,6 +131,26 @@ func CopyGuestDirectory(srcDir, dstDir string) error { }) } +// CopyRegularFile copies one regular file using the same reflink-first behavior +// as CopyGuestDirectory. +func CopyRegularFile(srcPath, dstPath string) error { + info, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("stat source file: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("source path is not a regular file: %s", srcPath) + } + if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { + return fmt.Errorf("create destination parent: %w", err) + } + state := ©State{} + if reflinkDisabled.Load() { + state.reflinkDead = true + } + return copyRegularFile(state, srcPath, dstPath, info.Mode().Perm()) +} + // copyRegularFile clones path to dstPath, preferring FICLONE reflink and // falling back to sparse extent copy. The state object lets us short-circuit // future reflink attempts once we observe an "unsupported" signal from the @@ -142,3 +178,7 @@ func shouldSkipDirectory(relPath string) bool { func shouldSkipRegularFile(relPath string) bool { return strings.HasSuffix(relPath, ".lz4.tmp") || strings.HasSuffix(relPath, ".zst.tmp") } + +func shouldSkipRuntimeSocket(relPath string) bool { + return strings.HasSuffix(filepath.Base(relPath), ".sock") +} diff --git a/lib/forkvm/copy_sparse_unix_test.go b/lib/forkvm/copy_sparse_unix_test.go index 109c3d5a..54e4d696 100644 --- a/lib/forkvm/copy_sparse_unix_test.go +++ b/lib/forkvm/copy_sparse_unix_test.go @@ -81,6 +81,7 @@ func TestCopyGuestDirectory_SkipsSocketRuntimeArtifacts(t *testing.T) { dst := filepath.Join(base, "dst") require.NoError(t, os.MkdirAll(src, 0755)) require.NoError(t, os.WriteFile(filepath.Join(src, "metadata.json"), []byte(`{"id":"abc"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "vsock.sock"), []byte("stale regular artifact"), 0644)) socketPath := filepath.Join(src, fmt.Sprintf("vz-%d.sock", time.Now().UnixNano())) listener, err := net.Listen("unix", socketPath) @@ -90,6 +91,7 @@ func TestCopyGuestDirectory_SkipsSocketRuntimeArtifacts(t *testing.T) { require.NoError(t, CopyGuestDirectory(src, dst)) assert.NoFileExists(t, filepath.Join(dst, filepath.Base(socketPath))) + assert.NoFileExists(t, filepath.Join(dst, "vsock.sock")) assert.FileExists(t, filepath.Join(dst, "metadata.json")) } diff --git a/lib/forkvm/copy_test.go b/lib/forkvm/copy_test.go index c71f6c4e..00634b26 100644 --- a/lib/forkvm/copy_test.go +++ b/lib/forkvm/copy_test.go @@ -44,6 +44,43 @@ func TestCopyGuestDirectory(t *testing.T) { assert.Equal(t, "metadata.json", linkTarget) } +func TestCopyGuestDirectoryWithOptionsSkipsRelativePaths(t *testing.T) { + src := filepath.Join(t.TempDir(), "src") + dst := filepath.Join(t.TempDir(), "dst") + + require.NoError(t, os.MkdirAll(filepath.Join(src, "snapshots", "snapshot-latest"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "memory"), []byte("memory"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "state"), []byte("state"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "overlay.raw"), []byte("overlay"), 0644)) + + require.NoError(t, CopyGuestDirectoryWithOptions(src, dst, CopyOptions{ + SkipRelativePaths: map[string]struct{}{ + filepath.Join("snapshots", "snapshot-latest", "memory"): {}, + }, + })) + + assert.NoFileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory")) + assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "state")) + assert.FileExists(t, filepath.Join(dst, "overlay.raw")) +} + +func TestCopyRegularFile(t *testing.T) { + src := filepath.Join(t.TempDir(), "src", "memory") + dst := filepath.Join(t.TempDir(), "dst", "snapshots", "snapshot-latest", "memory") + + require.NoError(t, os.MkdirAll(filepath.Dir(src), 0755)) + require.NoError(t, os.WriteFile(src, []byte("memory"), 0640)) + + require.NoError(t, CopyRegularFile(src, dst)) + + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) + info, err := os.Stat(dst) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0640), info.Mode().Perm()) +} + func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) { src := filepath.Join(t.TempDir(), "src") dst := filepath.Join(t.TempDir(), "dst") diff --git a/lib/guestmemory/controller_test.go b/lib/guestmemory/controller_test.go index 4720ec7a..0b7ea7e9 100644 --- a/lib/guestmemory/controller_test.go +++ b/lib/guestmemory/controller_test.go @@ -47,9 +47,11 @@ func (s *stubHypervisor) Shutdown(ctx context.Context) error { return nil } func (s *stubHypervisor) GetVMInfo(ctx context.Context) (*hypervisor.VMInfo, error) { return &hypervisor.VMInfo{State: hypervisor.StateRunning}, nil } -func (s *stubHypervisor) Pause(ctx context.Context) error { return nil } -func (s *stubHypervisor) Resume(ctx context.Context) error { return nil } -func (s *stubHypervisor) Snapshot(ctx context.Context, destPath string) error { return nil } +func (s *stubHypervisor) Pause(ctx context.Context) error { return nil } +func (s *stubHypervisor) Resume(ctx context.Context) error { return nil } +func (s *stubHypervisor) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { + return nil +} func (s *stubHypervisor) ResizeMemory(ctx context.Context, bytes int64) error { return nil } func (s *stubHypervisor) ResizeMemoryAndWait(ctx context.Context, bytes int64, timeout time.Duration) error { return nil diff --git a/lib/hypervisor/README.md b/lib/hypervisor/README.md index 956bd8a1..6b274b24 100644 --- a/lib/hypervisor/README.md +++ b/lib/hypervisor/README.md @@ -33,7 +33,7 @@ Before using optional features, callers check capabilities: ```go if hv.Capabilities().SupportsSnapshot { - hv.Snapshot(ctx, path) + hv.Snapshot(ctx, path, hypervisor.SnapshotOptions{}) } ``` diff --git a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go index 1d3a7f14..8f6f2484 100644 --- a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go +++ b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go @@ -160,7 +160,7 @@ func (c *CloudHypervisor) Resume(ctx context.Context) error { } // Snapshot creates a VM snapshot. -func (c *CloudHypervisor) Snapshot(ctx context.Context, destPath string) error { +func (c *CloudHypervisor) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { snapshotURL := "file://" + destPath snapshotConfig := vmm.VmSnapshotConfig{DestinationUrl: &snapshotURL} resp, err := c.client.PutVmSnapshotWithResponse(ctx, snapshotConfig) diff --git a/lib/hypervisor/firecracker/config_test.go b/lib/hypervisor/firecracker/config_test.go index c5e8dc6c..fcbe7c6b 100644 --- a/lib/hypervisor/firecracker/config_test.go +++ b/lib/hypervisor/firecracker/config_test.go @@ -101,6 +101,37 @@ func TestSnapshotLoadParamsSupportsUFFDBackend(t *testing.T) { assert.Equal(t, "/tmp/pager.sock", load.MemBackend.BackendPath) } +func TestMaterializeDeferredSnapshotMemory(t *testing.T) { + t.Parallel() + + sourcePath := filepath.Join(t.TempDir(), "source-memory") + snapshotDir := filepath.Join(t.TempDir(), "snapshot-latest") + require.NoError(t, os.WriteFile(sourcePath, []byte("memory"), 0644)) + + require.NoError(t, materializeDeferredSnapshotMemory(snapshotDir, sourcePath)) + + got, err := os.ReadFile(filepath.Join(snapshotDir, "memory")) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) +} + +func TestMaterializeDeferredSnapshotMemoryUsesRetainedSnapshotAlternate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sourcePath := filepath.Join(root, "snapshots", "snapshot-base", "memory") + alternatePath := filepath.Join(root, "snapshots", "snapshot-latest", "memory") + destPath := filepath.Join(t.TempDir(), "snapshot-latest") + require.NoError(t, os.MkdirAll(filepath.Dir(alternatePath), 0755)) + require.NoError(t, os.WriteFile(alternatePath, []byte("memory"), 0644)) + + require.NoError(t, materializeDeferredSnapshotMemory(destPath, sourcePath)) + + got, err := os.ReadFile(filepath.Join(destPath, "memory")) + require.NoError(t, err) + assert.Equal(t, []byte("memory"), got) +} + func TestToBalloonConfig(t *testing.T) { cfg := hypervisor.VMConfig{ GuestMemory: hypervisor.GuestMemoryConfig{ diff --git a/lib/hypervisor/firecracker/firecracker.go b/lib/hypervisor/firecracker/firecracker.go index 3512f653..c2bb7db3 100644 --- a/lib/hypervisor/firecracker/firecracker.go +++ b/lib/hypervisor/firecracker/firecracker.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/kernel/hypeman/lib/forkvm" "github.com/kernel/hypeman/lib/hypervisor" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -112,10 +113,13 @@ func (f *Firecracker) Resume(ctx context.Context) error { return nil } -func (f *Firecracker) Snapshot(ctx context.Context, destPath string) error { +func (f *Firecracker) Snapshot(ctx context.Context, destPath string, opts hypervisor.SnapshotOptions) error { if err := os.MkdirAll(destPath, 0755); err != nil { return fmt.Errorf("create snapshot directory: %w", err) } + if err := materializeDeferredSnapshotMemory(destPath, opts.DeferredMemoryBackingPath); err != nil { + return err + } params := toSnapshotCreateParams(destPath) if _, err := f.do(ctx, http.MethodPut, "/snapshot/create", params, http.StatusNoContent); err != nil { return fmt.Errorf("create snapshot: %w", err) @@ -123,6 +127,62 @@ func (f *Firecracker) Snapshot(ctx context.Context, destPath string) error { return nil } +func materializeDeferredSnapshotMemory(destPath, sourcePath string) error { + sourcePath = strings.TrimSpace(sourcePath) + if sourcePath == "" { + return nil + } + targetPath := filepath.Join(destPath, "memory") + if _, err := os.Stat(targetPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat deferred snapshot memory target: %w", err) + } + resolvedSourcePath, err := resolveDeferredSnapshotMemorySourcePath(sourcePath) + if err != nil { + return err + } + if err := forkvm.CopyRegularFile(resolvedSourcePath, targetPath); err != nil { + return fmt.Errorf("materialize deferred snapshot memory: %w", err) + } + return nil +} + +func resolveDeferredSnapshotMemorySourcePath(sourcePath string) (string, error) { + if _, err := os.Stat(sourcePath); err == nil { + return sourcePath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat deferred snapshot memory source: %w", err) + } + + alternatePath := alternateRetainedSnapshotMemoryPath(sourcePath) + if alternatePath == "" { + return sourcePath, nil + } + if _, err := os.Stat(alternatePath); err == nil { + return alternatePath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat alternate deferred snapshot memory source: %w", err) + } + return sourcePath, nil +} + +func alternateRetainedSnapshotMemoryPath(sourcePath string) string { + if filepath.Base(sourcePath) != "memory" { + return "" + } + snapshotDir := filepath.Dir(sourcePath) + snapshotsDir := filepath.Dir(snapshotDir) + switch filepath.Base(snapshotDir) { + case "snapshot-base": + return filepath.Join(snapshotsDir, "snapshot-latest", "memory") + case "snapshot-latest": + return filepath.Join(snapshotsDir, "snapshot-base", "memory") + default: + return "" + } +} + func (f *Firecracker) ResizeMemory(ctx context.Context, bytes int64) error { return hypervisor.ErrNotSupported } diff --git a/lib/hypervisor/firecracker/fork_test.go b/lib/hypervisor/firecracker/fork_test.go index 5ffe5617..1a0022e4 100644 --- a/lib/hypervisor/firecracker/fork_test.go +++ b/lib/hypervisor/firecracker/fork_test.go @@ -44,7 +44,7 @@ func TestPrepareFork_SnapshotRewritePersistsRestoreMetadata(t *testing.T) { assert.True(t, meta.RetainSnapshotSourceDataDirAlias) } -func TestPrepareFork_DoesNotRetainExistingSourceAlias(t *testing.T) { +func TestPrepareFork_RetainsExistingSourceAlias(t *testing.T) { starter := NewStarter() tmp := t.TempDir() sourceDir := filepath.Join(tmp, "source") diff --git a/lib/hypervisor/firecracker/process.go b/lib/hypervisor/firecracker/process.go index 8d6d30c2..b30a4b37 100644 --- a/lib/hypervisor/firecracker/process.go +++ b/lib/hypervisor/firecracker/process.go @@ -142,10 +142,14 @@ func (s *Starter) RestoreVM(ctx context.Context, p *paths.Paths, version string, if sessionID == "" { sessionID = filepath.Base(filepath.Dir(socketPath)) } + backingMemoryPath := strings.TrimSpace(opts.SnapshotMemoryBackingPath) + if backingMemoryPath == "" { + backingMemoryPath = snapshotMemoryPath(snapshotPath) + } resp, err := s.uffd.CreateSession(ctx, uffdpager.CreateSessionRequest{ SessionID: sessionID, InstanceID: sessionID, - BackingMemoryPath: snapshotMemoryPath(snapshotPath), + BackingMemoryPath: backingMemoryPath, CacheKey: opts.SnapshotMemoryCacheKey, }) if err != nil { diff --git a/lib/hypervisor/hypervisor.go b/lib/hypervisor/hypervisor.go index 6d6d9292..ce96fa85 100644 --- a/lib/hypervisor/hypervisor.go +++ b/lib/hypervisor/hypervisor.go @@ -132,9 +132,14 @@ const ( ) type RestoreOptions struct { - SnapshotMemoryBackend SnapshotMemoryBackend - SnapshotMemoryCacheKey string - SnapshotMemorySessionID string + SnapshotMemoryBackend SnapshotMemoryBackend + SnapshotMemoryBackingPath string + SnapshotMemoryCacheKey string + SnapshotMemorySessionID string +} + +type SnapshotOptions struct { + DeferredMemoryBackingPath string } // ForkNetworkConfig contains network identity fields for fork preparation. @@ -194,7 +199,7 @@ type Hypervisor interface { // Snapshot creates a VM snapshot at the given path. // Check Capabilities().SupportsSnapshot before calling. - Snapshot(ctx context.Context, destPath string) error + Snapshot(ctx context.Context, destPath string, opts SnapshotOptions) error // ResizeMemory changes the VM's memory allocation. // Check Capabilities().SupportsHotplugMemory before calling. diff --git a/lib/hypervisor/qemu/qemu.go b/lib/hypervisor/qemu/qemu.go index 1da70965..70809f90 100644 --- a/lib/hypervisor/qemu/qemu.go +++ b/lib/hypervisor/qemu/qemu.go @@ -140,7 +140,7 @@ func (q *QEMU) Resume(ctx context.Context) error { // Snapshot creates a VM snapshot using QEMU's migrate-to-file mechanism. // The VM state is saved to destPath/memory file. // The VM config is copied to destPath for restore (QEMU requires exact arg match). -func (q *QEMU) Snapshot(ctx context.Context, destPath string) error { +func (q *QEMU) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { // QEMU uses migrate to file for snapshots // The "file:" protocol is deprecated in QEMU 7.2+, use "exec:cat > path" instead memoryFile := destPath + "/memory" diff --git a/lib/hypervisor/tracing.go b/lib/hypervisor/tracing.go index 0ee93ae0..f15a25fc 100644 --- a/lib/hypervisor/tracing.go +++ b/lib/hypervisor/tracing.go @@ -217,14 +217,14 @@ func (h *tracingHypervisor) Resume(ctx context.Context) (err error) { return h.next.Resume(ctx) } -func (h *tracingHypervisor) Snapshot(ctx context.Context, destPath string) (err error) { +func (h *tracingHypervisor) Snapshot(ctx context.Context, destPath string, opts SnapshotOptions) (err error) { ctx, span := startTraceSpan(ctx, h.tracer, "hypervisor.snapshot", h.spanAttrs( attribute.String("operation", "snapshot"), )..., ) defer func() { finishTraceSpan(span, err) }() - return h.next.Snapshot(ctx, destPath) + return h.next.Snapshot(ctx, destPath, opts) } func (h *tracingHypervisor) ResizeMemory(ctx context.Context, bytes int64) (err error) { diff --git a/lib/hypervisor/tracing_test.go b/lib/hypervisor/tracing_test.go index 409e1bd0..eb8162ed 100644 --- a/lib/hypervisor/tracing_test.go +++ b/lib/hypervisor/tracing_test.go @@ -24,9 +24,11 @@ func (fakeHypervisor) Shutdown(context.Context) error { return nil } func (fakeHypervisor) GetVMInfo(context.Context) (*VMInfo, error) { return &VMInfo{State: StateRunning}, nil } -func (fakeHypervisor) Pause(context.Context) error { return nil } -func (fakeHypervisor) Resume(context.Context) error { return nil } -func (fakeHypervisor) Snapshot(context.Context, string) error { return nil } +func (fakeHypervisor) Pause(context.Context) error { return nil } +func (fakeHypervisor) Resume(context.Context) error { return nil } +func (fakeHypervisor) Snapshot(context.Context, string, SnapshotOptions) error { + return nil +} func (fakeHypervisor) ResizeMemory(context.Context, int64) error { return nil } func (fakeHypervisor) ResizeMemoryAndWait(context.Context, int64, time.Duration) error { return nil @@ -41,9 +43,11 @@ func (fakeHypervisorGetVMInfoError) Shutdown(context.Context) error { return nil func (fakeHypervisorGetVMInfoError) GetVMInfo(context.Context) (*VMInfo, error) { return nil, errors.New("vm info failed") } -func (fakeHypervisorGetVMInfoError) Pause(context.Context) error { return nil } -func (fakeHypervisorGetVMInfoError) Resume(context.Context) error { return nil } -func (fakeHypervisorGetVMInfoError) Snapshot(context.Context, string) error { return nil } +func (fakeHypervisorGetVMInfoError) Pause(context.Context) error { return nil } +func (fakeHypervisorGetVMInfoError) Resume(context.Context) error { return nil } +func (fakeHypervisorGetVMInfoError) Snapshot(context.Context, string, SnapshotOptions) error { + return nil +} func (fakeHypervisorGetVMInfoError) ResizeMemory(context.Context, int64) error { return nil } diff --git a/lib/hypervisor/vz/client.go b/lib/hypervisor/vz/client.go index c3132c1b..f7e31e7f 100644 --- a/lib/hypervisor/vz/client.go +++ b/lib/hypervisor/vz/client.go @@ -249,7 +249,7 @@ func (c *Client) Resume(ctx context.Context) error { return c.doPut(ctx, "/api/v1/vm.resume", nil) } -func (c *Client) Snapshot(ctx context.Context, destPath string) error { +func (c *Client) Snapshot(ctx context.Context, destPath string, _ hypervisor.SnapshotOptions) error { req := snapshotRequest{DestinationPath: destPath} body, err := json.Marshal(req) if err != nil { diff --git a/lib/instances/firecracker_test.go b/lib/instances/firecracker_test.go index 6bb28572..111c11b8 100644 --- a/lib/instances/firecracker_test.go +++ b/lib/instances/firecracker_test.go @@ -28,6 +28,7 @@ import ( "github.com/kernel/hypeman/lib/resources" snapshottest "github.com/kernel/hypeman/lib/snapshot/testsupport" "github.com/kernel/hypeman/lib/system" + "github.com/kernel/hypeman/lib/uffdpager" "github.com/kernel/hypeman/lib/volumes" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -36,6 +37,10 @@ import ( ) func setupTestManagerForFirecrackerWithNetworkConfig(t *testing.T, networkCfg config.NetworkConfig) (*manager, string) { + return setupTestManagerForFirecrackerWithConfig(t, networkCfg, ManagerConfig{}) +} + +func setupTestManagerForFirecrackerWithConfig(t *testing.T, networkCfg config.NetworkConfig, managerConfig ManagerConfig) (*manager, string) { tmpDir := t.TempDir() prepareIntegrationTestDataDir(t, tmpDir) cfg := &config.Config{ @@ -57,7 +62,9 @@ func setupTestManagerForFirecrackerWithNetworkConfig(t *testing.T, networkCfg co MaxVcpusPerInstance: 0, MaxMemoryPerInstance: 0, } - mgr := NewManager(p, imageManager, systemManager, networkManager, deviceManager, volumeManager, limits, hypervisor.TypeFirecracker, SnapshotPolicy{}, nil, nil).(*manager) + mgrInterface, err := NewManagerWithConfigE(p, imageManager, systemManager, networkManager, deviceManager, volumeManager, limits, hypervisor.TypeFirecracker, SnapshotPolicy{}, managerConfig, nil, nil) + require.NoError(t, err) + mgr := mgrInterface.(*manager) resourceMgr := resources.NewManager(cfg, p) resourceMgr.SetInstanceLister(mgr) @@ -88,6 +95,15 @@ func requireFirecrackerIntegrationPrereqs(t *testing.T) { } } +func requireUserfaultfdIntegrationPrereqs(t *testing.T) { + t.Helper() + file, err := os.OpenFile("/dev/userfaultfd", os.O_RDWR, 0) + if err != nil { + t.Skipf("/dev/userfaultfd is not accessible, skipping UFFD integration test: %v", err) + } + _ = file.Close() +} + func createNginxImageAndWait(t *testing.T, ctx context.Context, imageManager images.Manager) { t.Helper() @@ -648,6 +664,261 @@ func TestFirecrackerWarmForkChain(t *testing.T) { require.NoError(t, mgr.DeleteSnapshot(ctx, snapshot.Id)) } +func TestFCUFFDOneShotLifecycle(t *testing.T) { + t.Parallel() + requireFirecrackerIntegrationPrereqs(t) + requireUserfaultfdIntegrationPrereqs(t) + if pagerBinary := strings.TrimSpace(os.Getenv("HYPEMAN_UFFD_PAGER_BINARY")); pagerBinary == "" { + t.Skip("HYPEMAN_UFFD_PAGER_BINARY must point at hypeman-uffd-pager for UFFD integration tests") + } else if st, err := os.Stat(pagerBinary); err != nil || !st.Mode().IsRegular() { + t.Skipf("HYPEMAN_UFFD_PAGER_BINARY is not a regular file: %s", pagerBinary) + } + + mgr, tmpDir := setupTestManagerForFirecrackerWithConfig(t, legacyParallelTestNetworkConfig(testNetworkSeq.Add(1)), ManagerConfig{ + FirecrackerSnapshotMemoryBackend: uffdpager.BackendUFFD, + FirecrackerUFFDCacheMaxBytes: 512 << 20, + }) + ctx := context.Background() + p := paths.New(tmpDir) + + imageManager, err := images.NewManager(p, 1, nil) + require.NoError(t, err) + imageName := integrationTestImageRef(t, "docker.io/library/alpine:latest") + snapshottest.EnsureImageReady(t, ctx, p, imageManager, imageName) + + systemManager := system.NewManager(p) + require.NoError(t, systemManager.EnsureSystemFiles(ctx)) + + source, err := mgr.CreateInstance(ctx, CreateInstanceRequest{ + Name: "fc-uffd-oneshot-src", + Image: imageName, + Size: 1024 * 1024 * 1024, + OverlaySize: 1024 * 1024 * 1024, + Vcpus: 1, + NetworkEnabled: false, + Hypervisor: hypervisor.TypeFirecracker, + Cmd: []string{"sleep", "infinity"}, + }) + require.NoError(t, err) + sourceID := source.Id + sourceDeleted := false + t.Cleanup(func() { + if !sourceDeleted { + _ = mgr.DeleteInstance(context.Background(), sourceID) + } + }) + + source = requireRunningSleepInstance(t, ctx, mgr, sourceID) + requireGuestTmpfs(t, ctx, source) + writeGuestFile(t, ctx, source, "/root/uffd-lifecycle/source", "source-disk") + writeGuestFile(t, ctx, source, "/dev/shm/uffd-lifecycle/source", "source-memory") + + snapshot, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStandby, + Name: "fc-uffd-oneshot-snap", + }) + require.NoError(t, err) + require.Equal(t, SnapshotKindStandby, snapshot.Kind) + snapshotDeleted := false + t.Cleanup(func() { + if !snapshotDeleted { + _ = mgr.DeleteSnapshot(context.Background(), snapshot.Id) + } + }) + snapshotMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(p.SnapshotGuestDir(snapshot.Id)) + require.FileExists(t, snapshotMemoryPath) + + source = requireRunningSleepInstance(t, ctx, mgr, sourceID) + assertGuestFile(t, ctx, source, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, source, "/dev/shm/uffd-lifecycle/source", "source-memory") + + parent, err := mgr.ForkSnapshot(ctx, snapshot.Id, ForkSnapshotRequest{ + Name: "fc-uffd-oneshot-parent", + TargetState: StateRunning, + }) + require.NoError(t, err) + parentID := parent.Id + parentDeleted := false + t.Cleanup(func() { + if !parentDeleted { + _ = mgr.DeleteInstance(context.Background(), parentID) + } + }) + parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/source", "source-memory") + writeGuestFile(t, ctx, parent, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + writeGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + parentSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(parentID), "memory") + require.NoFileExists(t, parentSnapshotMemoryPath, "UFFD snapshot fanout should defer the memory clone while running") + parentMeta, err := mgr.loadMetadata(parentID) + require.NoError(t, err) + require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Equal(t, snapshotMemoryPath, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.NotEmpty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) + + parent, err = mgr.StandbyInstance(ctx, parentID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, parent.State) + require.FileExists(t, parentSnapshotMemoryPath, "standby should materialize a normal file-backed snapshot from the deferred UFFD backing") + parentMeta, err = mgr.loadMetadata(parentID) + require.NoError(t, err) + require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) + + parent, err = mgr.RestoreInstance(ctx, parentID) + require.NoError(t, err) + parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/source", "source-memory") + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + parentMeta, err = mgr.loadMetadata(parentID) + require.NoError(t, err) + require.False(t, parentMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) + require.FileExists(t, filepath.Join(p.InstanceSnapshotBase(parentID), "memory"), "file-backed resume should retain the standby snapshot as the next diff base") + + child, err := mgr.ForkInstance(ctx, parentID, ForkInstanceRequest{ + Name: "fc-uffd-oneshot-child", + FromRunning: true, + TargetState: StateRunning, + }) + require.NoError(t, err) + childID := child.Id + childDeleted := false + t.Cleanup(func() { + if !childDeleted { + _ = mgr.DeleteInstance(context.Background(), childID) + } + }) + + parent = requireRunningSleepInstance(t, ctx, mgr, parentID) + child = requireRunningSleepInstance(t, ctx, mgr, childID) + assertGuestFile(t, ctx, parent, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, parent, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/source", "source-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + writeGuestFile(t, ctx, child, "/root/uffd-lifecycle/child-only", "child-disk") + writeGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/child-only", "child-memory") + assertGuestFileAbsent(t, ctx, parent, "/root/uffd-lifecycle/child-only") + assertGuestFileAbsent(t, ctx, parent, "/dev/shm/uffd-lifecycle/child-only") + + childSnapshotMemoryPath := filepath.Join(p.InstanceSnapshotLatest(childID), "memory") + require.NoFileExists(t, childSnapshotMemoryPath, "running-source child should defer the memory clone while running") + childMeta, err := mgr.loadMetadata(childID) + require.NoError(t, err) + require.False(t, childMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.NotEmpty(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.FileExists(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.NotEmpty(t, childMeta.StoredMetadata.FirecrackerUFFDSessionID) + + parent, err = mgr.StandbyInstance(ctx, parentID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, parent.State) + parentSnapshotMemoryPath = filepath.Join(p.InstanceSnapshotLatest(parentID), "memory") + require.FileExists(t, parentSnapshotMemoryPath) + parentMeta, err = mgr.loadMetadata(parentID) + require.NoError(t, err) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, parentMeta.StoredMetadata.FirecrackerUFFDSessionID) + + child, err = mgr.StandbyInstance(ctx, childID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, child.State) + require.FileExists(t, childSnapshotMemoryPath, "child standby should materialize from its deferred UFFD backing") + childMeta, err = mgr.loadMetadata(childID) + require.NoError(t, err) + require.False(t, childMeta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + require.Empty(t, childMeta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + require.Empty(t, childMeta.StoredMetadata.FirecrackerUFFDSessionID) + + child, err = mgr.RestoreInstance(ctx, childID) + require.NoError(t, err) + child = requireRunningSleepInstance(t, ctx, mgr, childID) + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/source", "source-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/source", "source-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/parent-after-uffd", "parent-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/parent-after-uffd", "parent-memory") + assertGuestFile(t, ctx, child, "/root/uffd-lifecycle/child-only", "child-disk") + assertGuestFile(t, ctx, child, "/dev/shm/uffd-lifecycle/child-only", "child-memory") + + child, err = mgr.StandbyInstance(ctx, childID, StandbyInstanceRequest{}) + require.NoError(t, err) + require.Equal(t, StateStandby, child.State) + + require.NoError(t, mgr.DeleteInstance(ctx, childID)) + childDeleted = true + require.NoError(t, mgr.DeleteInstance(ctx, parentID)) + parentDeleted = true + require.NoError(t, mgr.DeleteInstance(ctx, sourceID)) + sourceDeleted = true + require.NoError(t, mgr.DeleteSnapshot(ctx, snapshot.Id)) + snapshotDeleted = true +} + +func requireRunningSleepInstance(t *testing.T, ctx context.Context, mgr Manager, instanceID string) *Instance { + t.Helper() + inst, err := waitForInstanceState(ctx, mgr, instanceID, StateRunning, integrationTestTimeout(20*time.Second)) + require.NoError(t, err) + require.Equal(t, StateRunning, inst.State) + + require.Eventually(t, func() bool { + current, err := mgr.GetInstance(ctx, instanceID) + if err != nil { + t.Logf("get instance %s: %v", instanceID, err) + return false + } + output, exitCode, err := execInInstance(ctx, current, "sh", "-c", "ps | grep '[s]leep' | grep -q infinity") + if err != nil { + t.Logf("exec sleep check for %s: %v", instanceID, err) + return false + } + if exitCode != 0 { + t.Logf("sleep check for %s exited %d: %s", instanceID, exitCode, output) + return false + } + return true + }, integrationTestTimeout(30*time.Second), 250*time.Millisecond) + + inst, err = mgr.GetInstance(ctx, instanceID) + require.NoError(t, err) + return inst +} + +func requireGuestTmpfs(t *testing.T, ctx context.Context, inst *Instance) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "sh", "-c", "mkdir -p /dev/shm && if ! grep -q ' /dev/shm ' /proc/mounts; then mount -t tmpfs -o size=16m tmpfs /dev/shm; fi && grep -q ' /dev/shm ' /proc/mounts") + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) +} + +func writeGuestFile(t *testing.T, ctx context.Context, inst *Instance, path, contents string) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "sh", "-c", "mkdir -p \"$1\" && printf '%s' \"$2\" > \"$3\" && sync", "sh", filepath.Dir(path), contents, path) + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) +} + +func assertGuestFile(t *testing.T, ctx context.Context, inst *Instance, path, contents string) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "cat", path) + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) + require.Equal(t, contents, output) +} + +func assertGuestFileAbsent(t *testing.T, ctx context.Context, inst *Instance, path string) { + t.Helper() + output, exitCode, err := execCommand(ctx, inst, "test", "!", "-e", path) + require.NoError(t, err) + require.Equal(t, 0, exitCode, output) +} + // TestFirecrackerForkIsolation verifies CoW isolation between a firecracker // source's standby snapshot and a fork derived from it. A fork must end up // with its own mem-file inode (reflink-cloned, not hardlinked) so that diff --git a/lib/instances/firecracker_uffd.go b/lib/instances/firecracker_uffd.go index 4a4dc67a..18ceb137 100644 --- a/lib/instances/firecracker_uffd.go +++ b/lib/instances/firecracker_uffd.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "syscall" "time" @@ -16,30 +17,135 @@ import ( "github.com/kernel/hypeman/lib/uffdpager" ) +const firecrackerSnapshotMemoryRelPath = "snapshots/snapshot-latest/memory" + func (m *manager) useFirecrackerUFFD(stored *StoredMetadata) bool { return stored != nil && stored.HypervisorType == hypervisor.TypeFirecracker && m.firecrackerSnapshotMemoryBackend == uffdpager.BackendUFFD } +func useFirecrackerUFFDOnNextRestore(hvType hypervisor.Type, sourceIsStandby bool, targetState State) bool { + return hvType == hypervisor.TypeFirecracker && sourceIsStandby && targetState != StateStopped +} + +func (m *manager) shouldDeferFirecrackerSnapshotMemoryCopy(stored *StoredMetadata, sourceIsStandby bool, targetState State) bool { + return m.useFirecrackerUFFD(stored) && useFirecrackerUFFDOnNextRestore(stored.HypervisorType, sourceIsStandby, targetState) +} + +func clearFirecrackerUFFDRestoreState(stored *StoredMetadata) { + if stored == nil { + return + } + stored.FirecrackerUseUFFDOnNextRestore = false + stored.FirecrackerUFFDSessionID = "" + stored.FirecrackerUFFDPagerVersion = "" + stored.FirecrackerDeferredSnapshotMemoryPath = "" +} + +func firecrackerSnapshotMemoryPathInGuestDir(guestDir string) string { + return filepath.Join(guestDir, firecrackerSnapshotMemoryRelPath) +} + +func (m *manager) lockFirecrackerSnapshotSource(path string) func() { + key := firecrackerSnapshotSourceLockKey(path) + if key == "" { + return func() {} + } + value, _ := m.snapshotSourceLocks.LoadOrStore(key, &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func firecrackerSnapshotSourceLockKey(path string) string { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || path == "" { + return "" + } + if filepath.Base(path) != "memory" { + return path + } + snapshotDir := filepath.Dir(path) + if base := filepath.Base(snapshotDir); base != "snapshot-latest" && base != "snapshot-base" { + return path + } + snapshotsDir := filepath.Dir(snapshotDir) + if filepath.Base(snapshotsDir) != "snapshots" { + return path + } + return filepath.Dir(snapshotsDir) +} + +func resolveFirecrackerSnapshotMemoryBackingPath(memoryPath string) (string, error) { + if _, err := os.Stat(memoryPath); err == nil { + return memoryPath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat firecracker snapshot memory backing: %w", err) + } + + alternatePath := alternateFirecrackerRetainedSnapshotMemoryPath(memoryPath) + if alternatePath == "" { + return memoryPath, nil + } + if _, err := os.Stat(alternatePath); err == nil { + return alternatePath, nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("stat alternate firecracker snapshot memory backing: %w", err) + } + return memoryPath, nil +} + +func alternateFirecrackerRetainedSnapshotMemoryPath(memoryPath string) string { + if filepath.Base(memoryPath) != "memory" { + return "" + } + snapshotDir := filepath.Dir(memoryPath) + snapshotsDir := filepath.Dir(snapshotDir) + switch filepath.Base(snapshotDir) { + case "snapshot-base": + return filepath.Join(snapshotsDir, "snapshot-latest", "memory") + case "snapshot-latest": + return filepath.Join(snapshotsDir, "snapshot-base", "memory") + default: + return "" + } +} + +func firecrackerDeferredSnapshotMemoryPath(stored *StoredMetadata, guestDir string) string { + if stored != nil { + if path := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath); path != "" { + return path + } + } + return firecrackerSnapshotMemoryPathInGuestDir(guestDir) +} + func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snapshotDir string) (hypervisor.RestoreOptions, error) { opts := hypervisor.RestoreOptions{SnapshotMemoryBackend: hypervisor.SnapshotMemoryBackendFile} if stored == nil || stored.HypervisorType != hypervisor.TypeFirecracker { return opts, nil } - if !m.useFirecrackerUFFD(stored) { - stored.FirecrackerUFFDSessionID = "" - stored.FirecrackerUFFDPagerVersion = "" + if !m.useFirecrackerUFFD(stored) || !stored.FirecrackerUseUFFDOnNextRestore { + clearFirecrackerUFFDRestoreState(stored) return opts, nil } if m.firecrackerUFFDPager == nil { return opts, fmt.Errorf("firecracker uffd snapshot restore is enabled but the pager is not configured") } + backingMemoryPath := strings.TrimSpace(stored.FirecrackerDeferredSnapshotMemoryPath) + if backingMemoryPath == "" { + backingMemoryPath = filepath.Join(snapshotDir, "memory") + } + backingMemoryPath, err := resolveFirecrackerSnapshotMemoryBackingPath(backingMemoryPath) + if err != nil { + return opts, err + } cacheKey := strings.TrimSpace(stored.FirecrackerSnapshotCacheKey) if cacheKey == "" { var err error - cacheKey, err = firecrackerSnapshotCacheKey(stored, snapshotDir) + cacheKey, err = firecrackerSnapshotCacheKeyForPaths(stored, backingMemoryPath, filepath.Join(snapshotDir, "state")) if err != nil { return opts, err } @@ -48,6 +154,7 @@ func (m *manager) firecrackerSnapshotRestoreOptions(stored *StoredMetadata, snap stored.FirecrackerUFFDSessionID = stored.Id stored.FirecrackerUFFDPagerVersion = m.firecrackerUFFDPager.VersionKey() opts.SnapshotMemoryBackend = hypervisor.SnapshotMemoryBackendUFFD + opts.SnapshotMemoryBackingPath = backingMemoryPath opts.SnapshotMemoryCacheKey = cacheKey opts.SnapshotMemorySessionID = stored.FirecrackerUFFDSessionID return opts, nil @@ -116,11 +223,15 @@ func (m *manager) checkFirecrackerUFFDSessionHealth(ctx context.Context, stored } func firecrackerSnapshotCacheKey(stored *StoredMetadata, snapshotDir string) (string, error) { - memoryInfo, err := os.Stat(filepath.Join(snapshotDir, "memory")) + return firecrackerSnapshotCacheKeyForPaths(stored, filepath.Join(snapshotDir, "memory"), filepath.Join(snapshotDir, "state")) +} + +func firecrackerSnapshotCacheKeyForPaths(stored *StoredMetadata, memoryPath string, statePath string) (string, error) { + memoryInfo, err := os.Stat(memoryPath) if err != nil { return "", fmt.Errorf("stat firecracker snapshot memory for uffd cache key: %w", err) } - stateInfo, err := os.Stat(filepath.Join(snapshotDir, "state")) + stateInfo, err := os.Stat(statePath) if err != nil { return "", fmt.Errorf("stat firecracker snapshot state for uffd cache key: %w", err) } diff --git a/lib/instances/firecracker_uffd_test.go b/lib/instances/firecracker_uffd_test.go new file mode 100644 index 00000000..dc5ed57d --- /dev/null +++ b/lib/instances/firecracker_uffd_test.go @@ -0,0 +1,343 @@ +package instances + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/paths" + "github.com/kernel/hypeman/lib/uffdpager" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFirecrackerSnapshotRestoreOptionsOneShotUFFDGate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + backend string + useNext bool + wantBackend hypervisor.SnapshotMemoryBackend + wantErr bool + }{ + { + name: "config file ignores armed one-shot flag", + backend: uffdpager.BackendFile, + useNext: true, + wantBackend: hypervisor.SnapshotMemoryBackendFile, + }, + { + name: "config uffd with unarmed flag uses file backend", + backend: uffdpager.BackendUFFD, + useNext: false, + wantBackend: hypervisor.SnapshotMemoryBackendFile, + }, + { + name: "config uffd with armed flag requires pager", + backend: uffdpager.BackendUFFD, + useNext: true, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mgr := &manager{firecrackerSnapshotMemoryBackend: tc.backend} + stored := &StoredMetadata{ + Id: "fc-restore-options", + HypervisorType: hypervisor.TypeFirecracker, + FirecrackerSnapshotCacheKey: "snapshot-cache-key", + FirecrackerUseUFFDOnNextRestore: tc.useNext, + FirecrackerUFFDSessionID: "stale-session", + FirecrackerUFFDPagerVersion: "stale-version", + } + + opts, err := mgr.firecrackerSnapshotRestoreOptions(stored, t.TempDir()) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "pager is not configured") + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantBackend, opts.SnapshotMemoryBackend) + assert.Empty(t, opts.SnapshotMemoryCacheKey) + assert.Empty(t, opts.SnapshotMemorySessionID) + assert.Empty(t, stored.FirecrackerUFFDSessionID) + assert.Empty(t, stored.FirecrackerUFFDPagerVersion) + }) + } +} + +func TestForkInstanceFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-instance-source" + createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(sourceID)) + require.NoError(t, os.WriteFile(sourceMemoryPath, []byte("source memory"), 0644)) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD + + forked, _, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "one-shot-uffd-instance-fork", + TargetState: StateStandby, + }, true) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.True(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Equal(t, "shared-template-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Equal(t, sourceMemoryPath, meta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDSessionID) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDPagerVersion) + assert.NoFileExists(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(forked.Id))) + assert.FileExists(t, filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), "state")) +} + +func TestForkInstanceFromStandbyDoesNotArmOneShotUFFDForStoppedTarget(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-instance-stopped-source" + createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(sourceID)) + require.NoError(t, os.WriteFile(sourceMemoryPath, []byte("source memory"), 0644)) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD + + forked, _, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ + Name: "one-shot-uffd-instance-stopped-fork", + TargetState: StateStopped, + }, true) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.False(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Equal(t, "shared-template-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Empty(t, meta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + assert.FileExists(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(forked.Id))) +} + +func TestForkSnapshotFromStandbyArmsOneShotUFFDForFirecrackerRestore(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-snapshot-source" + createStandbySnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + require.NoError(t, os.WriteFile(firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(sourceID)), []byte("source memory"), 0644)) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-snapshot-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendUFFD + + snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStandby, + Name: "one-shot-uffd-snapshot", + }) + require.NoError(t, err) + + forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{ + Name: "one-shot-uffd-snapshot-fork", + TargetState: StateStandby, + }) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.True(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Equal(t, "shared-snapshot-cache", meta.StoredMetadata.FirecrackerSnapshotCacheKey) + assert.Equal(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.SnapshotGuestDir(snap.Id)), meta.StoredMetadata.FirecrackerDeferredSnapshotMemoryPath) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDSessionID) + assert.Empty(t, meta.StoredMetadata.FirecrackerUFFDPagerVersion) + assert.NoFileExists(t, firecrackerSnapshotMemoryPathInGuestDir(mgr.paths.InstanceDir(forked.Id))) + assert.FileExists(t, filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), "state")) +} + +func TestForkSnapshotFromStoppedDoesNotArmOneShotUFFD(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + ctx := context.Background() + + sourceID := "one-shot-uffd-stopped-snapshot-source" + createStoppedSnapshotSourceFixture(t, mgr, sourceID, sourceID, hypervisor.TypeFirecracker) + sourceMeta, err := mgr.loadMetadata(sourceID) + require.NoError(t, err) + sourceMeta.StoredMetadata.FirecrackerSnapshotCacheKey = "stopped-snapshot-cache" + require.NoError(t, mgr.saveMetadata(sourceMeta)) + + snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{ + Kind: SnapshotKindStopped, + Name: "one-shot-uffd-stopped-snapshot", + }) + require.NoError(t, err) + + forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{ + Name: "one-shot-uffd-stopped-snapshot-fork", + TargetState: StateStopped, + }) + require.NoError(t, err) + + meta, err := mgr.loadMetadata(forked.Id) + require.NoError(t, err) + assert.False(t, meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore) + assert.Empty(t, meta.StoredMetadata.FirecrackerSnapshotCacheKey) +} + +func TestRestoreInstanceClearsOneShotUFFDFlagAfterSuccessfulRestore(t *testing.T) { + t.Parallel() + + mgr, _ := setupTestManager(t) + installOneShotFirecrackerStarter(t, mgr) + mgr.firecrackerSnapshotMemoryBackend = uffdpager.BackendFile + ctx := context.Background() + + id := "one-shot-uffd-restore-clear" + createStandbySnapshotSourceFixture(t, mgr, id, id, hypervisor.TypeFirecracker) + meta, err := mgr.loadMetadata(id) + require.NoError(t, err) + meta.StoredMetadata.FirecrackerSnapshotCacheKey = "shared-template-cache" + meta.StoredMetadata.FirecrackerUseUFFDOnNextRestore = true + require.NoError(t, mgr.saveMetadata(meta)) + + inst, err := mgr.restoreInstance(ctx, id) + require.NoError(t, err) + require.NotNil(t, inst) + + updated, err := mgr.loadMetadata(id) + require.NoError(t, err) + assert.False(t, updated.StoredMetadata.FirecrackerUseUFFDOnNextRestore) +} + +func TestResolveFirecrackerSnapshotMemoryBackingPathUsesRetainedBaseAlternate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + latestMemoryPath := filepath.Join(root, "snapshots", "snapshot-latest", "memory") + baseMemoryPath := filepath.Join(root, "snapshots", "snapshot-base", "memory") + require.NoError(t, os.MkdirAll(filepath.Dir(baseMemoryPath), 0755)) + require.NoError(t, os.WriteFile(baseMemoryPath, []byte("base memory"), 0644)) + + resolved, err := resolveFirecrackerSnapshotMemoryBackingPath(latestMemoryPath) + require.NoError(t, err) + assert.Equal(t, baseMemoryPath, resolved) +} + +func TestFirecrackerSnapshotSourceLockKeyUsesGuestDirectory(t *testing.T) { + t.Parallel() + + guestDir := filepath.Join(t.TempDir(), "guests", "source") + assert.Equal(t, guestDir, firecrackerSnapshotSourceLockKey(filepath.Join(guestDir, "snapshots", "snapshot-latest", "memory"))) + assert.Equal(t, guestDir, firecrackerSnapshotSourceLockKey(filepath.Join(guestDir, "snapshots", "snapshot-base", "memory"))) +} + +func installOneShotFirecrackerStarter(t *testing.T, mgr *manager) { + t.Helper() + previous, hadPrevious := mgr.vmStarters[hypervisor.TypeFirecracker] + mgr.vmStarters[hypervisor.TypeFirecracker] = oneShotFirecrackerTestStarter{} + t.Cleanup(func() { + if hadPrevious { + mgr.vmStarters[hypervisor.TypeFirecracker] = previous + return + } + delete(mgr.vmStarters, hypervisor.TypeFirecracker) + }) +} + +type oneShotFirecrackerTestStarter struct{} + +func (oneShotFirecrackerTestStarter) SocketName() string { + return "firecracker.sock" +} + +func (oneShotFirecrackerTestStarter) GetBinaryPath(*paths.Paths, string) (string, error) { + return "/bin/true", nil +} + +func (oneShotFirecrackerTestStarter) GetVersion(*paths.Paths) (string, error) { + return "test", nil +} + +func (oneShotFirecrackerTestStarter) StartVM(context.Context, *paths.Paths, string, string, hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { + return 1234, oneShotFirecrackerTestHypervisor{}, nil +} + +func (oneShotFirecrackerTestStarter) RestoreVM(context.Context, *paths.Paths, string, string, string, hypervisor.RestoreOptions) (int, hypervisor.Hypervisor, error) { + return 1234, oneShotFirecrackerTestHypervisor{}, nil +} + +func (oneShotFirecrackerTestStarter) PrepareFork(context.Context, hypervisor.ForkPrepareRequest) (hypervisor.ForkPrepareResult, error) { + return hypervisor.ForkPrepareResult{}, nil +} + +type oneShotFirecrackerTestHypervisor struct{} + +func (oneShotFirecrackerTestHypervisor) DeleteVM(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) Shutdown(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) GetVMInfo(context.Context) (*hypervisor.VMInfo, error) { + return &hypervisor.VMInfo{State: hypervisor.StateRunning}, nil +} + +func (oneShotFirecrackerTestHypervisor) Pause(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) Resume(context.Context) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) Snapshot(context.Context, string, hypervisor.SnapshotOptions) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) ResizeMemory(context.Context, int64) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) ResizeMemoryAndWait(context.Context, int64, time.Duration) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) SetTargetGuestMemoryBytes(context.Context, int64) error { + return nil +} + +func (oneShotFirecrackerTestHypervisor) GetTargetGuestMemoryBytes(context.Context) (int64, error) { + return 0, nil +} + +func (oneShotFirecrackerTestHypervisor) Capabilities() hypervisor.Capabilities { + return hypervisor.Capabilities{SupportsSnapshot: true, SupportsPause: true} +} diff --git a/lib/instances/fork.go b/lib/instances/fork.go index bbafeabf..ff8cfa25 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -7,6 +7,7 @@ import ( "fmt" "hash/crc32" "os" + "path/filepath" "strings" "time" @@ -100,6 +101,14 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR } return nil, "", false, fmt.Errorf("restore source instance after fork: %w", restoreErr) } + if forkErr == nil && targetState != StateStopped { + if err := m.repointForkDeferredSnapshotMemoryToSourceBase(forked.Id, id); err != nil { + forkErr = fmt.Errorf("update fork deferred snapshot memory backing: %w", err) + if cleanupErr := m.cleanupForkInstanceOnError(ctx, forked.Id); cleanupErr != nil { + forkErr = fmt.Errorf("%v; additionally failed to cleanup forked instance %s: %v", forkErr, forked.Id, cleanupErr) + } + } + } if restoredSource != nil && !restoredSource.NetworkEnabled && (restoredSource.State == StateRunning || restoredSource.State == StateInitializing) { if err := ensureGuestAgentReadyForForkPhase(ctx, &restoredSource.StoredMetadata, "after restoring running fork source"); err != nil { if forkErr != nil { @@ -201,6 +210,29 @@ func generateForkSourceVsockCID(sourceID, forkID string, current int64) int64 { return cid } +func (m *manager) repointForkDeferredSnapshotMemoryToSourceBase(forkID, sourceID string) error { + meta, err := m.loadMetadata(forkID) + if err != nil { + return err + } + stored := &meta.StoredMetadata + if stored.HypervisorType != hypervisor.TypeFirecracker || stored.FirecrackerDeferredSnapshotMemoryPath == "" { + return nil + } + + sourceLatestMemoryPath := firecrackerSnapshotMemoryPathInGuestDir(m.paths.InstanceDir(sourceID)) + if stored.FirecrackerDeferredSnapshotMemoryPath != sourceLatestMemoryPath { + return nil + } + + sourceBaseMemoryPath := filepath.Join(m.paths.InstanceSnapshotBase(sourceID), "memory") + if _, err := os.Stat(sourceBaseMemoryPath); err != nil { + return fmt.Errorf("stat source retained base memory %q: %w", sourceBaseMemoryPath, err) + } + stored.FirecrackerDeferredSnapshotMemoryPath = sourceBaseMemoryPath + return m.saveMetadata(meta) +} + func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id string, req ForkInstanceRequest, supportValidated bool) (*Instance, bool, error) { log := logger.FromContext(ctx) @@ -224,6 +256,10 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin return nil, false, err } } + targetState, err := resolveForkTargetState(req.TargetState, source.State) + if err != nil { + return nil, false, err + } if err := validateForkVolumeSafety(stored.Volumes); err != nil { return nil, false, err } @@ -252,13 +288,28 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin srcDir := m.paths.InstanceDir(id) dstDir := m.paths.InstanceDir(forkID) + deferredSnapshotMemoryPath := "" + if m.shouldDeferFirecrackerSnapshotMemoryCopy(stored, source.State == StateStandby, targetState) { + deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(stored, srcDir) + } + unlockSnapshotSource := func() {} + snapshotSourceLocked := false + if deferredSnapshotMemoryPath != "" { + unlockSnapshotSource = m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) + snapshotSourceLocked = true + } + defer func() { + if snapshotSourceLocked { + unlockSnapshotSource() + } + }() cu := cleanup.Make(func() { _ = os.RemoveAll(dstDir) }) defer cu.Clean() - if err := m.copyForkSourceGuestDirectory(ctx, source.State, id, stored, srcDir, dstDir); err != nil { + if err := m.copyForkSourceGuestDirectory(ctx, source.State, id, stored, srcDir, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, false, err } @@ -283,8 +334,11 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin forkMeta.RestartStatus = restartpolicy.Status{} forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" + forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(forkMeta.HypervisorType, source.State == StateStandby, targetState) + forkMeta.FirecrackerDeferredSnapshotMemoryPath = deferredSnapshotMemoryPath if source.State != StateStandby { forkMeta.FirecrackerSnapshotCacheKey = "" + forkMeta.FirecrackerDeferredSnapshotMemoryPath = "" } // Forks are new instances; phase accounting must not inherit the source's // cumulative durations. The first transition into the fork's runtime @@ -347,6 +401,10 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin } restoreNeedsSourceLock = prepareResult.RequiresSnapshotSourceAlias } + if snapshotSourceLocked { + unlockSnapshotSource() + snapshotSourceLocked = false + } newMeta := &metadata{StoredMetadata: forkMeta} if err := m.saveForkMetadata(ctx, newMeta); err != nil { @@ -490,6 +548,8 @@ func (m *manager) applyForkTargetState(ctx context.Context, forkID string, targe return nil, fmt.Errorf("load stopped fork metadata: %w", err) } meta.StoredMetadata.VsockCID = generateVsockCID(forkID) + meta.StoredMetadata.FirecrackerSnapshotCacheKey = "" + clearFirecrackerUFFDRestoreState(&meta.StoredMetadata) if err := m.saveMetadata(meta); err != nil { return nil, fmt.Errorf("save stopped fork metadata: %w", err) } diff --git a/lib/instances/lifecycle_noop_test.go b/lib/instances/lifecycle_noop_test.go index 41cf252a..b430b567 100644 --- a/lib/instances/lifecycle_noop_test.go +++ b/lib/instances/lifecycle_noop_test.go @@ -38,9 +38,11 @@ func (h lifecycleNoopHypervisor) Shutdown(context.Context) error { return nil } func (h lifecycleNoopHypervisor) GetVMInfo(context.Context) (*hypervisor.VMInfo, error) { return &hypervisor.VMInfo{State: h.state}, nil } -func (h lifecycleNoopHypervisor) Pause(context.Context) error { return nil } -func (h lifecycleNoopHypervisor) Resume(context.Context) error { return nil } -func (h lifecycleNoopHypervisor) Snapshot(context.Context, string) error { return nil } +func (h lifecycleNoopHypervisor) Pause(context.Context) error { return nil } +func (h lifecycleNoopHypervisor) Resume(context.Context) error { return nil } +func (h lifecycleNoopHypervisor) Snapshot(context.Context, string, hypervisor.SnapshotOptions) error { + return nil +} func (h lifecycleNoopHypervisor) ResizeMemory(context.Context, int64) error { return nil } diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 8e8e25f3..b1bf7578 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -171,6 +171,7 @@ type manager struct { resourceValidator ResourceValidator // Optional validator for aggregate resource limits instanceLocks sync.Map // map[string]*sync.RWMutex - per-instance locks forkMetadataMu sync.Mutex + snapshotSourceLocks sync.Map // map[string]*sync.Mutex - Firecracker snapshot source alias locks, retained for process lifetime bootMarkerScans sync.Map // map[string]time.Time next allowed boot-marker rescan hypervisorStateCache sync.Map // map[string]hypervisorStateCacheEntry - last observed hypervisor state per instance hostTopology *HostTopology // Cached host CPU topology diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 13863d30..d2afa0fe 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -363,6 +363,9 @@ func (m *manager) restoreInstance( // before markers ever hydrated we resume in Initializing. resumePhase, _ := runningPhaseFromMarkers(stored) stored.Phases.Record(resumePhase, time.Now().UTC()) + // The one-shot restore has been consumed, but a UFFD-backed VM may still + // need its pager session until the next standby materializes snapshot memory. + stored.FirecrackerUseUFFDOnNextRestore = false meta = &metadata{StoredMetadata: *stored} if err := m.saveMetadata(meta); err != nil { // VM is running but metadata failed diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 17f19b98..749c134e 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -318,6 +318,10 @@ func (m *manager) restoreSnapshot(ctx context.Context, id string, snapshotID str } restored.SocketPath = m.paths.InstanceSocket(id, starter.SocketName()) restored.VsockSocket = m.paths.InstanceSocket(id, hypervisor.VsockSocketNameForType(targetHypervisor)) + clearFirecrackerUFFDRestoreState(&restored) + if targetState == StateStopped { + restored.FirecrackerSnapshotCacheKey = "" + } if rec.Snapshot.Kind == SnapshotKindStopped { restored.VsockCID = generateVsockCID(id) } @@ -413,7 +417,25 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if target != nil && target.State == compressionJobStateRunning { m.recordSnapshotCompressionPreemption(ctx, snapshotCompressionPreemptionForkSnapshot, target.Target) } - if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir); err != nil { + snapshotGuestDir := m.paths.SnapshotGuestDir(snapshotID) + deferredSource := rec.StoredMetadata + deferredSource.HypervisorType = targetHypervisor + deferredSnapshotMemoryPath := "" + if m.shouldDeferFirecrackerSnapshotMemoryCopy(&deferredSource, rec.Snapshot.Kind == SnapshotKindStandby, targetState) { + deferredSnapshotMemoryPath = firecrackerDeferredSnapshotMemoryPath(&rec.StoredMetadata, snapshotGuestDir) + } + unlockSnapshotSource := func() {} + snapshotSourceLocked := false + if deferredSnapshotMemoryPath != "" { + unlockSnapshotSource = m.lockFirecrackerSnapshotSource(deferredSnapshotMemoryPath) + snapshotSourceLocked = true + } + defer func() { + if snapshotSourceLocked { + unlockSnapshotSource() + } + }() + if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir, deferredSnapshotMemoryPath); err != nil { return nil, err } @@ -446,8 +468,11 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS forkMeta.RestartStatus = restartpolicy.Status{} forkMeta.FirecrackerUFFDSessionID = "" forkMeta.FirecrackerUFFDPagerVersion = "" + forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(targetHypervisor, rec.Snapshot.Kind == SnapshotKindStandby, targetState) + forkMeta.FirecrackerDeferredSnapshotMemoryPath = deferredSnapshotMemoryPath if rec.Snapshot.Kind != SnapshotKindStandby { forkMeta.FirecrackerSnapshotCacheKey = "" + forkMeta.FirecrackerDeferredSnapshotMemoryPath = "" } if rec.Snapshot.Kind == SnapshotKindStandby { forkMeta.VsockCID = rec.StoredMetadata.VsockCID @@ -488,6 +513,10 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS "target_data_dir", forkMeta.DataDir) } } + if snapshotSourceLocked { + unlockSnapshotSource() + snapshotSourceLocked = false + } if err := m.saveMetadata(&metadata{StoredMetadata: forkMeta}); err != nil { return nil, fmt.Errorf("save fork metadata: %w", err) diff --git a/lib/instances/snapshot_alias_lock.go b/lib/instances/snapshot_alias_lock.go index b6942c95..af7f72bd 100644 --- a/lib/instances/snapshot_alias_lock.go +++ b/lib/instances/snapshot_alias_lock.go @@ -31,14 +31,18 @@ func copyGuestDirectoryWithAliasReadLock(srcDir, dstDir string) error { }) } -func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState State, sourceID string, stored *StoredMetadata, srcDir, dstDir string) error { +func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState State, sourceID string, stored *StoredMetadata, srcDir, dstDir, deferredSnapshotMemoryPath string) error { if sourceState == StateStandby { if err := m.ensureSnapshotMemoryReady(ctx, m.paths.InstanceSnapshotLatest(sourceID), m.snapshotJobKeyForInstance(sourceID), stored.HypervisorType); err != nil { return fmt.Errorf("prepare standby snapshot for fork: %w", err) } } + copyOptions := forkvm.CopyOptions{} + if deferredSnapshotMemoryPath != "" { + copyOptions.SkipRelativePaths = map[string]struct{}{firecrackerSnapshotMemoryRelPath: {}} + } return withSnapshotSourceAliasReadLock(func() error { - if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil { + if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOptions); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } @@ -48,12 +52,16 @@ func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState }) } -func (m *manager) copySnapshotGuestDirectoryForFork(ctx context.Context, snapshotID string, hvType hypervisor.Type, dstDir string) error { +func (m *manager) copySnapshotGuestDirectoryForFork(ctx context.Context, snapshotID string, hvType hypervisor.Type, dstDir, deferredSnapshotMemoryPath string) error { if err := m.ensureSnapshotMemoryReady(ctx, m.paths.SnapshotGuestDir(snapshotID), "", hvType); err != nil { return fmt.Errorf("prepare snapshot memory for fork: %w", err) } + copyOptions := forkvm.CopyOptions{} + if deferredSnapshotMemoryPath != "" { + copyOptions.SkipRelativePaths = map[string]struct{}{firecrackerSnapshotMemoryRelPath: {}} + } return withSnapshotSourceAliasReadLock(func() error { - if err := forkvm.CopyGuestDirectory(m.paths.SnapshotGuestDir(snapshotID), dstDir); err != nil { + if err := forkvm.CopyGuestDirectoryWithOptions(m.paths.SnapshotGuestDir(snapshotID), dstDir, copyOptions); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return fmt.Errorf("fork from snapshot requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } diff --git a/lib/instances/standby.go b/lib/instances/standby.go index 33328600..ff857ec1 100644 --- a/lib/instances/standby.go +++ b/lib/instances/standby.go @@ -146,7 +146,11 @@ func (m *manager) standbyInstance( attribute.String("operation", "create_snapshot"), attribute.Bool("reuse_snapshot_base", reuseSnapshotBase), ) - if err := createSnapshot(snapshotCtx, hv, snapshotDir, reuseSnapshotBase); err != nil { + snapshotOptions := hypervisor.SnapshotOptions{} + if stored.HypervisorType == hypervisor.TypeFirecracker { + snapshotOptions.DeferredMemoryBackingPath = stored.FirecrackerDeferredSnapshotMemoryPath + } + if err := createSnapshot(snapshotCtx, hv, snapshotDir, reuseSnapshotBase, snapshotOptions); err != nil { snapshotSpanEnd(err) // Snapshot failed - try to resume VM log.ErrorContext(ctx, "snapshot failed, attempting to resume VM", "instance_id", id, "error", err) @@ -217,6 +221,7 @@ func (m *manager) standbyInstance( stored.StoppedAt = &now stored.HypervisorPID = nil stored.PendingStandbyCompression = nil + clearFirecrackerUFFDRestoreState(stored) if err := m.refreshFirecrackerSnapshotCacheKey(stored, snapshotDir); err != nil { log.WarnContext(ctx, "failed to refresh firecracker snapshot cache key", "instance_id", id, "error", err) } @@ -274,7 +279,7 @@ func (m *manager) standbyInstance( } // createSnapshot creates a snapshot using the hypervisor interface -func createSnapshot(ctx context.Context, hv hypervisor.Hypervisor, snapshotDir string, reuseSnapshotBase bool) error { +func createSnapshot(ctx context.Context, hv hypervisor.Hypervisor, snapshotDir string, reuseSnapshotBase bool, opts hypervisor.SnapshotOptions) error { log := logger.FromContext(ctx) // Remove old snapshot if the hypervisor does not support reusing snapshots @@ -290,7 +295,7 @@ func createSnapshot(ctx context.Context, hv hypervisor.Hypervisor, snapshotDir s // Create snapshot via hypervisor API log.DebugContext(ctx, "invoking hypervisor snapshot API", "snapshot_dir", snapshotDir) - if err := hv.Snapshot(ctx, snapshotDir); err != nil { + if err := hv.Snapshot(ctx, snapshotDir, opts); err != nil { return fmt.Errorf("snapshot: %w", err) } diff --git a/lib/instances/stop.go b/lib/instances/stop.go index 8c128351..fb60c68c 100644 --- a/lib/instances/stop.go +++ b/lib/instances/stop.go @@ -307,6 +307,7 @@ func (m *manager) stopInstance( stored.ProgramStartedAt = nil stored.GuestAgentReadyAt = nil stored.FirecrackerSnapshotCacheKey = "" + clearFirecrackerUFFDRestoreState(stored) stored.Phases.Record(phasetracking.PhaseStopped, now) meta = &metadata{StoredMetadata: *stored} diff --git a/lib/instances/types.go b/lib/instances/types.go index 0603beba..9a73dcde 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -118,9 +118,11 @@ type StoredMetadata struct { HypervisorPID *int // Hypervisor process ID (may be stale after host restart) // Firecracker UFFD snapshot restore metadata. - FirecrackerSnapshotCacheKey string - FirecrackerUFFDSessionID string - FirecrackerUFFDPagerVersion string + FirecrackerSnapshotCacheKey string + FirecrackerUseUFFDOnNextRestore bool + FirecrackerUFFDSessionID string + FirecrackerUFFDPagerVersion string + FirecrackerDeferredSnapshotMemoryPath string // Paths SocketPath string // Path to API socket diff --git a/lib/uffdpager/VERSION b/lib/uffdpager/VERSION index 17e51c38..d917d3e2 100644 --- a/lib/uffdpager/VERSION +++ b/lib/uffdpager/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/lib/uffdpager/server_sessions_linux.go b/lib/uffdpager/server_sessions_linux.go index 72989766..cfb14273 100644 --- a/lib/uffdpager/server_sessions_linux.go +++ b/lib/uffdpager/server_sessions_linux.go @@ -25,6 +25,12 @@ func (s *server) createSession(req CreateSessionRequest) (*session, error) { if err != nil { return nil, fmt.Errorf("listen for uffd session %s: %w", id, err) } + backingFile, err := os.Open(req.BackingMemoryPath) + if err != nil { + _ = listener.Close() + _ = os.Remove(socketPath) + return nil, fmt.Errorf("open backing memory for uffd session %s: %w", id, err) + } sess := &session{ id: id, @@ -35,6 +41,7 @@ func (s *server) createSession(req CreateSessionRequest) (*session, error) { listener: listener, server: s, done: make(chan struct{}), + backingFile: backingFile, uffdFD: -1, } @@ -109,12 +116,14 @@ func (s *session) run() { } s.conn = conn - file, err := os.Open(s.backingMemoryPath) - if err != nil { - log.Printf("uffd session %s open backing memory: %v", s.id, err) - return + if s.backingFile == nil { + file, err := os.Open(s.backingMemoryPath) + if err != nil { + log.Printf("uffd session %s open backing memory: %v", s.id, err) + return + } + s.backingFile = file } - s.backingFile = file mappings, uffdFD, err := recvMappingsAndFD(conn) if err != nil { diff --git a/lib/uffdpager/supervisor_linux.go b/lib/uffdpager/supervisor_linux.go index 93a39369..c7fb4c47 100644 --- a/lib/uffdpager/supervisor_linux.go +++ b/lib/uffdpager/supervisor_linux.go @@ -5,6 +5,8 @@ package uffdpager import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -13,6 +15,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "time" @@ -21,17 +24,20 @@ import ( const ( controlSocketFile = "control.sock" pagerPIDFile = "pager.pid" - pagerEnvFile = "pager.env" sessionsDir = "sessions" - systemdUnitTemplate = "hypeman-uffd@.service" + systemdUnitTemplate = "hypeman-uffd@.service" + systemdRuntimeEnvDir = "/run/hypeman/uffd" ) +var invalidSystemdInstancePartChars = regexp.MustCompile(`[^A-Za-z0-9._-]`) + type Supervisor struct { - dataDir string - versionKey string - cacheMaxBytes int64 - controlSocket string + dataDir string + versionKey string + systemdInstanceKey string + cacheMaxBytes int64 + controlSocket string mu sync.Mutex clients map[string]*http.Client @@ -43,11 +49,12 @@ func NewSupervisor(ctx context.Context, dataDir string, cacheMaxBytes int64) (*S return nil, fmt.Errorf("uffd pager version is empty") } s := &Supervisor{ - dataDir: dataDir, - versionKey: versionKey, - cacheMaxBytes: normalizeCacheMaxBytes(cacheMaxBytes), - controlSocket: pagerControlSocket(dataDir, versionKey), - clients: make(map[string]*http.Client), + dataDir: dataDir, + versionKey: versionKey, + systemdInstanceKey: systemdInstanceKey(versionKey, dataDir), + cacheMaxBytes: normalizeCacheMaxBytes(cacheMaxBytes), + controlSocket: pagerControlSocket(dataDir, versionKey), + clients: make(map[string]*http.Client), } if err := s.ensureRunning(ctx); err != nil { return nil, err @@ -132,11 +139,14 @@ func (s *Supervisor) ensureRunningSystemd(ctx context.Context) error { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create uffd pager directory: %w", err) } - env := fmt.Sprintf("HYPEMAN_UFFD_CACHE_MAX_BYTES=%d\n", s.cacheMaxBytes) - if err := os.WriteFile(filepath.Join(dir, pagerEnvFile), []byte(env), 0644); err != nil { + if err := os.MkdirAll(systemdRuntimeEnvDir, 0755); err != nil { + return fmt.Errorf("create uffd pager systemd environment directory: %w", err) + } + env := s.systemdEnvironment() + if err := os.WriteFile(systemdEnvFile(s.systemdInstanceKey), []byte(env), 0644); err != nil { return fmt.Errorf("write uffd pager systemd environment: %w", err) } - unit := systemdUnitName(s.versionKey) + unit := systemdUnitName(s.systemdInstanceKey) cmd := exec.CommandContext(ctx, "systemctl", "start", unit) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("start uffd pager systemd unit %s: %w: %s", unit, err, strings.TrimSpace(string(out))) @@ -171,6 +181,17 @@ func (s *Supervisor) systemdTemplateInstalled(ctx context.Context) bool { return cmd.Run() == nil } +func (s *Supervisor) systemdEnvironment() string { + var b strings.Builder + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_CACHE_MAX_BYTES", fmt.Sprintf("%d", s.cacheMaxBytes))) + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_DATA_DIR", s.dataDir)) + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_VERSION_KEY", s.versionKey)) + if binary := strings.TrimSpace(os.Getenv("HYPEMAN_UFFD_PAGER_BINARY")); binary != "" { + b.WriteString(systemdEnvLine("HYPEMAN_UFFD_BINARY", binary)) + } + return b.String() +} + func (s *Supervisor) isHealthy(ctx context.Context, versionKey string) bool { var health HealthResponse return s.doJSON(ctx, versionKey, http.MethodGet, "/health", nil, &health) == nil @@ -258,6 +279,33 @@ func urlPathEscape(value string) string { return replacer.Replace(value) } -func systemdUnitName(versionKey string) string { - return "hypeman-uffd@" + versionKey + ".service" +func systemdInstanceKey(versionKey, dataDir string) string { + prefix := sanitizeSystemdInstancePart(os.Getenv("HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX")) + if prefix == "" { + return versionKey + } + sum := sha256.Sum256([]byte(filepath.Clean(dataDir))) + return prefix + "-" + versionKey + "-" + hex.EncodeToString(sum[:4]) +} + +func sanitizeSystemdInstancePart(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return strings.Trim(invalidSystemdInstancePartChars.ReplaceAllString(value, "-"), "-") +} + +func systemdEnvFile(instanceKey string) string { + return filepath.Join(systemdRuntimeEnvDir, instanceKey+".env") +} + +func systemdEnvLine(key, value string) string { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return key + `="` + escaped + `"` + "\n" +} + +func systemdUnitName(instanceKey string) string { + return "hypeman-uffd@" + instanceKey + ".service" } diff --git a/scripts/install.sh b/scripts/install.sh index 349b9ca9..bc293e55 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -612,8 +612,12 @@ After=network.target [Service] Type=simple Environment="HOME=${DATA_DIR}" -EnvironmentFile=${DATA_DIR}/uffd/%i/pager.env -ExecStart=${INSTALL_DIR}/${UFFD_PAGER_BINARY_NAME} --data-dir ${DATA_DIR} --version-key %i --cache-max-bytes \${HYPEMAN_UFFD_CACHE_MAX_BYTES} +Environment="HYPEMAN_UFFD_BINARY=${INSTALL_DIR}/${UFFD_PAGER_BINARY_NAME}" +Environment="HYPEMAN_UFFD_DATA_DIR=${DATA_DIR}" +Environment="HYPEMAN_UFFD_VERSION_KEY=%i" +Environment="HYPEMAN_UFFD_CACHE_MAX_BYTES=4294967296" +EnvironmentFile=-/run/hypeman/uffd/%i.env +ExecStart=/bin/sh -c 'exec "\${HYPEMAN_UFFD_BINARY}" --data-dir "\${HYPEMAN_UFFD_DATA_DIR}" --version-key "\${HYPEMAN_UFFD_VERSION_KEY}" --cache-max-bytes "\${HYPEMAN_UFFD_CACHE_MAX_BYTES}"' Restart=on-failure RestartSec=5 KillMode=process