Skip to content

Commit 355ad7f

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 2738278 commit 355ad7f

5 files changed

Lines changed: 243 additions & 1 deletion

File tree

lib/forkvm/copy.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,30 @@ type copyState struct {
3333
reflinkDead bool
3434
}
3535

36+
// CopyOptions tunes CopyGuestDirectory behavior. The zero value reproduces
37+
// the original full-copy semantics; callers can opt into skipping specific
38+
// paths when the consumer arranges its own substitute (e.g. a symlink to a
39+
// template-shared mem-file).
40+
type CopyOptions struct {
41+
// SkipRelPaths lists relative paths under srcDir that should not be
42+
// materialized in dstDir. Comparison is exact and uses forward-slash
43+
// separators on all platforms.
44+
SkipRelPaths []string
45+
}
46+
3647
// CopyGuestDirectory recursively copies a guest directory to a new destination.
3748
// Regular files are cloned via reflink (FICLONE) when the underlying filesystem
3849
// supports it; otherwise we fall back to a sparse extent copy
3950
// (SEEK_DATA/SEEK_HOLE). Runtime sockets and logs are skipped because they are
4051
// host-runtime artifacts.
4152
func CopyGuestDirectory(srcDir, dstDir string) error {
53+
return CopyGuestDirectoryWithOptions(srcDir, dstDir, CopyOptions{})
54+
}
55+
56+
// CopyGuestDirectoryWithOptions is the option-taking variant of
57+
// CopyGuestDirectory. Use this when forking with template-shared assets, so
58+
// the caller can install a symlink in place of a heavy copied file.
59+
func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error {
4260
srcInfo, err := os.Stat(srcDir)
4361
if err != nil {
4462
return fmt.Errorf("stat source directory: %w", err)
@@ -56,6 +74,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
5674
state.reflinkDead = true
5775
}
5876

77+
skipSet := make(map[string]struct{}, len(opts.SkipRelPaths))
78+
for _, p := range opts.SkipRelPaths {
79+
skipSet[filepath.ToSlash(p)] = struct{}{}
80+
}
81+
5982
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
6083
if walkErr != nil {
6184
return walkErr
@@ -68,6 +91,12 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
6891
if relPath == "." {
6992
return nil
7093
}
94+
if _, skip := skipSet[filepath.ToSlash(relPath)]; skip {
95+
if d.IsDir() {
96+
return filepath.SkipDir
97+
}
98+
return nil
99+
}
71100
if d.IsDir() && shouldSkipDirectory(relPath) {
72101
return filepath.SkipDir
73102
}

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: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,19 +255,39 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
255255

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

258+
// shareMemFile gates mem-file fan-out from the source's standby snapshot.
259+
// Firecracker only: it mmaps the snapshot mem-file MAP_PRIVATE on restore,
260+
// so all forks safely COW from the same backing file. Cloud-hypervisor and
261+
// other hypervisors take a copy-mode path and don't benefit. Restricted to
262+
// Template sources because they are explicitly promoted as fork-only and
263+
// can never be restored — sharing the mem-file with a non-Template source
264+
// would let a later RestoreInstance mutate the file out from under live
265+
// forks.
266+
shareMemFile := source.State == StateTemplate && stored.HypervisorType == hypervisor.TypeFirecracker
267+
258268
if fromSnapshot {
259269
if err := m.ensureSnapshotMemoryReady(ctx, m.paths.InstanceSnapshotLatest(id), m.snapshotJobKeyForInstance(id), stored.HypervisorType); err != nil {
260270
return nil, fmt.Errorf("prepare standby snapshot for fork: %w", err)
261271
}
262272
}
263273

264-
if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil {
274+
copyOpts := forkvm.CopyOptions{}
275+
if shareMemFile {
276+
copyOpts.SkipRelPaths = []string{templateSharedMemFileRelPath}
277+
}
278+
if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil {
265279
if errors.Is(err, forkvm.ErrSparseCopyUnsupported) {
266280
return nil, fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err)
267281
}
268282
return nil, fmt.Errorf("clone guest directory: %w", err)
269283
}
270284

285+
if shareMemFile {
286+
if err := m.installForkSharedMemFile(dstDir, id); err != nil {
287+
return nil, fmt.Errorf("install shared mem-file: %w", err)
288+
}
289+
}
290+
271291
starter, err := m.getVMStarter(stored.HypervisorType)
272292
if err != nil {
273293
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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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_FromTemplate verifies the end-to-end fork
56+
// path: when the source is a Firecracker Template 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_FromTemplate(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+
promoteFixtureToTemplate(t, mgr, sourceID)
69+
70+
srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID)
71+
srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName)
72+
require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644))
73+
snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID)
74+
require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755))
75+
require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644))
76+
77+
forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{
78+
Name: "shared-memfile-fc-fork",
79+
TargetState: StateStopped,
80+
}, true)
81+
require.NoError(t, err)
82+
require.NotNil(t, forked)
83+
84+
forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName)
85+
info, err := os.Lstat(forkMem)
86+
require.NoError(t, err)
87+
assert.NotZero(t, info.Mode()&os.ModeSymlink, "fork mem-file must be a symlink for firecracker fan-out")
88+
89+
target, err := os.Readlink(forkMem)
90+
require.NoError(t, err)
91+
assert.Equal(t, srcMem, target)
92+
}
93+
94+
// TestForkFirecrackerStandbySourceDoesNotShareMemFile guards the
95+
// non-Template carve-out: forking a plain Standby source must copy the
96+
// mem-file outright. Sharing would let a later RestoreInstance on the source
97+
// mutate the file out from under live forks.
98+
func TestForkFirecrackerStandbySourceDoesNotShareMemFile(t *testing.T) {
99+
t.Parallel()
100+
101+
mgr, _ := setupTestManager(t)
102+
ctx := context.Background()
103+
104+
sourceID := "standby-fork-fc-src"
105+
createStandbySnapshotSourceFixture(t, mgr, sourceID, "standby-fork-fc-src", hypervisor.TypeFirecracker)
106+
107+
srcSnapshotDir := mgr.paths.InstanceSnapshotLatest(sourceID)
108+
srcMem := filepath.Join(srcSnapshotDir, templateSharedMemFileName)
109+
require.NoError(t, os.WriteFile(srcMem, []byte("firecracker mem-file contents"), 0o644))
110+
snapshotConfigPath := mgr.paths.InstanceSnapshotConfig(sourceID)
111+
require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755))
112+
require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644))
113+
114+
forked, err := mgr.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{
115+
Name: "standby-fork-fc-fork",
116+
TargetState: StateStopped,
117+
}, true)
118+
require.NoError(t, err)
119+
require.NotNil(t, forked)
120+
121+
forkMem := filepath.Join(mgr.paths.InstanceSnapshotLatest(forked.Id), templateSharedMemFileName)
122+
info, err := os.Lstat(forkMem)
123+
require.NoError(t, err)
124+
assert.Zero(t, info.Mode()&os.ModeSymlink, "standby-source fork mem-file must be a regular file copy, not a symlink")
125+
}
126+
127+
// promoteFixtureToTemplate marks the source's stored metadata as a Template
128+
// without invoking the full PromoteToTemplate lifecycle (which would require
129+
// a live VM). Test-only shortcut.
130+
func promoteFixtureToTemplate(t *testing.T, mgr *manager, id string) {
131+
t.Helper()
132+
meta, err := mgr.loadMetadata(id)
133+
require.NoError(t, err)
134+
meta.IsTemplate = true
135+
require.NoError(t, mgr.saveMetadata(meta))
136+
}

0 commit comments

Comments
 (0)