Skip to content

Commit 06abd04

Browse files
committed
fork: hardlink snapshot mem-file into snapshot forks
Snapshot forks copy the source guest dir into the fork instance dir; the dominant cost is the multi-GB mem-file. Hardlink it instead and skip the file from the directory walk via CopyOptions.SkipRelPaths (introduced for template forks). This is safe because: - snapshot mem-files are immutable - the hypervisor mmaps them MAP_PRIVATE on restore, so fork writes never reach the underlying file - hardlinks survive snapshot deletion via inode refcount, so a deleted snapshot never strands a running fork Falls back to the regular copy walk when no raw mem-file is present.
1 parent 7b799f7 commit 06abd04

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

lib/instances/snapshot.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"os"
8+
"path/filepath"
89
"time"
910

1011
"github.com/kernel/hypeman/lib/forkvm"
@@ -409,16 +410,27 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS
409410
if target != nil && target.State == compressionJobStateRunning {
410411
m.recordSnapshotCompressionPreemption(ctx, snapshotCompressionPreemptionForkSnapshot, target.Target)
411412
}
412-
if err := m.ensureSnapshotMemoryReady(ctx, m.paths.SnapshotGuestDir(snapshotID), "", rec.StoredMetadata.HypervisorType); err != nil {
413+
srcDir := m.paths.SnapshotGuestDir(snapshotID)
414+
if err := m.ensureSnapshotMemoryReady(ctx, srcDir, "", rec.StoredMetadata.HypervisorType); err != nil {
413415
return nil, fmt.Errorf("prepare snapshot memory for fork: %w", err)
414416
}
415417

416-
if err := forkvm.CopyGuestDirectory(m.paths.SnapshotGuestDir(snapshotID), dstDir); err != nil {
418+
copyOpts := forkvm.CopyOptions{}
419+
srcMemPath, srcMemRel, hasSharedMem := snapshotMemHardlinkSource(srcDir)
420+
if hasSharedMem {
421+
copyOpts.SkipRelPaths = []string{srcMemRel}
422+
}
423+
if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil {
417424
if errors.Is(err, forkvm.ErrSparseCopyUnsupported) {
418425
return nil, fmt.Errorf("fork from snapshot requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err)
419426
}
420427
return nil, fmt.Errorf("clone snapshot payload: %w", err)
421428
}
429+
if hasSharedMem {
430+
if err := installForkSnapshotMemHardlink(srcMemPath, dstDir, srcMemRel); err != nil {
431+
return nil, fmt.Errorf("hardlink snapshot mem-file into fork: %w", err)
432+
}
433+
}
422434

423435
starter, err := m.getVMStarter(targetHypervisor)
424436
if err != nil {
@@ -638,3 +650,38 @@ func (m *manager) listSnapshotRecords() ([]snapshotRecord, error) {
638650
}
639651
return records, nil
640652
}
653+
654+
// snapshotMemHardlinkSource resolves the raw mem-file under a snapshot guest
655+
// dir. Returns its absolute path and forward-slash-relative path for use as a
656+
// CopyOptions skip key. Returns ok=false if no raw mem-file is present (e.g.
657+
// only-compressed snapshot whose decompression failed, or a snapshot kind that
658+
// doesn't carry guest memory). Callers fall back to the regular copy walk.
659+
func snapshotMemHardlinkSource(srcDir string) (absPath, relSlash string, ok bool) {
660+
abs, found := findRawSnapshotMemoryFile(srcDir)
661+
if !found {
662+
return "", "", false
663+
}
664+
rel, err := filepath.Rel(srcDir, abs)
665+
if err != nil {
666+
return "", "", false
667+
}
668+
return abs, filepath.ToSlash(rel), true
669+
}
670+
671+
// installForkSnapshotMemHardlink hardlinks the source snapshot mem-file into
672+
// the fork's data dir at the matching relative path. Snapshot mem-files are
673+
// immutable and the hypervisor mmaps them MAP_PRIVATE on restore, so all
674+
// forks of a snapshot can safely share the same inode — fork writes never
675+
// reach the underlying file. Hardlinks are FS-local and survive snapshot
676+
// deletion via inode refcount, so a deleted snapshot never strands a fork.
677+
func installForkSnapshotMemHardlink(srcMemPath, dstDir, relSlash string) error {
678+
dstMem := filepath.Join(dstDir, filepath.FromSlash(relSlash))
679+
if err := os.MkdirAll(filepath.Dir(dstMem), 0o755); err != nil {
680+
return fmt.Errorf("ensure fork mem-file parent dir: %w", err)
681+
}
682+
_ = os.Remove(dstMem)
683+
if err := os.Link(srcMemPath, dstMem); err != nil {
684+
return fmt.Errorf("link snapshot mem-file: %w", err)
685+
}
686+
return nil
687+
}

lib/instances/snapshot_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,62 @@ func TestForkSnapshotFromCompressedSourceCopiesRawMemory(t *testing.T) {
280280
assert.False(t, ok, "forked snapshot payload should not retain compressed memory artifacts from the source snapshot")
281281
}
282282

283+
func TestForkSnapshotHardlinksRawMemoryFile(t *testing.T) {
284+
t.Parallel()
285+
286+
mgr, _ := setupTestManager(t)
287+
ctx := context.Background()
288+
289+
hvType := mgr.defaultHypervisor
290+
sourceID := "snapshot-fork-hardlink-src"
291+
createStandbySnapshotSourceFixture(t, mgr, sourceID, "snapshot-fork-hardlink-src", hvType)
292+
293+
snap, err := mgr.CreateSnapshot(ctx, sourceID, CreateSnapshotRequest{
294+
Kind: SnapshotKindStandby,
295+
Name: "standby-for-fork-hardlink",
296+
})
297+
require.NoError(t, err)
298+
299+
// Plant the raw mem-file at the top of the snapshot guest dir so it
300+
// survives applyForkTargetState's snapshot-latest wipe and we can stat
301+
// the fork's hardlinked copy after the call returns. Production layout
302+
// nests it under snapshots/snapshot-latest/memory; the helpers under
303+
// test treat both paths identically via findRawSnapshotMemoryFile.
304+
memContents := []byte("guest memory bytes for hardlink test")
305+
snapshotDir := mgr.paths.SnapshotGuestDir(snap.Id)
306+
snapshotMem := filepath.Join(snapshotDir, "memory-ranges")
307+
require.NoError(t, os.WriteFile(snapshotMem, memContents, 0o644))
308+
snapshotConfigPath := filepath.Join(snapshotDir, "snapshots", "snapshot-latest", "config.json")
309+
require.NoError(t, os.MkdirAll(filepath.Dir(snapshotConfigPath), 0o755))
310+
require.NoError(t, os.WriteFile(snapshotConfigPath, []byte(`{}`), 0o644))
311+
312+
srcInfo, err := os.Stat(snapshotMem)
313+
require.NoError(t, err)
314+
315+
forked, err := mgr.ForkSnapshot(ctx, snap.Id, ForkSnapshotRequest{
316+
Name: "snapshot-fork-hardlink",
317+
TargetState: StateStopped,
318+
})
319+
require.NoError(t, err)
320+
t.Cleanup(func() { _ = mgr.DeleteInstance(context.Background(), forked.Id) })
321+
322+
forkMem := filepath.Join(mgr.paths.InstanceDir(forked.Id), "memory-ranges")
323+
forkInfo, err := os.Stat(forkMem)
324+
require.NoError(t, err, "fork should have a hardlinked memory file alongside its instance dir")
325+
assert.True(t, os.SameFile(srcInfo, forkInfo), "fork mem-file should share an inode with the snapshot mem-file (hardlink)")
326+
327+
got, err := os.ReadFile(forkMem)
328+
require.NoError(t, err)
329+
assert.Equal(t, memContents, got, "fork mem-file should expose the same bytes as the snapshot mem-file")
330+
331+
// Hardlinks survive deletion of the source path: removing the snapshot
332+
// drops one reference but leaves the inode alive for the fork.
333+
require.NoError(t, mgr.DeleteSnapshot(ctx, snap.Id))
334+
stillThere, err := os.ReadFile(forkMem)
335+
require.NoError(t, err, "fork mem-file should remain readable after snapshot deletion")
336+
assert.Equal(t, memContents, stillThere)
337+
}
338+
283339
func createStoppedSnapshotSourceFixture(t *testing.T, mgr *manager, id, name string, hvType hypervisor.Type) {
284340
t.Helper()
285341
require.NoError(t, mgr.ensureDirectories(id))

0 commit comments

Comments
 (0)