F1 (rework): source guest RAM cap from docker info; deploy guard uses committed memory

The cgroup-only approach was a no-op on the demo: the controller container's OWN
cgroup is unlimited (the 2GB cap is on the LXC ancestor, hidden), and /proc has no
lxcfs, so it kept reporting the host's 16GB. The Docker daemon runs IN the LXC, so
'docker info' MemTotal reports the guest's real cap (2048MB) — now the authoritative
source (cgroup limit preferred when present, e.g. non-nested). The deploy memory
guard now uses the controller's committed-app memory (sum of running mem requests)
for 'used' — accurate and cheap — instead of host /proc RSS (unobservable per guest;
would make the guard never/always fire). /api/system/info reports the guest cap as
total and committed memory as used. Tests: cgroup-limit path, docker-info fallback
(nested case), GuestMemTotalMB fallback (dockerMemTotalFn stub).
This commit is contained in:
2026-06-14 10:18:22 +02:00
parent d6c428b5dd
commit 4989513a96
5 changed files with 142 additions and 23 deletions
+69 -12
View File
@@ -4,8 +4,10 @@ package system
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
@@ -113,20 +115,38 @@ func readMemInfo(info *SystemInfo) {
info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
// F1: the controller runs as a Docker container inside an LXC. /proc/meminfo reports the HOST's
// RAM (no lxcfs in the container), which massively overstates the guest's real ceiling and defeats
// the deploy memory-headroom guard. Prefer the cgroup memory LIMIT when it is finite and below the
// host total — that is the amount this guest can actually use. Fall back to /proc/meminfo otherwise.
if limitMB, ok := readCgroupMemLimitMB(cgroupRoot); ok && limitMB > 0 && limitMB < info.TotalMemMB {
info.TotalMemMB = limitMB
if curMB, okC := readCgroupMemCurrentMB(cgroupRoot); okC && curMB <= limitMB {
info.UsedMemMB = curMB
} else if info.UsedMemMB > limitMB {
info.UsedMemMB = limitMB
// F1: the controller runs as a Docker container inside an LXC. /proc/meminfo reports the HOST's RAM
// (no lxcfs in the container) and the container's OWN cgroup is unlimited (the 2GB cap lives on the
// LXC, an ancestor hidden from the container), so the reported total massively overstates the guest's
// real ceiling and defeats the deploy memory-headroom guard. Determine the true guest cap from, in
// order: the container's cgroup limit (correct when Docker sets -m, e.g. non-nested deploys), else
// `docker info` MemTotal (dockerd runs IN the LXC and reports the guest's lxcfs-backed RAM — the
// accurate cap in the nested-LXC case). The instantaneous guest-wide RSS is NOT observable from the
// container, so when we override the cap we scale the host's used-fraction onto it as an estimate for
// display; the CAP itself (what the headroom math depends on) is accurate. The deploy guard uses the
// controller's own committed-memory accounting for "used", so safety does not rely on this estimate.
capMB := uint64(0)
if v, ok := readCgroupMemLimitMB(cgroupRoot); ok && v > 0 && v < info.TotalMemMB {
capMB = v
}
if capMB == 0 {
if v, ok := guestMemTotalMB(); ok && v > 0 && v < info.TotalMemMB {
capMB = v
}
}
if capMB > 0 && capMB < info.TotalMemMB {
frac := 0.0
if info.TotalMemMB > 0 {
frac = float64(info.UsedMemMB) / float64(info.TotalMemMB)
}
info.TotalMemMB = capMB
// Scaled host-pressure estimate (the container can't read guest-wide RSS). The /api/system/info
// handler overrides this with the controller's committed-app memory for an accurate figure; this
// estimate covers the other GetInfo callers (monitoring) without alarming at ~100%.
info.UsedMemMB = uint64(float64(capMB) * frac)
info.AvailMemMB = info.TotalMemMB - info.UsedMemMB
debugf("[DEBUG] [system] readMemInfo: using cgroup limit=%dMB (host total was %dKB) → used=%dMB avail=%dMB",
limitMB, totalKB, info.UsedMemMB, info.AvailMemMB)
debugf("[DEBUG] [system] readMemInfo: guest cap=%dMB (host total was %dKB) → used%dMB avail%dMB",
capMB, totalKB, info.UsedMemMB, info.AvailMemMB)
}
if info.TotalMemMB > 0 {
@@ -136,6 +156,43 @@ func readMemInfo(info *SystemInfo) {
totalKB, availKB, info.TotalMemMB, info.AvailMemMB, info.UsedMemMB, info.MemPercent)
}
// guestMemTotalMB returns the guest's total RAM (MB) as reported by the Docker daemon. The daemon runs
// inside the LXC, so `docker info` MemTotal reflects the guest's lxcfs-backed /proc/meminfo (the real
// cap) — unlike the container's own /proc/meminfo, which shows the Proxmox host's RAM. Overridable in
// tests via dockerMemTotalFn.
func guestMemTotalMB() (uint64, bool) {
if dockerMemTotalFn != nil {
return dockerMemTotalFn()
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.MemTotal}}").Output()
if err != nil {
return 0, false
}
bytesVal, err := strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64)
if err != nil || bytesVal == 0 {
return 0, false
}
return bytesVal / (1024 * 1024), true
}
// dockerMemTotalFn lets tests stub the docker-info read.
var dockerMemTotalFn func() (uint64, bool)
// GuestMemTotalMB returns the guest's memory cap in MB (docker-info MemTotal), preferring the cgroup
// limit when finite. ok=false if neither is determinable. The deploy memory guard uses this as the
// accurate denominator (the controller container cannot read the guest cap from /proc — no lxcfs).
func GuestMemTotalMB() (int, bool) {
if v, ok := readCgroupMemLimitMB(cgroupRoot); ok && v > 0 {
return int(v), true
}
if v, ok := guestMemTotalMB(); ok && v > 0 {
return int(v), true
}
return 0, false
}
// readCgroupMemLimitMB returns the cgroup memory limit in MB. It tries cgroup v2 (memory.max) first,
// then v1 (memory/memory.limit_in_bytes). A sentinel ("max" on v2, or a near-uint64-max value on v1)
// means "unlimited" → ok=false so the caller keeps the /proc/meminfo value.