Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

spindle/microvm: vacate populated cgroup namespace roots at init

initCgroupParent skips the parent-process move at group "/", assuming
the real root cgroup, which is exempt from the no-internal-process
constraint. in a private cgroup namespace (podman and docker's default
on cgroup v2) "/" is a populated delegated NON-root cgroup instead, so
the first workflow fails: 'failed to write subtree controllers
[memory pids] to "/sys/fs/cgroup/cgroup.subtree_control"': EBUSY.

userspace cannot tell the two "/"s apart — cgroup namespaces
virtualize /proc/self/cgroup and mountinfo for the reader — but the
constraint can: it exempts only the real root. probe it by writing
+memory +pids to cgroup.subtree_control: succeeds at the real root
(the same write prepareCgroup does per microVM anyway), EBUSY at a
populated namespace root, where we then vacate exactly as on the
group != "/" path. a read-only cgroupfs now fails loudly at init
instead of mid-workflow.

verified in scratch podman containers: unpatched reproduces the EBUSY,
patched passes the new regression test and enforces memory.max.

Signed-off-by: Winter <winter@madoka.systems>

author
Winter
committer
Tangled
date (Jul 25, 2026, 10:52 PM +0300) commit 566697c6 parent 539dbcca change-id yqzspnnk
+76
+17
spindle/engines/microvm/cgroup.go
··· 1 1 package microvm 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log/slog" 6 7 "os" 7 8 "path/filepath" 8 9 "regexp" 9 10 "strings" 11 + "syscall" 10 12 11 13 cgroups "github.com/containerd/cgroups/v3" 12 14 "github.com/containerd/cgroups/v3/cgroup2" ··· 64 66 } 65 67 66 68 if group != "/" { 69 + if err := moveParentProcesses(root, supervisorMemoryMinMiB, logger); err != nil { 70 + return nil, err 71 + } 72 + } else if err := probeRootSubtreeControl(mountpoint); err != nil { 73 + if !errors.Is(err, syscall.EBUSY) { 74 + return nil, fmt.Errorf("enable controllers in subtree_control of cgroup root %q: %w", mountpoint, err) 75 + } 76 + // populated namespace root, not the real root: vacate it too 67 77 if err := moveParentProcesses(root, supervisorMemoryMinMiB, logger); err != nil { 68 78 return nil, err 69 79 } ··· 162 172 return false 163 173 } 164 174 return metrics.MemoryEvents.OomKill > 0 175 + } 176 + 177 + // probeRootSubtreeControl enables the domain controllers the engine needs 178 + // in the "/" parent's subtree. this fails EBUSY at a populated cgroup 179 + // namespace root (no-internal-process constraint); the real root is exempt. 180 + func probeRootSubtreeControl(mountpoint string) error { 181 + return os.WriteFile(filepath.Join(mountpoint, "cgroup.subtree_control"), []byte("+memory +pids"), 0) 165 182 } 166 183 167 184 func resolveCgroupParent(parent string) (string, string, error) {
+59
spindle/engines/microvm/cgroup_test.go
··· 1 1 package microvm 2 2 3 3 import ( 4 + "log/slog" 5 + "os" 4 6 "testing" 7 + 8 + cgroups "github.com/containerd/cgroups/v3" 5 9 ) 6 10 7 11 func TestSanitizeCgroupName(t *testing.T) { ··· 22 26 t.Errorf("sanitizeCgroupName(%q) = %q, want %q", tc.in, got, tc.want) 23 27 } 24 28 } 29 + } 30 + 31 + // regression test for the cgroup-namespace-root case: at a populated 32 + // namespace root the engine must vacate the parent before enabling 33 + // subtree controllers. 34 + // 35 + // run with: 36 + // 37 + // go test -c -o microvm.test ./spindle/engines/microvm/ 38 + // podman run --rm --cap-add SYS_ADMIN --security-opt seccomp=unconfined \ 39 + // -v $PWD/microvm.test:/t:Z -e SPINDLE_CGROUP_INTEGRATION=1 \ 40 + // --entrypoint /bin/sh docker.io/library/golang:1.25 \ 41 + // -c "mount -t cgroup2 cgroup2 /sys/fs/cgroup && exec /t -test.run TestCgroupParentVacatesPopulatedNamespaceRoot" 42 + func TestCgroupParentVacatesPopulatedNamespaceRoot(t *testing.T) { 43 + if os.Getenv("SPINDLE_CGROUP_INTEGRATION") != "1" { 44 + t.Skip("see test doc comment on how to run") 45 + } 46 + if cgroups.Mode() != cgroups.Unified { 47 + t.Skip("requires cgroup v2 unified mode") 48 + } 49 + 50 + group, err := selfCgroupV2Path() 51 + if err != nil { 52 + t.Fatal(err) 53 + } 54 + if group != "/" { 55 + t.Skipf("only meaningful at a cgroup namespace root, self cgroup is %q", group) 56 + } 57 + 58 + logger := slog.Default() 59 + parent, err := initCgroupParent(cgroupParentSelf, 0, logger) 60 + if err != nil { 61 + t.Fatalf("initCgroupParent at a cgroup namespace root: %v", err) 62 + } 63 + 64 + procs, err := parent.root.Procs(false) 65 + if err != nil { 66 + t.Fatalf("list parent cgroup processes: %v", err) 67 + } 68 + if len(procs) != 0 { 69 + t.Errorf("namespace root still holds %d processes after initCgroupParent; "+ 70 + "enabling subtree controllers for microVM cgroups would fail EBUSY", len(procs)) 71 + } 72 + 73 + handle, err := prepareCgroup(CgroupLimits{ 74 + Enabled: true, 75 + Parent: parent, 76 + Name: "cgtest-nsroot", 77 + MemoryMaxMiB: 64, 78 + PidsMax: 256, 79 + }, logger) 80 + if err != nil { 81 + t.Fatalf("create controller-enabled child at namespace root: %v", err) 82 + } 83 + t.Cleanup(func() { _ = handle.Close() }) 25 84 } 26 85 27 86 func TestCgroupResourcesSwapOnlyStillSetsMemory(t *testing.T) {