接着上一篇的 FROM scratch 套路,这次写一个真的会不断分配、真的会写入内存的 Go 程序,分别在有 --memory 限制和没有限制的情况下真实跑,看它到底会不会被杀掉、什么时候被杀掉、退出码是什么。顺带用同样的思路测了 --cpus,结果比想象中更有意思——不是简单的"限制到 0.25 核就慢 4 倍"。
这台机器的 Docker Desktop 用的是 cgroup v2(docker info 真实确认),runc 依赖的 opencontainers/cgroups 库把命令行参数翻译成两个纯文本文件。
opencontainers/cgroups · fs2/memory.go · cgroups @ v0.0.6, L10 func numToStr(value int64) string { switch value { case 0: return "" // 0(没传 --memory)不写任何值 case -1: return "max" // -1 代表"无限制",写字符串 "max" } return strconv.FormatInt(value, 10) // 否则原样写字节数 } // setMemory() 里:cgroups.WriteFile(dirPath, "memory.max", numToStr(r.Memory))
opencontainers/cgroups · fs2/cpu.go · cgroups @ v0.0.6, L47 str := "max" if r.CpuQuota > 0 { str = strconv.FormatInt(r.CpuQuota, 10) } period := r.CpuPeriod if period == 0 { period = 100000 // 默认周期 100ms(100000 微秒) } str += " " + strconv.FormatUint(period, 10) // 写进 cpu.max,格式是 ""
--memory=50m 会被换算成字节数直接写进 memory.max;--cpus=0.5 会被换算成"每 100000 微秒的周期里,只允许用 50000 微秒的 CPU 时间",写进 cpu.max。这两个文件本身就是内核 cgroup v2 文件系统的一部分,runc 只是负责把人类友好的参数翻译成内核认识的格式。
容器里读 /sys/fs/cgroup/memory.max 和 /sys/fs/cgroup/cpu.max——之所以能直接读到"只属于这个容器"的数字,而不是宿主机整棵 cgroup 树的汇总值,靠的是上一篇提到的 Cgroup 命名空间:每个容器都以为自己就站在 cgroup 文件系统的根上。
$ docker run --rm cginfo:scratch # 不加任何限制 $ docker run --rm --memory=50m --cpus=0.5 cginfo:scratch # 加限制不加限制: memory.max -> max cpu.max -> max 100000 --memory=50m --cpus=0.5: memory.max -> 52428800 # 精确等于 50 × 1024 × 1024 cpu.max -> 50000 100000 # 精确等于 0.5 × 100000
数字严丝合缝对上了——50m 换算成字节就是 52428800,0.5 个 CPU 换算成每 100ms 周期里 50000 微秒的配额,跟上面源码里的换算逻辑完全对得上。
写一个 Go 程序,循环分配 5MB 的 byte 切片,并且真的把每个字节写一遍(不写的话可能只是虚拟地址,内核未必会立刻分配物理页),在 --memory=50m 限制下跑。
$ docker run --name oom_test --memory=50m oomdemo:scratch
已分配 5 MB
已分配 10 MB
...
已分配 85 MB
已分配 90 MB
$ echo $?
137
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' oom_test
OOMKilled=true ExitCode=137
限制是 50m,但程序真实打印到了 90 MB 才被杀掉——这不是限制没生效,退出码 137(128 + 9,SIGKILL)和 docker inspect 的 OOMKilled=true 都实打实确认了内核真的出手了。超出的这部分来自几个真实、不可完全消除的延迟:Go 运行时自身的元数据/GC 开销不计入我们自己数的字节数、内核在真正判定"这个 cgroup 已经没救了、必须杀"之前会先尝试一轮内存回收、加上 docker logs 落盘本身也有缓冲延迟。结论是"限制真实生效",但"在哪一字节精确触发"不是一个可以指望到字节级精确的保证。
写一个纯 CPU busy loop(固定迭代次数,GOMAXPROCS=1 避免多核干扰),测真实耗时。
$ docker run --rm cpuload:scratch # 不限制 $ docker run --rm --cpus=0.25 cpuload:scratch # 限制 0.25 核 $ docker run --rm --cpus=1 cpuload:scratch # 限制 1 核(不该有影响)不限制: 耗时 = 1.28s --cpus=0.25: 耗时 = 7.50s (不是 1.28 × 4 = 5.12s) --cpus=1: 耗时 = 1.28s (跟不限制几乎一样)
如果只按"允许用 1/4 的 CPU"这么简单换算,应该是 5.12s 左右,但真实测出来是 7.5s。看一眼容器自己的 /sys/fs/cgroup/cpu.stat 就知道差距从哪来:
$ docker run --rm --cpus=0.25 cpuload2:scratch # 打印耗时 + cpu.statbusy loop 完成,耗时 = 7.66s (sink=...) usage_usec 1935340 # 真实消耗的 CPU 时间,约 1.94s nr_periods 77 # 一共经历了 77 个 100ms 调度周期 nr_throttled 77 # 这 77 个周期,每一个都被限流了 throttled_usec 6120358 # 累计被限流(强制挂起)约 6.12s
77 × 100ms ≈ 7.7s,跟真实测到的 7.66s 墙钟时间几乎精确对上;而这个程序真实消耗的 CPU 时间是 1.94s(比不限制时的 1.28s 略高,本身也有节流恢复调度的开销),不是 1.28s。nr_periods == nr_throttled == 77 说明每一个 100ms 周期,程序都在用完 25ms 配额之后被强制挂起,直到下个周期才能继续——正确的估算公式应该是"真实需要的 CPU 时间 ÷ 配额比例",而不是"不限制时的墙钟时间 ÷ 配额比例",这也是为什么很多人拿 --cpus 粗略估算延迟,结果都比实测出来的更乐观。
对应上面真实实测的吃内存程序,--memory=50m 限制下,一步步看内存占用怎么爬过限制线、最终触发 Kill。
数据直接取自上面真实实测的分配轨迹(每步 5MB,共 18 步到 90MB 处被杀),限制线画在真实的 50MB(memory.max=52428800)处——图上能清楚看到分配量越过限制线之后还继续走了几步才真正被 Kill,这正是上一节讲的"生效但非字节级瞬时"。