暗色模式

Linux cgroup 资源控制:从 CPU 与内存限制到 systemd 运行单元

技术教程
2026-08-23
13
0
本文要点
  • 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:

在 demo 服务器上识别 cgroup v2

  • cat /sys/fs/cgroup/cgroup.controllers 列出内核支持的控制器:cpuset(CPU 亲和性)、cpu(CPU 配额)、io(磁盘 IO)、memory(内存)、hugetlb(大页)、pids(进程数)、rdmamisc 等;
  • /proc/self/cgroup 显示当前命令属于 0::/user.slice/user-0.slice/session-...scope0:: 里的 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.maxcpu.maxpids.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 把资源限制做成了一组标准属性(MemoryMaxCPUQuotaTasksMax 等,见 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=$?

内存上限 100M 触发 OOM 杀死进程

输出里能看到三件事:

  1. Running as unit: run-xxxx.scope —— systemd 为这次命令创建了一个 transient(临时)scope 单元,进程被装进对应的 cgroup;
  2. bash: ... Killed ... —— 进程被内核 OOM Killer 直接 SIGKILL;
  3. 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

CPU 配额 50% 与不设限的实时对比

top 实时快照里,两个 stress 的 %CPU 一目了然:

  • 不设限的 stress(对应 full.service)≈ 100%,占满一个核;
  • 设了 CPUQuota=50%stressrestricted.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.service

systemctl show -p MemoryMax --value 立即返回 52428800(字节),说明 200MB 的上限已被热降成 50MB。这种"运行中动态调价"的能力,让 cgroup 特别适合做容量治理:先给新服务设一个宽松额度跑起来,观察一段时间后,用 set-property 逐步收紧,找到资源与性能的平衡点。

如果只想调整到下一次重启、不写进磁盘配置,可加 --runtime 参数:

systemctl set-property --runtime myservice.service CPUQuota=75%

常见坑与小结

踩坑清单(本文均实测验证):

  • 只限内存不限 swapmemory.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 绑核、内存压力事件监控等)可以在掌握了这套思路之后按图索骥继续深入。

参考资料

发表评论

暂无评论,快来抢沙发吧!