From f505726daca7db217a477de8c41e6753bae529f7 Mon Sep 17 00:00:00 2001 From: default Date: Sun, 25 Oct 2026 20:33:43 +0000 Subject: [PATCH] fix: read cgroupv2 cpu usage from the cgroup that owns cpu.max --- cgroupv2.go | 44 +++++++++++++++++++++++++++++++++++++++++++ stat_internal_test.go | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/cgroupv2.go b/cgroupv2.go index e3b7aaa..9288cb8 100644 --- a/cgroupv2.go +++ b/cgroupv2.go @@ -88,7 +88,33 @@ func getFromParentOrFallback[T any]( return fallback() } +// cpuUsed reads usage from the cgroup that owns the effective cpu.max so +// that used and total describe the same domain. cpu.stat includes all +// descendants, so this also counts sibling cgroups under the limit owner +// (for example /docker/ next to /init.scope in docker-in-docker). func (s cgroupV2Statter) cpuUsed() (used float64, err error) { + owner, err := s.cpuQuotaOwner() + if err != nil { + return 0, xerrors.Errorf("get cpu quota owner: %w", err) + } + + if owner != nil && owner.path != s.path { + if used, err := owner.cpuUsedOwn(); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return 0, xerrors.Errorf("read limit owner cpu: %w", err) + } + // The limit owner does not expose cpu.stat, so fall back to + // the process's own cgroup below. + } else { + return used, nil + } + } + + return s.cpuUsedOwn() +} + +// cpuUsedOwn reads usage from this cgroup's cpu.stat. +func (s cgroupV2Statter) cpuUsedOwn() (used float64, err error) { cpuStatPath := filepath.Join(s.path, cgroupV2CPUStat) usageUs, err := readInt64Prefix(s.fs, cpuStatPath, "usage_usec") @@ -122,6 +148,24 @@ func (s cgroupV2Statter) cpuQuota() (float64, error) { return float64(quotaUs), nil } +// cpuQuotaOwner returns the nearest cgroup, starting at s and walking up, +// whose cpu.max sets a quota. It returns nil when no cgroup sets one. +func (s *cgroupV2Statter) cpuQuotaOwner() (*cgroupV2Statter, error) { + cpuMaxPath := filepath.Join(s.path, cgroupV2CPUMax) + + if _, err := readInt64SepIdx(s.fs, cpuMaxPath, " ", 0); err != nil { + if !errors.Is(err, strconv.ErrSyntax) && !errors.Is(err, fs.ErrNotExist) { + return nil, xerrors.Errorf("get cpu quota: %w", err) + } + + return getFromParentOrFallback(s, (*cgroupV2Statter).cpuQuotaOwner, func() (*cgroupV2Statter, error) { + return nil, nil + }) + } + + return s, nil +} + func (s cgroupV2Statter) cpuPeriod() (float64, error) { cpuMaxPath := filepath.Join(s.path, cgroupV2CPUMax) diff --git a/stat_internal_test.go b/stat_internal_test.go index cc139de..7f21582 100644 --- a/stat_internal_test.go +++ b/stat_internal_test.go @@ -418,6 +418,30 @@ func TestStatter(t *testing.T) { assert.Equal(t, "cores", cpu.Unit) }) + t.Run("CPU/UsageFromLimitOwner", func(t *testing.T) { + t.Parallel() + + fs := initFS(t, fsContainerCgroupV2DockerInDockerCPU) + fakeWait := func(time.Duration) { + // The nested container in /docker/ uses 4 cores' + // worth of CPU time, which is rolled up into the root. + mungeFS(t, fs, filepath.Join(cgroupRootPath, cgroupV2CPUStat), "usage_usec 401000") + mungeFS(t, fs, filepath.Join(cgroupRootPath, "init.scope", cgroupV2CPUStat), "usage_usec 1000") + mungeFS(t, fs, filepath.Join(cgroupRootPath, cgroupV2Path, cgroupV2CPUStat), "usage_usec 400000") + } + s, err := New(WithFS(fs), withWait(fakeWait), withIsCgroupV2(true)) + require.NoError(t, err) + + cpu, err := s.ContainerCPU() + require.NoError(t, err) + + require.NotNil(t, cpu) + assert.Equal(t, 4.01, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 12.0, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) + }) + t.Run("CPU/InitScopeDefaultPeriod", func(t *testing.T) { t.Parallel() @@ -835,6 +859,26 @@ sysboxfs /proc/sys sysboxfs rw,nosuid,nodev,noexec,relatime 0 0`, filepath.Join(cgroupRootPath, "init.scope", cgroupV2MemoryStat): "inactive_file 268435456", filepath.Join(cgroupRootPath, "init.scope", cgroupV2MemoryUsageBytes): "536870912", } + // fsContainerCgroupV2DockerInDockerCPU simulates a docker-in-docker workspace. + // The agent runs in /init.scope and a nested container runs in the + // sibling /docker/. Only the container root sets cpu.max, so usage + // must be read from the root to include both children. + fsContainerCgroupV2DockerInDockerCPU = map[string]string{ + procOneCgroup: "0::/init.scope", + procSelfCgroup: "0::/init.scope", + procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 +proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + sysCgroupType: "domain", + + filepath.Join(cgroupRootPath, cgroupV2CPUMax): "1200000 100000", + filepath.Join(cgroupRootPath, cgroupV2CPUStat): "usage_usec 0", + + filepath.Join(cgroupRootPath, "init.scope", cgroupV2CPUMax): "max 100000", + filepath.Join(cgroupRootPath, "init.scope", cgroupV2CPUStat): "usage_usec 0", + + filepath.Join(cgroupRootPath, cgroupV2Path, cgroupV2CPUMax): "max 100000", + filepath.Join(cgroupRootPath, cgroupV2Path, cgroupV2CPUStat): "usage_usec 0", + } // Variant where child has memory.stat but NOT memory.current (should inherit from root) fsContainerCgroupV2KubernetesMissingMemoryCurrent = map[string]string{ procOneCgroup: "0::/",