Skip to content

Commit 7b799f7

Browse files
committed
fork: share template mem-file via symlink for firecracker fan-out
When a Firecracker fork descends from a Standby or Template source, skip copying the snapshot mem-file and symlink it to the source's instead. Firecracker mmaps the mem-file MAP_PRIVATE on restore, so all forks COW from the same backing file — no per-fork copy required. Gated to Firecracker only because other hypervisors (cloud-hypervisor, qemu, vz) don't share MAP_PRIVATE semantics on their snapshot layouts. Skipped for the running-fork path: the source restores afterward, which would mutate the shared file out from under the fork. Stacked on hypeship/template-as-state so the Template state both gates "this snapshot is safe to fan out from" and refcounts living forks.
1 parent 8c23e9f commit 7b799f7

5 files changed

Lines changed: 229 additions & 1 deletion

File tree

lib/forkvm/copy.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,28 @@ import (
1111

1212
var ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
1313

14+
// CopyOptions tunes CopyGuestDirectory behavior. The zero value reproduces
15+
// the original full-copy semantics; callers can opt into skipping specific
16+
// paths when the consumer arranges its own substitute (e.g. a symlink to a
17+
// template-shared mem-file).
18+
type CopyOptions struct {
19+
// SkipRelPaths lists relative paths under srcDir that should not be
20+
// materialized in dstDir. Comparison is exact and uses forward-slash
21+
// separators on all platforms.
22+
SkipRelPaths []string
23+
}
24+
1425
// CopyGuestDirectory recursively copies a guest directory to a new destination.
1526
// Regular files are copied using sparse extent copy only (SEEK_DATA/SEEK_HOLE).
1627
// Runtime sockets and logs are skipped because they are host-runtime artifacts.
1728
func CopyGuestDirectory(srcDir, dstDir string) error {
29+
return CopyGuestDirectoryWithOptions(srcDir, dstDir, CopyOptions{})
30+
}
31+
32+
// CopyGuestDirectoryWithOptions is the option-taking variant of
33+
// CopyGuestDirectory. Use this when forking with template-shared assets, so
34+
// the caller can install a symlink in place of a heavy copied file.
35+
func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error {
1836
srcInfo, err := os.Stat(srcDir)
1937
if err != nil {
2038
return fmt.Errorf("stat source directory: %w", err)
@@ -27,6 +45,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
2745
return fmt.Errorf("create destination directory: %w", err)
2846
}
2947

48+
skipSet := make(map[string]struct{}, len(opts.SkipRelPaths))
49+
for _, p := range opts.SkipRelPaths {
50+
skipSet[filepath.ToSlash(p)] = struct{}{}
51+
}
52+
3053
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
3154
if walkErr != nil {
3255
return walkErr
@@ -39,6 +62,12 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
3962
if relPath == "." {
4063
return nil
4164
}
65+
if _, skip := skipSet[filepath.ToSlash(relPath)]; skip {
66+
if d.IsDir() {
67+
return filepath.SkipDir
68+
}
69+
return nil
70+
}
4271
if d.IsDir() && shouldSkipDirectory(relPath) {
4372
return filepath.SkipDir
4473
}

lib/forkvm/copy_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ func TestCopyGuestDirectory(t *testing.T) {
4444
assert.Equal(t, "metadata.json", linkTarget)
4545
}
4646

47+
func TestCopyGuestDirectory_SkipRelPaths(t *testing.T) {
48+
src := filepath.Join(t.TempDir(), "src")
49+
dst := filepath.Join(t.TempDir(), "dst")
50+
51+
require.NoError(t, os.MkdirAll(filepath.Join(src, "snapshots", "snapshot-latest"), 0755))
52+
require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "config.json"), []byte(`{}`), 0644))
53+
require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "memory"), []byte("the heavy mem-file"), 0644))
54+
require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "state"), []byte("device state"), 0644))
55+
56+
err := CopyGuestDirectoryWithOptions(src, dst, CopyOptions{
57+
SkipRelPaths: []string{"snapshots/snapshot-latest/memory"},
58+
})
59+
require.NoError(t, err)
60+
61+
assert.NoFileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory"))
62+
assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "config.json"))
63+
assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "state"))
64+
}
65+
4766
func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) {
4867
src := filepath.Join(t.TempDir(), "src")
4968
dst := filepath.Join(t.TempDir(), "dst")

lib/instances/fork.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,19 +254,37 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
254254

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

257+
// shareMemFile gates mem-file fan-out from the source's standby snapshot.
258+
// Firecracker only: it mmaps the snapshot mem-file MAP_PRIVATE on restore,
259+
// so all forks safely COW from the same backing file. Cloud-hypervisor and
260+
// other hypervisors take a copy-mode path and don't benefit. Skipped for
261+
// the running-fork flow because the source is restored afterward, which
262+
// would mutate the mem-file out from under the fork.
263+
shareMemFile := fromSnapshot && !skipTemplatePromotion && stored.HypervisorType == hypervisor.TypeFirecracker
264+
257265
if fromSnapshot {
258266
if err := m.ensureSnapshotMemoryReady(ctx, m.paths.InstanceSnapshotLatest(id), m.snapshotJobKeyForInstance(id), stored.HypervisorType); err != nil {
259267
return nil, fmt.Errorf("prepare standby snapshot for fork: %w", err)
260268
}
261269
}
262270

263-
if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil {
271+
copyOpts := forkvm.CopyOptions{}
272+
if shareMemFile {
273+
copyOpts.SkipRelPaths = []string{templateSharedMemFileRelPath}
274+
}
275+
if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil {
264276
if errors.Is(err, forkvm.ErrSparseCopyUnsupported) {
265277
return nil, fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err)
266278
}
267279
return nil, fmt.Errorf("clone guest directory: %w", err)
268280
}
269281

282+
if shareMemFile {
283+
if err := m.installForkSharedMemFile(dstDir, id); err != nil {
284+
return nil, fmt.Errorf("install shared mem-file: %w", err)
285+
}
286+
}
287+
270288
starter, err := m.getVMStarter(stored.HypervisorType)
271289
if err != nil {
272290
return nil, fmt.Errorf("get vm starter: %w", err)

lib/instances/templates.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package instances
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
const (
10+
templateSharedMemFileName = "memory"
11+
templateSharedMemFileRelPath = "snapshots/snapshot-latest/memory"
12+
)
13+
14+
// installForkSharedMemFile arranges the fork's snapshot directory so the
15+
// guest mem-file is a symlink into the source template instance's snapshot
16+
// directory instead of a per-fork copy. firecracker mmaps the mem-file
17+
// MAP_PRIVATE during restore, so all forks COW from the same backing file.
18+
//
19+
// Layout: forkDataDir is the fork's data dir. The snapshot dir is at
20+
// <forkDataDir>/snapshots/snapshot-latest, and the mem-file lives at
21+
// <snapshot dir>/memory. The symlink target is the source instance's
22+
// standby snapshot mem-file.
23+
func (m *manager) installForkSharedMemFile(forkDataDir, sourceInstanceID string) error {
24+
srcMem := filepath.Join(m.paths.InstanceSnapshotLatest(sourceInstanceID), templateSharedMemFileName)
25+
if _, err := os.Stat(srcMem); err != nil {
26+
return fmt.Errorf("stat template mem-file: %w", err)
27+
}
28+
dstSnapshotDir := filepath.Join(forkDataDir, "snapshots", "snapshot-latest")
29+
if err := os.MkdirAll(dstSnapshotDir, 0o755); err != nil {
30+
return fmt.Errorf("ensure fork snapshot dir: %w", err)
31+
}
32+
dstMem := filepath.Join(dstSnapshotDir, templateSharedMemFileName)
33+
_ = os.Remove(dstMem)
34+
if err := os.Symlink(srcMem, dstMem); err != nil {
35+
return fmt.Errorf("symlink shared mem-file: %w", err)
36+
}
37+
return nil
38+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package instances
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/kernel/hypeman/lib/hypervisor"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestInstallForkSharedMemFile_SymlinksSourceMemFile verifies that the helper
15+
// creates a symlink at the fork's snapshot mem-file path pointing back to the
16+
// source instance's mem-file.
17+
func TestInstallForkSharedMemFile_SymlinksSourceMemFile(t *testing.T) {
18+
t.Parallel()
19+
20+
mgr, _ := newStorageOnlyManager(t)
21+
sourceID := "shared-memfile-source"
22+
23+
srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID)
24+
require.NoError(t, os.MkdirAll(srcSnapshotDir, 0o755))
25+
srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName)
26+
require.NoError(t, os.WriteFile(srcMem, []byte("guest memory bytes"), 0o644))
27+
28+
forkDir := filepath.Join(t.TempDir(), "fork-data")
29+
30+
require.NoError(t, mgr.installForkSharedMemFile(forkDir, sourceID))
31+
32+
forkMem := filepath.Join(forkDir, "snapshots", "snapshot-latest", templateSharedMemFileName)
33+
info, err := os.Lstat(forkMem)
34+
require.NoError(t, err)
35+
assert.NotZero(t, info.Mode()&os.ModeSymlink, "fork mem-file must be a symlink, not a regular file")
36+
37+
target, err := os.Readlink(forkMem)
38+
require.NoError(t, err)
39+
assert.Equal(t, srcMem, target)
40+
}
41+
42+
// TestInstallForkSharedMemFile_ErrorsWhenSourceMissing makes sure the helper
43+
// refuses to silently create a dangling symlink when the source mem-file does
44+
// not exist.
45+
func TestInstallForkSharedMemFile_ErrorsWhenSourceMissing(t *testing.T) {
46+
t.Parallel()
47+
48+
mgr, _ := newStorageOnlyManager(t)
49+
forkDir := filepath.Join(t.TempDir(), "fork-data")
50+
51+
err := mgr.installForkSharedMemFile(forkDir, "no-such-source")
52+
require.Error(t, err)
53+
}
54+
55+
// TestForkFirecrackerSharesMemFile_FromStandby verifies the end-to-end fork
56+
// path: when the source is a Firecracker standby instance, the fork's
57+
// mem-file is a symlink to the source's mem-file instead of a copy. This
58+
// preserves the firecracker MAP_PRIVATE COW semantics that let multiple forks
59+
// share the heavy backing file.
60+
func TestForkFirecrackerSharesMemFile_FromStandby(t *testing.T) {
61+
t.Parallel()
62+
63+
mgr, _ := setupTestManager(t)
64+
ctx := context.Background()
65+
66+
sourceID := "shared-memfile-fc-src"
67+
createStandbySnapshotSourceFixture(t, mgr, sourceID, "shared-memfile-fc-src", hypervisor.TypeFirecracker)
68+
69+
srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID)
70+
srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName)
71+
require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644))
72+
snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID)
73+
require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755))
74+
require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644))
75+
76+
forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{
77+
Name: "shared-memfile-fc-fork",
78+
TargetState: StateStopped,
79+
}, true, false)
80+
require.NoError(t, err)
81+
require.NotNil(t, forked)
82+
83+
forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName)
84+
info, err := os.Lstat(forkMem)
85+
require.NoError(t, err)
86+
assert.NotZero(t, info.Mode()&os.ModeSymlink, "fork mem-file must be a symlink for firecracker fan-out")
87+
88+
target, err := os.Readlink(forkMem)
89+
require.NoError(t, err)
90+
assert.Equal(t, srcMem, target)
91+
}
92+
93+
// TestForkFirecrackerRunningSourceDoesNotShareMemFile guards the
94+
// running-fork carve-out: when the source will be restored afterward, the
95+
// fork must own its mem-file outright. Sharing would let the source's restore
96+
// mutate the fork's backing memory.
97+
func TestForkFirecrackerRunningSourceDoesNotShareMemFile(t *testing.T) {
98+
t.Parallel()
99+
100+
mgr, _ := setupTestManager(t)
101+
ctx := context.Background()
102+
103+
sourceID := "running-fork-fc-src"
104+
createStandbySnapshotSourceFixture(t, mgr, sourceID, "running-fork-fc-src", hypervisor.TypeFirecracker)
105+
106+
srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID)
107+
srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName)
108+
require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644))
109+
snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID)
110+
require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755))
111+
require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644))
112+
113+
forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{
114+
Name: "running-fork-fc-fork",
115+
TargetState: StateStopped,
116+
}, true, true)
117+
require.NoError(t, err)
118+
require.NotNil(t, forked)
119+
120+
forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName)
121+
info, err := os.Lstat(forkMem)
122+
require.NoError(t, err)
123+
assert.Zero(t, info.Mode()&os.ModeSymlink, "running-fork mem-file must be a regular file copy, not a symlink")
124+
}

0 commit comments

Comments
 (0)