Skip to content

Commit 3d53de8

Browse files
authored
microvm MVP cleanup: align rootfs behavior to gvisor, ... (agent-substrate#313)
Follow-up to agent-substrate#287 / agent-substrate#123 - align snapshot behavior: use a tmpfs (for writes) on top of read-only viritio-fs mount for the container image rootfs instead of ext4 images - enable multiple container support - cleanup stale comments > It's a good idea to open an issue first for discussion. - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR
1 parent 125180e commit 3d53de8

26 files changed

Lines changed: 743 additions & 1175 deletions

.ko.yaml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@ defaultPlatforms:
2121
baseImageOverrides:
2222
github.com/agent-substrate/substrate/demos/sandbox: alpine
2323
github.com/agent-substrate/substrate/demos/agent-secret: alpine
24-
# ateom-microvm owns the cloud-hypervisor boot and builds the actor's writable
25-
# virtio-blk rootfs at runtime, which needs mkfs.ext4 (e2fsprogs) plus glibc for
26-
# the fetched cloud-hypervisor binary. The committed debian:stable-slim base has
27-
# glibc + coreutils but NOT mkfs.ext4, so this default cannot build the rootfs on
28-
# its own. hack/run-microvm-demo.sh builds hack/ateom-base (debian-slim +
29-
# e2fsprogs) and overrides this base at build time via KO_CONFIG_PATH, so running
30-
# the demo never edits this file. The committed default stays debian:stable-slim.
24+
# ateom-microvm needs glibc (for the fetched cloud-hypervisor binary) and mount/umount
25+
# (to bind the image into the virtiofsd shared dir) — both in debian:stable-slim but
26+
# not in the distroless static default.
3127
github.com/agent-substrate/substrate/cmd/ateom-microvm: debian:stable-slim

cmd/ateom-microvm/checkpoint.go

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ import (
3232

3333
// CheckpointWorkload suspends the actor and writes a portable CH snapshot.
3434
//
35-
// Contract with atelet (mirrors ateom-gvisor): after we return, atelet uploads
36-
// the checkpoint dir to object storage, then tears down bundles and resets the
37-
// actor dir.
35+
// Contract with atelet: after we return, atelet uploads the checkpoint dir to object
36+
// storage, then tears down bundles and resets the actor dir.
3837
//
39-
// ateom drives the ateom-owned CH's REST api-socket: pause -> snapshot
40-
// file://<CheckpointStateDir> (config.json + state.json + sparse memory-ranges) ->
41-
// tear the VMM down. The actor's rootfs lives on the host-backed /dev/vdb, not a
42-
// guest tmpfs overlay-upper, so the snapshot is naturally memory-only and small —
43-
// no RAM-backed upper to wipe and no balloon to inflate before snapshot.
38+
// ateom drives the CH REST api-socket: pause -> snapshot file://<CheckpointStateDir>
39+
// (config.json + state.json + sparse memory-ranges) -> tear the VMM down. Each
40+
// container's rootfs is overlay(virtio-fs RO lower + guest-tmpfs upper), so the
41+
// writable upper lives in guest RAM and is captured by the memory snapshot — process
42+
// memory and rootfs writes both persist across suspend/resume. The RO lower is
43+
// reconstructed from the OCI image at restore, so nothing rootfs-related ships here.
4444
func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.CheckpointWorkloadRequest) (*ateompb.CheckpointWorkloadResponse, error) {
4545
s.lock.Lock()
4646
defer s.lock.Unlock()
@@ -79,9 +79,9 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec
7979
}
8080

8181
// Record the FROZEN base id (the id the guest's virtio-fs find-paths are pinned
82-
// to, <baseID>/rootfs). For a cold (owned-boot) actor this is its own id; for a
83-
// restored actor it is the golden id propagated via ra.baseID (set from the
84-
// snapshot we restored from). RestoreWorkload reads this to lay the
82+
// to, <baseID>/rootfs). For a cold-run actor this is its own id; for a restored
83+
// actor it is the golden id propagated via ra.baseID (set from the snapshot we
84+
// restored from). RestoreWorkload reads this to lay the
8585
// reconstructed-from-image base at the path the guest expects. We can NOT derive
8686
// it from config.json (its socket paths get rewritten to the current id on every
8787
// restore, losing the invariant golden id).
@@ -120,25 +120,13 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec
120120
slog.String("id", id), slog.Duration("merge", time.Since(tMerge)))
121121
}
122122

123-
// reset-to-golden support: save the actor's /dev/vdb AS-OF this (paused,
124-
// consistent) snapshot as a verbatim golden template, so future restores can
125-
// recreate the disk byte-identical to what the snapshot's guest RAM expects
126-
// while discarding the actor's later rootfs writes. Saved once (the first/golden
127-
// checkpoint) and kept; best-effort (without it, restore reopens the live disk =
128-
// continuity). TODO: ship the template with the snapshot for cross-node restore
129-
// (it's golden, shipped once per template, like the OCI base).
130-
actorDir := ateompath.ActorPath(ns, name, id)
131-
if tmpl := filepath.Join(actorDir, goldenRootfsDiskName); fileMissing(tmpl) {
132-
if cerr := copyDiskFile(ctx, filepath.Join(actorDir, actorRootfsDiskName), tmpl); cerr != nil {
133-
slog.WarnContext(ctx, "Failed to save golden rootfs template; restore will reopen live disk", slog.Any("err", cerr))
134-
} else {
135-
slog.InfoContext(ctx, "Saved golden rootfs disk template", slog.String("id", id))
136-
}
137-
}
123+
// Nothing rootfs-related ships: the overlay's writable upper is a guest tmpfs, so
124+
// the actor's rootfs writes are already in the memory snapshot above, and the RO
125+
// lower is reconstructed from the OCI image at restore (it never changes).
138126

139127
// Report exactly the files we wrote so atelet ships precisely the CH snapshot
140-
// (config.json + state.json + memory-ranges + base-id), not gVisor's fixed set.
141-
// Memory-only: the RO base is reconstructed from the OCI image at restore.
128+
// (config.json + state.json + memory-ranges + base-id). The RO base is
129+
// reconstructed from the OCI image at restore.
142130
snapshotFiles, err := listFiles(checkpointDir)
143131
if err != nil {
144132
return nil, fmt.Errorf("while listing snapshot files: %w", err)
@@ -151,7 +139,7 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec
151139
dTeardown := time.Since(tTeardown)
152140
delete(s.running, id)
153141

154-
// Tear down the per-activation actor network (mirrors gVisor).
142+
// Tear down the per-activation actor network.
155143
if err := s.cleanupActorNetwork(ctx); err != nil {
156144
slog.WarnContext(ctx, "Failed to clean up actor network after checkpoint", slog.Any("err", err))
157145
}
@@ -207,6 +195,11 @@ func (s *AteomService) teardownActor(ctx context.Context, id string, ra *running
207195
_ = ra.chCmd.Process.Kill()
208196
_, _ = ra.chCmd.Process.Wait()
209197
}
198+
// Kill the virtiofsd serving the overlay RO lower (after CH, its only client).
199+
if ra.vfsdCmd != nil && ra.vfsdCmd.Process != nil {
200+
_ = ra.vfsdCmd.Process.Kill()
201+
_, _ = ra.vfsdCmd.Process.Wait()
202+
}
210203
}
211204

212205
// Sweep any leftover per-sandbox host-side state + orphaned per-sandbox

cmd/ateom-microvm/internal/ch/createvm.go

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,35 @@ import (
2020
)
2121

2222
// VmConfig is the body of /api/v1/vm.create — the subset of cloud-hypervisor's
23-
// VmConfig ateom sets to boot a kata guest itself (the "ateom owns the boot"
24-
// path, replacing the kata shim). Modeled on kata's clh driver
25-
// (src/runtime/virtcontainers/clh.go) and the proven suspend-bench vmConfig.
26-
// vm.create + vm.boot use PUT (empirically accepted by CH, like the bench).
23+
// VmConfig ateom sets to boot the kata guest. Modeled on kata's clh driver
24+
// (src/runtime/virtcontainers/clh.go). vm.create + vm.boot are issued with PUT.
2725
type VmConfig struct {
28-
Cpus CpusConfig `json:"cpus"`
29-
Memory MemoryConfig `json:"memory"`
30-
Payload PayloadConfig `json:"payload"`
31-
Disks []DiskConfig `json:"disks,omitempty"`
32-
Rng *RngConfig `json:"rng,omitempty"`
33-
Serial *ConsoleConfig `json:"serial,omitempty"`
34-
Console *ConsoleConfig `json:"console,omitempty"`
35-
Vsock *VsockConfig `json:"vsock,omitempty"`
26+
Cpus CpusConfig `json:"cpus"`
27+
Memory MemoryConfig `json:"memory"`
28+
Payload PayloadConfig `json:"payload"`
29+
Disks []DiskConfig `json:"disks,omitempty"`
30+
Fs []FsConfig `json:"fs,omitempty"`
31+
Rng *RngConfig `json:"rng,omitempty"`
32+
Serial *ConsoleConfig `json:"serial,omitempty"`
33+
Console *ConsoleConfig `json:"console,omitempty"`
34+
Vsock *VsockConfig `json:"vsock,omitempty"`
35+
Platform *PlatformConfig `json:"platform,omitempty"`
36+
}
37+
38+
// FsConfig is a virtio-fs device backed by a vhost-user (virtiofsd) socket. The
39+
// overlay rootfs path uses it as the RO lower; the guest mounts it via the FsTag.
40+
type FsConfig struct {
41+
Tag string `json:"tag"`
42+
Socket string `json:"socket"`
43+
NumQueues int32 `json:"num_queues,omitempty"`
44+
QueueSize int32 `json:"queue_size,omitempty"`
45+
PciSegment int32 `json:"pci_segment,omitempty"`
46+
}
47+
48+
// PlatformConfig sets VM-wide platform options. NumPciSegments must be >1 when a
49+
// virtio-fs device sits on a non-zero PCI segment (kata puts fs on segment 1).
50+
type PlatformConfig struct {
51+
NumPciSegments int32 `json:"num_pci_segments,omitempty"`
3652
}
3753

3854
// CpusConfig sets the boot/max vCPU counts.
@@ -56,10 +72,9 @@ type PayloadConfig struct {
5672
Cmdline string `json:"cmdline"`
5773
}
5874

59-
// DiskConfig is one virtio-blk disk. The kata guest image is disk 0 (/dev/vda,
60-
// readonly); ateom appends the actor rootfs as disk 1 (/dev/vdb, writable). The
61-
// guest sees disks in config order. NumQueues/QueueSize mirror kata's clh
62-
// (num_queues = vcpus, queue_size = 1024).
75+
// DiskConfig is one virtio-blk disk. The only disk is the kata guest image
76+
// (/dev/vda, read-only); the actor rootfs is an overlay served over virtio-fs, not a
77+
// disk. NumQueues/QueueSize mirror kata's clh (num_queues = vcpus, queue_size = 1024).
6378
type DiskConfig struct {
6479
Path string `json:"path"`
6580
Readonly bool `json:"readonly"`

cmd/ateom-microvm/internal/kata/agentclient.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@ func (a *AgentClient) StartContainer(ctx context.Context, containerID string) er
158158

159159
// CreateSandbox establishes the agent's sandbox context (sandbox id, hostname,
160160
// sandbox pidns) before any container is created. The kata shim normally issues
161-
// this once at VM boot; on the ateom-owned-boot path (no shim) ateom must call it
162-
// itself so the agent has a sandbox to attach containers to. Storages is empty —
163-
// the actor rootfs arrives as a per-container "blk" storage, not a sandbox mount.
161+
// this once at VM boot; ateom (no shim) must call it itself so the agent has a
162+
// sandbox to attach containers to. Storages carries the shared virtio-fs mount
163+
// (the overlay lowers); each container's rootfs is assembled per-container.
164164
// Mirrors grpc.AgentService/CreateSandbox (returns google.protobuf.Empty).
165165
func (a *AgentClient) CreateSandbox(ctx context.Context, req *agentpb.CreateSandboxRequest) error {
166166
if err := a.client.Call(ctx, "grpc.AgentService", "CreateSandbox", req, &emptypb.Empty{}); err != nil {
@@ -169,10 +169,10 @@ func (a *AgentClient) CreateSandbox(ctx context.Context, req *agentpb.CreateSand
169169
return nil
170170
}
171171

172-
// UpdateInterface configures a guest network interface (the kata shim's job; on
173-
// the owned-boot path ateom does it). The agent matches the link by HwAddr, then
174-
// applies the name/IP/MTU. Mirrors grpc.AgentService/UpdateInterface (returns the
175-
// resulting Interface).
172+
// UpdateInterface configures a guest network interface (the kata shim's job, which
173+
// ateom does itself). The agent matches the link by HwAddr, then applies the
174+
// name/IP/MTU. Mirrors grpc.AgentService/UpdateInterface (returns the resulting
175+
// Interface).
176176
func (a *AgentClient) UpdateInterface(ctx context.Context, iface *agentpb.Interface) error {
177177
req := &agentpb.UpdateInterfaceRequest{Interface: iface}
178178
if err := a.client.Call(ctx, "grpc.AgentService", "UpdateInterface", req, &agentpb.Interface{}); err != nil {
@@ -208,7 +208,7 @@ func (a *AgentClient) AddARPNeighbors(ctx context.Context, neighbors []*agentpb.
208208
// buffered (up to max), so callers loop until it returns an error — the agent
209209
// returns an error/EOF-like status once the stream ends (container exit / connection
210210
// close). Mirrors grpc.AgentService/ReadStdout. The kata-agent keys the stream by
211-
// ExecId, which the owned-boot path sets equal to ContainerId (see StartBlkWorkload).
211+
// ExecId, which ateom sets equal to ContainerId.
212212
func (a *AgentClient) ReadStdout(ctx context.Context, containerID, execID string, max uint32) ([]byte, error) {
213213
resp := &agentpb.ReadStreamResponse{}
214214
req := &agentpb.ReadStreamRequest{ContainerId: containerID, ExecId: execID, Len: max}
@@ -246,8 +246,8 @@ type StreamReader struct {
246246
}
247247

248248
// NewStdioReader returns an io.Reader over the container's stdout (stderr=false)
249-
// or stderr (stderr=true). execID matches the value passed to StartBlkWorkload
250-
// (equal to containerID on the owned-boot path).
249+
// or stderr (stderr=true). execID equals containerID (ateom sets ExecId ==
250+
// ContainerId when it creates the container).
251251
func NewStdioReader(ctx context.Context, ac *AgentClient, containerID, execID string, stderr bool) *StreamReader {
252252
return &StreamReader{ctx: ctx, ac: ac, containerID: containerID, execID: execID, stderr: stderr}
253253
}

cmd/ateom-microvm/internal/kata/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ type KataConfig struct {
3232
VCPUs int
3333
// KernelParams is the guest kernel command line ([hypervisor.clh]
3434
// kernel_params): the kata-agent parameters (agent.log, the systemd target,
35-
// etc.). The owned boot appends these to the cloud-hypervisor payload cmdline,
36-
// since there is no kata shim to inject them.
35+
// etc.). ateom appends these to the cloud-hypervisor payload cmdline, since
36+
// there is no kata shim to inject them.
3737
KernelParams string
3838
}
3939

@@ -52,7 +52,7 @@ type clhConfigTOML struct {
5252
// ParseConfig reads the guest sizing and kernel_params from a kata
5353
// configuration.toml. memDefault/vcpuDefault are substituted when the key is
5454
// absent or non-positive (kata also accepts default_vcpus = -1 meaning "all host
55-
// CPUs", which the owned boot does not support).
55+
// CPUs", which ateom does not support).
5656
func ParseConfig(base []byte, memDefault, vcpuDefault int) (KataConfig, error) {
5757
var c clhConfigTOML
5858
if err := toml.Unmarshal(base, &c); err != nil {

cmd/ateom-microvm/internal/kata/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestParseConfig(t *testing.T) {
4949

5050
// TestParseConfigDefaults asserts the mem/vcpu defaults kick in when the keys are
5151
// absent or non-positive (kata also accepts default_vcpus = -1 meaning "all host
52-
// CPUs", which the owned boot does not support).
52+
// CPUs", which ateom does not support).
5353
func TestParseConfigDefaults(t *testing.T) {
5454
for _, tc := range []struct {
5555
name string

cmd/ateom-microvm/internal/kata/disk.go

Lines changed: 0 additions & 140 deletions
This file was deleted.

0 commit comments

Comments
 (0)