线上机器 CPU 被某个跑飞的进程打满、内存被一个没设上限的批处理吃到触发 OOM、日志切割脚本 fork 出上千个进程把 pid 耗尽——这几类事故的根因往往不是什么高深问题,而是没人给进程划定边界。Linux cgroup(control group)就是内核提供的这套边界工具:它把进程按层级归组,给每组挂上 CPU、内存、IO、进程数这些控制器,让一组进程的总消耗被约束在设定范围内。我从早期在 v1 上手工挂载 cpu 子系统,到后来在 v2 统一层级下写cpu.max做限流,中间踩过的坑能写满好几页。这篇就把 cgroup 的架构设计、控制器参数、验证测试流程,以及我自己遇到的"配置写了却不生效"的情况一次讲透,适合给服务做资源兜底的运维、做运行时改造的后端、以及想搞明白 systemd 里那些MemoryMax到底落到哪儿的开发者。
1. cgroup 的核心价值:把"抢资源"变成"按配额分资源"
1.1 一台机器上多个负载互相踩踏的连锁反应
我先描述一个特别典型的现场。一台 16 核 64G 的物理机,上面同时跑着一个在线 API 服务、一个定时跑的数据同步任务、还有一套构建环境。某天同步任务因为上游数据量翻了三倍,内存一路涨到 40G,内核开始频繁回收 page cache,构建环境里读文件突然从几毫秒变成几百毫秒,构建超时告警;同时同步任务自身多线程并发写盘,IO 队列被打满,API 服务的响应 P99 从 80ms 飙到 2 秒。排查的时候看监控,CPU 不高、内存也没到 100%,但每个服务的体验都烂了。
这类问题的本质是共享资源没有归属感。内核调度器默认只关心"哪个线程该跑",不关心"这个线程属于谁、它已经用了多少"。在没有任何约束的模型里,谁跑得欢谁就多拿,一个失控的进程就能拖垮整台机器上所有邻居。cgroup 做的事情,就是给每个进程贴上一个"属于哪个组"的标签,然后以组为单位做计量和限制:这组进程合计最多用 2 个核、最多占 4G 内存、最多起 512 个进程、对某块盘的最大带宽是 200MB/s。超了就排队、就回收、就拒绝,而不是让它无限膨胀。
这个思路的价值在于它是层级化的。你可以先按业务分大组,再在业务组里按服务分子组,父组的配额是子组之和的上限,子组之间按权重分父组剩下的资源。这种树形结构和现实中部门预算的分配方式几乎一模一样:公司给部门一笔预算,部门再分给各个项目,项目之间可以互相借,但总额不能超。
1.2 cgroup 管什么、不管什么
这里有个特别容易搞混的边界,我见过不少人把 cgroup 当成"资源分配器",以为设了cpu.max内核就会主动给这组进程安排好 CPU 时间片。不是的。cgroup 干的是两件事:统计和约束。统计是记录这组进程用了多少 CPU、多少内存、发生了多少次限流;约束是在资源紧张时执行"刹车"动作——CPU 到顶就把任务踢出运行队列等下一个周期,内存到顶就触发回收甚至 OOM,进程数到顶就返回EAGAIN让 fork 失败。真正的调度决策、页面回收策略,仍然是内核调度器和内存管理子系统在做,cgroup 只是给它们提供了一条"这不是普通进程,它有额外规则"的信息通道。
与之容易混淆的是namespace。namespace 解决的是"看得见看不见"的问题——PID namespace 里的进程看不到外面的进程,mount namespace 里看到的是另一套文件系统。cgroup namespace 只是把/proc/self/cgroup里的路径视图裁掉一段,让容器里的进程以为自己在根上,它本身不产生任何资源限制。所以一个容器"隔离得很好"不等于"限制得很好",没配 cgroup 限制的容器照样能把宿主机吃干净。这两者一个管可见性、一个管消耗量,配合起来才是完整的隔离。
1.3 影响范围:哪些场景会被 cgroup 的配置直接决定
cgroup 的影响面比很多人想的要大。第一层是容器运行时,无论是哪种容器方案,底层给容器设 CPU/内存上限靠的都是写 cgroup 文件,容器的--memory、--cpus参数最终都翻译成memory.max、cpu.max。第二层是systemd 托管的服务,service 文件里写MemoryMax=1G、CPUQuota=200%,systemd 会为这个 unit 建一个 cgroup 并把限制写进去,这是目前最省事的落地方式。第三层是桌面和交互环境,systemd 的 user session 本身就在 cgroup 里,浏览器标签页、编译任务、视频转码之间的体验差异,很大程度上取决于它们被分到了哪个 slice、权重多少。第四层是批处理和多租户平台,给每个用户任务建一个 cgroup、跑完就删,这是防止单个任务拖垮共享机器的标准做法。
反过来说,判断一个环境有没有 cgroup 保护,看一个指标就够了:随便起一个吃内存的进程,看它会不会把整机拖进 swap。会,说明那台机器上的关键服务基本处在"裸奔"状态。
2. v1 与 v2 的架构差异:多层级与统一层级的取舍
2.1 v1 的"一个控制器一棵树"及其代价
cgroup v1 的设计是每个控制器一棵独立的树。你在/sys/fs/cgroup/下面会看到cpu、cpuacct、memory、blkio、pids、devices等一堆挂载点,每个挂载点下都能mkdir建组,但每个组是彼此独立的。同一个进程可以同时属于 cpu 树的/A/B和 memory 树的/X/Y,两棵树之间没有任何对应关系。
这个设计在早期看很灵活,你想怎么组织就怎么组织,但实际上带来了一堆麻烦。第一是管理复杂度爆炸:想给一个服务同时限 CPU 和内存,你得在两个目录下各建一遍同名组,把进程 ID 往两个tasks文件里各写一次,两边还得靠脚本保证名字一致。第二是规则冲突:不同控制器对"层级"的理解不一样,一个控制器限制子组之和不能超过父组,另一个不管,出问题时很难判断到底哪条规则在起作用。第三是统计口径不一致,cpuacct 和 cpu 分属两个挂载点,查个用量要跨目录拼数据。v1 后期为了修这些问题打了很多补丁,但根子上的多树模型改不掉。
我在 v1 时代写过一套部署脚本,核心逻辑就是遍历一个"控制器→目录"的映射表,挨个建目录、挨个写参数。脚本本身不难,难的是后面维护:新增一个控制器就要改映射表,某个控制器的参数名写错了不会报错只是静默不生效,出问题排查时要在五六个目录之间来回跳。
2.2 v2 的单一层级与三条硬规则
cgroup v2 把多棵树合并成一棵树,统一挂在/sys/fs/cgroup/下,所有控制器共用同一套层级。任何一个 cgroup 目录里都可以同时存在 cpu、memory、io、pids 的控制文件,一个进程只在一个位置出现。这个改动看着简单,实际把上面三个问题一次解决了:管理只需在一个目录树里操作,父子约束用统一的规则表达,统计数据都在同一个目录下。
但统一带来了三条必须记住的硬规则,不回这几条基本没法干活。
第一条是"控制器要先启用才能看到文件"。新建的组里不是默认就有cpu.max,你得先在父级的cgroup.subtree_control里写+cpu,子目录才会长出对应的控制文件。这个设计是为了避免每建一个目录就挂一堆用不上的控制器。我第一次在 v2 上建组,mkdir完发现目录是空的,一度以为内核没编进 cpu 控制器,折腾了半小时才想起subtree_control。
第二条是"内部进程约束"(no internal process constraint)。一个非根 cgroup 如果下面有子 cgroup,它自己就不能再挂进程。换句话说,只有叶子节点能放进程。这条规则的作用是让资源归属清晰:父节点纯粹作为"配额池",资源全部分给叶子。实际影响是,你把进程写进一个已有子目录的父组,会直接收到Invalid argument。
第三条是"资源只能往下分,不能往上借"。父组设了memory.max=1G,两个子组各设 800M,加起来超了也不会怎样,因为父组的总量是硬上限,谁先抢到算谁的。这个语义和 v1 部分控制器的行为不同,迁移时容易误判。
2.3 我的选型判断:什么情况还得留 v1
现在主流发行版默认都是 v2 了,新上环境没有理由选 v1。但有几类情况确实还得捏着鼻子兼容 v1,或者说至少要知道 v1 的写法。
一是老内核。4.x 系列的部分版本对 v2 的支持不完整,比如 memory 控制器的某些统计项缺失,或者io.max对某些块设备不生效。如果业务跑在和一些老系统绑定的内核上,只能在 v1 上做。
二是混合模式。有些发行版用systemd.unified_cgroup_hierarchy=0保留了混合挂载,cpu 和 memory 走 v1、其余走 v2。这种状态下/sys/fs/cgroup/的结构很迷惑,一部分控制器在根下直接可见,另一部分藏在unified子目录里。在这种环境上排查问题,第一件事永远是先跑stat -fc %T /sys/fs/cgroup/,看到cgroup2fs才是纯 v2,看到tmpfs就是混合或 v1。
三是运行时和 systemd 的管理者冲突。某些容器运行时默认用 cgroupfs 驱动自己管 cgroup,而 systemd 也在管同一棵树,两边各建各的目录,结果就是systemd-cgls里看不到容器的组,配额也可能被覆盖。纯 v2 环境下这个冲突更明显,因为只有一棵树,谁都想当管理者。解决办法是让运行时把管理权交给 systemd,配成 systemd 驱动,别各管一摊。
3. 控制器参数拆解:CPU、内存、IO、pids
3.1 CPU:cpu.max 定硬上限,cpu.weight 定相对权重
CPU 这块最常用的两个文件,语义完全不同,一定不能混。
cpu.max是带宽限制,格式是"配额 周期",单位微秒。写50000 100000的意思是:每 100ms 为一个周期,这个组里的所有任务合计最多使用 50ms 的 CPU 时间,换算下来就是半个核。这是硬上限,到点就被节流(throttle),任务被踢出运行队列,等下个周期再跑。这个参数适合做"兜底",防止某个服务把核吃满。
cpu.weight是相对权重,范围 1 到 10000,默认 100。它只在多个组争抢同一个 CPU 时起作用,权重高的组分到的比例大。如果机器空闲、没有竞争,权重完全不产生任何限制效果。这个参数适合做"优先级分配",比如在线服务给 300、离线任务给 50,保证高峰期在线服务能拿到大部分时间片。
这里有个迁移陷阱要提醒:v1 的cpu.shares和 v2 的cpu.weight不是同一量纲,不能把 1024 直接抄成 1024。v1 的 shares 范围是 2 到 262144,默认 1024;v2 的 weight 是 1 到 10000,默认 100。两者之间有一层换算关系,而且不同管理工具(比如 systemd)的映射方式还不完全一样。我的做法是不抄数字,只抄比例:原来 A 服务的 shares 是 B 服务的两倍,那在 v2 里就把 A 的 weight 设成 B 的两倍,比如 200 和 100。比例关系保住了,绝对数值无所谓。
还有一个容易被忽略的参数是cpu.pressure,它给出这个组的 CPU 压力停顿时间统计,能直接看出"任务被限流后到底等了多久"。做容量评估的时候,cpu.stat里的nr_throttled和throttled_usec比平均值有用得多——限流次数多说明配额设紧了,限流次数为零说明配额设松了。
3.2 内存:max、high、low、swap 的四层水位
内存控制器是参数最多、也最容易配错的一块。v2 里有四个关键水位,从高到低理解。
memory.max是硬上限。用量触到这个值,内核先做直接回收(reclaim),把能扔的 page cache 扔掉;回收不动了就触发 OOM,杀掉组里的进程。这是真正的红线,设的时候要留出余量,别按进程峰值正好设。
memory.high是软上限,也叫节流水位。用量超过它,内核不会杀进程,而是强制回收并显著降低这组进程的分配速度,让它们自己慢下来。这个参数的价值在于"温和施压":你希望某个服务别涨太快,但不想它被杀,用 high 比用 max 合适。实际经验是 high 设在 max 的 80% 到 90% 比较合理,给回收留出操作空间。
memory.low是保护水位。它表示"这部分内存尽量别回收",在内核做全局回收时会优先保护这个范围内的页。这个参数适合给关键服务加一层保险:机器内存紧张、全局在疯狂回收时,low范围内的页不会被首先牺牲。
memory.swap.max控制这个组能用多少 swap。很多人只在物理机上想这个参数,其实在容器里它更重要——如果容器的 swap 上限不设,内存吃紧时它的页会被换到磁盘,表现为"内存没超但延迟暴涨",很难查。我的习惯是给延迟敏感的服务把swap.max设成 0 或很小的值,宁可 OOM 也别让它换出去。
还有一个开关值得单独说:memory.oom.group。默认情况下 OOM 只杀组里"最该杀"的那一个进程,但在一个组里有多个进程协作的场景(比如主进程加几个 worker),杀掉一个可能让整体进入半死不活的状态。把oom.group设成 1,OOM 时会把整组一起杀掉,让上层调度器干净地重启,这比留一堆孤儿进程好处理得多。
3.3 IO 与 pids:容易被忽略但事故率高
IO 控制器在 v2 里通过io.max和io.weight工作,但有个前置条件经常绊人:必须用设备号定位,不能写设备名。格式是主设备号:次设备号 参数=值,比如8:0 rbps=209715200 wbps=104857600表示对 8:0 这块盘的读带宽上限 200MB/s、写带宽上限 100MB/s。设备号怎么拿?lsblk或者cat /proc/partitions里都有。麻烦的是容器场景下的 overlay 文件系统和各种虚拟块设备,实际落盘的设备和你在容器里看到的设备号经常对不上,这时候限制就得配在宿主机层面的父组上,而不是容器自己的组里。
io.max支持四个维度:rbps、wbps(字节每秒)和riops、wiops(IO 次数每秒)。做数据库这类随机 IO 密集的场景,限制 iops 往往比限制带宽更贴近真实瓶颈,因为小随机 IO 的瓶颈在队列深度不在带宽。我一般两个都配,先按 iops 卡住次数,再按带宽兜一层。
pids 控制器简单但事故率极高。pids.max限制这个组能创建的进程/线程总数,触顶时fork()直接返回EAGAIN。这个限制的价值在于防 fork 炸弹和防线程泄漏:一个写错的定时任务在循环里起子进程,几秒钟就能把整机的 pid 耗尽,届时连 sshd 都起不来,只能硬重启。把关键服务的pids.max设成一个符合预期的数(比如 200 或 500),泄漏时会立刻报错而不是拖死整机。需要注意的是,线程也算,一个 Java 服务开几百个线程是常态,设太小会直接启动失败,得先摸清实际的线程数。
3.4 关键参数对照表
| 资源维度 | v2 文件 | 典型写法 | 语义 | 常见误用 |
|---|---|---|---|---|
| CPU 上限 | cpu.max | 50000 100000 | 每 100ms 最多 50ms,即 0.5 核 | 误以为能自动升配额 |
| CPU 权重 | cpu.weight | 200 | 竞争时按比例分,默认 100 | 空闲时以为会有加速 |
| 内存硬限 | memory.max | 1G | 触顶回收,回收不动就 OOM | 按峰值精确设置,没有余量 |
| 内存软限 | memory.high | 800M | 触顶降速回收,不杀进程 | 设得比 max 还高,等于没设 |
| 内存保护 | memory.low | 512M | 全局回收时优先保留 | 误以为能阻止本组回收 |
| swap 上限 | memory.swap.max | 0 | 禁用本组 swap | 忘记设,导致延迟抖动 |
| OOM 整组 | memory.oom.group | 1 | OOM 时杀整组 | 多进程协作时不设,留下孤儿 |
| IO 限速 | io.max | 8:0 wbps=104857600 | 按设备号限制读写 | 写设备名、写容器内设备号 |
| 进程数 | pids.max | 500 | 进程+线程总数上限 | 设太小,Java 服务起不来 |
4. 验证测试:从建组到压测的完整过程
4.1 开工前先确认版本与挂载状态
不看清楚版本就动手,是我见过最多的无效操作。三行命令先确认环境。
# 判断是 v1、v2 还是混合 stat -fc %T /sys/fs/cgroup/ # 输出 cgroup2fs 表示纯 v2;输出 tmpfs 表示 v1 或混合 # 查看当前进程属于哪个 cgroup cat /proc/self/cgroup # 查看根组启用了哪些控制器 cat /sys/fs/cgroup/cgroup.controllerscgroup.controllers里列出的就是当前内核支持并启用的控制器,通常是cpuset cpu io memory hugetlb pids rdma misc这样一串。这个文件是只读的,要启用某个控制器得往cgroup.subtree_control里写,写法是带加号的字符串:
echo "+cpu +memory +pids +io" > /sys/fs/cgroup/cgroup.subtree_control cat /sys/fs/cgroup/cgroup.subtree_control # 输出 cpu memory pids io 表示启用成功写完以后再mkdir子目录,对应的控制文件才会出现。这个顺序不能反,先建目录再启用控制器的话,得回头重新写一次subtree_control才会生效。
4.2 CPU 限流验证:写死循环,看 thottled 计数
验证限流最直接的办法是跑一个纯计算任务,看它实际用了多少 CPU。
# 建组并设定 0.5 核上限 mkdir -p /sys/fs/cgroup/demo-cpu echo "50000 100000" > /sys/fs/cgroup/demo-cpu/cpu.max # 把当前 shell 放进这个组 echo $$ > /sys/fs/cgroup/demo-cpu/cgroup.procs # 起一个单核满载任务,跑 20 秒 stress-ng --cpu 1 --timeout 20s --metrics-brief跑的过程中另开一个终端,用top看这个 stress 进程的%CPU。如果限制生效,应该稳定在 50% 左右,而不是 100%。更精确的验证是读cpu.stat:
cat /sys/fs/cgroup/demo-cpu/cpu.stat输出里几个字段要会看。nr_periods是经历过的周期总数,20 秒 100ms 一个周期就是 200 左右;nr_throttled是被节流的周期数,限流生效的话这个数应该和nr_periods接近;throttled_usec是累计被节流的微秒数,除以nr_throttled就是平均每次等了多久。如果nr_throttled是 0,说明配额根本没被碰到,要么压力不够,要么参数没写进去。
注意:往
cpu.max里写值的时候,两个数字之间是空格不是逗号,写成50000,100000会直接报Invalid argument。这个错误太常见了,我第一次写也栽在这儿。
如果想同时验证权重,就建两个兄弟组,各放一个满载任务,权重分别设 100 和 300,跑一分钟看两个进程的 CPU 占比,正常情况应该接近 1:3。前提是机器上确实有 CPU 竞争,空闲机器上看不出权重效果。
4.3 内存上限与 OOM 触发验证
内存验证要分两步走,先验证"温和限制",再验证"硬杀"。
第一步验证memory.high的节流效果。建组设memory.high=200M,memory.max=1G,然后跑一个持续分配内存的进程,观察memory.current的增长速度和memory.events里的计数:
mkdir -p /sys/fs/cgroup/demo-mem echo 200M > /sys/fs/cgroup/demo-mem/memory.high echo 1G > /sys/fs/cgroup/demo-mem/memory.max echo $$ > /sys/fs/cgroup/demo-mem/cgroup.procs stress-ng --vm 1 --vm-bytes 800M --timeout 30s # 另开终端持续观察 watch -n 1 'cat /sys/fs/cgroup/demo-mem/memory.current; cat /sys/fs/cgroup/demo-mem/memory.events'memory.events里high计数会持续增长,说明软限在起作用;max计数如果一直是 0,说明硬限没被碰到,进程被"降速"而不是"被杀"。这就是 high 和 max 的行为差异,实测一遍比看十遍文档都清楚。
第二步验证 OOM。把memory.max调到 100M,同时设memory.oom.group=1,再跑一次 800M 的分配。这时候memory.events里的oom和oom_kill会各加一次,进程被 SIGKILL 干掉,dmesg里能看到带 cgroup 路径的 OOM 日志。日志里那句oom-kill:constraint=CONSTRAINT_MEMCG很关键,它明确告诉你这次 OOM 是 cgroup 限制触发的,不是整机内存耗尽——这两种情况的排查方向完全不同。
实操心得:OOM 验证完记得把
memory.oom.group改回去或者直接删组。有些环境里这个组是 systemd 托管的服务组,改错了会导致下次 OOM 时整个服务被一起带走,影响面比预期大。
Docker 环境有个额外坑:不要用docker stats判断容器内存限制是否生效。这个命令的数值来自容器自己的统计口径,有时候和 cgroup 文件里的memory.current对不上。要确认限制,直接看/sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max,以文件里的值为准。
4.4 pids 限制验证与 fork 失败的表现
pids 验证最好写个小脚本,因为 shell 里fork失败的报错信息很不直观。
mkdir -p /sys/fs/cgroup/demo-pids echo 20 > /sys/fs/cgroup/demo-pids/pids.max echo $$ > /sys/fs/cgroup/demo-pids/cgroup.procs # 循环 fork 子进程,观察在哪一步失败 for i in $(seq 1 40); do sleep 60 & echo "spawned $i, current: $(cat /sys/fs/cgroup/demo-pids/pids.current)" done跑起来之后,大概在第 18 到 19 个左右就会开始报fork: retry: Resource temporarily unavailable,pids.current卡在 20 附近不动。这个EAGAIN就是 pids 触顶的典型表现。注意pids.current统计的是进程加线程的总数,一个 java 进程可能自己就占了几十个,所以设限制之前一定要先cat pids.current看看现状,在此基础上留出 2 到 3 倍余量。
验证完记得清理:killall sleep,然后rmdir掉创建的所有组。v2 里删组有个硬性要求——组里不能有任何进程,包括僵尸。如果rmdir报Device or resource busy,先cat cgroup.procs看看还剩谁,把它挪到父组或者杀掉。
4.5 交给 systemd 托管,避免重启后配置丢失
手工mkdir出来的 cgroup 目录,机器一重启用全没了。生产环境的路子是让 systemd 管。
临时验证用systemd-run最省事,一条命令建一个 scope 并把限制写进去:
systemd-run --scope \ -p CPUQuota=50% \ -p MemoryMax=512M \ -p MemoryHigh=400M \ -p TasksMax=200 \ --unit=demo-limited \ stress-ng --cpu 2 --timeout 60s命令跑起来之后,去/sys/fs/cgroup/system.slice/demo-limited.scope/下就能看到cpu.max、memory.max、pids.max的真实值。这里有个换算要留意:CPUQuota=50%落到cpu.max是50000 100000,而CPUQuota=200%是200000 100000,也就是两个核。这个百分比是"相当于几个核",不是"占整机百分比",在多核机器上按整机百分比的思路去设会差得很远。
永久生效要写 service 文件的 drop-in:
# /etc/systemd/system/my-service.service.d/resource.conf [Service] CPUQuota=150% MemoryMax=1G MemoryHigh=800M MemorySwapMax=0 TasksMax=500写完systemctl daemon-reload再重启服务。这里有个细节:改限制不需要重启服务吗?部分参数(比如MemoryHigh)是可以在线改的,systemctl set-property my-service MemoryHigh=900M立即生效;但CPUQuota这类改完以后,已经在跑的进程会按新配额走,不用重启。实际验证过几轮,set-property这条路比改文件再重启快得多,适合线上调参。
5. 排查实录:配置写了却不生效的几种情况
5.1 写入 cgroup.procs 报 Invalid argument
这个报错我遇到的次数最多,几乎全部指向"内部进程约束"。场景是这样的:建了一个组 A,又在 A 下面建了子组 B,然后想把进程写进 A 的cgroup.procs,直接报错。
原因就是前面提过的那条硬规则——非根组一旦有了子组,自己就不能再挂进程。解决方案只有两个:要么把进程写进叶子节点 B,要么把 A 下的子组都删掉,让 A 重新变成叶子。很多自动化脚本容易在这个地方翻车,因为它习惯在一个固定的父目录下反复建子目录、反复挪进程,一旦层级变深就会撞墙。
还有一个不太常见的触发原因是组已经被标记为删除。rmdir一个非空组会失败,但有些人用cgroup.kill直接把进程杀光再删,中间有个短暂状态里组是不能写新进程的。这种时候cat cgroup.events会看到populated 0,但目录还在,等几毫秒再操作就好。
5.2 内存到顶了但进程没被杀
memory.max设了 500M,memory.current显示 510M,进程活得好好的,这是怎么回事?
第一种可能是统计口径包含了 page cache。memory.current统计的是这个组里所有内存,包括文件缓存。一个大量读写文件的进程,current很高但其中一大半是可回收的缓存,回收一下就下去了,根本不需要 OOM。要判断真实占用,看memory.stat里的anon(匿名页,真正的进程内存)和file(文件缓存)两项,前者才是不回收就得杀的。
第二种可能是OOM 被别的地方先拦下了。如果组里配了memory.high,超 high 之后内核会拼命回收并让进程降速,进程自己分配失败、主动退出了,看起来是"没被杀",其实是被软限逼死的。这种时候memory.events里的high计数会很大,oom_kill是 0。
第三种是全局内存充足。cgroup 的 max 是硬限,但触发 OOM 的前提是"回收后仍然超限"。如果机器整体内存很空闲,进程超了 max 之后内核会先从全局空闲池里挪一些过来,此时进程可能短暂超限而不被杀。这种情况在压力测试里很常见,也是为什么做内存验证时最好让机器本身有点压力,或者用 cgroup 的memory.max配合足够的分配速率把边界压出来。
5.3 CPU 限住了但延迟更抖
给服务设了CPUQuota=100%,CPU 用量确实被限住了,但 P99 延迟从 50ms 变成 400ms,超时告警反而更多了。
这个问题出在节流粒度上。cpu.max的周期是 100ms,配额在这个周期内用完,剩下的时间进程完全拿不到 CPU,得等下个周期。如果一个请求刚好在配额快用完的时候到达,它要等最多 100ms 才能开始跑,这种"整段停顿"对延迟敏感的服务是致命的。
处理办法有两个方向。一是留余量,别把配额卡在实际峰值的 100%,留 30% 到 50% 的缓冲,让突发请求有地方跑,代价是资源利用率低一些。二是缩小周期间隔,把cpu.max写成5000 10000,周期从 100ms 缩到 10ms,每次节流的等待时间上限从 100ms 降到 10ms,虽然节流次数变多,但单次停顿短,延迟曲线会平滑很多。代价是调度开销略微增加,实测在大部分服务上可以接受。
判断到底是不是节流引起的抖动,看两个指标就够:cpu.stat里的nr_throttled在延迟飙升的时间段有没有同步上涨,cpu.pressure里的some avg10是不是也上去了。两个都对得上,基本可以定性。
5.4 问题速查表
| 现象 | 最可能的原因 | 快速确认方式 | 处理办法 |
|---|---|---|---|
写cgroup.procs报 Invalid argument | 组下有子组,违反内部进程约束 | ls看有没有子目录 | 写到叶子组,或删掉子组 |
子目录里没有cpu.max | 父级未启用对应控制器 | cat cgroup.subtree_control | 父级写入+cpu |
memory.max到顶进程不死 | 占用主要是可回收 page cache | 看memory.stat的anon项 | 关注 anon 而非 current |
| CPU 限流后延迟抖动加剧 | 周期太长,配额一次性耗尽 | cpu.stat的nr_throttled上涨 | 缩小周期或留配额余量 |
| fork 报 Resource temporarily unavailable | pids 触顶 | cat pids.current对比pids.max | 提高上限或修泄漏 |
io.max写了没效果 | 设备号不对或不支持限速 | lsblk核对主次设备号 | 改用宿主机层设备号 |
| 重启后限制消失 | 手工建的组未持久化 | 重启后ls目录 | 改用 systemd 托管 |
| 容器限制与预期不符 | 运行时与 systemd 管理冲突 | 看实际 cgroup 路径归属 | 统一管理驱动 |
6. 我在实际项目里沉淀的几条习惯
踩完这些坑之后,我给自己定了几个固定动作,几乎每次配资源限制都会走一遍。
先量后设,别拍脑袋。上线任何限制之前,先让服务裸跑至少一个完整的业务周期(比如一周,覆盖工作日和周末的高峰低谷),把memory.peak、cpu.stat的峰值、pids.current的最大值记下来,然后按峰值的 1.3 到 1.5 倍设限制。直接按"感觉"设个 512M,大概率会在某个业务高峰被打脸。
限制要成对出现。只设memory.max不设memory.high,等于放弃温和施压的手段,要么没事要么直接 OOM,没有中间态。只设cpu.max不设cpu.weight,机器空闲时看不出问题,一有竞争就发现关键服务抢不过离线任务。成对配置才是完整的。
每次改完必须验证。我见过太多"配置写进去了,但组建错了位置、控制器没启用、或者被上层覆盖了"的情况。改完的标准动作是:读一遍实际生效的文件值、跑一次压测看统计计数、确认memory.events或cpu.stat里对应的计数器有变化。三步都过了才算完。
留一条逃生通道。给容器或服务设 pids 和内存限制时,永远在宿主机层面留一个不受这些限制的管理通道,比如一个单独的 ssh 会话或者一条独立的管理链路。真遇到限制配错导致服务起不来、连不上时,这条通道是唯一的救命绳。这个教训来自一次把pids.max设成 30 导致某个服务启动时线程创建失败、而服务本身又承载着登录入口的经历,最后是从带外控制台救回来的。
限制值写进版本管理。手工echo到 cgroup 文件里的值,重启就没了,而且没人记得当时设的是多少。所有限制必须以 service 文件、编排配置或配置管理代码的形式落盘,跟业务代码一起走 review 和发布流程。这条听起来像废话,但真正做到的环境不多,大部分事故的根因就是"某次临时调大以后忘了改回去"。