Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions lib/forkvm/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,30 @@ type copyState struct {
reflinkDead bool
}

// CopyOptions tunes CopyGuestDirectory behavior. The zero value reproduces
// the original full-copy semantics; callers can opt into skipping specific
// paths when the consumer arranges its own substitute (e.g. a symlink to a
// template-shared mem-file).
type CopyOptions struct {
// SkipRelPaths lists relative paths under srcDir that should not be
// materialized in dstDir. Comparison is exact and uses forward-slash
// separators on all platforms.
SkipRelPaths []string
}

// 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 the option-taking variant of
// CopyGuestDirectory. Use this when forking with template-shared assets, so
// the caller can install a symlink in place of a heavy copied file.
func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error {
srcInfo, err := os.Stat(srcDir)
if err != nil {
return fmt.Errorf("stat source directory: %w", err)
Expand All @@ -56,6 +74,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
state.reflinkDead = true
}

skipSet := make(map[string]struct{}, len(opts.SkipRelPaths))
for _, p := range opts.SkipRelPaths {
skipSet[filepath.ToSlash(p)] = struct{}{}
}

return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
Expand All @@ -68,6 +91,12 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
if relPath == "." {
return nil
}
if _, skip := skipSet[filepath.ToSlash(relPath)]; skip {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() && shouldSkipDirectory(relPath) {
return filepath.SkipDir
}
Expand Down
19 changes: 19 additions & 0 deletions lib/forkvm/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ func TestCopyGuestDirectory(t *testing.T) {
assert.Equal(t, "metadata.json", linkTarget)
}

func TestCopyGuestDirectory_SkipRelPaths(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", "config.json"), []byte(`{}`), 0644))
require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "memory"), []byte("the heavy mem-file"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "state"), []byte("device state"), 0644))

err := CopyGuestDirectoryWithOptions(src, dst, CopyOptions{
SkipRelPaths: []string{"snapshots/snapshot-latest/memory"},
})
require.NoError(t, err)

assert.NoFileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory"))
assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "config.json"))
assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "state"))
}

func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) {
src := filepath.Join(t.TempDir(), "src")
dst := filepath.Join(t.TempDir(), "dst")
Expand Down
22 changes: 21 additions & 1 deletion lib/instances/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,39 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin

fromSnapshot := source.State == StateStandby || source.State == StateTemplate

// shareMemFile gates mem-file fan-out from the source's standby snapshot.
// Firecracker only: it mmaps the snapshot mem-file MAP_PRIVATE on restore,
// so all forks safely COW from the same backing file. Cloud-hypervisor and
// other hypervisors take a copy-mode path and don't benefit. Restricted to
// Template sources because they are explicitly promoted as fork-only and
// can never be restored — sharing the mem-file with a non-Template source
// would let a later RestoreInstance mutate the file out from under live
// forks.
shareMemFile := source.State == StateTemplate && stored.HypervisorType == hypervisor.TypeFirecracker

if fromSnapshot {
if err := m.ensureSnapshotMemoryReady(ctx, m.paths.InstanceSnapshotLatest(id), m.snapshotJobKeyForInstance(id), stored.HypervisorType); err != nil {
return nil, fmt.Errorf("prepare standby snapshot for fork: %w", err)
}
}

if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil {
copyOpts := forkvm.CopyOptions{}
if shareMemFile {
copyOpts.SkipRelPaths = []string{templateSharedMemFileRelPath}
}
if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil {
if errors.Is(err, forkvm.ErrSparseCopyUnsupported) {
return nil, fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err)
}
return nil, fmt.Errorf("clone guest directory: %w", err)
}

if shareMemFile {
if err := m.installForkSharedMemFile(dstDir, id); err != nil {
return nil, fmt.Errorf("install shared mem-file: %w", err)
}
}

starter, err := m.getVMStarter(stored.HypervisorType)
if err != nil {
return nil, fmt.Errorf("get vm starter: %w", err)
Expand Down
51 changes: 49 additions & 2 deletions lib/instances/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"time"

"github.com/kernel/hypeman/lib/forkvm"
Expand Down Expand Up @@ -414,16 +415,27 @@ 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.ensureSnapshotMemoryReady(ctx, m.paths.SnapshotGuestDir(snapshotID), "", rec.StoredMetadata.HypervisorType); err != nil {
srcDir := m.paths.SnapshotGuestDir(snapshotID)
if err := m.ensureSnapshotMemoryReady(ctx, srcDir, "", rec.StoredMetadata.HypervisorType); err != nil {
return nil, fmt.Errorf("prepare snapshot memory for fork: %w", err)
}

if err := forkvm.CopyGuestDirectory(m.paths.SnapshotGuestDir(snapshotID), dstDir); err != nil {
copyOpts := forkvm.CopyOptions{}
srcMemPath, srcMemRel, hasSharedMem := snapshotMemHardlinkSource(srcDir)
if hasSharedMem {
copyOpts.SkipRelPaths = []string{srcMemRel}
}
if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil {
if errors.Is(err, forkvm.ErrSparseCopyUnsupported) {
return nil, fmt.Errorf("fork from snapshot requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err)
}
return nil, fmt.Errorf("clone snapshot payload: %w", err)
}
if hasSharedMem {
if err := installForkSnapshotMemHardlink(srcMemPath, dstDir, srcMemRel); err != nil {
return nil, fmt.Errorf("hardlink snapshot mem-file into fork: %w", err)
}
}

starter, err := m.getVMStarter(targetHypervisor)
if err != nil {
Expand Down Expand Up @@ -645,3 +657,38 @@ func (m *manager) listSnapshotRecords() ([]snapshotRecord, error) {
}
return records, nil
}

// snapshotMemHardlinkSource resolves the raw mem-file under a snapshot guest
// dir. Returns its absolute path and forward-slash-relative path for use as a
// CopyOptions skip key. Returns ok=false if no raw mem-file is present (e.g.
// only-compressed snapshot whose decompression failed, or a snapshot kind that
// doesn't carry guest memory). Callers fall back to the regular copy walk.
func snapshotMemHardlinkSource(srcDir string) (absPath, relSlash string, ok bool) {
abs, found := findRawSnapshotMemoryFile(srcDir)
if !found {
return "", "", false
}
rel, err := filepath.Rel(srcDir, abs)
if err != nil {
return "", "", false
}
return abs, filepath.ToSlash(rel), true
}

// installForkSnapshotMemHardlink hardlinks the source snapshot mem-file into
// the fork's data dir at the matching relative path. Snapshot mem-files are
// immutable and the hypervisor mmaps them MAP_PRIVATE on restore, so all
// forks of a snapshot can safely share the same inode — fork writes never
// reach the underlying file. Hardlinks are FS-local and survive snapshot
// deletion via inode refcount, so a deleted snapshot never strands a fork.
func installForkSnapshotMemHardlink(srcMemPath, dstDir, relSlash string) error {
dstMem := filepath.Join(dstDir, filepath.FromSlash(relSlash))
if err := os.MkdirAll(filepath.Dir(dstMem), 0o755); err != nil {
return fmt.Errorf("ensure fork mem-file parent dir: %w", err)
}
_ = os.Remove(dstMem)
if err := os.Link(srcMemPath, dstMem); err != nil {
return fmt.Errorf("link snapshot mem-file: %w", err)
}
return nil
}
20 changes: 20 additions & 0 deletions lib/instances/snapshot_integration_scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package instances

import (
"context"
"os"
"testing"
"time"

Expand Down Expand Up @@ -108,4 +109,23 @@ func runStandbySnapshotScenario(t *testing.T, mgr *manager, tmpDir string, cfg s
require.Equal(t, StateStandby, currentFork.State)

assertCopyReflinked(t, p.SnapshotGuestDir(snapshot.Id), p.InstanceDir(forkID))
assertSnapshotForkMemoryHardlinked(t, p.SnapshotGuestDir(snapshot.Id), p.InstanceDir(forkID))
}

func assertSnapshotForkMemoryHardlinked(t *testing.T, srcDir, forkDir string) {
t.Helper()

srcMem, ok := findRawSnapshotMemoryFile(srcDir)
if !ok {
t.Logf("raw snapshot memory file not recognized under %s; hardlink assertion skipped", srcDir)
return
}
forkMem, ok := findRawSnapshotMemoryFile(forkDir)
require.True(t, ok, "fork should contain the raw snapshot memory file")

srcInfo, err := os.Stat(srcMem)
require.NoError(t, err)
forkInfo, err := os.Stat(forkMem)
require.NoError(t, err)
require.True(t, os.SameFile(srcInfo, forkInfo), "fork snapshot memory file should hardlink the source snapshot memory file")
}
56 changes: 56 additions & 0 deletions lib/instances/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,62 @@ func TestForkSnapshotFromCompressedSourceCopiesRawMemory(t *testing.T) {
assert.False(t, ok, "forked snapshot payload should not retain compressed memory artifacts from the source snapshot")
}

func TestForkSnapshotHardlinksRawMemoryFile(t *testing.T) {
t.Parallel()

mgr, _ := setupTestManager(t)
ctx := context.Background()

hvType := mgr.defaultHypervisor
sourceID := "snapshot-fork-hardlink-src"
createStandbySnapshotSourceFixture(t, mgr, sourceID, "snapshot-fork-hardlink-src", hvType)

snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{
Kind: SnapshotKindStandby,
Name: "standby-for-fork-hardlink",
})
require.NoError(t, err)

// Plant the raw mem-file at the top of the snapshot guest dir so it
// survives applyForkTargetState's snapshot-latest wipe and we can stat
// the fork's hardlinked copy after the call returns. Production layout
// nests it under snapshots/snapshot-latest/memory; the helpers under
// test treat both paths identically via findRawSnapshotMemoryFile.
memContents := []byte("guest memory bytes for hardlink test")
snapshotDir := mgr.paths.SnapshotGuestDir(snap.Id)
snapshotMem := filepath.Join(snapshotDir, "memory-ranges")
require.NoError(t, os.WriteFile(snapshotMem, memContents, 0o644))
snapshotConfigPath := filepath.Join(snapshotDir, "snapshots", "snapshot-latest", "config.json")
require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755))
require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644))

srcInfo, err := os.Stat(snapshotMem)
require.NoError(t, err)

forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{
Name: "snapshot-fork-hardlink",
TargetState: StateStopped,
})
require.NoError(t, err)
t.Cleanup(func() { _ = mgr.DeleteInstance(context.Background(), forked.Id) })

forkMem := filepath.Join(mgr.paths.InstanceDir(forked.Id), "memory-ranges")
forkInfo, err := os.Stat(forkMem)
require.NoError(t, err, "fork should have a hardlinked memory file alongside its instance dir")
assert.True(t, os.SameFile(srcInfo, forkInfo), "fork mem-file should share an inode with the snapshot mem-file (hardlink)")

got, err := os.ReadFile(forkMem)
require.NoError(t, err)
assert.Equal(t, memContents, got, "fork mem-file should expose the same bytes as the snapshot mem-file")

// Hardlinks survive deletion of the source path: removing the snapshot
// drops one reference but leaves the inode alive for the fork.
require.NoError(t, mgr.DeleteSnapshot(ctx, snap.Id))
stillThere, err := os.ReadFile(forkMem)
require.NoError(t, err, "fork mem-file should remain readable after snapshot deletion")
assert.Equal(t, memContents, stillThere)
}

func createStoppedSnapshotSourceFixture(t *testing.T, mgr *manager, id, name string, hvType hypervisor.Type) {
t.Helper()
require.NoError(t, mgr.ensureDirectories(id))
Expand Down
46 changes: 46 additions & 0 deletions lib/instances/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package instances

import (
"fmt"
"os"
"path/filepath"
)

const (
templateSharedMemFileName = "memory"
templateSharedMemFileRelPath = "snapshots/snapshot-latest/memory"
)

// installForkSharedMemFile arranges the fork's snapshot directory so the
// guest mem-file is a hardlink to the source template instance's snapshot
// mem-file instead of a per-fork copy. firecracker mmaps the mem-file
// MAP_PRIVATE during restore, so all forks COW from the same backing inode.
//
// Layout: forkDataDir is the fork's data dir. The snapshot dir is at
// <forkDataDir>/snapshots/snapshot-latest, and the mem-file lives at
// <snapshot dir>/memory. The hardlink shares the inode with the source
// instance's standby snapshot mem-file.
//
// We use a hardlink rather than a symlink because firecracker's restore
// path temporarily aliases the source data dir to the fork data dir while
// it loads the snapshot (see withSnapshotSourceDirAlias). A symlink whose
// target traverses the source dir would resolve back into the fork dir
// during that window and trip ELOOP; a hardlink resolves by inode so the
// alias has no effect on it. Hardlinks require both paths on the same
// filesystem, which holds for our standard data-dir layout.
func (m *manager) installForkSharedMemFile(forkDataDir, sourceInstanceID string) error {
srcMem := filepath.Join(m.paths.InstanceSnapshotLatest(sourceInstanceID), templateSharedMemFileName)
if _, err := os.Stat(srcMem); err != nil {
return fmt.Errorf("stat template mem-file: %w", err)
}
dstSnapshotDir := filepath.Join(forkDataDir, "snapshots", "snapshot-latest")
if err := os.MkdirAll(dstSnapshotDir, 0o755); err != nil {
return fmt.Errorf("ensure fork snapshot dir: %w", err)
}
dstMem := filepath.Join(dstSnapshotDir, templateSharedMemFileName)
_ = os.Remove(dstMem)
if err := os.Link(srcMem, dstMem); err != nil {
return fmt.Errorf("hardlink shared mem-file: %w", err)
}
return nil
}
Loading
Loading