Skip to content

Commit ac6c946

Browse files
committed
sys: cpu and memory (cgroup v2 in a constrained container)
* test cgroup v2 manually - `docker run --rm --cpus=1.5 --memory=512m ...` * add README section * add TestMain to `sys.Init()` only once ------ * part four, prev. commit: 3fb3aeb Signed-off-by: Alex Aizman <alex.aizman@gmail.com>
1 parent 3dd0155 commit ac6c946

3 files changed

Lines changed: 96 additions & 22 deletions

File tree

sys/README.md

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The `sys` package provides lightweight runtime visibility into host and containe
2222
- [Container memory (cgroup v1)](#container-memory-cgroup-v1)
2323
- [Container detection](#container-detection)
2424
- [Fallback](#fallback)
25+
- [Example: testing `sys` package inside a constrained container](#example-testing-sys-package-inside-a-constrained-container)
2526
- [Current limitations and future plans](#current-limitations-and-future-plans)
2627
- [cgroup v1 deprecation](#cgroup-v1-deprecation)
2728
- [Hardcoded thresholds](#hardcoded-thresholds)
@@ -171,7 +172,86 @@ The package follows these rules:
171172
- for CPU: preserve a usable percentage whenever possible
172173
- for memory: prefer host stats over failing when container-specific files cannot be read
173174

174-
This makes the package resilient across bare-metal, VMs, containers, and mixed environments, at the cost of occasionally returning an approximation instead of a strict container-scoped answer.
175+
## Example: testing `sys` package inside a constrained container
176+
177+
A simple way to validate container-aware CPU and memory reporting is to compare the same test run on the host and inside a Docker container with explicit CPU and memory limits.
178+
179+
### Host run
180+
181+
```bash
182+
go test -v -tags=debug
183+
```
184+
185+
Example output:
186+
187+
```text
188+
=== RUN TestNumCPU
189+
--- PASS: TestNumCPU (0.00s)
190+
=== RUN TestLoadAvg
191+
sys_test.go:41: Load average: 0.63, 0.49, 0.49
192+
--- PASS: TestLoadAvg (0.00s)
193+
=== RUN TestMaxProcs
194+
--- PASS: TestMaxProcs (0.00s)
195+
=== RUN TestMemoryStats
196+
sys_test.go:76: Memory stats: {used 29GiB, free 2GiB, buffcache 20GiB, actfree 23GiB}
197+
sys_test.go:79: Either swap is off or failed to read its stats
198+
--- PASS: TestMemoryStats (0.00s)
199+
=== RUN TestProcAndMaxLoad
200+
sys_test.go:110: First call: load=0, extreme=false
201+
...
202+
sys_test.go:133: Second call: load=3, extreme=false
203+
sys_test.go:145: Process CPU usage: 1.85%
204+
--- PASS: TestProcAndMaxLoad (5.69s)
205+
PASS
206+
ok github.com/NVIDIA/aistore/sys 5.696s
207+
```
208+
209+
### Container run
210+
211+
```bash
212+
docker run --rm \
213+
--cpus=1.5 \
214+
--memory=512m \
215+
-v "$PWD":/src -w /src \
216+
-v "$HOME/go/pkg/mod":/go/pkg/mod \
217+
-v "$HOME/.cache/go-build":/root/.cache/go-build \
218+
golang:1.25 \
219+
go test ./sys -run . -v -count=1 2>&1
220+
```
221+
222+
Example output:
223+
224+
```text
225+
=== RUN TestNumCPU
226+
--- PASS: TestNumCPU (0.00s)
227+
=== RUN TestLoadAvg
228+
sys_test.go:41: Load average: 0.36, 0.41, 0.47
229+
--- PASS: TestLoadAvg (0.00s)
230+
=== RUN TestMaxProcs
231+
--- PASS: TestMaxProcs (0.00s)
232+
=== RUN TestMemoryStats
233+
sys_test.go:76: Memory stats: {used 29MiB, free 483MiB, buffcache 152KiB, actfree 483MiB}
234+
sys_test.go:79: Either swap is off or failed to read its stats
235+
--- PASS: TestMemoryStats (0.00s)
236+
=== RUN TestProcAndMaxLoad
237+
sys_test.go:110: First call: load=0, extreme=false
238+
...
239+
sys_test.go:133: Second call: load=15, extreme=false
240+
sys_test.go:145: Process CPU usage: 14.82%
241+
--- PASS: TestProcAndMaxLoad (5.70s)
242+
```
243+
244+
The comparison illustrates several points:
245+
246+
* On the host, `TestMemoryStats` reports host-scale memory totals.
247+
* Inside the container, the same test reports memory bounded by the cgroup limit (`--memory=512m`) rather than the host's physical RAM.
248+
* `TestNumCPU` and `TestMaxProcs` exercise init-time CPU detection and container-aware CPU count.
249+
* `TestProcAndMaxLoad` burns CPU in-process and verifies that `MaxLoad2()` reports non-zero utilization on a subsequent sample.
250+
* Swap may report as zero or be unavailable inside short-lived containers; this is not unusual.
251+
252+
To further confirm container-scoped memory accounting, rerun the container example with a different limit (for example, `--memory=4G`). `TestMemoryStats` should then report a total close to 4 GiB instead of 512 MiB.
253+
254+
> The container example above assumes cgroup v2 - the default on modern Linux distributions and container runtimes.
175255
176256
## Current limitations and future plans
177257

sys/init.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ func init() {
2828

2929
func isContainerized() bool { return contDetected || contForced }
3030

31+
// func CgroupVer() int { return cgroupVer } // uncomment for unit tests
32+
3133
// container-aware CPU count; GOMAXPROCS; okv2 if { container && can read cgroup v2 }
3234
// - AIS node (`aisnode`) calls Init() once upon startup
3335
// - external modules that skip it still get a sane NumCPU() - see above

sys/sys_test.go

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,23 @@ import (
1717
"github.com/NVIDIA/aistore/tools/tassert"
1818
)
1919

20+
// example running it in a constrained container:
21+
// docker run --rm --cpus=1.5 --memory=512m -v "$PWD":/src -w /src \
22+
// -v "$HOME/go/pkg/mod":/go/pkg/mod -v "$HOME/.cache/go-build":/root/.cache/go-build \
23+
// golang:1.25 go test ./sys -run . -v -count=1 2>&
24+
25+
func TestMain(m *testing.M) {
26+
sys.Init(false /*forceCont*/)
27+
os.Exit(m.Run())
28+
}
29+
2030
func checkSkipOS(t *testing.T, oss ...string) {
2131
if slices.Contains(oss, runtime.GOOS) {
2232
t.Skipf("skipping test for %s platform", runtime.GOOS)
2333
}
2434
}
2535

2636
func TestNumCPU(t *testing.T) {
27-
sys.Init(false)
2837
checkSkipOS(t, "darwin")
2938
if sys.NumCPU() < 1 || sys.NumCPU() > runtime.NumCPU() {
3039
t.Errorf("Wrong number of CPUs %d (%d)", sys.NumCPU(), runtime.NumCPU())
@@ -64,30 +73,14 @@ func TestMemoryStats(t *testing.T) {
6473
tassert.Errorf(t, mem.ActualUsed+mem.ActualFree == mem.Total, "ActualUsed + ActualFree must equal Total: %+v", mem)
6574
tassert.Errorf(t, mem.Used >= mem.ActualUsed, "Used must be >= ActualUsed (BuffCache=%d): %+v", mem.BuffCache, mem)
6675

76+
// t.Logf("cgroup version: %d", sys.CgroupVer()) // uncomment for visibility
77+
6778
var sb cos.SB
6879
sb.Grow(80)
6980
mem.Str(&sb)
7081
t.Logf("Memory stats: %s", sb)
7182

72-
checkSkipOS(t, "darwin")
73-
74-
var memHost sys.MemStat
75-
err = memHost.Get()
76-
tassert.CheckFatal(t, err)
77-
78-
// NOTE: force "containerized" even though it'll then fail to read cgroups
79-
sys.Init(true /*forceCont*/)
80-
81-
var memCont sys.MemStat
82-
err = memCont.Get()
83-
tassert.CheckFatal(t, err)
84-
85-
// this check in particular would make more sense if running in a container
86-
tassert.Errorf(t, memHost.Total >= memCont.Total,
87-
"Container's memory total is greater than the host one.\nOS: %+v\nContainer: %+v", memHost, memCont)
88-
89-
if memHost.SwapTotal == 0 && memHost.SwapFree == 0 {
90-
// Not an error(e.g, Jenkins VM has swap off) - just a warning
83+
if mem.SwapTotal == 0 && mem.SwapFree == 0 {
9184
t.Log("Either swap is off or failed to read its stats")
9285
}
9386
}
@@ -96,7 +89,6 @@ func TestProcAndMaxLoad(t *testing.T) {
9689
if testing.Short() {
9790
t.Skipf("skipping %s in short mode", t.Name())
9891
}
99-
sys.Init(false)
10092
checkSkipOS(t, "darwin")
10193

10294
pid := os.Getpid()

0 commit comments

Comments
 (0)