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
+14
View File
@@ -719,6 +719,20 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
// F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide
// "used" is not observable from the container. Report the controller's accurate committed-app memory
// (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI.
if r.stackMgr != nil && info.TotalMemMB > 0 {
if reqMB, _ := r.stackMgr.CommittedMemory(); reqMB >= 0 {
used := uint64(reqMB)
if used > info.TotalMemMB {
used = info.TotalMemMB
}
info.UsedMemMB = used
info.AvailMemMB = info.TotalMemMB - used
info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100
}
}
syncStatus := r.syncer.Status()
data := map[string]interface{}{
"system": info,
+14 -2
View File
@@ -160,16 +160,28 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
var deployWarning string
reservedMB := m.cfg.System.ReservedMemoryMB
totalMB, usedMB, memErr := system.GetMemoryMB()
// F1: the controller container cannot read the guest's RAM cap from /proc (no lxcfs) or its own
// cgroup (the cap is on the LXC ancestor). Prefer the guest cap from the Docker daemon (runs in the
// LXC). And use the controller's OWN committed-memory accounting for "used" — accurate and cheap —
// rather than host /proc RSS, which is unobservable-per-guest and would otherwise make this guard
// either never fire (host total) or always fire (host used > guest cap).
if gt, ok := system.GuestMemTotalMB(); ok && gt > 0 {
totalMB = gt
memErr = nil
}
if committedReqMB, _ := m.CommittedMemory(); committedReqMB > 0 || memErr == nil {
usedMB = committedReqMB
}
if memErr != nil {
m.logger.Printf("[WARN] [stacks] Cannot read system memory: %v — skipping memory check", memErr)
} else {
usableMB := totalMB - reservedMB
newReqMB := ParseMemoryMB(meta.Resources.MemRequest)
m.logger.Printf("[INFO] [stacks] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, real_used=%dMB, new_req=%dMB, remaining=%dMB",
m.logger.Printf("[INFO] [stacks] Memory check: total=%dMB, reserved=%dMB, usable=%dMB, committed_used=%dMB, new_req=%dMB, remaining=%dMB",
totalMB, reservedMB, usableMB, usedMB, newReqMB, usableMB-usedMB-newReqMB)
// Hard block: real used + new request exceeds usable memory
// Hard block: committed + new request exceeds usable memory
if newReqMB > 0 && usedMB+newReqMB > usableMB {
clearDeploying()
return "", fmt.Errorf(
+40 -9
View File
@@ -13,11 +13,8 @@ import (
// not the host RAM. On the pre-fix code this test fails because readMemInfo ignored cgroup entirely.
func TestReadMemInfoUsesCgroupV2Limit(t *testing.T) {
dir := t.TempDir()
// 2 GiB limit, 512 MiB current usage.
const twoGiB = uint64(2 * 1024 * 1024 * 1024)
const halfGiB = uint64(512 * 1024 * 1024)
mustWriteFile(t, filepath.Join(dir, "memory.max"), []byte(itoa(twoGiB)))
mustWriteFile(t, filepath.Join(dir, "memory.current"), []byte(itoa(halfGiB)))
old := cgroupRoot
cgroupRoot = dir
@@ -30,14 +27,48 @@ func TestReadMemInfoUsesCgroupV2Limit(t *testing.T) {
if info.TotalMemMB != 2048 {
t.Fatalf("TotalMemMB = %d, want 2048 (cgroup cap), not host RAM", info.TotalMemMB)
}
if info.UsedMemMB != 512 {
t.Fatalf("UsedMemMB = %d, want 512 (memory.current)", info.UsedMemMB)
// Used is a scaled estimate (the container cannot read guest-wide RSS); just assert it is sane.
if info.UsedMemMB > info.TotalMemMB {
t.Fatalf("UsedMemMB = %d exceeds TotalMemMB = %d", info.UsedMemMB, info.TotalMemMB)
}
if info.AvailMemMB != 1536 {
t.Fatalf("AvailMemMB = %d, want 1536", info.AvailMemMB)
if info.AvailMemMB != info.TotalMemMB-info.UsedMemMB {
t.Fatalf("AvailMemMB inconsistent: %d != %d-%d", info.AvailMemMB, info.TotalMemMB, info.UsedMemMB)
}
if info.MemPercent < 24 || info.MemPercent > 26 {
t.Fatalf("MemPercent = %.1f, want ~25", info.MemPercent)
}
// TestReadMemInfoUsesDockerInfoWhenCgroupUnlimited asserts the NESTED-LXC case (the real demo): the
// container's own cgroup is unlimited ("max"), so the guest cap must come from `docker info` MemTotal.
// Pre-fix (and the cgroup-only attempt) reports the host RAM here.
func TestReadMemInfoUsesDockerInfoWhenCgroupUnlimited(t *testing.T) {
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "memory.max"), []byte("max")) // container cgroup unlimited
old := cgroupRoot
cgroupRoot = dir
defer func() { cgroupRoot = old }()
oldFn := dockerMemTotalFn
dockerMemTotalFn = func() (uint64, bool) { return 2048, true }
defer func() { dockerMemTotalFn = oldFn }()
var info SystemInfo
readMemInfo(&info)
if info.TotalMemMB != 2048 {
t.Fatalf("TotalMemMB = %d, want 2048 (docker-info guest cap)", info.TotalMemMB)
}
}
// TestGuestMemTotalMB_DockerInfoFallback asserts GuestMemTotalMB (used by the deploy guard) falls back
// to docker-info when no cgroup limit is present.
func TestGuestMemTotalMB_DockerInfoFallback(t *testing.T) {
dir := t.TempDir() // no cgroup files → no limit
old := cgroupRoot
cgroupRoot = dir
defer func() { cgroupRoot = old }()
oldFn := dockerMemTotalFn
dockerMemTotalFn = func() (uint64, bool) { return 2048, true }
defer func() { dockerMemTotalFn = oldFn }()
if v, ok := GuestMemTotalMB(); !ok || v != 2048 {
t.Fatalf("GuestMemTotalMB = (%d, %v), want (2048, true)", v, ok)
}
}
+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.
+5
View File
@@ -18,3 +18,8 @@ func GetTotalMemoryMB() (int, error) {
func GetMemoryMB() (totalMB, usedMB int, err error) {
return 0, 0, fmt.Errorf("/proc/meminfo not available on this platform")
}
// GuestMemTotalMB is not determinable on non-Linux platforms.
func GuestMemTotalMB() (int, bool) {
return 0, false
}