From 55956c4aa4840d962daf09260b2bd58d587cc45a Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:46:00 +0000 Subject: [PATCH] Enable kernel-paged Cloud Hypervisor restores --- .../cloudhypervisor/cloudhypervisor.go | 9 + lib/hypervisor/cloudhypervisor/config.go | 19 +- lib/hypervisor/cloudhypervisor/config_test.go | 26 ++ .../cloudhypervisor/kernel_paging.go | 403 ++++++++++++++++++ .../cloudhypervisor/kernel_paging_test.go | 249 +++++++++++ lib/hypervisor/cloudhypervisor/process.go | 60 ++- lib/hypervisor/config.go | 7 +- lib/instances/create.go | 43 +- lib/instances/fork.go | 2 +- lib/instances/metrics.go | 5 +- lib/instances/snapshot.go | 2 +- lib/instances/snapshot_alias_lock.go | 19 +- ...cracker_memfile.go => snapshot_memfile.go} | 54 ++- lib/instances/snapshot_memfile_test.go | 50 +++ 14 files changed, 899 insertions(+), 49 deletions(-) create mode 100644 lib/hypervisor/cloudhypervisor/kernel_paging.go create mode 100644 lib/hypervisor/cloudhypervisor/kernel_paging_test.go rename lib/instances/{firecracker_memfile.go => snapshot_memfile.go} (59%) create mode 100644 lib/instances/snapshot_memfile_test.go diff --git a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go index 1d3a7f14f..1838fc1ec 100644 --- a/lib/hypervisor/cloudhypervisor/cloudhypervisor.go +++ b/lib/hypervisor/cloudhypervisor/cloudhypervisor.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/vmm" ) @@ -70,6 +71,7 @@ func CapabilitiesForVersion(v vmm.CHVersion) hypervisor.Capabilities { switch v { case vmm.V51_1: caps.SupportsDiskResize = true + caps.SupportsConcurrentForkPrepare = true } return caps } @@ -170,6 +172,13 @@ func (c *CloudHypervisor) Snapshot(ctx context.Context, destPath string) error { if resp.StatusCode() != 204 { return fmt.Errorf("snapshot failed with status %d", resp.StatusCode()) } + optimized, err := prepareSnapshotForKernelPaging(destPath) + if err != nil { + return fmt.Errorf("prepare kernel-paged snapshot: %w", err) + } + if optimized { + logger.FromContext(ctx).DebugContext(ctx, "prepared Cloud Hypervisor snapshot for kernel paging", "snapshot_dir", destPath) + } return nil } diff --git a/lib/hypervisor/cloudhypervisor/config.go b/lib/hypervisor/cloudhypervisor/config.go index e9f91fe4a..360d3c781 100644 --- a/lib/hypervisor/cloudhypervisor/config.go +++ b/lib/hypervisor/cloudhypervisor/config.go @@ -25,6 +25,8 @@ func serialSocketPath(logPath string) string { return filepath.Join(filepath.Dir(filepath.Dir(logPath)), "serial.sock") } +const kernelPagingMemoryZoneID = "hypeman-kernel-paging" + // ToVMConfig converts hypervisor.VMConfig to Cloud Hypervisor's vmm.VmConfig. func ToVMConfig(cfg hypervisor.VMConfig) vmm.VmConfig { // Payload configuration (kernel + initramfs) @@ -51,8 +53,21 @@ func ToVMConfig(cfg hypervisor.VMConfig) vmm.VmConfig { } // Memory configuration - memory := vmm.MemoryConfig{ - Size: cfg.MemoryBytes, + memory := vmm.MemoryConfig{Size: cfg.MemoryBytes} + if cfg.MemoryBackingFile != "" { + zones := []vmm.MemoryZoneConfig{{ + Id: kernelPagingMemoryZoneID, + Size: cfg.MemoryBytes, + File: ptr(cfg.MemoryBackingFile), + Shared: ptr(false), + Prefault: ptr(false), + Hugepages: ptr(false), + }} + memory.Size = 0 + memory.Shared = ptr(false) + memory.Prefault = ptr(false) + memory.Hugepages = ptr(false) + memory.Zones = &zones } if cfg.HotplugBytes > 0 { memory.HotplugSize = &cfg.HotplugBytes diff --git a/lib/hypervisor/cloudhypervisor/config_test.go b/lib/hypervisor/cloudhypervisor/config_test.go index b5cdb96e9..69e11e4ad 100644 --- a/lib/hypervisor/cloudhypervisor/config_test.go +++ b/lib/hypervisor/cloudhypervisor/config_test.go @@ -8,6 +8,32 @@ import ( "github.com/stretchr/testify/require" ) +func TestToVMConfigFileBackedMemory(t *testing.T) { + t.Parallel() + + vmCfg := ToVMConfig(hypervisor.VMConfig{ + VCPUs: 2, + MemoryBytes: 8 * 1024 * 1024 * 1024, + MemoryBackingFile: "/var/lib/hypeman/memory.raw", + }) + + require.NotNil(t, vmCfg.Memory) + assert.Zero(t, vmCfg.Memory.Size) + require.NotNil(t, vmCfg.Memory.Zones) + require.Len(t, *vmCfg.Memory.Zones, 1) + zone := (*vmCfg.Memory.Zones)[0] + assert.Equal(t, kernelPagingMemoryZoneID, zone.Id) + assert.Equal(t, int64(8*1024*1024*1024), zone.Size) + require.NotNil(t, zone.File) + assert.Equal(t, "/var/lib/hypeman/memory.raw", *zone.File) + require.NotNil(t, zone.Shared) + assert.False(t, *zone.Shared) + require.NotNil(t, zone.Prefault) + assert.False(t, *zone.Prefault) + require.NotNil(t, zone.Hugepages) + assert.False(t, *zone.Hugepages) +} + func TestToVMConfig_GuestMemoryBalloon(t *testing.T) { cfg := hypervisor.VMConfig{ VCPUs: 1, diff --git a/lib/hypervisor/cloudhypervisor/kernel_paging.go b/lib/hypervisor/cloudhypervisor/kernel_paging.go new file mode 100644 index 000000000..cb8817a97 --- /dev/null +++ b/lib/hypervisor/cloudhypervisor/kernel_paging.go @@ -0,0 +1,403 @@ +package cloudhypervisor + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + cloudHypervisorConfigFile = "config.json" + cloudHypervisorStateFile = "state.json" + cloudHypervisorMemoryFile = "memory-ranges" + cloudHypervisorMemoryID = "memory-manager" +) + +type snapshotMemoryRange struct { + GPA uint64 `json:"gpa"` + Length uint64 `json:"length"` +} + +type snapshotMemoryRanges struct { + Data []snapshotMemoryRange `json:"data"` +} + +type snapshotGuestRAMMapping struct { + Slot uint32 `json:"slot"` + GPA uint64 `json:"gpa"` + Size uint64 `json:"size"` + ZoneID string `json:"zone_id"` + VirtioMem bool `json:"virtio_mem"` + FileOffset uint64 `json:"file_offset"` +} + +type snapshotRangeKey struct { + gpa uint64 + length uint64 +} + +// prepareSnapshotForKernelPaging changes an ordinary Cloud Hypervisor snapshot +// from eager-copy restore to a private file-backed restore. Cloud Hypervisor +// already supports MAP_PRIVATE memory zones; clearing the saved range table +// makes restore use that mapping directly instead of copying memory-ranges into +// anonymous RAM first. The snapshot memory file must remain immutable while a +// restored VM is running. +// +// Unsupported device/memory layouts are left untouched and use Cloud +// Hypervisor's normal eager restore path. +func prepareSnapshotForKernelPaging(snapshotDir string) (bool, error) { + configPath := filepath.Join(snapshotDir, cloudHypervisorConfigFile) + statePath := filepath.Join(snapshotDir, cloudHypervisorStateFile) + memoryPath := filepath.Join(snapshotDir, cloudHypervisorMemoryFile) + + configData, err := os.ReadFile(configPath) + if err != nil { + return false, fmt.Errorf("read snapshot config: %w", err) + } + if _, eligible, err := kernelPagingConfig(configData, memoryPath, nil); err != nil { + return false, err + } else if !eligible { + return false, nil + } + + stateData, err := os.ReadFile(statePath) + if err != nil { + return false, fmt.Errorf("read snapshot state: %w", err) + } + memoryInfo, err := os.Stat(memoryPath) + if err != nil { + return false, fmt.Errorf("stat snapshot memory: %w", err) + } + + stateFile, memoryState, ranges, mappings, err := decodeSnapshotMemoryState(stateData) + if err != nil { + return false, err + } + if len(ranges) == 0 { + return false, nil + } + + fileOffsets, totalSize, ok := snapshotMappingFileOffsets(ranges, mappings) + if !ok || totalSize != uint64(memoryInfo.Size()) { + return false, nil + } + for i := range mappings { + mappings[i].FileOffset = fileOffsets[i] + } + + updatedConfig, ok, err := kernelPagingConfig(configData, memoryPath, mappings) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + + emptyRanges, err := json.Marshal(snapshotMemoryRanges{Data: make([]snapshotMemoryRange, 0)}) + if err != nil { + return false, fmt.Errorf("marshal empty snapshot memory ranges: %w", err) + } + updatedMappings, err := json.Marshal(mappings) + if err != nil { + return false, fmt.Errorf("marshal snapshot memory mappings: %w", err) + } + memoryState["memory_ranges"] = emptyRanges + memoryState["guest_ram_mappings"] = updatedMappings + updatedState, err := encodeSnapshotMemoryState(stateFile, memoryState) + if err != nil { + return false, err + } + + // Commit config first. If state replacement fails, the old range table + // still drives an eager copy into the private mapping, which remains safe. + // Replacing state last commits the lazy restore atomically. + if err := replaceFile(configPath, updatedConfig); err != nil { + return false, fmt.Errorf("replace snapshot config: %w", err) + } + if err := replaceFile(statePath, updatedState); err != nil { + return false, fmt.Errorf("replace snapshot state: %w", err) + } + return true, nil +} + +func decodeSnapshotMemoryState(stateData []byte) ( + map[string]json.RawMessage, + map[string]json.RawMessage, + []snapshotMemoryRange, + []snapshotGuestRAMMapping, + error, +) { + var stateFile map[string]json.RawMessage + if err := json.Unmarshal(stateData, &stateFile); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode snapshot state: %w", err) + } + + var snapshots map[string]json.RawMessage + if err := json.Unmarshal(stateFile["snapshots"], &snapshots); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode snapshot components: %w", err) + } + memoryComponent, ok := snapshots[cloudHypervisorMemoryID] + if !ok { + return nil, nil, nil, nil, errors.New("snapshot is missing memory-manager state") + } + + var component map[string]json.RawMessage + if err := json.Unmarshal(memoryComponent, &component); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode memory-manager component: %w", err) + } + var snapshotData map[string]json.RawMessage + if err := json.Unmarshal(component["snapshot_data"], &snapshotData); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode memory-manager snapshot data: %w", err) + } + var encodedState string + if err := json.Unmarshal(snapshotData["state"], &encodedState); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode memory-manager state string: %w", err) + } + + var memoryState map[string]json.RawMessage + if err := json.Unmarshal([]byte(encodedState), &memoryState); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode memory-manager state: %w", err) + } + var ranges snapshotMemoryRanges + if err := json.Unmarshal(memoryState["memory_ranges"], &ranges); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode snapshot memory ranges: %w", err) + } + var mappings []snapshotGuestRAMMapping + if err := json.Unmarshal(memoryState["guest_ram_mappings"], &mappings); err != nil { + return nil, nil, nil, nil, fmt.Errorf("decode guest RAM mappings: %w", err) + } + + return stateFile, memoryState, ranges.Data, mappings, nil +} + +func encodeSnapshotMemoryState(stateFile, memoryState map[string]json.RawMessage) ([]byte, error) { + encodedMemoryState, err := json.Marshal(memoryState) + if err != nil { + return nil, fmt.Errorf("marshal memory-manager state: %w", err) + } + + var snapshots map[string]json.RawMessage + if err := json.Unmarshal(stateFile["snapshots"], &snapshots); err != nil { + return nil, fmt.Errorf("decode snapshot components: %w", err) + } + var component map[string]json.RawMessage + if err := json.Unmarshal(snapshots[cloudHypervisorMemoryID], &component); err != nil { + return nil, fmt.Errorf("decode memory-manager component: %w", err) + } + var snapshotData map[string]json.RawMessage + if err := json.Unmarshal(component["snapshot_data"], &snapshotData); err != nil { + return nil, fmt.Errorf("decode memory-manager snapshot data: %w", err) + } + stateString, err := json.Marshal(string(encodedMemoryState)) + if err != nil { + return nil, fmt.Errorf("marshal memory-manager state string: %w", err) + } + snapshotData["state"] = stateString + component["snapshot_data"], err = json.Marshal(snapshotData) + if err != nil { + return nil, fmt.Errorf("marshal memory-manager snapshot data: %w", err) + } + snapshots[cloudHypervisorMemoryID], err = json.Marshal(component) + if err != nil { + return nil, fmt.Errorf("marshal memory-manager component: %w", err) + } + stateFile["snapshots"], err = json.Marshal(snapshots) + if err != nil { + return nil, fmt.Errorf("marshal snapshot components: %w", err) + } + updated, err := json.Marshal(stateFile) + if err != nil { + return nil, fmt.Errorf("marshal snapshot state: %w", err) + } + return updated, nil +} + +func snapshotMappingFileOffsets( + ranges []snapshotMemoryRange, + mappings []snapshotGuestRAMMapping, +) ([]uint64, uint64, bool) { + offsetsByRange := make(map[snapshotRangeKey]uint64, len(ranges)) + var offset uint64 + for _, r := range ranges { + if r.Length == 0 || offset > ^uint64(0)-r.Length { + return nil, 0, false + } + key := snapshotRangeKey{gpa: r.GPA, length: r.Length} + if _, exists := offsetsByRange[key]; exists { + return nil, 0, false + } + offsetsByRange[key] = offset + offset += r.Length + } + if len(mappings) != len(ranges) { + return nil, 0, false + } + + mappingOffsets := make([]uint64, len(mappings)) + seen := make(map[snapshotRangeKey]struct{}, len(mappings)) + for i, mapping := range mappings { + if mapping.VirtioMem || mapping.Size == 0 { + return nil, 0, false + } + key := snapshotRangeKey{gpa: mapping.GPA, length: mapping.Size} + fileOffset, exists := offsetsByRange[key] + if !exists { + return nil, 0, false + } + if _, duplicate := seen[key]; duplicate { + return nil, 0, false + } + seen[key] = struct{}{} + mappingOffsets[i] = fileOffset + } + return mappingOffsets, offset, true +} + +func kernelPagingConfig( + configData []byte, + memoryPath string, + mappings []snapshotGuestRAMMapping, +) ([]byte, bool, error) { + var config map[string]json.RawMessage + if err := json.Unmarshal(configData, &config); err != nil { + return nil, false, fmt.Errorf("decode snapshot config: %w", err) + } + if hasConfiguredDevice(config["balloon"]) || + hasConfiguredDevice(config["devices"]) || + hasConfiguredDevice(config["user_devices"]) || + hasConfiguredDevice(config["vdpa"]) || + hasConfiguredDevice(config["fs"]) || + hasConfiguredDevice(config["ivshmem"]) { + return nil, false, nil + } + if devicesUseVhostUser(config["disks"]) || devicesUseVhostUser(config["net"]) { + return nil, false, nil + } + + var memory map[string]json.RawMessage + if err := json.Unmarshal(config["memory"], &memory); err != nil { + return nil, false, fmt.Errorf("decode snapshot memory config: %w", err) + } + if rawBool(memory["shared"]) || rawBool(memory["hugepages"]) || + hasConfiguredDevice(memory["hotplug_size"]) || hasConfiguredDevice(memory["hotplugged_size"]) { + return nil, false, nil + } + + if !hasConfiguredDevice(memory["zones"]) { + // Switching an existing VM from implicit memory to explicit zones + // changes its platform topology. Only VMs booted with file-backed zones + // can safely restore through this path. + return nil, false, nil + } + var zones []map[string]json.RawMessage + if err := json.Unmarshal(memory["zones"], &zones); err != nil { + return nil, false, fmt.Errorf("decode snapshot memory zones: %w", err) + } + if len(zones) != 1 { + return nil, false, nil + } + + zone := zones[0] + if rawBool(zone["shared"]) || rawBool(zone["hugepages"]) || + hasConfiguredDevice(zone["hotplug_size"]) || hasConfiguredDevice(zone["hotplugged_size"]) { + return nil, false, nil + } + var zoneID string + if err := json.Unmarshal(zone["id"], &zoneID); err != nil || zoneID != kernelPagingMemoryZoneID { + return nil, false, nil + } + for _, mapping := range mappings { + if mapping.ZoneID != zoneID { + return nil, false, nil + } + } + + encodedMemoryPath, err := json.Marshal(memoryPath) + if err != nil { + return nil, false, fmt.Errorf("marshal snapshot memory path: %w", err) + } + zone["file"] = encodedMemoryPath + zone["shared"] = json.RawMessage("false") + zone["prefault"] = json.RawMessage("false") + zonesData, err := json.Marshal(zones) + if err != nil { + return nil, false, fmt.Errorf("marshal snapshot memory zones: %w", err) + } + + memory["size"] = json.RawMessage("0") + memory["shared"] = json.RawMessage("false") + memory["hugepages"] = json.RawMessage("false") + memory["prefault"] = json.RawMessage("false") + memory["hotplug_size"] = json.RawMessage("null") + memory["hotplugged_size"] = json.RawMessage("null") + memory["zones"] = zonesData + updatedMemory, err := json.Marshal(memory) + if err != nil { + return nil, false, fmt.Errorf("marshal snapshot memory config: %w", err) + } + config["memory"] = updatedMemory + updated, err := json.Marshal(config) + if err != nil { + return nil, false, fmt.Errorf("marshal snapshot config: %w", err) + } + return updated, true, nil +} + +func hasConfiguredDevice(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) && !bytes.Equal(trimmed, []byte("[]")) +} + +func rawBool(raw json.RawMessage) bool { + var value bool + return json.Unmarshal(raw, &value) == nil && value +} + +func devicesUseVhostUser(raw json.RawMessage) bool { + var devices []map[string]json.RawMessage + if json.Unmarshal(raw, &devices) != nil { + return true + } + for _, device := range devices { + if rawBool(device["vhost_user"]) { + return true + } + } + return false +} + +func replaceFile(path string, data []byte) (retErr error) { + info, err := os.Stat(path) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if retErr != nil { + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} diff --git a/lib/hypervisor/cloudhypervisor/kernel_paging_test.go b/lib/hypervisor/cloudhypervisor/kernel_paging_test.go new file mode 100644 index 000000000..caf1c0a9c --- /dev/null +++ b/lib/hypervisor/cloudhypervisor/kernel_paging_test.go @@ -0,0 +1,249 @@ +package cloudhypervisor + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrepareSnapshotForKernelPaging(t *testing.T) { + t.Parallel() + + dir := writeKernelPagingSnapshot(t, kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{ + {GPA: 0, Length: 4096}, + {GPA: 1 << 32, Length: 8192}, + }, + Mappings: []snapshotGuestRAMMapping{ + {Slot: 1, GPA: 1 << 32, Size: 8192, ZoneID: kernelPagingMemoryZoneID}, + {Slot: 0, GPA: 0, Size: 4096, ZoneID: kernelPagingMemoryZoneID}, + }, + MemorySize: 0, + Zones: []map[string]any{ + {"id": kernelPagingMemoryZoneID, "size": 12288, "file": "/old/memory.raw"}, + }, + }) + + optimized, err := prepareSnapshotForKernelPaging(dir) + require.NoError(t, err) + require.True(t, optimized) + + configData, err := os.ReadFile(filepath.Join(dir, cloudHypervisorConfigFile)) + require.NoError(t, err) + var config struct { + Memory struct { + Size uint64 `json:"size"` + Zones []struct { + ID string `json:"id"` + Size uint64 `json:"size"` + File string `json:"file"` + Shared bool `json:"shared"` + Prefault bool `json:"prefault"` + } `json:"zones"` + } `json:"memory"` + } + require.NoError(t, json.Unmarshal(configData, &config)) + assert.Zero(t, config.Memory.Size) + require.Len(t, config.Memory.Zones, 1) + assert.Equal(t, kernelPagingMemoryZoneID, config.Memory.Zones[0].ID) + assert.Equal(t, uint64(12288), config.Memory.Zones[0].Size) + assert.Equal(t, filepath.Join(dir, cloudHypervisorMemoryFile), config.Memory.Zones[0].File) + assert.False(t, config.Memory.Zones[0].Shared) + assert.False(t, config.Memory.Zones[0].Prefault) + + stateData, err := os.ReadFile(filepath.Join(dir, cloudHypervisorStateFile)) + require.NoError(t, err) + _, _, ranges, mappings, err := decodeSnapshotMemoryState(stateData) + require.NoError(t, err) + assert.Empty(t, ranges) + require.Len(t, mappings, 2) + assert.Equal(t, uint64(4096), mappings[0].FileOffset) + assert.Zero(t, mappings[1].FileOffset) + assert.Contains(t, string(stateData), "18446744073709551615") + + optimized, err = prepareSnapshotForKernelPaging(dir) + require.NoError(t, err) + assert.False(t, optimized) +} + +func TestPrepareSnapshotForKernelPagingIgnoresUnmarkedMemoryZones(t *testing.T) { + t.Parallel() + + dir := writeKernelPagingSnapshot(t, kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{Slot: 0, GPA: 0, Size: 4096, ZoneID: "custom"}}, + MemorySize: 0, + Zones: []map[string]any{{"id": "custom", "size": 4096}}, + }) + require.NoError(t, os.Remove(filepath.Join(dir, cloudHypervisorMemoryFile))) + + optimized, err := prepareSnapshotForKernelPaging(dir) + require.NoError(t, err) + assert.False(t, optimized) +} + +func TestPrepareSnapshotForKernelPagingLeavesUnsupportedSnapshotsUnchanged(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fixture kernelPagingSnapshotFixture + }{ + { + name: "implicit memory layout", + fixture: kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{GPA: 0, Size: 4096, ZoneID: "mem0"}}, + MemorySize: 4096, + }, + }, + { + name: "balloon", + fixture: kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{GPA: 0, Size: 4096, ZoneID: kernelPagingMemoryZoneID}}, + MemorySize: 0, + Zones: []map[string]any{{"id": kernelPagingMemoryZoneID, "size": 4096}}, + Balloon: map[string]any{"size": 0}, + }, + }, + { + name: "hugepages", + fixture: kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{GPA: 0, Size: 4096, ZoneID: kernelPagingMemoryZoneID}}, + MemorySize: 0, + Zones: []map[string]any{{"id": kernelPagingMemoryZoneID, "size": 4096}}, + Hugepages: true, + }, + }, + { + name: "vhost-user network", + fixture: kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{GPA: 0, Size: 4096, ZoneID: kernelPagingMemoryZoneID}}, + MemorySize: 0, + Zones: []map[string]any{{"id": kernelPagingMemoryZoneID, "size": 4096}}, + Net: []map[string]any{{"vhost_user": true}}, + }, + }, + { + name: "PCI passthrough", + fixture: kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{GPA: 0, Size: 4096, ZoneID: kernelPagingMemoryZoneID}}, + MemorySize: 0, + Zones: []map[string]any{{"id": kernelPagingMemoryZoneID, "size": 4096}}, + Devices: []map[string]any{{"path": "/sys/bus/pci/devices/0000:00:00.0"}}, + }, + }, + { + name: "partial virtio-mem range", + fixture: kernelPagingSnapshotFixture{ + Ranges: []snapshotMemoryRange{{GPA: 0, Length: 4096}}, + Mappings: []snapshotGuestRAMMapping{{GPA: 0, Size: 8192, ZoneID: kernelPagingMemoryZoneID, VirtioMem: true}}, + MemorySize: 0, + Zones: []map[string]any{{"id": kernelPagingMemoryZoneID, "size": 8192}}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := writeKernelPagingSnapshot(t, tc.fixture) + configPath := filepath.Join(dir, cloudHypervisorConfigFile) + statePath := filepath.Join(dir, cloudHypervisorStateFile) + configBefore, err := os.ReadFile(configPath) + require.NoError(t, err) + stateBefore, err := os.ReadFile(statePath) + require.NoError(t, err) + + optimized, err := prepareSnapshotForKernelPaging(dir) + require.NoError(t, err) + assert.False(t, optimized) + configAfter, err := os.ReadFile(configPath) + require.NoError(t, err) + stateAfter, err := os.ReadFile(statePath) + require.NoError(t, err) + assert.Equal(t, configBefore, configAfter) + assert.Equal(t, stateBefore, stateAfter) + }) + } +} + +type kernelPagingSnapshotFixture struct { + Ranges []snapshotMemoryRange + Mappings []snapshotGuestRAMMapping + MemorySize uint64 + Zones []map[string]any + Balloon any + Hugepages bool + Net any + Devices any +} + +func writeKernelPagingSnapshot(t *testing.T, fixture kernelPagingSnapshotFixture) string { + t.Helper() + + dir := t.TempDir() + memorySize := int64(0) + for _, r := range fixture.Ranges { + memorySize += int64(r.Length) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, cloudHypervisorMemoryFile), nil, 0600)) + require.NoError(t, os.Truncate(filepath.Join(dir, cloudHypervisorMemoryFile), memorySize)) + + memory := map[string]any{ + "size": fixture.MemorySize, + "mergeable": false, + "hotplug_method": "Acpi", + "hotplug_size": nil, + "hotplugged_size": nil, + "shared": false, + "hugepages": fixture.Hugepages, + "hugepage_size": nil, + "prefault": false, + "zones": fixture.Zones, + "thp": true, + } + config := map[string]any{ + "memory": memory, + "balloon": fixture.Balloon, + "devices": fixture.Devices, + "user_devices": nil, + "vdpa": nil, + "fs": nil, + "disks": []any{}, + "net": fixture.Net, + } + configData, err := json.Marshal(config) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, cloudHypervisorConfigFile), configData, 0600)) + + memoryState := map[string]any{ + "memory_ranges": snapshotMemoryRanges{Data: fixture.Ranges}, + "guest_ram_mappings": fixture.Mappings, + "arch_mem_regions": []any{ + map[string]any{"base": uint64(0), "size": ^uint64(0), "r_type": "Ram"}, + }, + } + memoryStateData, err := json.Marshal(memoryState) + require.NoError(t, err) + state := map[string]any{ + "snapshots": map[string]any{ + cloudHypervisorMemoryID: map[string]any{ + "snapshots": map[string]any{}, + "snapshot_data": map[string]any{"state": string(memoryStateData)}, + }, + }, + } + stateData, err := json.Marshal(state) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, cloudHypervisorStateFile), stateData, 0600)) + return dir +} diff --git a/lib/hypervisor/cloudhypervisor/process.go b/lib/hypervisor/cloudhypervisor/process.go index 3caa36843..674014885 100644 --- a/lib/hypervisor/cloudhypervisor/process.go +++ b/lib/hypervisor/cloudhypervisor/process.go @@ -73,7 +73,24 @@ func NewStarter() *Starter { // Verify Starter implements the interface var _ hypervisor.VMStarter = (*Starter)(nil) -func (s *Starter) ValidateConfig(hypervisor.VMConfig) error { return nil } +func (s *Starter) ValidateConfig(config hypervisor.VMConfig) error { + if config.MemoryBackingFile == "" { + return nil + } + if config.MemoryBytes <= 0 { + return fmt.Errorf("file-backed memory requires a positive memory size") + } + if config.HotplugBytes > 0 { + return fmt.Errorf("file-backed memory does not support hotplug memory") + } + if config.GuestMemory.EnableBalloon { + return fmt.Errorf("file-backed memory does not support ballooning") + } + if len(config.PCIDevices) > 0 { + return fmt.Errorf("file-backed memory does not support PCI passthrough") + } + return nil +} // SocketName returns the socket filename for Cloud Hypervisor. func (s *Starter) SocketName() string { @@ -111,11 +128,19 @@ func (s *Starter) ResolveVersion(p *paths.Paths, requested string) (string, erro func (s *Starter) StartVM(ctx context.Context, p *paths.Paths, version string, socketPath string, config hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { log := logger.FromContext(ctx) - // Validate version chVersion := vmm.CHVersion(version) if !vmm.IsVersionSupported(chVersion) { return 0, nil, fmt.Errorf("unsupported cloud-hypervisor version: %s", version) } + if config.MemoryBackingFile != "" && chVersion != vmm.V51_1 { + return 0, nil, fmt.Errorf("file-backed memory requires cloud-hypervisor %s", vmm.V51_1) + } + if err := s.ValidateConfig(config); err != nil { + return 0, nil, err + } + if err := prepareMemoryBackingFile(config.MemoryBackingFile, config.MemoryBytes); err != nil { + return 0, nil, fmt.Errorf("prepare memory backing file: %w", err) + } // 0. Start the serial reader before CH so the unix socket is bound by // the time CH boots and tries to connect. @@ -254,6 +279,37 @@ func (s *Starter) RestoreVM(ctx context.Context, p *paths.Paths, version string, return pid, hv, nil } +func prepareMemoryBackingFile(path string, size int64) (retErr error) { + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".memory.*.tmp") + if err != nil { + return err + } + tmpPath := file.Name() + defer func() { + if retErr != nil { + _ = os.Remove(tmpPath) + } + }() + if err := file.Chmod(0600); err != nil { + _ = file.Close() + return err + } + if err := file.Truncate(size); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + func ptr[T any](v T) *T { return &v } diff --git a/lib/hypervisor/config.go b/lib/hypervisor/config.go index 2562868da..72a87e065 100644 --- a/lib/hypervisor/config.go +++ b/lib/hypervisor/config.go @@ -7,8 +7,11 @@ type VMConfig struct { VCPUs int MemoryBytes int64 HotplugBytes int64 - Topology *CPUTopology - GuestMemory GuestMemoryConfig + // MemoryBackingFile enables private file-backed guest RAM for hypervisors + // that can restore snapshots through the kernel page cache. + MemoryBackingFile string + Topology *CPUTopology + GuestMemory GuestMemoryConfig // Storage Disks []DiskConfig diff --git a/lib/instances/create.go b/lib/instances/create.go index 014892421..3a5418278 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -19,6 +19,7 @@ import ( "github.com/kernel/hypeman/lib/network" "github.com/kernel/hypeman/lib/system" "github.com/kernel/hypeman/lib/tags" + "github.com/kernel/hypeman/lib/vmm" "github.com/kernel/hypeman/lib/volumes" "github.com/nrednav/cuid2" "go.opentelemetry.io/otel/attribute" @@ -30,7 +31,8 @@ const ( // to a single instance. This limit exists because volume devices are named // /dev/vdd, /dev/vde, ... /dev/vdz (letters d-z = 23 devices). // Devices a-c are reserved for rootfs, overlay, and config disk. - MaxVolumesPerInstance = 23 + MaxVolumesPerInstance = 23 + cloudHypervisorMemoryBackingFilename = "memory.raw" ) // systemDirectories are paths that cannot be used as volume mount points @@ -922,22 +924,31 @@ func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, ima } } + guestMemory := m.guestMemoryConfig() + memoryBackingFile := "" + if inst.HypervisorType == hypervisor.TypeCloudHypervisor && + inst.HypervisorVersion == string(vmm.V51_1) && + inst.HotplugSize == 0 && !guestMemory.EnableBalloon && len(pciDevices) == 0 { + memoryBackingFile = filepath.Join(inst.DataDir, cloudHypervisorMemoryBackingFilename) + } + return hypervisor.VMConfig{ - VCPUs: inst.Vcpus, - MemoryBytes: inst.Size, - HotplugBytes: inst.HotplugSize, - Topology: topology, - GuestMemory: m.guestMemoryConfig(), - Disks: disks, - Networks: networks, - SerialLogPath: m.paths.InstanceAppLog(inst.Id), - VsockCID: inst.VsockCID, - VsockSocket: inst.VsockSocket, - PCIDevices: pciDevices, - KernelPath: kernelPath, - InitrdPath: initrdPath, - KernelArgs: m.kernelArgs(inst.HypervisorType), - EnableRosetta: inst.EnableRosetta, + VCPUs: inst.Vcpus, + MemoryBytes: inst.Size, + HotplugBytes: inst.HotplugSize, + MemoryBackingFile: memoryBackingFile, + Topology: topology, + GuestMemory: guestMemory, + Disks: disks, + Networks: networks, + SerialLogPath: m.paths.InstanceAppLog(inst.Id), + VsockCID: inst.VsockCID, + VsockSocket: inst.VsockSocket, + PCIDevices: pciDevices, + KernelPath: kernelPath, + InitrdPath: initrdPath, + KernelArgs: m.kernelArgs(inst.HypervisorType), + EnableRosetta: inst.EnableRosetta, }, nil } diff --git a/lib/instances/fork.go b/lib/instances/fork.go index ea6d3a4c5..1f92e95bf 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -257,7 +257,7 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin srcDir := m.paths.InstanceDir(id) dstDir := m.paths.InstanceDir(forkID) - shareMemFile := stored.HypervisorType == hypervisor.TypeFirecracker && source.State == StateStandby + shareMemFile := source.State == StateStandby && supportsSharedSnapshotMemory(stored.HypervisorType) cu := cleanup.Make(func() { _ = os.RemoveAll(dstDir) diff --git a/lib/instances/metrics.go b/lib/instances/metrics.go index 494cc2402..5a6a88984 100644 --- a/lib/instances/metrics.go +++ b/lib/instances/metrics.go @@ -255,7 +255,7 @@ func newInstanceMetrics(meter metric.Meter, tracer trace.Tracer, m *manager) (*M forkMemFileShareFallbacksTotal, err := meter.Int64Counter( "hypeman_fork_memfile_share_fallbacks_total", - metric.WithDescription("Total number of fork mem-file hardlink failures that fell back to a full copy"), + metric.WithDescription("Total number of fork snapshot memory hardlink failures that fell back to copying"), ) if err != nil { return nil, err @@ -653,12 +653,13 @@ func (m *manager) recordSnapshotCodecFallback(ctx context.Context, algorithm sna )) } -func (m *manager) recordForkMemFileShareFallback(ctx context.Context, reason string) { +func (m *manager) recordForkMemFileShareFallback(ctx context.Context, hvType hypervisor.Type, reason string) { if m.metrics == nil { return } m.metrics.forkMemFileShareFallbacksTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("hypervisor", string(hvType)), attribute.String("reason", reason), )) } diff --git a/lib/instances/snapshot.go b/lib/instances/snapshot.go index 71bc562d5..2ae540cc3 100644 --- a/lib/instances/snapshot.go +++ b/lib/instances/snapshot.go @@ -412,7 +412,7 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS if target != nil && target.State == compressionJobStateRunning { m.recordSnapshotCompressionPreemption(ctx, snapshotCompressionPreemptionForkSnapshot, target.Target) } - shareMemFile := rec.StoredMetadata.HypervisorType == hypervisor.TypeFirecracker && rec.Snapshot.Kind == SnapshotKindStandby + shareMemFile := rec.Snapshot.Kind == SnapshotKindStandby && supportsSharedSnapshotMemory(rec.StoredMetadata.HypervisorType) if err := m.copySnapshotGuestDirectoryForFork(ctx, snapshotID, rec.StoredMetadata.HypervisorType, dstDir, shareMemFile); err != nil { return nil, err } diff --git a/lib/instances/snapshot_alias_lock.go b/lib/instances/snapshot_alias_lock.go index d994bf331..714b73410 100644 --- a/lib/instances/snapshot_alias_lock.go +++ b/lib/instances/snapshot_alias_lock.go @@ -73,7 +73,7 @@ func (m *manager) copyForkSourceGuestDirectory(ctx context.Context, sourceState attribute.Bool("share_mem_file", shareMemFile), ) retErr = withSnapshotSourceAliasReadLock(func() error { - if err := m.cloneGuestDirectoryForFork(ctx, srcDir, dstDir, shareMemFile); err != nil { + if err := m.cloneGuestDirectoryForFork(ctx, stored.HypervisorType, srcDir, dstDir, shareMemFile); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } @@ -115,7 +115,7 @@ func (m *manager) copySnapshotGuestDirectoryForFork(ctx context.Context, snapsho attribute.Bool("share_mem_file", shareMemFile), ) retErr = withSnapshotSourceAliasReadLock(func() error { - if err := m.cloneGuestDirectoryForFork(ctx, m.paths.SnapshotGuestDir(snapshotID), dstDir, shareMemFile); err != nil { + if err := m.cloneGuestDirectoryForFork(ctx, hvType, m.paths.SnapshotGuestDir(snapshotID), dstDir, shareMemFile); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return fmt.Errorf("fork from snapshot requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } @@ -128,11 +128,14 @@ func (m *manager) copySnapshotGuestDirectoryForFork(ctx context.Context, snapsho } // cloneGuestDirectoryForFork copies a guest directory for a fork. When -// shareMemFile is set and the source has a raw snapshot mem-file, the mem-file -// is skipped from the copy walk and hardlinked into place instead. -func (m *manager) cloneGuestDirectoryForFork(ctx context.Context, srcDir, dstDir string, shareMemFile bool) error { +// shareMemFile is set and the source has raw snapshot memory, that file is +// skipped from the copy walk and hardlinked into place instead. +func (m *manager) cloneGuestDirectoryForFork(ctx context.Context, hvType hypervisor.Type, srcDir, dstDir string, shareMemFile bool) error { + memoryRelPath, supportsSharing := sharedSnapshotMemoryRelPath(hvType) + shareMemFile = shareMemFile && supportsSharing if shareMemFile { - if _, err := os.Stat(firecrackerSnapshotMemoryPathInGuestDir(srcDir)); err != nil { + memoryPath, _ := sharedSnapshotMemoryPathInGuestDir(srcDir, hvType) + if _, err := os.Stat(memoryPath); err != nil { if !os.IsNotExist(err) { return fmt.Errorf("stat source snapshot memory: %w", err) } @@ -142,13 +145,13 @@ func (m *manager) cloneGuestDirectoryForFork(ctx context.Context, srcDir, dstDir copyOptions := forkvm.CopyOptions{} if shareMemFile { - copyOptions.SkipRelativePaths = map[string]struct{}{firecrackerSnapshotMemoryRelPath: {}} + copyOptions.SkipRelativePaths = map[string]struct{}{memoryRelPath: {}} } if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOptions); err != nil { return err } if shareMemFile { - return m.linkForkFirecrackerMemFile(ctx, srcDir, dstDir) + return m.linkForkSnapshotMemory(ctx, hvType, srcDir, dstDir) } return nil } diff --git a/lib/instances/firecracker_memfile.go b/lib/instances/snapshot_memfile.go similarity index 59% rename from lib/instances/firecracker_memfile.go rename to lib/instances/snapshot_memfile.go index a02a2b9bb..ab391645d 100644 --- a/lib/instances/firecracker_memfile.go +++ b/lib/instances/snapshot_memfile.go @@ -10,19 +10,46 @@ import ( "time" "github.com/kernel/hypeman/lib/forkvm" + "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/logger" ) -// linkForkFirecrackerMemFile hardlinks the source's snapshot mem-file into the -// fork's guest dir so all forks of a snapshot share one inode: fanout costs no -// copy I/O, and the pager's backing reads hit the kernel page cache warmed by -// sibling forks. Falls back to a reflink/sparse copy when linking fails. -// Sharing an inode is safe because Firecracker mmaps the mem-file MAP_PRIVATE -// and the only file writer, the standby diff snapshot, unshares first via -// ensureExclusiveSnapshotMemoryOwnership. -func (m *manager) linkForkFirecrackerMemFile(ctx context.Context, srcGuestDir, dstGuestDir string) error { - srcMem := firecrackerSnapshotMemoryPathInGuestDir(srcGuestDir) - dstMem := firecrackerSnapshotMemoryPathInGuestDir(dstGuestDir) +const cloudHypervisorSnapshotMemoryRelPath = "snapshots/snapshot-latest/memory-ranges" + +func sharedSnapshotMemoryRelPath(hvType hypervisor.Type) (string, bool) { + switch hvType { + case hypervisor.TypeFirecracker: + return firecrackerSnapshotMemoryRelPath, true + case hypervisor.TypeCloudHypervisor: + return cloudHypervisorSnapshotMemoryRelPath, true + default: + return "", false + } +} + +func supportsSharedSnapshotMemory(hvType hypervisor.Type) bool { + _, ok := sharedSnapshotMemoryRelPath(hvType) + return ok +} + +func sharedSnapshotMemoryPathInGuestDir(guestDir string, hvType hypervisor.Type) (string, bool) { + relPath, ok := sharedSnapshotMemoryRelPath(hvType) + if !ok { + return "", false + } + return filepath.Join(guestDir, relPath), true +} + +// linkForkSnapshotMemory hardlinks the source snapshot memory into the fork so +// sibling restores map the same inode and share the kernel page cache. +// Firecracker and kernel-paged Cloud Hypervisor restores map the file privately; +// ordinary Cloud Hypervisor restores only read it into guest RAM. +func (m *manager) linkForkSnapshotMemory(ctx context.Context, hvType hypervisor.Type, srcGuestDir, dstGuestDir string) error { + srcMem, ok := sharedSnapshotMemoryPathInGuestDir(srcGuestDir, hvType) + if !ok { + return fmt.Errorf("snapshot memory sharing is not supported for hypervisor %s", hvType) + } + dstMem, _ := sharedSnapshotMemoryPathInGuestDir(dstGuestDir, hvType) if err := os.MkdirAll(filepath.Dir(dstMem), 0755); err != nil { return fmt.Errorf("create fork snapshot dir: %w", err) } @@ -30,12 +57,9 @@ func (m *manager) linkForkFirecrackerMemFile(ctx context.Context, srcGuestDir, d if err == nil { return nil } - // A fallback copy loses the zero-copy fanout and the shared page cache - // across sibling forks; it should never happen on a correctly provisioned - // host, so make it visible. logger.FromContext(ctx).WarnContext(ctx, "hardlink of fork snapshot memory failed; falling back to copy", - "source", srcMem, "target", dstMem, "error", err) - m.recordForkMemFileShareFallback(ctx, linkFallbackReason(err)) + "hypervisor", hvType, "source", srcMem, "target", dstMem, "error", err) + m.recordForkMemFileShareFallback(ctx, hvType, linkFallbackReason(err)) if err := forkvm.CopyRegularFile(srcMem, dstMem); err != nil { return fmt.Errorf("copy fork snapshot memory: %w", err) } diff --git a/lib/instances/snapshot_memfile_test.go b/lib/instances/snapshot_memfile_test.go new file mode 100644 index 000000000..81703a222 --- /dev/null +++ b/lib/instances/snapshot_memfile_test.go @@ -0,0 +1,50 @@ +package instances + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloneCloudHypervisorSnapshotSharesMemoryInode(t *testing.T) { + t.Parallel() + + srcDir := filepath.Join(t.TempDir(), "source") + dstDir := filepath.Join(t.TempDir(), "fork") + srcMemory, ok := sharedSnapshotMemoryPathInGuestDir(srcDir, hypervisor.TypeCloudHypervisor) + require.True(t, ok) + require.NoError(t, os.MkdirAll(filepath.Dir(srcMemory), 0755)) + require.NoError(t, os.WriteFile(srcMemory, []byte("snapshot memory"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "metadata.json"), []byte("{}"), 0600)) + + mgr := &manager{} + require.NoError(t, mgr.cloneGuestDirectoryForFork( + context.Background(), hypervisor.TypeCloudHypervisor, srcDir, dstDir, true, + )) + + dstMemory, ok := sharedSnapshotMemoryPathInGuestDir(dstDir, hypervisor.TypeCloudHypervisor) + require.True(t, ok) + assertSameSnapshotMemoryInode(t, srcMemory, dstMemory) + assert.FileExists(t, filepath.Join(dstDir, "metadata.json")) +} + +func assertSameSnapshotMemoryInode(t *testing.T, first, second string) { + t.Helper() + firstInfo, err := os.Stat(first) + require.NoError(t, err) + secondInfo, err := os.Stat(second) + require.NoError(t, err) + firstStat, ok := firstInfo.Sys().(*syscall.Stat_t) + require.True(t, ok) + secondStat, ok := secondInfo.Sys().(*syscall.Stat_t) + require.True(t, ok) + assert.Equal(t, firstStat.Dev, secondStat.Dev) + assert.Equal(t, firstStat.Ino, secondStat.Ino) + assert.GreaterOrEqual(t, uint64(firstStat.Nlink), uint64(2)) +}