cgroup v2 资源隔离是把「一个服务出问题」限制在「这个服务自己死掉」范围内的关键机制。没有它,一个内存泄漏的进程可以把整台机器的 OOM killer 拖进来,一个失控的 fork 循环可以在几十秒内耗尽 pid 上限导致 SSH 都登不上。本文以 cgroup v2 统一层级为基础,用 systemd 提供的配置项给服务加上内存、CPU、进程数、磁盘带宽四道闸门,并给出验证与观测方法,全部配置可直接复制使用。
适用场景
第一类是需要在一台机器上混跑多个服务的场景,比如同一台服务器上既有 Nginx 又有 Java 应用还有定时任务,任何一个组件的资源使用失控都会波及邻居。第二类是容器化环境,容器本身就是靠 cgroup 做限制的,宿主机如果不理解 cgroup v2 的层级结构,排查「容器里看到的内存和宿主机不一致」这类问题会非常吃力。第三类是已经遭遇过或担心遭遇资源耗尽型攻击的环境,比如慢速连接堆积、正则回溯、压缩炸弹这类不靠带宽而是靠内存和进程数打垮服务的攻击。
反过来,纯粹的固定用途设备、单进程且资源需求恒定的场景,做这套隔离的收益有限,可以先跳过。资源隔离的价值主要体现在「边界」和「突发」两个词上,没有共享就没有边界问题。
前置条件
- 内核版本 4.15 以上,推荐 5.8 以上,后者对 cgroup v2 的控制器支持更完整。
- systemd 版本 243 以上,才能使用 OOMPolicy、ManagedOOMMemoryPressure 等较新的配置项。
- 具备 root 权限,能够修改 systemd unit 文件并执行 daemon-reload。
- 已确认系统运行在 cgroup v2 统一层级下,混跑 v1 与 v2 时配置行为会不一致。
原理说明
cgroup v2 与 v1 最大的差别是「统一层级」。v1 时代每个控制器(cpu、memory、blkio 等)各自挂载成一棵树,一个进程可以同时属于多棵树的不同节点,关系错综复杂,资源归属难以计算。v2 改为单一层级树,所有控制器在同一棵树上生效,每个进程只属于一个 cgroup 节点,父节点的限制天然对子节点构成上限。
systemd 在这棵树上做了自己的编排:根下划分为 system.slice、user.slice、machine.slice 三个大分支,每个 service 对应 system.slice 下的一个子节点,unit 名就是路径名。所以给一个服务设限制,本质上是给它在树上的那个节点写控制文件,systemd 只是把写文件这件事做成了声明式配置。
四类控制器的语义需要分清。memory 控制器管内存与 swap,MemoryMax 是硬上限,超过就触发 OOM;MemoryHigh 是软上限,超过后内核会主动回收并放慢分配速度,属于「先刹车再撞墙」的设计。cpu 控制器用 CPUQuota 表达配额,100% 等于一个核。pids 控制器用 TasksMax 限制任务数,这是防 fork 炸弹最直接的手段。io 控制器按设备做带宽限制,注意它针对的是块设备而不是文件系统。
还有一个常被忽略的能力是 PSI(Pressure Stall Information)。cgroup v2 在每个节点下都暴露了 cpu.pressure、memory.pressure、io.pressure 三个文件,记录的是「因为资源不足而实际卡住的时间比例」。这比看使用率有用得多:使用率 90% 不一定有问题,但 memory.pressure 的 full 值持续偏高,说明进程真的在等内存,这时候加限制或者扩容才有依据。
操作步骤
第一步:确认运行在 cgroup v2
判断方法很直接,看 /sys/fs/cgroup 的文件系统类型。返回 cgroup2fs 说明是 v2,返回 tmpfs 说明还是 v1 混合模式。混合模式下 cgroup v2 的控制器可能没有全部委派下来,需要额外处理。
# 判断层级类型
stat -fc %T /sys/fs/cgroup
# 查看已启用的控制器(v2 下为一行空格分隔的列表)
cat /sys/fs/cgroup/cgroup.controllers
# 查看当前挂载情况
mount | grep cgroup
# 若为混合模式,可在内核启动参数中强制统一
# 编辑 /etc/default/grub,在 GRUB_CMDLINE_LINUX 中加入:
# systemd.unified_cgroup_hierarchy=1 cgroup_no_v1=all
# 然后执行 update-grub 并重启
第二步:为服务写入资源上限
推荐用 drop-in 方式而不是直接改原始 unit 文件。drop-in 放在 /etc/systemd/system/<unit>.d/ 目录下,包升级时不会被覆盖,出问题删掉文件即可回滚,这是运维上更稳妥的做法。
mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/limits.conf <<'EOF'
[Service]
# 内存:软上限提前刹车,硬上限强制兜底
MemoryHigh=800M
MemoryMax=1G
# 禁用 swap,避免内存压力下反复换入换出拖慢整机
MemorySwapMax=0
# CPU:150% 表示最多用 1.5 个核
CPUQuota=150%
# 进程数上限:防 fork 炸弹最有效的一道闸
TasksMax=256
# 磁盘带宽限制,按实际块设备名调整
IOReadBandwidthMax=/dev/sda 80M
IOWriteBandwidthMax=/dev/sda 40M
# OOM 时按整个 cgroup 处理,避免进程半死状态
OOMPolicy=stop
EOF
systemctl daemon-reload
systemctl restart nginx
几个参数的取值逻辑需要说明。MemoryMax 不要贴着实际用量设,留出 20% 到 30% 的缓冲,否则正常的流量波动就会触发 OOM。TasksMax 的默认值通常是内核的 pid 上限,等于没有限制,必须显式调小。IOReadBandwidthMax 里的设备名必须是真实的块设备,写成分区名或者写错设备会导致该条配置被静默忽略,这一点在验证环节要专门确认。
第三步:为一次性任务临时限流
不是所有负载都跑在常驻服务里。数据导入、日志压缩、备份打包这类任务往往是临时拉起的,用 systemd-run 把他们放进独立的 scope 里跑,任务结束自动清理,不需要维护 unit 文件。
# 限制内存 512M、最多 64 个进程、CPU 半个核,运行备份脚本
systemd-run --scope --unit=nightly-backup \
-p MemoryMax=512M \
-p MemorySwapMax=0 \
-p TasksMax=64 \
-p CPUQuota=50% \
/usr/local/bin/backup.sh
# 查看 scope 是否创建成功
systemctl status nightly-backup.scope
第四步:开启整组 OOM 击杀
默认行为下 OOM killer 只杀一个进程,这对于多进程服务(比如 PHP-FPM、Node 集群)是个隐患:主进程被杀掉、子进程变成孤儿继续占资源,服务处于半死状态且无法自愈。把 memory.oom.group 置为 1,可以让内核在一次 OOM 中把整个 cgroup 里的进程全部清掉,配合 systemd 的自动重启,服务能干净地拉起。
# 方式一:unit 里写 OOMPolicy=stop(推荐,systemd 243+)
# 已在第二步的 limits.conf 中配置
# 方式二:运行时直接写控制文件(作用于任意 cgroup 节点)
echo 1 > /sys/fs/cgroup/system.slice/nginx.service/memory.oom.group
# 查看当前取值
cat /sys/fs/cgroup/system.slice/nginx.service/memory.oom.group
第五步:容器侧对齐宿主机配置
容器运行时的资源参数最终都落到 cgroup 上,所以宿主机是 v2 时,容器参数的含义与 v1 略有差别。最典型的是 swap 的算法:v2 下 memory.swap.max 是独立的,而 v1 下 –memory-swap 是内存加 swap 的总和。这个差异会让同一条命令在两种环境下产生完全不同的实际限制。
# Docker:内存 512M、禁用 swap、限制进程数、CPU 半个核
docker run -d --name api \
--memory=512m --memory-swap=512m --memory-swappiness=0 \
--pids-limit=128 --cpus=0.5 \
api:latest
# Podman(rootless 同样依赖 cgroup v2 委派)
podman run -d --name api \
--memory=512m --pids-limit=128 --cpus=0.5 \
api:latest
# 验证容器实际生效的限制
docker inspect api --format '{{.HostConfig.Memory}} {{.HostConfig.PidsLimit}} {{.HostConfig.NanoCpus}}'
一个高频困惑是「容器里 free 看到的内存是宿主机的」。这属于预期现象,free 读的是 /proc/meminfo,那是宿主机视图,不会因为 cgroup 限制而改变。要看真实限制,应当读容器内的 /sys/fs/cgroup/memory.max。同理,Java 应用如果读不到 cgroup 限制,会按宿主机内存去算堆大小,结果是被 OOM 直接打死,这类问题需要在启动参数里显式指定 -Xmx 或者确保 JVM 版本支持容器感知。
配置验证
配置是否真正生效,不能只看 unit 文件写了什么,必须读运行时的控制文件。下面这组命令覆盖了四类控制器的核验。
UNIT=nginx.service
CG=/sys/fs/cgroup/system.slice/$UNIT
echo "--- 内存 ---"
cat $CG/memory.max # 期望 1073741824(即 1G)
cat $CG/memory.high # 期望 838860800(即 800M)
cat $CG/memory.swap.max # 期望 0
cat $CG/memory.current # 当前实际占用
echo "--- CPU ---"
cat $CG/cpu.max # 格式为 "配额 周期",150% 应显示 150000 100000
echo "--- 进程数 ---"
cat $CG/pids.max # 期望 256
cat $CG/pids.current
echo "--- IO ---"
cat $CG/io.max # 应包含 8:0 rbps=... wbps=...
echo "--- OOM 组 ---"
cat $CG/memory.oom.group # 期望 1
压力观测建议单独作为一项例行检查。PSI 的三个指标里,full 表示「所有任务都被阻塞」的时间占比,avg10 是最近 10 秒的平均值。如果 memory.pressure 的 avg10 full 长期高于 5,说明内存已经构成瓶颈;如果 io.pressure 偏高而 CPU 空闲,问题在磁盘而不是计算资源。
# 实时观察各 cgroup 资源占用,按内存排序
systemd-cgtop -m --order=memory
# 查看 PSI 压力指标
cat /sys/fs/cgroup/system.slice/nginx.service/memory.pressure
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.pressure
cat /sys/fs/cgroup/system.slice/nginx.service/io.pressure
# 若出现 OOM,从内核日志里确认是哪个 cgroup 触发
dmesg -T | grep -i "oom-kill" | tail -20
journalctl -u nginx.service --since "1 hour ago" | grep -i oom
最后做一次实测更可靠:用一行命令在受限 scope 里申请超过上限的内存,观察是否被干净地终止,以及服务是否自动重启。这一步能同时验证 MemoryMax 与 OOMPolicy 两个配置是否真正落地。
# 在 64M 限制下申请 200M,期望进程被立即终止
systemd-run --scope -p MemoryMax=64M -p MemorySwapMax=0 \
python3 -c "a=bytearray(200*1024*1024); print('should not reach')"
# 期望输出类似:Killed / signal=KILL
echo $?
常见问题
Q1:MemoryMax 设了但进程内存还是涨到几百 G,为什么没被限制?
按可能性从高到低排查四点。第一,控制器没有启用:cgroup v2 下子节点需要父节点把控制器委派下来,如果 cgroup.subtree_control 里没有 memory,写入的限制文件不会生效,检查方法是看该节点是否存在 memory.max 文件。第二,写入路径错了:限制必须写在进程实际所属的节点上,进程如果被 systemd 挪到了别的 slice,写 nginx.service 目录是无效的。第三,swap 没关:MemorySwapMax 未设时,进程可以把匿名页换到 swap,看起来内存没超但系统已经被拖垮。第四,统计口径差异:memory.current 包含页缓存,看到数字很大不代表进程真的占了那么多匿名内存,要看 memory.stat 里的 anon 字段。
Q2:TasksMax 设成 256 之后,Nginx 高并发时报错 cannot allocate memory 怎么处理?
这是限制生效后的正常表现,不是故障。Nginx 的每个 worker 连接不占进程,但 upstream 连接、临时文件、线程池会消耗任务数,同时 PHP-FPM 这类FastCGI 后端每个请求可能对应一个进程。定位方法是先看 pids.current 是否贴近 pids.max,如果是,按实际并发量重新估算上限,通常按「预期并发连接数除以单进程承载能力」再乘一个 2 倍安全系数。另一个方向是减少不必要的进程模型,比如用事件驱动替代每请求一进程,从根本上降低进程数需求。
Q3:cgroup v2 下还能用 net_cls 做流量分类吗?
不能沿用 v1 的思路。net_cls 与 net_prio 在 cgroup v2 中没有对应的控制器,官方方向是用 eBPF 程序挂到 cgroup 上做流量分类与限速。如果现有方案依赖 net_cls 打标记再由 tc 分类,迁移时需要改为在 cgroup 挂载点加载 eBPF 程序,或者退回到按网卡队列与 IP 段做策略。这一块属于结构性变化,规划迁移时必须单独评估,不能假设配置文件平移就能用。
总结
cgroup v2 资源隔离的核心不是记住几个配置项,而是理解「单一层级 + 父节点构成上限」这个模型,之后任何限制都能自己推导出来。落地时按「确认层级、写 drop-in、开整组 OOM、对齐容器、读控制文件验证」五步走,每一步都有可观测的验收标准,不依赖猜测。最后强调一点:限制值必须来自实测数据而不是拍脑袋,先用 systemd-cgtop 和 PSI 采集一周基线,再设上限,才能既挡住失控又不误伤正常业务。