没有 OOM 也会失去响应 一次容器资源隔离排查与复现

一台小规格 Linux 云主机突然变得几乎无法操作:服务超时,SSH 连接困难,最后通过强制重启恢复。第一反应是磁盘出了故障,但已有日志里没有找到能坐实硬件故障的块设备或文件系统错误,CPU 用量也不高。

继续检查,线索落在了一个限制为 256 MiB 的容器上。它没有发生 OOM,进程仍在运行,却累计产生了数十 GB 的读取。相关统计显示,文件页被反复回收、重新读入,云盘读 IOPS 则在失联前持续接近性能上限。

这些现象指向一条容易被忽略的故障链:容器内存紧张,文件页反复回收,读盘量增加,最终影响同盘上的其他服务。 后来在隔离虚拟机里的复现,也验证了这条机制。

原始故障缺少完整的历史内存和延迟数据,因此不能断言已经还原了当时的每个触发条件。下面分别记录现场证据、实验结果,以及它们能支持的结论。文中只保留通用系统行为和合成实验数据。

先区分磁盘故障和磁盘忙不过来

磁盘还有空间,不代表磁盘还能及时完成请求。小规格云盘可能先达到 IOPS 上限;大量小块随机读取的吞吐看起来并不大,也足以形成排队。

这次排查中,比较有价值的信号是:

  • 云平台的读 IOPS 持续贴近该规格的上限,CPU 没有同步升高。
  • 某个容器的读量明显高于其他容器,且主要涉及反复访问的文件映射。
  • 容器的 memory.events:max 多次增长,oom_kill 仍为零;文件页 refault 和 major fault 也在增长。
  • 容器和管理操作开始超时,但没有找到足以证明磁盘硬件故障的日志。

这组证据更符合资源竞争。它也说明,仅凭“磁盘异常”这样的感受,很容易把容量、性能和硬件错误混为一谈。

没有 swap 仍然可能反复读盘

Linux 的内存里既有程序分配的匿名页,也有文件页,例如可执行文件、共享库和通过 mmap 访问的数据。

没有 swap 时,普通匿名页通常无法换出到磁盘。干净的文件页却可以被丢弃,因为磁盘上还有原件。下次访问这些页时,系统再把它们读回来。

如果容器的匿名内存已经占据大部分额度,剩下的空间装不下持续访问的文件页,就可能出现这样的循环:

1
2
3
4
5
6
容器接近内存上限
→ 回收文件页
→ 程序再次访问这些页
→ 缺页并重新读盘
→ 为新读入的页继续回收其他页
→ 重复

这就是文件页抖动。Chris Down 在 In defence of swap 中解释过:关闭 swap 可能把回收压力转移到热文件缓存和代码页,不能据此认定磁盘 I/O 会减少。

OOM 也不一定出现。只要回收还能腾出空间,内存分配就可能继续成功;内核没有必要立即杀掉进程,但应用已经把大量时间花在等待上。

几个计数器要配合使用:memory.events:max 表示触及内存上限时的事件;workingset_refault_file 帮助观察文件页回收后再次访问的情况;pgmajfault 反映需要 I/O 的缺页。它们是累计值,应观察时间窗口内的增量,并结合 io.stat 和延迟分析,不能单独当作故障判定。cgroup v2 接口说明

在隔离虚拟机中复现文件页抖动

为了验证机制,使用 Lima 创建了一台独立虚拟机:2 个 vCPU、4 GiB 内存,Ubuntu 26.04 ARM64、Linux 7.0.0、Docker 29.1.3,不挂载宿主机目录。

实验程序在内存限制为 256 MiB、无 swap 的容器里运行:

  1. 分配并触碰约 224 MiB 匿名内存。
  2. 对一个 64 MiB 文件建立只读映射,随机访问其中的页面。
  3. 保持匿名内存活跃,让文件页持续参与回收。

这组持续访问的数据无法同时装进容器额度,足以制造文件页抖动。

另一个独立探针读取不同文件:每次用 O_DIRECT 随机读 4 KiB,完成后等待 10 ms,再发起下一次读取。它记录自身读操作延迟,用来观察同盘任务是否受到影响。

实验组共享约 2400 读 IOPS 和 10 MiB/s 的上限,模拟受限磁盘;管理进程在实验组之外。这个设计用于验证资源竞争和隔离效果,没有刻意把整台虚拟机拖到 SSH 失联。它也不是对原机架构、内核和应用的逐项复制。

各阶段通常持续 30 秒。下面使用排除前 10 秒后的稳态结果,数值作了四舍五入:

场景 压力容器读 IOPS 独立探针读延迟 P95 OOM kill 增量
无压力容器 — 1.13 ms —
未单独限制 I/O 第一次 2374 76.75 ms 0
未单独限制 I/O 第二次 2377 81.94 ms 0
单独限制为 300 读 IOPS 第一次 299 1.23 ms 0
单独限制为 300 读 IOPS 第二次 299 1.08 ms 0

两次未限速实验中,压力容器都保持存活,探针的尾延迟却明显恶化。加上 I/O 限额后,压力任务继续运行,探针延迟恢复到接近空闲水平。另一次使用父级资源组限额的 90 秒实验中,探针稳态 P95 约为 1.11 ms,压力任务仍有回收和缺页活动。

I/O 限额保护了邻居,却没有修复压力程序自身的内存不足。 要让这个程序恢复正常吞吐,仍需调整内存预算、降低工作集或改变访问方式。300 IOPS 只是本次实验参数,不能直接当作其他服务的默认值。

THP 对照说明了什么

透明大页 THP 可以减少地址转换开销,但某些稀疏使用或部分释放的分配模式,也可能付出较大的内存成本。内核文档描述了这一取舍。

为区分“实际工作集太大”和“分配方式放大了内存成本”,又构造了一组实验:保留 120 MiB 活跃匿名内存,额外分配并触碰 128 MiB 区域,再对每个 2 MiB 区间只保留 4 KiB,其余通过 MADV_DONTNEED 释放,同时访问前面的 64 MiB 文件。程序没有显式请求 MADV_HUGEPAGE,让它遵循系统策略。

稀疏分配场景 采样内存峰值 稳态读 IOPS 探针稳态 P95
THP 为 always 约 256 MiB 2371 79.50 ms
always 且仅将 max_ptes_none 调为 0 约 256 MiB 2371 76.57 ms
仅将 THP 调为 madvise 约 186 MiB 0 0.70 ms

这里的峰值来自周期采样的 cgroup 内存用量,不是进程 RSS,也不是整个虚拟机的内存用量。

这个合成分配模式里,改为 madvise 消除了持续抖动;单独调整 max_ptes_none 没有取得同样效果。但把同样的 THP 调整用于前一组“224 MiB 匿名内存加 64 MiB 文件工作集”的实验,抖动依旧存在,探针稳态 P95 约为 83 ms。

这两个结果需要一起看:THP 调整可以缓解某种分配模式带来的额外成本,却无法让真正装不下的工作集凭空变小,也不能反过来证明原始事故完全由 THP 触发。

冷启动也要单独观察。madvise 场景的文件仍需首次读入;包含启动阶段的全程探针 P99 约为 70 ms,明显高于稳态值。只展示稳态,会掩盖这部分成本。

实际调整时还应核对每种页尺寸的策略:多尺寸 THP 可以独立配置,不能只改一个顶层开关就假定全部生效。具体行为以目标内核提供的接口为准。THP 控制接口

限制要覆盖正确的进程并持续生效

这次验证也暴露了配置层面的几个陷阱。

首先要确认限制作用在哪里。 服务进程、容器、应用父组和宿主机后台任务可能属于不同的 cgroup。容器限额不会自动覆盖宿主机上的构建、镜像解包和备份。多个容器各自不超限,也可能共同耗尽一块磁盘,因此还要考虑父级总预算。

然后要确认实际生效。 在本次运行时组合中,在线更新请求成功返回,并不总能证明预期的 I/O 限额已经落到内核。检查目标组及祖先的 io.max,再通过受控负载观察 io.stat 的速率,才完成了核验。

最后要验证生命周期。 临时写入某个容器 scope 的限制,可能在重启或重建后丢失。实验中分别验证了启动、重启和虚拟机重启后的行为。可以使用稳定的父级 slice,或由启动流程对新 cgroup 重设策略;最终都应回读并测试。

I/O 控制还与块设备、文件系统、共享文件的归属有关。尤其是缓冲写回,需要底层文件系统支持;不能把某一种直接读取实验的结果推广到所有 I/O 路径。内核的 I/O 控制和写回说明

另外三个容易误判的资源故障

沿着这次排查继续看社区案例,还能发现相似的模式:进程存活和资源平均用量,都不足以说明服务在正常完成工作。以下三项同样在隔离虚拟机内做了对照。

CPU 平均用量低仍可能被节流

根据 Dan Luu 与 David Mackey 的案例,构造两个线程,每 500 ms 同时执行一次约 20 ms 的 CPU 工作。整个服务平均只使用约 0.08 个 CPU 核,但子组配额只有 0.25 核,周期为 100 ms;并行突发可以提前耗尽周期额度。

30 次突发中记录到 30 次节流,任务 P95 约为 79 ms。取消子组硬配额、改用权重共享,同时保留父组 1.5 核总上限后,P95 约为 21 ms,子组没有节流,两组消耗的总 CPU 时间接近。

这验证了平均只用配额的约 32% 仍可能遇到节流。对照组利用了空闲突发容量,没有验证高竞争负载下的全部行为;实际服务还需要结合并发和容量测试决定配额。

进程被 OOM 杀掉后 tmpfs 仍可能占内存

Facebook 的 Sandcastle 案例提到,共享 tmpfs 上的文件可能在构建进程死亡后继续保留。

实验让一个受 128 MiB 内存限制的任务先写入 64 MiB tmpfs 文件,再分配并触碰更多内存,触发 cgroup OOM。两组都采用整个任务单元的 OOM 清理策略,只改变挂载的生命周期:

  • 使用持续存在的共享 tmpfs 时,任务死亡后文件仍在,系统 Shmem 增加约 64 MiB。
  • 使用任务独立的挂载命名空间和 tmpfs,且没有外部引用保留它们时,任务退出后挂载释放,没有相应内存残留。

因此,恢复能力还取决于资源是否随任务一起释放。仅确认主进程退出,检查得还不够。

短暂变慢可以通过重试变成长时间过载

Google SRE 的级联故障章节讨论了队列、超时和重试之间的反馈。实验使用真实的本机 TCP 请求,4 个处理任务,每项正常耗时 40 ms,理论容量约为 100 请求每秒。持续 6 秒以每秒 60 个请求发起 360 个原始请求,只在开始的 1.5 秒将处理耗时提高到 120 ms。

每次尝试的客户端超时为 120 ms。未保护方案允许最多三次尝试,超时后立即重试;服务端继续处理已经超过客户端期限的请求。

策略 实际尝试次数 全程成功请求 队列峰值 测试结束时积压 恢复观察窗口成功请求
无变慢的基线 360 360 1 0 180 / 180
变慢加激进重试 1080 0 574 557 0 / 180
限制重试次数和预算并退避 406 142 66 0 142 / 180
有界队列和原始请求截止时间 360 267 4 0 180 / 180
组合保护 360 270 4 0 180 / 180

恢复观察窗口指第 3 至第 6 秒发起的 180 个请求。积压在负载测试结束时采样,之后再清理实验连接和任务。

组合保护仍在变慢期间拒绝或超时了一部分请求,但恢复窗口内请求全部成功。在这组负载下,有界队列和截止时间已经起主要作用,不能把结果全部归功于重试预算。

内核可以限制这个服务占用多少资源;是否继续接收请求、重试几次、是否执行已经过期的工作,则需要应用自己决定。

个人开发者可以怎样检查

对于少量服务,先把检查做得可重复,比堆叠调优参数更有用。每项记录为“通过、有风险、未知、不适用”,并保留时间和采样范围。

部署前检查

  • 内存预算包含正常峰值、系统开销和发布时新旧实例共存的成本。
  • 内存、CPU、进程和连接上限与实际负载相符,程序堆大小给其他内存占用留有余量。
  • 高 I/O 服务有单独预算,多服务和后台任务还有总预算。
  • 日志有轮转,备份、缓存、上传和临时文件有保留或清理规则。
  • 请求大小、队列和并发有上限,依赖调用有超时,重试有次数和预算。
  • 任务失败能释放子进程、临时文件和挂载;健康检查与重启策略不会放大短暂故障。
  • 重建或重启后再次验证实际限制;数据有独立备份,发布可以回滚。

上线后和每周检查

  • 对比成功率、尾延迟、超时、重试、重启次数和队列长度。
  • 查看内存事件、缺页、refault 和 CPU 节流的增量,关联同一时间段的延迟。
  • 分别观察磁盘容量、inode、IOPS、吞吐、延迟和队列,检查 tmpfs 及空间增长趋势。
  • 查看进程、文件句柄、连接和临时资源是否持续增长,关联部署、备份和定时任务。
  • 检查备份和告警是否成功;定期实际恢复一次。

如果内核支持 PSI,可以通过 /proc/pressure/ 和每个 cgroup 的压力文件,观察任务等待 CPU、内存和 I/O 的时间。PSI 文档说明了指标含义。接口缺失应记为观测缺口,不能当作压力为零。

Docker 的 json-file 日志默认不限制大小;修改守护进程默认日志配置,也不会自动更新已有容器。类似的“默认值”和“实际生效值”值得单独核查。

让 AI 协助巡检

AI 适合帮助查找配置、计算计数器增量、关联时间线和整理证据。巡检范围和操作边界需要先说清楚,尤其是在一台已经很慢的机器上,避免全盘扫描或临时安装工具增加负载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
请对我明确指定的 Linux 服务做一次轻量巡检。

目标机器和服务:由我填写
检查模式:部署前 / 上线后 / 定期巡检 / 故障排查
禁止访问的目标:由我填写

第一轮只读,不做生产压测、全盘递归扫描、安装工具、
拉镜像、重启、修改配置或清理文件。
不要输出密码、令牌、完整环境变量或未脱敏的业务日志。

先识别系统、容器运行时、cgroup 版本和实际进程归属。
检查内存、CPU、I/O、空间、inode、tmpfs、日志、
进程、句柄、连接、队列、重试,以及备份和恢复能力。

核对运行实例和祖先资源组,不只阅读配置文件。
对累计计数器采集短时增量;解释历史故障必须有历史证据。
没有权限、监控、代码或负载数据的项目,标记为未知。

每项输出:通过 / 有风险 / 未知 / 不适用。
风险项给出证据、可能影响、最小修复建议和验证方法。
区分已确认故障与待验证推测,优先列出最值得处理的三项。

不要仅凭 CPU 低、容器存活、没有 OOM,
或配置文件中写了限额,就判定服务健康。

从这次排查里留下的习惯,是在检查资源用量的同时,检查服务是否还在完成工作;在检查单个容器的同时,检查它对共享资源和其他服务造成的影响。限额是否合适、故障能否清理、解除压力后能否恢复,都需要实际验证。