Docker核心技术之Cgroup
03 · Cgroup:资源限额
重要程度:★★★★☆ 重要 | 适用角色:SRE / 运维 | 前置:01 Namespace、02 OverlayFS
一、为什么必须学
坑 1:一个容器内存泄漏,把整台机器拖垮
Docker 默认不限制任何资源。容器里一个内存泄漏的进程会吃光宿主机全部内存,触发宿主机 OOM Killer——连带杀掉同机的其他服务。
在 1GB 内存的机器上跑生产服务,这是真实发生过的事。
坑 2:设了 --memory=32m,容器里 free -m 显示 1019MB
docker run --rm --memory=32m alpine:3.20 sh -c 'free -m | head -2'
# Mem: 1019 270 71 ...
看到 1019MB 会以为限制没生效。free 读 /proc/meminfo(全局统计),不感知 cgroup 层级。 要看真实限额必须读 /sys/fs/cgroup/memory.max。
这个误解会导致两种错误:要么以为没限制而放弃设限,要么以为 free 显示的是真实值而误判。
坑 3:以为 --cpus=0.5 是"独占半个核"
不是,是配额。 两个 --cpus=1 的容器可以同时跑满两个核。--cpus 限的是"最多用多少 CPU 时间",不是"只能用哪些核"。
真正独占要 --cpuset-cpus 绑核。
坑 4:容器里起了几百个进程,宿主被拖垮
没有 --pids-limit,容器内的 fork 炸弹能把宿主机进程表撑爆。生产环境必配。
二、理论:cgroup 做什么
一句话:namespace 让容器看不见,cgroup 让容器用不了超过限额的资源。
两者正交,缺一不可:
- 只有 namespace → 容器能看见宿主机所有资源,且能全部吃掉
- 只有 cgroup → 资源被限制了,但仍能看到全部(可能引发信息泄露)
cgroup v2 统一层级
cgroup v2 是单一树状结构,所有控制器挂在一起。v1 时代各控制器有独立挂载点(/sys/fs/cgroup/memory、/sys/fs/cgroup/cpu 等),v2 统一到 /sys/fs/cgroup。
| 维度 |
cgroup v1 |
cgroup v2 |
| 层级结构 |
各控制器独立挂载点 |
统一单棵树 |
| 挂载点 |
多个 |
单一 /sys/fs/cgroup |
| 控制器 |
分散管理 |
cgroup.subtree_control 显式启用 |
| 内核要求 |
3.8+ |
4.5+(推荐 5.x) |
| Docker 24+ |
— |
默认使用 v2 |
Docker 的 cgroup 组织方式
Docker 把每个容器注册为 systemd 的一个 scope 单元:
/ ← cgroup 根
└── system.slice ← systemd 管理的切片
└── docker-<容器ID>.scope ← 每个容器一个 scope
这个设计有实际价值:进程退出时 systemd 能精确知道是哪个容器,彻底避免"cgroup 里残留孤儿进程"。
三、实操验证
3.1 cgroup 层级
stat -fc %T /sys/fs/cgroup/
# cgroup2fs
启动带资源限制的容器:
docker run -d --name cg-lab --memory=128m --cpus=0.3 --pids-limit=64 \
alpine:3.20 sh -c 'while true; do sleep 5; done'
sleep 2
CGPID=$(docker inspect cg-lab --format '{{.State.Pid}}')
cat /proc/$CGPID/cgroup
实测输出:
0::/system.slice/docker-9013cef73738fffa3bd55f309645994bbd2fe25e547870eb37675d0e25cdcf5e.scope
宿主机上对应的目录:
ls -d /sys/fs/cgroup/system.slice/docker-*
/sys/fs/cgroup/system.slice/docker-54bcc47f3e442edccd406393052e1e528de8ed808dc5520a41902b4c59ee95fc.scope
/sys/fs/cgroup/system.slice/docker-9013cef73738fffa3bd55f309645994bbd2fe25e547870eb37675d0e25cdcf5e.scope
路径里的容器 ID 与 docker ps 显示的一致,验证了"容器 = systemd scope"的设计。
3.2 限额参数精确落地
CGREL=$(cat /proc/$CGPID/cgroup | grep -oE '0::.*' | cut -d: -f3)
printf "%-28s %s\n" "memory.max" "$(cat /sys/fs/cgroup$CGREL/memory.max)"
printf "%-28s %s\n" "cpu.max" "$(cat /sys/fs/cgroup$CGREL/cpu.max)"
printf "%-28s %s\n" "pids.max" "$(cat /sys/fs/cgroup$CGREL/pids.max)"
printf "%-28s %s\n" "memory.current" "$(cat /sys/fs/cgroup$CGREL/memory.current)"
printf "%-28s %s\n" "cgroup.procs" "$(cat /sys/fs/cgroup$CGREL/cgroup.procs | tr '\n' ' ')"
实测输出:
memory.max 134217728
cpu.max 30000 100000
pids.max 64
memory.current 425984
cgroup.procs 781037 781058
对照启动参数:
| Docker 参数 |
期望值 |
cgroup 文件 |
实测 |
✓ |
--memory=128m |
134217728 |
memory.max |
134217728 |
✓ |
--cpus=0.3 |
30000 100000 |
cpu.max |
30000 100000 |
✓ |
--pids-limit=64 |
64 |
pids.max |
64 |
✓ |
这是最有说服力的一组数据:命令行参数精确地落到了内核的 cgroup 文件里,一个 bit 都不差。
3.3 cpu.max 的含义
cpu.max = 30000 100000
读作:每 100000 微秒(100ms)周期内,该组所有进程合计最多使用 30000 微秒 CPU 时间。
--cpus |
cpu.max |
含义 |
0.3 |
30000 100000 |
三成 CPU 时间 |
0.5 |
50000 100000 |
半数 |
1 |
100000 100000 |
满一核 |
2 |
200000 100000 |
两核 |
关键:--cpus 是配额不是独占。两个 --cpus=1 的容器可以同时跑满两个核。真正的物理核绑定要 --cpuset-cpus。
3.4 内存限制真实生效:memory.events 计数
用 memory.events 观察限额被触发的次数:
docker run -d --name ev-lab --memory=64m --memory-swap=64m \
alpine:3.20 sh -c 'while true; do sleep 5; done'
sleep 2
CGPID=$(docker inspect ev-lab --format '{{.State.Pid}}')
CGREL=$(cat /proc/$CGPID/cgroup | grep -oE '0::.*' | cut -d: -f3)
echo "--- 初始 memory.events ---"
cat /sys/fs/cgroup$CGREL/memory.events
low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0
制造内存压力(写入 250MB 文件):
docker exec ev-lab sh -c 'for i in 1 2 3 4 5; do dd if=/dev/zero of=/tmp/f$i bs=1M count=50 2>/dev/null; done'
sleep 2
echo "--- 压力后 memory.events ---"
cat /sys/fs/cgroup$CGREL/memory.events
low 0
high 0
max 760 ← 被拒绝了 760 次
oom 0
oom_kill 0
oom_group_kill 0
max 760 —— 分配请求被内核拒绝了 760 次。限额确实在生效。
同时看实时占用和构成:
cat /sys/fs/cgroup$CGREL/memory.current
cat /sys/fs/cgroup$CGREL/memory.high
grep -E "^(anon|file|inactive_file|active_file|slab) " /sys/fs/cgroup$CGREL/memory.stat
65708032 ← memory.current = 62.7MB
max ← memory.high 默认是 max (无限制)
anon 114688 ← 匿名内存仅 112KB
file 62914560 ← page cache 占 60MB
inactive_file 62914560
active_file 0
slab 2522888
3.5 重要发现:为什么写文件不触发 OOM
注意上面的 anon 114688(匿名内存仅 112KB)而 file 62914560(page cache 60MB)。
内核会回收 page cache 而不是杀进程——文件写进 page cache 是可回收的,不算"超限"。
所以:
| 内存消耗方式 |
触发 OOM? |
原因 |
| 写文件(dd 写磁盘) |
否 |
page cache 可回收 |
| 申请匿名内存(malloc) |
是 |
不可回收,必须 OOM |
这解释了为什么很多测试 OOM 的脚本用 dd 一直测不出来——必须制造匿名内存分配。
实测触发 OOM:
docker run --name oom-lab --memory=32m --memory-swap=32m alpine:3.20 sh -c '
echo "cgroup memory.max = $(cat /sys/fs/cgroup/memory.max) 字节 (32MB)"
echo "开始申请 100MB 匿名内存 (不可回收)..."
awk "BEGIN{ s=\"\"; for(i=0;i<200000;i++) s = s \"X\"; while(1) { hold[++n]=s } }"
echo "不应该走到这里"
' 2>&1 | tail -5
cgroup memory.max = 33554432 字节 (32MB)
开始申请 100MB 匿名内存 (不可回收)...
Killed
不应该走到这里
Killed —— 进程被 OOM Killer 杀掉了。
3.6 free 命令不反映限制(关键误区)
docker run --rm --memory=32m alpine:3.20 sh -c 'free -m | head -2'
total used free shared buff/cache available
Mem: 1019 270 71 14 678 590
显示 1019MB——宿主机的总内存,不是 32MB 限制。
原因:free 读 /proc/meminfo,这是全局统计,不感知 cgroup 层级。
要看容器真实限额:
docker run --rm --memory=32m alpine:3.20 cat /sys/fs/cgroup/memory.max
# 33554432
或者用 docker stats(它直接读 cgroup 的 memory.current,是准确的):
docker stats --no-stream
四、cgroup v2 核心文件速查
| 文件 |
作用 |
对应 Docker 参数 |
memory.max |
内存硬上限(字节) |
--memory |
memory.high |
软限制,超过则强制回收 |
—(需手动设) |
memory.low |
软限制,优先回收保护 |
--memory-reservation |
memory.swap.max |
swap 上限 |
--memory-swap |
memory.current |
当前占用 |
— |
memory.events |
事件计数(max/oom/oom_kill) |
— |
memory.stat |
内存构成明细(anon/file/slab) |
— |
cpu.max |
CPU 配额 (quota period) |
--cpus |
cpu.stat |
统计,含 nr_throttled |
— |
cpuset.cpus.effective |
绑定的物理核 |
--cpuset-cpus |
pids.max |
进程数上限 |
--pids-limit |
pids.current |
当前进程数 |
— |
cgroup.procs |
属于此 cgroup 的进程 |
— |
cgroup.controllers |
可用控制器 |
— |
事件计数的诊断价值
cat /sys/fs/cgroup<rel>/memory.events
| 计数器 |
含义 |
处置方向 |
max |
分配被拒次数 |
限额偏小,或有内存泄漏 |
oom |
触发 OOM 次数 |
同上 |
oom_kill |
进程被 OOM 杀掉次数 |
内存泄漏,需调大限额或优化 |
oom_kill > 0 是生产事故的明确信号,说明容器内进程被内核杀了。
五、生产配置准则
1GB 内存机器上的最小配置
docker run -d \
--name myapp \
--restart=unless-stopped \
--memory=256m \
--memory-swap=256m \
--cpus=1 \
--pids-limit=100 \
myapp:1.0
--memory-swap 必须 ≥ --memory,否则 swap 行为异常。设成相等表示禁用 swap。
内存限额的取值方法
--memory = 应用稳态占用 × 2~3
先用 docker stats 观察真实占用,再留 2~3 倍余量给突发流量。
别照抄别人的数值——不同应用的内存曲线完全不同。
CPU 配额 vs 核绑定
| 需求 |
参数 |
效果 |
| 防止某容器抢占全部 CPU |
--cpus=0.5 |
配额,不独占 |
| 强制独占某些核 |
--cpuset-cpus=0,1 |
绑定物理核 |
| 降低优先级 |
--cpu-shares=512 |
相对权重(v1 语义,v2 映射为 cpu.weight) |
生产必配清单
六、常见误解
误解 1:--memory 后容器内 free 会显示限制值
错。 实测 --memory=32m 时 free -m 仍显示 1019MB。
free 读 /proc/meminfo(全局统计),不感知 cgroup。要看限制读 /sys/fs/cgroup/memory.max,或用 docker stats。
误解 2:容器隔离了 CPU
没有隔离,是限额。 --cpus=0.5 只是限制最多用 50% 的 CPU 时间,两个 --cpus=1 的容器可以同时跑满两个核。
真正的 CPU 隔离要用 --cpuset-cpus 绑核。
误解 3:Docker 默认会限制容器资源
完全不会。 不加任何参数,容器可以用光宿主机全部 CPU 和内存,直到触发宿主机 OOM Killer。
误解 4:cgroup v1 和 v2 只是版本新旧,功能一样
结构完全不同。 v1 各控制器独立挂载点,v2 统一单棵树。这直接影响脚本写法——v2 下所有限额文件都在 /sys/fs/cgroup/<path>/ 同一层。
误解 5:容器 OOM 会连带影响宿主
不会(v2 下)。 cgroup 级别的 OOM 只杀该 cgroup 内的进程,宿主其他服务不受影响。
但如果没设 --memory,容器吃光的是宿主物理内存,此时触发的是宿主 OOM Killer,可能杀掉任何进程——包括你的其他服务。这才是真正的危险。
误解 6:docker stats 显示的内存是宿主机视角
不是。 docker stats 读的是 cgroup 的 memory.current,是容器的真实占用。这和容器内 free 的行为不同——同一台机器上两个命令给出不同答案,是正常的。
七、本篇速查
# 容器 cgroup 路径
CGPID=$(docker inspect <c> --format '{{.State.Pid}}')
cat /proc/$CGPID/cgroup
CGREL=$(cat /proc/$CGPID/cgroup | grep -oE '0::.*' | cut -d: -f3)
# 读限额
cat /sys/fs/cgroup$CGREL/memory.max
cat /sys/fs/cgroup$CGREL/cpu.max
cat /sys/fs/cgroup$CGREL/pids.max
# 读实时状态
cat /sys/fs/cgroup$CGREL/memory.current
cat /sys/fs/cgroup$CGREL/memory.events
grep -E "^(anon|file|slab) " /sys/fs/cgroup$CGREL/memory.stat
# 容器内读自己的限额
docker exec <c> cat /sys/fs/cgroup/memory.max
docker exec <c> cat /sys/fs/cgroup/cpu.max
# 全局资源占用
docker stats --no-stream
docker system df
# 快速诊断 OOM
docker inspect <c> --format '{{.State.OOMKilled}}'
docker inspect <c> --format '{{.State.ExitCode}}' # 137 = OOM
上一篇:02 · OverlayFS:镜像与容器文件层
下一篇:04 · Capabilities:细粒度权限
相关:专题索引