Skip to content

Commit d46be7a

Browse files
committed
fork: share template mem-file via symlink for fan-out forks
When ForkInstanceRequest.TemplateID is set, the fork resolves the source instance from the templates registry, skips the per-fork mem-file copy, and installs a symlink to the template's snapshot mem-file instead. firecracker mmaps the symlinked mem-file MAP_PRIVATE during restore, so many concurrent forks COW from the same backing file rather than each holding a private copy. Also wires: - templateGuard on Start/Restore so a template parent is never resumed while live forks share its mem-file. - Refcount lifecycle: bump on fork creation, decrement on fork delete. - Delete safety: deleting a template instance refuses while ForkCount>0 via templates.ErrInUse. - forkvm.CopyOptions.SkipRelPaths so callers can opt out of specific files in the source dir without breaking the existing copy semantics.
1 parent 27fb0b4 commit d46be7a

10 files changed

Lines changed: 231 additions & 6 deletions

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/delete.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ func (m *manager) deleteInstance(
3535
stored := &meta.StoredMetadata
3636
log.DebugContext(ctx, "loaded instance", "instance_id", id, "state", inst.State)
3737

38+
// If this instance was promoted to a template parent, refuse to delete
39+
// it while live forks reference it. Removing the registry entry now
40+
// (instead of after the data wipe) gives us a single transactional
41+
// "in-use" check via templates.ErrInUse.
42+
if stored.IsTemplate && stored.TemplateID != "" && m.templateRegistry != nil {
43+
if err := m.templateRegistry.Delete(ctx, stored.TemplateID); err != nil {
44+
return fmt.Errorf("delete template registry entry for instance %s: %w", id, err)
45+
}
46+
stored.IsTemplate = false
47+
stored.TemplateID = ""
48+
}
49+
3850
target, err := m.cancelAndWaitCompressionJob(ctx, m.snapshotJobKeyForInstance(id))
3951
if err != nil {
4052
return fmt.Errorf("wait for instance compression to stop: %w", err)
@@ -136,6 +148,12 @@ func (m *manager) deleteInstance(
136148
return fmt.Errorf("delete instance data: %w", err)
137149
}
138150

151+
// 9. If this instance was a fork of a template, drop the template's
152+
// fork refcount so the template can eventually be deleted.
153+
if stored.ForkOfTemplate != "" {
154+
m.dropTemplateForkRefcount(ctx, stored.ForkOfTemplate)
155+
}
156+
139157
log.InfoContext(ctx, "instance deleted successfully", "instance_id", id)
140158
return nil
141159
}

lib/instances/fork.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/kernel/hypeman/lib/hypervisor"
1616
"github.com/kernel/hypeman/lib/logger"
1717
"github.com/kernel/hypeman/lib/network"
18+
"github.com/kernel/hypeman/lib/templates"
1819
"github.com/nrednav/cuid2"
1920
"go.opentelemetry.io/otel/attribute"
2021
"gvisor.dev/gvisor/pkg/cleanup"
@@ -36,11 +37,22 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR
3637
return nil, "", err
3738
}
3839

40+
resolvedID, tpl, err := m.resolveForkFromTemplateRequest(ctx, id, req)
41+
if err != nil {
42+
return nil, "", err
43+
}
44+
id = resolvedID
45+
3946
meta, err := m.loadMetadata(id)
4047
if err != nil {
4148
return nil, "", err
4249
}
4350
source := m.toInstance(ctx, meta)
51+
if tpl != nil {
52+
if err := validateForkResolvedFromTemplate(tpl, source.HypervisorType); err != nil {
53+
return nil, "", err
54+
}
55+
}
4456
targetState, err := resolveForkTargetState(req.TargetState, source.State)
4557
if err != nil {
4658
return nil, "", err
@@ -65,7 +77,7 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR
6577
return nil, "", fmt.Errorf("standby source instance: %w", err)
6678
}
6779

68-
forked, forkErr := m.forkInstanceFromStoppedOrStandby(ctx, id, req, true)
80+
forked, forkErr := m.forkInstanceFromStoppedOrStandby(ctx, id, req, true, tpl)
6981
if forkErr == nil {
7082
if err := m.rotateSourceVsockForRestore(ctx, id, forked.Id); err != nil {
7183
forkErr = fmt.Errorf("prepare source snapshot for restore: %w", err)
@@ -104,7 +116,7 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR
104116
}
105117
return forked, targetState, nil
106118
case StateStopped, StateStandby:
107-
forked, err := m.forkInstanceFromStoppedOrStandby(ctx, id, req, false)
119+
forked, err := m.forkInstanceFromStoppedOrStandby(ctx, id, req, false, tpl)
108120
if err != nil {
109121
return nil, "", err
110122
}
@@ -192,7 +204,7 @@ func generateForkSourceVsockCID(sourceID, forkID string, current int64) int64 {
192204
return cid
193205
}
194206

195-
func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id string, req ForkInstanceRequest, supportValidated bool) (*Instance, error) {
207+
func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id string, req ForkInstanceRequest, supportValidated bool, tpl *templates.Template) (*Instance, error) {
196208
log := logger.FromContext(ctx)
197209

198210
meta, err := m.loadMetadata(id)
@@ -202,6 +214,9 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
202214

203215
source := m.toInstance(ctx, meta)
204216
stored := &meta.StoredMetadata
217+
if tpl != nil && !stored.IsTemplate {
218+
return nil, fmt.Errorf("%w: template %s source instance %s is not flagged as a template parent", ErrInvalidState, tpl.ID, id)
219+
}
205220

206221
switch source.State {
207222
case StateStopped, StateStandby:
@@ -255,12 +270,21 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
255270
}
256271
}
257272

258-
if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil {
273+
copyOpts := forkvm.CopyOptions{}
274+
if tpl != nil {
275+
copyOpts.SkipRelPaths = []string{templateSharedMemFileRelPath}
276+
}
277+
if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil {
259278
if errors.Is(err, forkvm.ErrSparseCopyUnsupported) {
260279
return nil, fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err)
261280
}
262281
return nil, fmt.Errorf("clone guest directory: %w", err)
263282
}
283+
if tpl != nil {
284+
if err := m.installForkSharedMemFile(dstDir, tpl); err != nil {
285+
return nil, fmt.Errorf("install shared mem-file: %w", err)
286+
}
287+
}
264288

265289
starter, err := m.getVMStarter(stored.HypervisorType)
266290
if err != nil {
@@ -280,6 +304,15 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
280304
forkMeta.VsockSocket = m.paths.InstanceSocket(forkID, hypervisor.VsockSocketNameForType(forkMeta.HypervisorType))
281305
forkMeta.ExitCode = nil
282306
forkMeta.ExitMessage = ""
307+
// Forks of a template carry the template id but never inherit the
308+
// IsTemplate flag — they are working copies.
309+
forkMeta.IsTemplate = false
310+
forkMeta.TemplateID = ""
311+
if tpl != nil {
312+
forkMeta.ForkOfTemplate = tpl.ID
313+
} else {
314+
forkMeta.ForkOfTemplate = stored.ForkOfTemplate
315+
}
283316

284317
// Keep the original CID for snapshot-based forks.
285318
// Rewriting CID in restored memory snapshots is not reliable across
@@ -324,6 +357,14 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
324357
return nil, fmt.Errorf("save fork metadata: %w", err)
325358
}
326359

360+
if tpl != nil {
361+
// Bumped before cu.Release so a refcount failure leaves no orphan
362+
// fork directory (deferred cu.Clean removes the data dir + metadata).
363+
if err := m.bumpTemplateForkRefcount(ctx, tpl); err != nil {
364+
return nil, fmt.Errorf("record template fork refcount: %w", err)
365+
}
366+
}
367+
327368
cu.Release()
328369
forked := m.toInstance(ctx, newMeta)
329370
log.InfoContext(ctx, "instance forked successfully",

lib/instances/fork_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func TestForkInstanceFromStandbyCancelsCompressionJobAndCopiesRawMemory(t *testi
265265
forked, err := manager.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{
266266
Name: "fork-standby-compressed-copy",
267267
TargetState: StateStopped,
268-
}, true)
268+
}, true, nil)
269269
require.NoError(t, err)
270270
require.NotNil(t, forked)
271271

lib/instances/manager.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,13 @@ func (m *manager) DeleteSnapshot(ctx context.Context, snapshotID string) error {
381381

382382
// ForkInstance creates a forked copy of an instance.
383383
func (m *manager) ForkInstance(ctx context.Context, id string, req ForkInstanceRequest) (*Instance, error) {
384-
lock := m.getInstanceLock(id)
384+
// Resolve TemplateID outside the lock so we hold the source instance
385+
// lock — not an empty string lock — when forking from a template.
386+
resolvedID, _, err := m.resolveForkFromTemplateRequest(ctx, id, req)
387+
if err != nil {
388+
return nil, err
389+
}
390+
lock := m.getInstanceLock(resolvedID)
385391
lock.Lock()
386392
forked, targetState, err := m.forkInstance(ctx, id, req)
387393
lock.Unlock()

lib/instances/restore.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ func (m *manager) restoreInstance(
5757
log.ErrorContext(ctx, "no snapshot available", "instance_id", id)
5858
return nil, fmt.Errorf("no snapshot available for instance %s", id)
5959
}
60+
if err := m.templateGuard(stored, "restore"); err != nil {
61+
log.ErrorContext(ctx, "refusing to restore template instance", "instance_id", id, "template_id", stored.TemplateID)
62+
return nil, err
63+
}
6064

6165
// 2b. Validate aggregate resource limits before allocating resources (if configured)
6266
reservedResources := false

lib/instances/start.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ func (m *manager) startInstance(
4747
log.ErrorContext(ctx, "invalid state for start", "instance_id", id, "state", inst.State)
4848
return nil, fmt.Errorf("%w: cannot start from state %s, must be Stopped", ErrInvalidState, inst.State)
4949
}
50+
if err := m.templateGuard(stored, "start"); err != nil {
51+
log.ErrorContext(ctx, "refusing to start template instance", "instance_id", id, "template_id", stored.TemplateID)
52+
return nil, err
53+
}
5054

5155
// 2a. Clear stale exit info from previous run and apply command overrides
5256
stored.ExitCode = nil

lib/instances/templates.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"os"
8+
"path/filepath"
79
"time"
810

911
"github.com/kernel/hypeman/lib/hypervisor"
@@ -219,3 +221,98 @@ func (m *manager) nowOrDefault() time.Time {
219221
}
220222
return m.now()
221223
}
224+
225+
// resolveForkFromTemplateRequest expands a ForkInstanceRequest with a
226+
// non-empty TemplateID into (sourceInstanceID, *Template). Returns
227+
// (instanceID, nil, nil) when TemplateID is empty so callers fall through
228+
// to the ordinary fork path. Returns an error when the caller passed both
229+
// instanceID and TemplateID, when the registry is unconfigured, or when
230+
// the template cannot be resolved.
231+
func (m *manager) resolveForkFromTemplateRequest(ctx context.Context, instanceID string, req ForkInstanceRequest) (string, *templates.Template, error) {
232+
if req.TemplateID == "" {
233+
return instanceID, nil, nil
234+
}
235+
if instanceID != "" {
236+
return "", nil, fmt.Errorf("%w: pass either an instance id or a template id, not both", ErrInvalidRequest)
237+
}
238+
if m.templateRegistry == nil {
239+
return "", nil, fmt.Errorf("%w: template registry not configured", ErrNotSupported)
240+
}
241+
tpl, err := m.templateForFork(ctx, req.TemplateID)
242+
if err != nil {
243+
return "", nil, fmt.Errorf("resolve template %q: %w", req.TemplateID, err)
244+
}
245+
if tpl == nil {
246+
return "", nil, fmt.Errorf("%w: template %q not found", ErrNotFound, req.TemplateID)
247+
}
248+
if tpl.SourceInstanceID == "" {
249+
return "", nil, fmt.Errorf("%w: template %s has no source instance", ErrInvalidState, tpl.ID)
250+
}
251+
return tpl.SourceInstanceID, tpl, nil
252+
}
253+
254+
// installForkSharedMemFile arranges the fork's snapshot directory so the
255+
// guest mem-file is a symlink into the template's snapshot directory
256+
// instead of a per-fork copy. firecracker mmaps the mem-file MAP_PRIVATE
257+
// during restore, so all forks COW from the same backing file.
258+
//
259+
// Layout: dst is the fork's data dir. The snapshot dir is at
260+
// <dst>/snapshots/snapshot-latest, and the mem-file lives at
261+
// <snapshot dir>/memory. The symlink target is the template's source
262+
// instance's standby snapshot mem-file.
263+
func (m *manager) installForkSharedMemFile(forkDataDir string, tpl *templates.Template) error {
264+
if tpl == nil {
265+
return nil
266+
}
267+
srcMem := filepath.Join(m.paths.InstanceSnapshotLatest(tpl.SourceInstanceID), templateSharedMemFileName)
268+
if _, err := os.Stat(srcMem); err != nil {
269+
return fmt.Errorf("stat template mem-file: %w", err)
270+
}
271+
dstSnapshotDir := filepath.Join(forkDataDir, "snapshots", "snapshot-latest")
272+
if err := os.MkdirAll(dstSnapshotDir, 0o755); err != nil {
273+
return fmt.Errorf("ensure fork snapshot dir: %w", err)
274+
}
275+
dstMem := filepath.Join(dstSnapshotDir, templateSharedMemFileName)
276+
// Tolerate a leftover entry (e.g. from a partial copy that wasn't fully
277+
// skipped on a different filesystem layout).
278+
_ = os.Remove(dstMem)
279+
if err := os.Symlink(srcMem, dstMem); err != nil {
280+
return fmt.Errorf("symlink shared mem-file: %w", err)
281+
}
282+
return nil
283+
}
284+
285+
// templateSharedMemFileRelPath is the relative path under the source data
286+
// dir that points at the snapshotted guest mem-file. Encoded here so the
287+
// fork copy step can skip it without importing firecracker internals.
288+
const (
289+
templateSharedMemFileName = "memory"
290+
templateSharedMemFileRelPath = "snapshots/snapshot-latest/memory"
291+
)
292+
293+
// bumpTemplateForkRefcount records that a fork now depends on a template.
294+
// Best-effort touch of LastUsedAt happens alongside.
295+
func (m *manager) bumpTemplateForkRefcount(ctx context.Context, tpl *templates.Template) error {
296+
if tpl == nil || m.templateRegistry == nil {
297+
return nil
298+
}
299+
if _, err := m.templateRegistry.IncrementForkCount(ctx, tpl.ID); err != nil {
300+
return fmt.Errorf("increment template fork count: %w", err)
301+
}
302+
m.touchTemplateUsage(ctx, tpl.ID)
303+
return nil
304+
}
305+
306+
// dropTemplateForkRefcount mirrors bumpTemplateForkRefcount and is called
307+
// when a fork instance is deleted. Missing templates are tolerated so
308+
// orphaned forks don't block delete.
309+
func (m *manager) dropTemplateForkRefcount(ctx context.Context, templateID string) {
310+
if templateID == "" || m.templateRegistry == nil {
311+
return
312+
}
313+
if _, err := m.templateRegistry.DecrementForkCount(ctx, templateID); err != nil {
314+
log := logger.FromContext(ctx)
315+
log.WarnContext(ctx, "failed to decrement template fork refcount",
316+
"template_id", templateID, "error", err)
317+
}
318+
}

0 commit comments

Comments
 (0)