From ee6bb64d727572efd7ded3c5812200c371a7cdc3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:08:00 +0100 Subject: [PATCH] Honor cgroup v1 service memory limits Resolve the process memory-controller hierarchy before selecting the tightest v1 hard limit, so systemd and nested appliance limits actually inform the existing Go runtime headroom. Contract-Neutral: runtime memory-limit detection only; no API or persistence contract change --- pkg/server/memlimit.go | 74 +++++++++++++++++++++++++++++++++++- pkg/server/memlimit_test.go | 75 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/pkg/server/memlimit.go b/pkg/server/memlimit.go index ce4a13171..490640ddb 100644 --- a/pkg/server/memlimit.go +++ b/pkg/server/memlimit.go @@ -58,9 +58,15 @@ func applyRuntimeMemoryLimit() { } func readCgroupMemoryLimit() (int64, bool) { - if limit, ok := readCgroupV2MemoryLimit(cgroupV2Root, "/proc/self/cgroup"); ok { + const procSelfCgroup = "/proc/self/cgroup" + if limit, ok := readCgroupV2MemoryLimit(cgroupV2Root, procSelfCgroup); ok { return limit, true } + if limit, ok := readCgroupV1HierarchyMemoryLimit("/sys/fs/cgroup/memory", procSelfCgroup); ok { + return limit, true + } + // Preserve the original best-effort fallback for unusual v1 mounts that + // expose the controller limit but not a readable /proc/self/cgroup. return readCgroupV1MemoryLimit("/sys/fs/cgroup/memory/memory.limit_in_bytes") } @@ -122,6 +128,72 @@ func cgroupV2PathFrom(content string) string { return "" } +// readCgroupV1HierarchyMemoryLimit resolves the process's memory-controller +// path and walks its ancestors just as the v2 reader does. Reading only the +// controller root misses a tighter systemd MemoryLimit/MemoryMax applied to a +// service cgroup on v1 hosts. +func readCgroupV1HierarchyMemoryLimit(root, procSelfCgroup string) (int64, bool) { + data, err := os.ReadFile(procSelfCgroup) + if err != nil { + return 0, false + } + rel := cgroupV1MemoryPathFrom(string(data)) + if rel == "" { + return 0, false + } + + lowest := int64(math.MaxInt64) + dir := filepath.Join(root, rel) + for { + if raw, err := os.ReadFile(filepath.Join(dir, "memory.limit_in_bytes")); err == nil { + if v, ok := parseCgroupMemoryValue(string(raw)); ok && v < cgroupV1NoLimit && v < lowest { + lowest = v + } + } + if dir == root { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + if lowest == math.MaxInt64 { + return 0, false + } + return lowest, true +} + +// cgroupV1MemoryPathFrom extracts the path for the v1 memory controller from +// /proc/self/cgroup. Controllers may be mounted together, so "memory" can be +// one item in a comma-separated controller field. +func cgroupV1MemoryPathFrom(content string) string { + for _, line := range strings.Split(content, "\n") { + fields := strings.SplitN(line, ":", 3) + if len(fields) != 3 { + continue + } + hasMemory := false + for _, controller := range strings.Split(fields[1], ",") { + if strings.TrimSpace(controller) == "memory" { + hasMemory = true + break + } + } + if !hasMemory { + continue + } + path := strings.TrimSpace(fields[2]) + if path == "/" { + return "." + } + return strings.TrimPrefix(path, "/") + } + return "" +} + func readCgroupV1MemoryLimit(limitFile string) (int64, bool) { raw, err := os.ReadFile(limitFile) if err != nil { diff --git a/pkg/server/memlimit_test.go b/pkg/server/memlimit_test.go index b7537bd36..4f6e3553c 100644 --- a/pkg/server/memlimit_test.go +++ b/pkg/server/memlimit_test.go @@ -118,6 +118,81 @@ func TestReadCgroupV2MemoryLimitNoLimitAnywhere(t *testing.T) { } } +func TestCgroupV1MemoryPathFrom(t *testing.T) { + cases := []struct { + name string + content string + want string + }{ + {"memory controller", "5:memory:/system.slice/pulse.service\n", "system.slice/pulse.service"}, + {"combined controllers", "7:cpu,cpuacct,memory:/docker/abc\n", "docker/abc"}, + {"namespaced root", "5:memory:/\n", "."}, + {"colon in path", "5:memory:/system.slice/pulse:worker.service\n", "system.slice/pulse:worker.service"}, + {"other controllers only", "2:cpu,cpuacct:/system.slice/pulse.service\n", ""}, + {"v2 only", "0::/system.slice/pulse.service\n", ""}, + {"malformed", "memory:/system.slice/pulse.service\n", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := cgroupV1MemoryPathFrom(tc.content); got != tc.want { + t.Fatalf("cgroupV1MemoryPathFrom(%q) = %q, want %q", tc.content, got, tc.want) + } + }) + } +} + +func TestReadCgroupV1HierarchyMemoryLimitAtNamespacedRoot(t *testing.T) { + root := t.TempDir() + proc := filepath.Join(root, "proc-self-cgroup") + writeCgroupFixture(t, proc, "5:memory:/\n") + writeCgroupFixture(t, filepath.Join(root, "memory.limit_in_bytes"), "536870912\n") + + got, ok := readCgroupV1HierarchyMemoryLimit(root, proc) + if !ok || got != 536870912 { + t.Fatalf("got (%d, %v), want (536870912, true)", got, ok) + } +} + +func TestReadCgroupV1HierarchyMemoryLimitWalksAncestors(t *testing.T) { + root := t.TempDir() + proc := filepath.Join(root, "proc-self-cgroup") + writeCgroupFixture(t, proc, "5:memory:/system.slice/pulse.service\n") + + // The service limit must win over the unlimited controller root. This is + // the layout used by systemd MemoryLimit/MemoryMax on cgroup v1. + writeCgroupFixture(t, filepath.Join(root, "system.slice/pulse.service/memory.limit_in_bytes"), "536870912\n") + writeCgroupFixture(t, filepath.Join(root, "system.slice/memory.limit_in_bytes"), "1073741824\n") + writeCgroupFixture(t, filepath.Join(root, "memory.limit_in_bytes"), strconv.FormatInt(cgroupV1NoLimit, 10)+"\n") + + got, ok := readCgroupV1HierarchyMemoryLimit(root, proc) + if !ok || got != 536870912 { + t.Fatalf("got (%d, %v), want (536870912, true)", got, ok) + } +} + +func TestReadCgroupV1HierarchyMemoryLimitTakesTightestAncestor(t *testing.T) { + root := t.TempDir() + proc := filepath.Join(root, "proc-self-cgroup") + writeCgroupFixture(t, proc, "8:cpu,memory:/parent/child\n") + writeCgroupFixture(t, filepath.Join(root, "parent/child/memory.limit_in_bytes"), "2147483648\n") + writeCgroupFixture(t, filepath.Join(root, "parent/memory.limit_in_bytes"), "805306368\n") + + got, ok := readCgroupV1HierarchyMemoryLimit(root, proc) + if !ok || got != 805306368 { + t.Fatalf("got (%d, %v), want (805306368, true)", got, ok) + } +} + +func TestReadCgroupV1HierarchyMemoryLimitNoMemoryController(t *testing.T) { + root := t.TempDir() + proc := filepath.Join(root, "proc-self-cgroup") + writeCgroupFixture(t, proc, "2:cpu,cpuacct:/system.slice/pulse.service\n") + + if got, ok := readCgroupV1HierarchyMemoryLimit(root, proc); ok { + t.Fatalf("expected no memory limit, got %d", got) + } +} + func TestReadCgroupV1MemoryLimit(t *testing.T) { dir := t.TempDir() limitFile := filepath.Join(dir, "memory.limit_in_bytes")