diff --git a/spindle/engines/microvm/cgroup.go b/spindle/engines/microvm/cgroup.go --- a/spindle/engines/microvm/cgroup.go +++ b/spindle/engines/microvm/cgroup.go @@ -1,12 +1,14 @@ package microvm import ( + "errors" "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" + "syscall" cgroups "github.com/containerd/cgroups/v3" "github.com/containerd/cgroups/v3/cgroup2" @@ -64,6 +66,14 @@ } if group != "/" { + if err := moveParentProcesses(root, supervisorMemoryMinMiB, logger); err != nil { + return nil, err + } + } else if err := probeRootSubtreeControl(mountpoint); err != nil { + if !errors.Is(err, syscall.EBUSY) { + return nil, fmt.Errorf("enable controllers in subtree_control of cgroup root %q: %w", mountpoint, err) + } + // populated namespace root, not the real root: vacate it too if err := moveParentProcesses(root, supervisorMemoryMinMiB, logger); err != nil { return nil, err } @@ -162,6 +172,13 @@ return false } return metrics.MemoryEvents.OomKill > 0 +} + +// probeRootSubtreeControl enables the domain controllers the engine needs +// in the "/" parent's subtree. this fails EBUSY at a populated cgroup +// namespace root (no-internal-process constraint); the real root is exempt. +func probeRootSubtreeControl(mountpoint string) error { + return os.WriteFile(filepath.Join(mountpoint, "cgroup.subtree_control"), []byte("+memory +pids"), 0) } func resolveCgroupParent(parent string) (string, string, error) { diff --git a/spindle/engines/microvm/cgroup_test.go b/spindle/engines/microvm/cgroup_test.go --- a/spindle/engines/microvm/cgroup_test.go +++ b/spindle/engines/microvm/cgroup_test.go @@ -1,7 +1,11 @@ package microvm import ( + "log/slog" + "os" "testing" + + cgroups "github.com/containerd/cgroups/v3" ) func TestSanitizeCgroupName(t *testing.T) { @@ -22,6 +26,61 @@ t.Errorf("sanitizeCgroupName(%q) = %q, want %q", tc.in, got, tc.want) } } +} + +// regression test for the cgroup-namespace-root case: at a populated +// namespace root the engine must vacate the parent before enabling +// subtree controllers. +// +// run with: +// +// go test -c -o microvm.test ./spindle/engines/microvm/ +// podman run --rm --cap-add SYS_ADMIN --security-opt seccomp=unconfined \ +// -v $PWD/microvm.test:/t:Z -e SPINDLE_CGROUP_INTEGRATION=1 \ +// --entrypoint /bin/sh docker.io/library/golang:1.25 \ +// -c "mount -t cgroup2 cgroup2 /sys/fs/cgroup && exec /t -test.run TestCgroupParentVacatesPopulatedNamespaceRoot" +func TestCgroupParentVacatesPopulatedNamespaceRoot(t *testing.T) { + if os.Getenv("SPINDLE_CGROUP_INTEGRATION") != "1" { + t.Skip("see test doc comment on how to run") + } + if cgroups.Mode() != cgroups.Unified { + t.Skip("requires cgroup v2 unified mode") + } + + group, err := selfCgroupV2Path() + if err != nil { + t.Fatal(err) + } + if group != "/" { + t.Skipf("only meaningful at a cgroup namespace root, self cgroup is %q", group) + } + + logger := slog.Default() + parent, err := initCgroupParent(cgroupParentSelf, 0, logger) + if err != nil { + t.Fatalf("initCgroupParent at a cgroup namespace root: %v", err) + } + + procs, err := parent.root.Procs(false) + if err != nil { + t.Fatalf("list parent cgroup processes: %v", err) + } + if len(procs) != 0 { + t.Errorf("namespace root still holds %d processes after initCgroupParent; "+ + "enabling subtree controllers for microVM cgroups would fail EBUSY", len(procs)) + } + + handle, err := prepareCgroup(CgroupLimits{ + Enabled: true, + Parent: parent, + Name: "cgtest-nsroot", + MemoryMaxMiB: 64, + PidsMax: 256, + }, logger) + if err != nil { + t.Fatalf("create controller-enabled child at namespace root: %v", err) + } + t.Cleanup(func() { _ = handle.Close() }) } func TestCgroupResourcesSwapOnlyStillSetsMemory(t *testing.T) {