容器 07:内存超限与 I/O 变慢不是一种失败
内存约束不是单个数字
cgroup v2 中 memory.current 提供当前使用量,memory.max 为硬上限,memory.high 引导回收与节流;memory.events 的 high、max、oom、oom_kill 记录不同事件。超过 memory.high 不意味着立即杀进程;达到 memory.max 也不能不看回收成败就声称发生了 OOM kill。计数器是累积的,需比较实验前后,记录关联进程和层级。先在专用 VM 给单一小组设置有界内存与申请上限,绝不在共享宿主引发全局压力;每轮记录进程退出状态、memory.events、memory.current 和 dmesg 是否有权限取得相应事件。
pids.max 限制组内任务数量,达到时新的 fork 可以失败,并非旧进程自动被终止。io.max 规定特定设备的 BPS/IOPS 上限,需要先确认设备号、后端以及页缓存是否掩盖了真实块 I/O;更换测试文件尺寸或重复读取时若命中缓存,观察到“没变慢”不能证明 io.max 无效。cgroup 不提供一个通用的“网络带宽=memory.max 那样的限额文件”;网络控制需要另查 tc/BPF 或插件机制。
memory.high 和 memory.max 分别处于哪一段路径
对一个程序的内存写入,首先问是否真正触碰了新分配的页面,而不是只请求一大段虚拟地址。共用 bounded_load.py 会分配限定在 32 MiB 以内的 bytearray,并按页触碰;但 Python 解释器、依赖和页缓存也会消耗组的内存,不能把参数 --memory-mib 8 直接等同于组内 memory.current=8 MiB。实验要在私有 cgroup 中记录运行前后的 memory.current、memory.stat、swap 配置和探针真实退出状态,再判断触碰哪些页面使计数变化。每轮输入有严格的字节数和时间上限,限制应由专用 VM 的管理员在隔离子树中预先设置,不允许用大规模申请在共享节点制造全局内存压力。
Linux 5.10 cgroup v2 文档指出,超过 memory.high 时任务可能被节流并进入直接回收压力,不因此触发该 cgroup 的 OOM killer;在极端情况下 usage 还可能短时超过 high。memory.max 则是硬性末级保护:若达到上限而内存无法通过回收降下来,可能触发该组的 OOM killer;不同种类的申请也可能直接返回 ENOMEM 或以别的方式失败。把一次延迟升高、一次 malloc 失败和一个被杀的进程合并为“内存限制触发了 OOM”,会抹掉这些不同分支。内存上限还须与宿主实际可用容量、祖先组限制和并发负载一起记录,不可只读目标组自己的配置。
memory.events 的 high、max、oom、oom_kill 分别描述进入相应处理路径、接近或达到限制、发生 OOM 情形和被 OOM killer 杀死的进程数,不是互相等价的四种拼写。文档指出该文件的事件是层级性的;若子组中也有任务,父组的增量可能来自后代,可用 memory.events.local 区分本级事件。oom_kill 的定义还包括属于此组、由任何类型的 OOM killer 所杀的进程,因此读到增量后仍应结合实际退出进程、关联时间及内核记录检查因果,不能单靠计数器推定恰是本次 memory.max 导致。一次 HTTP 503 不说明 OOM,退出码 137 也可能是普通的强制终止;两条都必须查对应证据链。
pids 限制的对象是任务数
v2 文档说明 pids 控制器按任务/TID 计数;多线程程序可能在一个进程里使用多个额度,不能只用 ps 行数预测。pids.current 统计组与子组中的数量,pids.max 是不能通过 fork/clone 超过的上限。若将一个已有超过上限数量的进程组迁入,或随后把上限调低,pids.current 甚至可能暂时高于 pids.max;这不是控制器完全失效,后续从该组再创建任务才会受阻。Linux 5.10 文档列出通过 fork/clone 违反限制时返回 EAGAIN,上层语言可能将它包装为“暂时无法创建线程/子进程”。已有进程不应因为单纯达到此阈值就被自动 SIGKILL。
受控失败案例只需已有一个父进程尝试创建极少量子进程,在专用私有组设合理的 pids.max,记录尝试顺序、每次返回码、pids.current 和 pids.events 的变化。不能用不受限循环的 fork bomb 教学;超过上限的失败路径与父进程能否等候已经创建的孩子,还取决于实验工具如何处理异常。共用归档只有时间与内存有界的计算负载,pids 专项另附最多创建 4 个子进程的可信脚本和源码校验;无 v2 限额时创建/回收两个孩子的实际结果见原始记录,它不证明 pids 限制已执行。读者只获取同名素材目录也能拿到专项脚本,而归档哈希不被冒充为新版本。
为什么 I/O 一定要记设备与缓存
io.max 的规则针对设备而不是任意路径字符串;须确认测试文件实际落在哪一个块设备,检查对应 major:minor 和目标组的 io.stat,再解释 BPS、IOPS 上限。页缓存使重复读取可能直接由内存响应,不再按预期产生等量块设备请求;文件系统压缩、写回缓冲或远程文件系统又可能把应用 write 返回时间与实际设备 I/O 分开。重复读一个已缓存的小文件、看到 1 GB/s,然后声称配置的 2 MB/s 没生效,是把系统调用吞吐错当成后端块设备计数。专用 VM 可使用自建测试文件和有界请求,记录首次访问与重复访问,但不得在共享宿主清空全局 page cache 来强迫实验“命中磁盘”。
如果目标目录背后不是可以用 io.max 限制的本地设备,实验应报告不适用而非假装存在设备号;如果 io.stat 在工作期间没有增长,应先核对设备、数据是否已缓存以及 I/O 统计接口是否可用。网络文件系统、管道和 socket 的延迟不一定经过同一控制路径,不能从一个 read 调用耗时推断块设备被限制。比较前后的 I/O 调用次数与字节数,记录写回完成条件和底层路径,才能把“应用感觉慢”收窄到设备层;无法控制这些条件时把结果标记为有偏差或 NOT_RUN,不制造毫秒级性能结论。
证据与失败路径
规范化正例:给自建组写有限的 memory.max、pids.max,运行有界申请与有界子进程程序,读取计数器增量并清理;对照案例:用已缓存的小文件做 I/O 测试而不记录设备与缓存,得不到可解释的设备限制结果。命令模板与停止规则在实验入口;源码包提供共同探针而没有可造成无界压力的 OOM 注入。现有云端 cgroup v1 只读,所有 v2 文件实验 NOT_RUN,没有 OOM、节流或磁盘性能数据。
运行次序从最温和的控制开始:先设 memory.high 而把 memory.max 保持在明确安全的高值,记录可能的回收与耗时变化;再按 VM 的真实内存预算设置不会伤及其他组的自建子组上限,查看是否真的出现预期的 memory.events 增量。若程序只是正常结束且计数器不变,这也是实际输出,不能把“预计会有 high 事件”回填到日志。随后单独测试 pids,而 I/O 对照要重新锁定设备与缓存状态;不要把三种负载叠在同一时刻运行,否则单个失败很难归因。清理时先停止仅属于该组的实验子进程,核对其不再占用子组,再删除自己创建的目录;尚无权限执行时明确写 NOT_RUN 并保存上述补跑条件。
同样的慢请求,证据可以相反
设想探针完成固定规模的哈希后写入文件,两次运行都比常态慢。若第一轮 memory.events.local high 增加,但 oom_kill 未变,且 memory.stat 显示内存回收活动,更合理的下一步是检查内存压力、工作集与 memory.high;不应因为响应耗时增加就报告进程被杀。若第二轮内存计数不变,而相应设备的 io.stat 字节数增长、任务对同一文件的读写明显受配置限流,下一步应查 I/O 设备与缓存,不能把“慢”反推为内存不足。若两组事件都没有有效增量,还要查 CPU 调度、锁争用或应用内部排队,不能从本文控制器凭空指定原因。
再设想主服务突然不再接纳连接。若容器侧仅见退出码 137,同时 memory.events.local oom_kill 在同一时间段增加,它比单独的退出码提供更强的内存事件线索,但仍要确认是哪一个进程被杀、计数属于哪个组及父组是否也触发限制。若 pids.events 显示无法创建任务而主服务一直存活,则可能是新线程或 helper 创建失败并导致服务无法接受新连接;这与主进程自己退出不同。持久的 pids.current 也可能因忘记回收孩子或线程池增大而增长,先确认 TID 数量与实际的父子关系。把进程树、时间窗口、errno 和计数器并列,是区分同一个外部故障症状的可靠起点。
补齐实验附件的约束
内存代码已有上限与退出条件,pids 专项只创建极少数、自有且短寿命的孩子,父进程即使遇到 EAGAIN 也须等待先前创建的孩子;I/O 专项仍缺固定设备配置及字节有界的实验脚本。没有专用 VM 时可在本机核对程序的参数上界和正常路径,但不能通过在没有 pids.max 的组里运行两次成功 fork 来宣布验证了限制。记录清单要区分“程序本身能执行”和“设定控制器并产生预期拒绝/节流”两栏,前者成功也不自动推进后者。脚本哈希单独保存在本篇同名素材目录中,不假设旧归档已经含有新源码。
| 现象 | 最先核验 | 注意 |
|---|---|---|
| 进程突然结束 | memory.events 增量、信号/退出状态 |
137 不能单独证明 OOM |
fork 返回错误 |
pids.current、pids.events |
与内存失败分开 |
| I/O 延迟高 | io.stat、设备、缓存 |
非块设备路径另论 |
下载本篇源码时先运行 sha256sum -c PIDS_CHECKSUMS.sha256,检查复制途中是否变更;再运行命令记录里的那一组固定参数。两个孩子的正常创建与回收只是自编程序可运行的局部证据,不是 VM 中 pids.max 拒绝场景。若在 VM 里得到失败,还要核对写限额前组内已有线程数量,避免把别的任务计入实验结果。
假如限额实验中的子进程创建如预期失败,但父进程在异常处理阶段也无法完成清理,应保存最后已创建的孩子 PID、父进程状态与 pids.current,只对本次实验的进程采取清理动作。若清理后计数仍不归零,应先确认是不是已有线程或后代尚在运行,而不是直接删除组目录并宣称清理成功。这里的补跑步骤不能在本机执行,因为本机 cgroup v1 只读。
练习
- 在 VM 先预测达到
memory.high而没有达到memory.max会发生什么,比较任务延迟与memory.events增量,再核对是否真的有 kill。 - 先预测缓存命中时重复读取对
io.max实验的影响;控制测试目录、设备与输入大小,分开记录缓存条件,不能在共享宿主清理全局缓存。
上一篇:06:CPU;下一篇:08:权限边界。延伸阅读:cgroup memory;旧文的数据不是本篇的运行记录。
系列总目录:从进程隔离到运行时与编排。






