cgroup v2,以及限制看上去没生效的时候
一句话总结
命名空间负责能看到什么,cgroup 负责能使用多少。而本课程中最常欺骗人的事实是:
cgroup 虽然会施加限制,却不会隐藏 /proc。
为什么需要它
cgroup 于 2006 年起源于 Google,并在 2007 年合入内核 2.6.24。它最初名为 “process container”,后来因为容易与当时已广泛使用的 container 一词混淆, 改名为 control group,简称 cgroup。这个名称的历史本身就解释了它的职责—— 它不是隔离机制,而是资源控制。
一台主机上运行三十个容器,其中一个耗尽全部内存,其余二十九个也会一同死亡。 命名空间完全无法解决这个问题,因为它们只是彼此不可见,仍共享同一套物理资源。
它如何运作
首先确认版本。如果 stat -fc %T /sys/fs/cgroup 返回 cgroup2fs,就是 v2;
返回 tmpfs,就是 v1。v2 的主要文件如下。
| 文件 | 含义 |
|---|---|
cpu.max |
quota period 两个值。50000 100000 表示 0.5 CPU |
cpu.weight |
1~10000 之间的相对权重,默认值为 100 |
cpu.stat |
nr_periods、nr_throttled、throttled_usec |
memory.min/low/high/max |
保护线与上限 |
memory.current |
当前使用量 |
memory.events |
触及限制的次数 |
pids.max |
进程数量上限 |
在 v1 中,对应文件分别是 cpu.cfs_quota_us、memory.limit_in_bytes。
区分 memory.high 与 memory.max 很重要。**超过 high 会产生回收压力并减慢分配,
但进程不会死亡;超过 max 则会触发 OOM Kill。**因此 high 表示“从这里开始施加痛苦”,
max 表示“这里就是墙”。
CPU 不会杀死进程,而是让它休眠。若 cpu.stat 中的 nr_throttled / nr_periods
超过 5%,就视为问题。1000 个周期中有 350 次被限流,就是 35%。此时会出现典型现象:
CPU 使用率图看起来很空闲,只有响应时间突然升高。
常用字节值值得记住:64MiB = 67108864、192MiB = 201326592、 256MiB = 268435456、512MiB = 536870912。
退出码 137 = 128 + 9 = SIGKILL。OOM kill 不会给应用任何异常,
无法用 try/except 捕获,也不会留下应用日志。必须在内核日志中看到
Memory cgroup out of memory: Killed process ... 才能确认,
在运行时侧则要用 State.OOMKilled 区分。
在实际工作中会遇到的情况
这是最重要的陷阱。在用 --memory=256m --cpus=0.5 启动的容器中运行
free -m,会看到 64228MB;运行 nproc,会看到 32。原因是 cgroup 会限制资源,
却不会隐藏 /proc。JVM 通过 UseContainerSupport 直接读取 cgroup 文件解决了这个问题,
但 Node 的 os.cpus() 与 Go 的 runtime.NumCPU() 仍然看到主机值。
所以必须为 Go 服务显式设置 GOMAXPROCS。分配 0.5 个核,却启动 32 个工作线程,
结果只会不断累积限流。
Kubernetes 的 QoS 类别也源于这里。Guaranteed 的 OOM score 为 -997, Burstable 为 2~999,BestEffort 为 1000。BestEffort 最先死亡并非一句策略描述, 而是 cgroup 与 OOM 分数直接作用的结果。
还有一点:在 rootless 环境中,即使设置了限制,也可能并未真正强制执行。
如果没有 systemd delegation(Delegate=cpu memory pids io),请求会被记录,
却不会反映到内核。因此后续实验会同时检查 docker inspect 的 HostConfig 请求值,
以及容器实际看到的值。两者若不同,这种差异本身就是要学习的内容。
v1 与 v2 表现不同的地方
cgroup v1 与 v2 同时存在,文件名和含义也不同。如果不先确认使用哪一种, 就会去寻找根本不存在的文件。
stat -fc %T /sys/fs/cgroup # cgroup2fs 면 v2, tmpfs 면 v1
| 内容 | v1 | v2 |
|---|---|---|
| 内存限制 | memory/memory.limit_in_bytes |
memory.max |
| 当前使用量 | memory.usage_in_bytes |
memory.current |
| CPU 限制 | cpu.cfs_quota_us / cpu.cfs_period_us |
cpu.max(两个值写在同一行) |
| 限流统计 | cpu.stat |
cpu.stat |
| OOM 记录 | (无) | memory.events |
**v2 的 memory.events 尤其有用。**v1 没有该文件,因此过去只能通过内核日志判断
“这个容器是否曾因 OOM 死亡”。
**没有限制时显示为 max。**它不是数字,而是字符串 max,因此解析代码会抛出异常。
开发工具时经常会碰到这个问题。
**内存限制也会计算页缓存。**大量读取文件的容器会不断积累缓存并触及上限,
但内核会回收缓存,所以通常不会死亡。因此,memory.current 本身紧贴上限并不代表问题。
只有 memory.events 中的 oom_kill 增加,才是真正的问题。
**CPU 限制不会体现在使用率上。**进程每 100ms 用完配额后便等待剩余时间,
因此平均使用率很低,响应却很慢。cpu.stat 的 nr_throttled 与
throttled_usec 就是证据。
**过小的限制反而有害。**把 CPU 限制设为 0.1 核,意味着每 100ms 只能运行 10ms, 连短任务也要跨越多个周期才能完成,延迟会显著增加。因此对响应时间而言, request 设置得低一些,limit 则宽松设置或不设更有利。
后续实验要做什么
确认 cgroup 版本和控制器列表,分别设置内存、CPU、PID 限制, 然后对照请求值与观测值。亲自用 SIGKILL 制造退出码 137, 并整理如何将它与 OOM kill 区分开来。