Files
beszel-ipv6/agent/cpu_linux_test.go
Santhi Prakash 01d91728f0 fix(agent): report LXC guest CPU usage from cgroup accounting (#2341)
Inside an LXC guest, lxcfs serves /proc/stat with the counters of the
host cores in the guest's cpuset, so an idle guest sharing a core with a
busy neighbor reported near-100% CPU.

When the agent detects it is running in an LXC guest (lxcfs mounted on
/proc/stat, container=lxc, or /run/systemd/container), derive CPU usage
from the guest's own cgroup instead: cpu.stat on cgroup v2, cpuacct on
v1, read at the cgroup mount root so it covers every process in the
guest. Usage is normalized by the usable cores (the smallest of the
affinity mask, cpuset and CPU quota).

Per-core usage is omitted in this mode, since the per-core /proc/stat
counters describe shared host cores and would contradict the total.
Iowait and steal are not available from cgroup accounting and report as
zero.

Hosts and Docker/Podman agents are unaffected and keep reading
/proc/stat, as does an LXC guest whose cgroup accounting is unreadable.

---------

Co-authored-by: hank <hank@henrygd.me>
2026-10-01 19:33:42 -04:00

351 lines
12 KiB
Go

//go:build testing && linux
package agent
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// swapCpuContainerSeams points every container-detection and cgroup path at
// empty fixtures under a temp dir, then restores them on cleanup.
func swapCpuContainerSeams(t *testing.T) {
t.Helper()
backup := struct {
root, mountinfo, selfCgroup, systemdCont string
numCPU func() int
now func() time.Time
}{
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath, cpuNumCPU, cpuNow,
}
origInLxc := inLxc
samples := lastCgroupCpuSamples
env, hadEnv := os.LookupEnv("container")
t.Cleanup(func() {
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuSystemdContPath = backup.root, backup.mountinfo, backup.selfCgroup, backup.systemdCont
cpuNumCPU, cpuNow = backup.numCPU, backup.now
inLxc = origInLxc
lastCgroupCpuSamples = samples
if hadEnv {
os.Setenv("container", env)
}
})
inLxc = sync.OnceValue(detectLxc)
lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
os.Unsetenv("container")
tmp := t.TempDir()
cpuCgroupRoot = filepath.Join(tmp, "cgroup")
cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo")
cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup")
cpuSystemdContPath = filepath.Join(tmp, "systemd-container")
}
func writeCpuFixture(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// markLxc makes detection see a systemd-based LXC guest.
func markLxc(t *testing.T) {
t.Helper()
writeCpuFixture(t, cpuSystemdContPath, "lxc\n")
}
// fakeNow installs a controllable clock and returns a function to advance it.
func fakeNow(t *testing.T) func(time.Duration) {
t.Helper()
cur := time.Unix(1_700_000_000, 0)
cpuNow = func() time.Time { return cur }
return func(d time.Duration) { cur = cur.Add(d) }
}
func TestDetectLxc(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
want bool
}{
{"plain host", func(t *testing.T) {}, false},
{"container env lxc", func(t *testing.T) { t.Setenv("container", "lxc") }, true},
{"container env podman", func(t *testing.T) { t.Setenv("container", "podman") }, false},
{"systemd container lxc", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true},
{"systemd container nspawn", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "systemd-nspawn\n") }, false},
{"lxcfs serving /proc/stat", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
}, true},
// an LXC host (e.g. Proxmox) mounts lxcfs too, but not over its own /proc
{"lxcfs mounted on host", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"45 25 0:40 / /var/lib/lxcfs rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
}, false},
{"cgroup-only mountinfo", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n")
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
swapCpuContainerSeams(t)
tt.setup(t)
assert.Equal(t, tt.want, detectLxc())
})
}
}
func TestReadCgroupV2CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
cpuNumCPU = func() int { return 8 }
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 3000000, s.usageUsec)
assert.EqualValues(t, 2000000, s.userUsec)
assert.EqualValues(t, 1000000, s.systemUsec)
assert.InDelta(t, 4, s.cores, 0.001) // cpuset 2,5-7 = 4 cores
}
// The agent may sit in a sub-cgroup of the guest (a systemd service, or the
// ".lxc" leaf when started from an attached shell); the mount root still
// accounts for the whole guest and must win.
func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
for _, rel := range []string{"system.slice/beszel-agent.service", ".lxc"} {
t.Run(rel, func(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/"+rel+"\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, rel, "cpu.stat"), "usage_usec 5\n")
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 9000, s.usageUsec)
})
}
}
// Same for v1: the cpuacct mount root covers the agent's sibling services.
func TestReadCgroupV1PrefersContainerRoot(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpu,cpuacct:/system.slice/beszel-agent.service\n")
v1 := filepath.Join(t.TempDir(), "cpu,cpuacct")
writeCpuFixture(t, cpuCgroupMountinfo,
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpu,cpuacct\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "9000000\n")
writeCpuFixture(t, filepath.Join(v1, "system.slice/beszel-agent.service/cpuacct.usage"), "5000\n")
s, ok := readContainerCpuSample()
require.True(t, ok)
assert.EqualValues(t, 9000, s.usageUsec)
}
func TestReadCgroupV1CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
v1 := filepath.Join(t.TempDir(), "cpuacct")
writeCpuFixture(t, cpuCgroupMountinfo,
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n")
cpuNumCPU = func() int { return 4 }
s, ok := readContainerCpuSample() // no 0:: line -> falls through to v1
require.True(t, ok)
assert.EqualValues(t, 2000000, s.usageUsec) // ns -> usec
assert.EqualValues(t, 1000000, s.userUsec) // 100 jiffies * 1e6/100
assert.EqualValues(t, 500000, s.systemUsec) // 50 jiffies
assert.InDelta(t, 4, s.cores, 0.001)
}
func TestContainerCpuMetricsMath(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n")
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n")
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total) // first call only seeds the baseline
// 1s elapsed, container burned 2 core-seconds on 4 usable cores
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01)
assert.InDelta(t, 25, m.User, 0.01)
assert.InDelta(t, 12.5, m.System, 0.01)
assert.Zero(t, m.Iowait)
assert.Zero(t, m.Steal)
assert.InDelta(t, 50, m.Idle, 0.01)
}
func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
containerCpuMetrics(60000)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota
}
func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
// seed the baseline, then do not advance the clock: elapsed <= 0
containerCpuMetrics(60000)
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total)
// counter goes backwards (cgroup recreated): report zero and re-baseline
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total)
// next tick measures from the new baseline, not the stale one
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores)
}
func TestContainerCpuMetricsFallbacks(t *testing.T) {
t.Run("not in lxc", func(t *testing.T) {
swapCpuContainerSeams(t)
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
// Docker agents monitor the host, so cgroup accounting must not kick in
// even when it is readable and no LXC marker is present.
t.Run("docker container", func(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
t.Run("in lxc without cgroup accounting", func(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
// cpuCgroupRoot has no cpu.stat
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
}
// The host path must keep reporting through gopsutil untouched.
func TestGetCpuMetricsHostFallback(t *testing.T) {
swapCpuContainerSeams(t)
m, err := getCpuMetrics(60000)
require.NoError(t, err)
assert.False(t, m.fromCgroup)
assert.GreaterOrEqual(t, m.Total, 0.0)
assert.LessOrEqual(t, m.Total, 100.0)
}
// Inside LXC getCpuMetrics must report the cgroup-derived value, not
// the host core counters from /proc/stat.
func TestGetCpuMetricsPrefersCgroup(t *testing.T) {
swapCpuContainerSeams(t)
markLxc(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n")
m, err := getCpuMetrics(60000)
require.NoError(t, err)
assert.Zero(t, m.Total)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, err = getCpuMetrics(60000)
require.NoError(t, err)
assert.InDelta(t, 50, m.Total, 0.01)
assert.True(t, m.fromCgroup) // per-core usage is skipped for this source
}
func TestCountCpuList(t *testing.T) {
assert.Equal(t, 4, countCpuList("0-3"))
assert.Equal(t, 4, countCpuList("2,5-7"))
assert.Equal(t, 1, countCpuList("2"))
assert.Equal(t, 0, countCpuList(""))
assert.Equal(t, 0, countCpuList("max"))
assert.Equal(t, 6, countCpuList("0-3,8-9"))
}
func TestCpuQuotaCores(t *testing.T) {
dir := t.TempDir()
_, ok := cpuQuotaCores(dir)
assert.False(t, ok) // no quota files
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "max 100000\n")
_, ok = cpuQuotaCores(dir)
assert.False(t, ok) // unlimited
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "150000 100000\n")
q, ok := cpuQuotaCores(dir)
require.True(t, ok)
assert.InDelta(t, 1.5, q, 0.001)
// v1 files
v1 := t.TempDir()
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "-1\n")
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_period_us"), "100000\n")
_, ok = cpuQuotaCores(v1)
assert.False(t, ok)
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "50000\n")
q, ok = cpuQuotaCores(v1)
require.True(t, ok)
assert.InDelta(t, 0.5, q, 0.001)
}