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
This commit is contained in:
pulse-triage[bot]
2026-08-30 02:08:00 +01:00
parent 184b3cbfa6
commit ee6bb64d72
2 changed files with 148 additions and 1 deletions
+73 -1
View File
@@ -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 {
+75
View File
@@ -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")