☰
Linux cgroup资源限制实战:CPU、内存、IO与pids配置避坑
2026/10/27 12:40:40 网站建设 项目流程

线上机器 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.max50000 100000每 100ms 最多 50ms,即 0.5 核误以为能自动升配额
CPU 权重cpu.weight200竞争时按比例分,默认 100空闲时以为会有加速
内存硬限memory.max1G触顶回收,回收不动就 OOM按峰值精确设置,没有余量
内存软限memory.high800M触顶降速回收,不杀进程设得比 max 还高,等于没设
内存保护memory.low512M全局回收时优先保留误以为能阻止本组回收
swap 上限memory.swap.max0禁用本组 swap忘记设,导致延迟抖动
OOM 整组memory.oom.group1OOM 时杀整组多进程协作时不设,留下孤儿
IO 限速io.max8:0 wbps=104857600按设备号限制读写写设备名、写容器内设备号
进程数pids.max500进程+线程总数上限设太小,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.controllers

cgroup.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 unavailablepids 触顶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 和发布流程。这条听起来像废话,但真正做到的环境不多,大部分事故的根因就是"某次临时调大以后忘了改回去"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询