本文要点
- cgroup v2 是内核统一的资源控制体系,通过
/sys/fs/cgroup文件系统暴露,可以限制进程的内存、CPU、进程数(pids)等资源。 - 确认系统是否为 cgroup v2:
stat -fc %T /sys/fs/cgroup输出cgroup2fs即为 v2;再用cat /sys/fs/cgroup/cgroup.controllers查看可用的控制器。 - 内存上限:
systemd-run --scope -p MemoryMax=100M -p MemorySwapMax=0 <命令>,超限触发 OOM,进程以退出码 137 被杀死(仅设 MemoryMax 时内核会优先把超额页换出到 swap 兜底)。 - CPU 配额:
-p CPUQuota=50%把进程限制到半核,top里实测 CPU 占用从 100% 降到约 50%。 - 实际运维优先用 systemd 资源控制属性(systemd-run / systemctl set-property),比直接改 cgroup 文件更可靠、可热调整、可持久化。
为什么需要 cgroup:从失控进程说起
一台多服务的 Linux 服务器上,最怕遇到的情况之一,就是一个进程"失控":内存泄漏把可用内存吃光,机器开始疯狂 swap、卡到无法 SSH;或者某个服务疯狂占满 CPU,让同一台机器上的其他服务全部响应缓慢。
早在 cgroup 之前,运维只能靠"盯监控、发现问题、手动 kill"来救火。cgroup(control group,控制组)就是内核提供的"资源闸门":把一组进程圈进一个控制组里,对这个组的 CPU、内存、IO、进程数等资源设定额度。圈内的进程用超了配额,就直接被限制或杀掉,绝不会拖垮整台机器。
本篇文章在 Ubuntu 24.04(内核 6.8)上实测验证,带你从认识 cgroup v2 开始,到用 systemd 优雅地完成资源限制。容器(Docker)能实现 CPU 和内存隔离,底层依赖的正是这套机制。
cgroup v2:现代 Linux 的资源控制体系
cgroup 经历了两代演进。老一代 cgroup v1 每个资源类型(cpu、memory、blkio)各模拟一套层级,进程可能同时挂在不同类型的树里,管理混乱;cgroup v2(自内核 4.5 引入、4.15 之后逐步完善)把所有资源统一到一棵树,子组只能继承父组的控制器,规则清晰,也更适配 systemd 调度模型。
主流发行版(Ubuntu 22.04+、Debian 12+、较新的 CentOS/RHEL)默认已经是 cgroup v2,没有额外的"开关"需要打开。
确认系统是 v2,并查看可用的控制器
登录服务器,一条命令套餐就能把三件事一次看清楚:确认文件系统类型、列出可用的控制器、查看当前命令自己属于哪个 cgroup:
stat -fc %T /sys/fs/cgroup && echo --- && cat /sys/fs/cgroup/cgroup.controllers && echo --- && cat /proc/self/cgroup输出 cgroup2fs 表示挂载的就是 v2 的 cgroup2 文件系统;如果是 tmpfs,则还是 v1。接着查看这台机器支持哪些控制器,以及当前 shell 自己属于哪个 cgroup:

cat /sys/fs/cgroup/cgroup.controllers列出内核支持的控制器:cpuset(CPU 亲和性)、cpu(CPU 配额)、io(磁盘 IO)、memory(内存)、hugetlb(大页)、pids(进程数)、rdma、misc等;/proc/self/cgroup显示当前命令属于0::/user.slice/user-0.slice/session-...scope。0::里的0是 v2 统一直线层级(v1 下每个资源类型各占一个序号),后面是它在这棵树里的路径。
控制器开关:subtree_control
v2 的每个 cgroup 目录默认"兼容"所有控制器,但子级目录只会挂出被父级开启(enable)的控制器文件。开启开关写在 cgroup.subtree_control 里,例如:
echo '+memory +cpu +pids' > /sys/fs/cgroup/cgroup.subtree_control系统里的根层 cgroup.subtree_control 默认已被 systemd 开启 cpuset cpu io memory hugetlb pids rdma misc 全部控制器,所以我们接下来的演示可以直接看到 memory.max、cpu.max、pids.max 这些控制文件,无需手工"开控制器"。
直接操作 cgroup 文件系统:万物皆文件
要理解 cgroup v2,最直观的方式就是把它当成普通目录来用。限制一个进程,本质上是四步:建目录 → 写限额 → 把进程 PID 写进 cgroup.procs → 清理时移出并删除目录。
先在系统根下建一个演示控制组:
mkdir /sys/fs/cgroup/demo-cg目录建好就是一张"空白账单"。给它设定内存上限 64MB、进程数上限 8:
echo 64M > /sys/fs/cgroup/demo-cg/memory.max
echo 8 > /sys/fs/cgroup/demo-cg/pids.max
cat /sys/fs/cgroup/demo-cg/memory.max输出 67108864(字节),说明写入生效。再启动一个后台进程,把它的 PID 写进 cgroup.procs 完成"入组":
sleep 300 &
echo $! > /sys/fs/cgroup/demo-cg/cgroup.procs
cat /proc/$!/cgroup/proc/<PID>/cgroup 会显示 0::/demo-cg,证明该 sleep 进程已被编入这个控制组。此时它的一切资源记账都归 demo-cg 管了。用完删除目录前,得先把进程移出(写回父组)或杀掉进程:
kill %1
rmdir /sys/fs/cgroup/demo-cg直接在系统根 cgroup 下建组只适合快速验证。生产环境里各个 cgroup 目录都归属 systemd 的 slice/scope/unit,手动建组容易和 systemd 的调度打架,日常运维更推荐下文用 systemd 的方式。
systemd-run:一条命令创建临时运行单元
systemd 把资源限制做成了一组标准属性(MemoryMax、CPUQuota、TasksMax 等,见 systemd.resource-control 手册)。systemd-run 可以临时拉起来一个带限制的运行单元,用完即焚,比改文件快得多。
限制内存并触发 OOM
先看一个 300MB 内存分配,在限了 100MB 内存、并禁掉 swap 兜底的控制组里会发生什么:
systemd-run --scope -p MemoryMax=100M -p MemorySwapMax=0 python3 -c "import time; x=bytearray(300*1024*1024); time.sleep(1)"; echo limit100M_exit=$?
输出里能看到三件事:
Running as unit: run-xxxx.scope—— systemd 为这次命令创建了一个 transient(临时)scope 单元,进程被装进对应的 cgroup;bash: ... Killed ...—— 进程被内核 OOM Killer 直接 SIGKILL;limit100M_exit=137—— 退出码 137(= 128 + 9,SIGKILL 信号号)。这串输出就是"超限被杀"的铁证。
这里故意加上了 MemorySwapMax=0(等价于关掉该组的 swap 使用)。为什么要关?我实测发现一个反直觉的现象:只设 MemoryMax=100M 时,同样的命令并不会被杀——内核会把内存控制组里"超额"的匿名页换出到系统 swap 兜底,进程只是变慢,检查 memory.events 也只能看到 max(触顶)计数不断上涨、而 oom 仍为 0。只有再挪掉 swap 这条退路,超额内存无处可去,才会触发 OOM kill。
这个细节在容器场景特别重要:很多容器平台默认不限制 swap,导致你明明设了 memory.limit,内存却可以在 swap 里"无限透支",最终拖垮宿主机。要硬性确保内存不超额,必须同时限制 swap 配额。限制 CPU 配额
CPU 不是按"兆赫"限的,而是按配额百分比。CPUQuota=50% 表示最多使用一个逻辑 CPU(100% = 一整核)的一半,即半核。起两个 CPU 压力进程对比——一个设 50% 配额,一个不设:
systemd-run --unit=restricted -p CPUQuota=50% stress --cpu 1 --timeout 8 & systemd-run --unit=full stress --cpu 1 --timeout 8 & sleep 3; top -b -n1 | grep -E "PID USER|stress"; systemctl stop restricted full
在 top 实时快照里,两个 stress 的 %CPU 一目了然:
- 不设限的
stress(对应full.service)≈ 100%,占满一个核; - 设了
CPUQuota=50%的stress(restricted.service)≈ 50%,稳定被卡在半核。
这台 demo 机只有 2 个逻辑核,所以单线程压测进程在不限的情况下封顶 100%。要是想像容器那样"用满多个核",把配额设大即可,比如 CPUQuota=200% 表示占用 2 个核。
限制进程数量(pids 控制器)
TasksMax 控制一个单元里最多能同时存在多少个任务(进程/线程)。把上限设为 2,再尝试一次性 fork 多个后台任务:
systemd-run --scope -p TasksMax=2 \
bash -c 'echo task; sleep 20 & sleep 20 & sleep 20 & sleep 20 & echo all_forked'会看到 echo task 正常执行,但第 2 个 sleep 之后再 fork 就开始失败,bash 反复报:
/usr/bin/bash: fork: retry: Resource temporarily unavailable因为单元里的任务数已经撞上 TasksMax=2 的墙。这很适合防"进程炸弹"场景——比如一个失控脚本疯狂 fork 子进程,pids 控制器能把它打回原形,保护整机 PID 空间不被耗尽。
systemctl set-property:运行中热调整
systemd-run --scope 起的临时单元是"一次性"的,进程结束就销毁。对常驻服务,更常用的是通过 systemctl set-property 在服务运行期间直接调整限额,无需重启服务:
systemd-run --unit=tune.service -p MemoryMax=200M sleep 300
systemctl set-property tune.service MemoryMax=50M
systemctl show tune.service -p MemoryMax --value # 输出 52428800,即 50MB
systemctl stop tune.servicesystemctl show -p MemoryMax --value 立即返回 52428800(字节),说明 200MB 的上限已被热降成 50MB。这种"运行中动态调价"的能力,让 cgroup 特别适合做容量治理:先给新服务设一个宽松额度跑起来,观察一段时间后,用 set-property 逐步收紧,找到资源与性能的平衡点。
如果只想调整到下一次重启、不写进磁盘配置,可加 --runtime 参数:
systemctl set-property --runtime myservice.service CPUQuota=75%常见坑与小结
踩坑清单(本文均实测验证):
- 只限内存不限 swap:
memory.max超限时会先换页到 swap 兜底,不必然立刻 OOM;要严格封顶需同时设置memory.swap.max(systemd 里用MemorySwapMax)。 - 百分比语义:
CPUQuota的 100% = 一个逻辑核,不是整机 CPU。2 核机器设 100% 意味着最多用 1 核。 - 临时单元要收尾:
systemd-run --scope前台阻塞、随终端关闭而结束;后台用途用--unit会注册成一个.service,记得systemctl stop收尾。 - 手动 mkdir 建 cgroup 需谨慎:根目录直建组会绕过 systemd 调度,测试完务必移出进程并
rmdir,生产环境优先 systemd 属性。
一句话总结:cgroup v2 把"进程资源"变成了可读可写的文件,而 systemd 则把"文件操作"包装成了稳定的运维接口。 先掌握 MemoryMax / CPUQuota / TasksMax 这三个属性,就够覆盖绝大多数资源限制诉求;更深层次(IO 读写带宽、cpuset 绑核、内存压力事件监控等)可以在掌握了这套思路之后按图索骥继续深入。
评论 (0)
暂无评论,快来抢沙发吧!