diff --git a/environment/docker/stats.go b/environment/docker/stats.go index e161c73b..48fa25e5 100644 --- a/environment/docker/stats.go +++ b/environment/docker/stats.go @@ -82,7 +82,7 @@ func (e *Environment) pollResources(ctx context.Context) error { Uptime: uptime, Memory: calculateDockerMemory(v.MemoryStats), MemoryLimit: v.MemoryStats.Limit, - CpuAbsolute: calculateDockerAbsoluteCpu(v.PreCPUStats, v.CPUStats), + CpuAbsolute: calculateDockerAbsoluteCpu(v), Network: environment.NetworkStats{}, } @@ -130,28 +130,16 @@ func calculateDockerMemory(stats container.MemoryStats) uint64 { // Calculates the absolute CPU usage used by the server process on the system, not constrained // by the defined CPU limits on the container. // -// @see https://github.com/docker/cli/blob/aa097cf1aa19099da70930460250797c8920b709/cli/command/container/stats_helpers.go#L166 -func calculateDockerAbsoluteCpu(pStats container.CPUStats, stats container.CPUStats) float64 { - // Calculate the change in CPU usage between the current and previous reading. - cpuDelta := float64(stats.CPUUsage.TotalUsage) - float64(pStats.CPUUsage.TotalUsage) - - // Calculate the change for the entire system's CPU usage between current and previous reading. - systemDelta := float64(stats.SystemUsage) - float64(pStats.SystemUsage) - - // Calculate the total number of CPU cores being used. - cpus := float64(stats.OnlineCPUs) - if cpus == 0.0 { - cpus = float64(len(stats.CPUUsage.PercpuUsage)) +// CPU time is compared to the elapsed time between samples because Podman's Docker-compatible API +// does not provide Docker-equivalent SystemUsage values. +func calculateDockerAbsoluteCpu(stats container.StatsResponse) float64 { + current := stats.CPUStats.CPUUsage.TotalUsage + previous := stats.PreCPUStats.CPUUsage.TotalUsage + if current <= previous || stats.PreRead.IsZero() || !stats.Read.After(stats.PreRead) { + return 0 } - percent := 0.0 - if systemDelta > 0.0 && cpuDelta > 0.0 { - percent = (cpuDelta / systemDelta) * 100.0 - - if cpus > 0 { - percent *= cpus - } - } - - return math.Round(percent*1000) / 1000 + cpuDelta := float64(current - previous) + timeDelta := float64(stats.Read.Sub(stats.PreRead).Nanoseconds()) + return math.Round((cpuDelta/timeDelta)*100*1000) / 1000 } diff --git a/environment/docker/stats_test.go b/environment/docker/stats_test.go new file mode 100644 index 00000000..6da32857 --- /dev/null +++ b/environment/docker/stats_test.go @@ -0,0 +1,80 @@ +package docker + +import ( + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/stretchr/testify/require" +) + +func TestCalculateDockerAbsoluteCpu(t *testing.T) { + base := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + stats container.StatsResponse + expected float64 + }{ + { + name: "uses elapsed wall time", + stats: func() container.StatsResponse { + stats := cpuStatsResponse(base, 2*time.Second, 5_000_000_000, 6_000_000_000) + stats.PreCPUStats.SystemUsage = 10_000_000_000 + stats.CPUStats.SystemUsage = 74_000_000_000 + stats.CPUStats.OnlineCPUs = 64 + return stats + }(), + expected: 50, + }, + { + name: "multiple cores", + stats: cpuStatsResponse(base, time.Second, 5_000_000_000, 7_500_000_000), + expected: 250, + }, + { + name: "rounds to three decimal places", + stats: cpuStatsResponse(base, 3*time.Second, 5_000_000_000, 6_000_000_000), + expected: 33.333, + }, + { + name: "no CPU usage", + stats: cpuStatsResponse(base, time.Second, 5_000_000_000, 5_000_000_000), + expected: 0, + }, + { + name: "CPU counter reset", + stats: cpuStatsResponse(base, time.Second, 5_000_000_000, 1_000_000_000), + expected: 0, + }, + { + name: "missing previous timestamp", + stats: cpuStatsResponse(time.Time{}, time.Second, 5_000_000_000, 6_000_000_000), + expected: 0, + }, + { + name: "non-increasing timestamp", + stats: cpuStatsResponse(base, 0, 5_000_000_000, 6_000_000_000), + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, calculateDockerAbsoluteCpu(tt.stats)) + }) + } +} + +func cpuStatsResponse(preRead time.Time, elapsed time.Duration, previous, current uint64) container.StatsResponse { + return container.StatsResponse{ + Read: preRead.Add(elapsed), + PreRead: preRead, + CPUStats: container.CPUStats{ + CPUUsage: container.CPUUsage{TotalUsage: current}, + }, + PreCPUStats: container.CPUStats{ + CPUUsage: container.CPUUsage{TotalUsage: previous}, + }, + } +}