bigywater.tech
Docker 系列 · 第二篇

--memory 写在命令行里,是不是真的能拦住一个疯狂吃内存的程序?

接着上一篇的 FROM scratch 套路,这次写一个真的会不断分配、真的会写入内存的 Go 程序,分别在有 --memory 限制和没有限制的情况下真实跑,看它到底会不会被杀掉、什么时候被杀掉、退出码是什么。顺带用同样的思路测了 --cpus,结果比想象中更有意思——不是简单的"限制到 0.25 核就慢 4 倍"。

退出码 137
本机真实实测:--memory=50m 下的吃内存程序被内核真实 OOM Kill,docker inspect 显示 OOMKilled=true
7.66s,不是 5.12s
本机真实实测:--cpus=0.25 下同样的计算量,耗时不是"无限制耗时 ×4",用真实的节流计数解释了差距

真实源码:--memory/--cpus 最终写进哪两个文件

这台机器的 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 只是负责把人类友好的参数翻译成内核认识的格式。

真实实测:容器内部读到的 memory.max / cpu.max,和参数精确对应

容器里读 /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 微秒的配额,跟上面源码里的换算逻辑完全对得上。

真实实测:真的会被 OOM Kill,但不是卡在限制的那一瞬间精确停下

写一个 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 落盘本身也有缓冲延迟。结论是"限制真实生效",但"在哪一字节精确触发"不是一个可以指望到字节级精确的保证。

真实实测:--cpus=0.25 不是简单的"变慢 4 倍"

写一个纯 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.stat
busy 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 粗略估算延迟,结果都比实测出来的更乐观。

演示:内存分配到超限,一步步走到 OOM Kill

对应上面真实实测的吃内存程序,--memory=50m 限制下,一步步看内存占用怎么爬过限制线、最终触发 Kill。

--memory=50m · 真实内存分配轨迹

数据直接取自上面真实实测的分配轨迹(每步 5MB,共 18 步到 90MB 处被杀),限制线画在真实的 50MB(memory.max=52428800)处——图上能清楚看到分配量越过限制线之后还继续走了几步才真正被 Kill,这正是上一节讲的"生效但非字节级瞬时"。

参考与说明

  • 本文全部真实实验(memory.max/cpu.max 跟 --memory/--cpus 参数的精确对应、真实吃内存程序在限制下被真实 OOM Kill 及退出码/OOMKilled 状态、--cpus=0.25 真实耗时与真实 cpu.stat 节流计数)均在本机真实运行的 Docker Desktop(cgroup v2,cgroupfs 驱动)上完成,用的是跟上一篇一样的 FROM scratch + 本机交叉编译静态 Go 二进制技巧,全程零网络镜像拉取,数据未做删改。
  • 源码引用(fs2/memory.go 的 numToStr/setMemory、fs2/cpu.go 的 setCPU)取自 opencontainers/cgroups 仓库 v0.0.6 标签——这是 runc v1.5.1(上一篇引用的同一个版本)go.mod 里实际依赖的版本号,不是随便挑的。
  • 演示动画的内存分配轨迹直接取自真实实测的 18 步分配日志和真实的 memory.max 数值,限制线位置和最终被杀的时间点都跟真实输出一致。
  • 没有涉及:cgroup v1 与 v2 在文件格式上的差异(memory.limit_in_bytes vs memory.max)、memory.high 软限制(只做内存回收压力、不直接 kill)和 memory.max 硬限制的区别、--memory-swap/swap 空间对 OOM 判定的影响、cgroup 内核 OOM killer 具体挑选"杀哪个进程"的打分算法(oom_score_adj)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号