一台小规格 Linux 云主机突然变得几乎无法操作:服务超时,SSH 连接困难,最后通过强制重启恢复。第一反应是磁盘出了故障,但已有日志里没有找到能坐实硬件故障的块设备或文件系统错误,CPU 用量也不高。
继续检查,线索落在了一个限制为 256 MiB 的容器上。它没有发生 OOM,进程仍在运行,却累计产生了数十 GB 的读取。相关统计显示,文件页被反复回收、重新读入,云盘读 IOPS 则在失联前持续接近性能上限。
这些现象指向一条容易被忽略的故障链:容器内存紧张,文件页反复回收,读盘量增加,最终影响同盘上的其他服务。 后来在隔离虚拟机里的复现,也验证了这条机制。
原始故障缺少完整的历史内存和延迟数据,因此不能断言已经还原了当时的每个触发条件。下面分别记录现场证据、实验结果,以及它们能支持的结论。文中只保留通用系统行为和合成实验数据。
先区分磁盘故障和磁盘忙不过来
磁盘还有空间,不代表磁盘还能及时完成请求。小规格云盘可能先达到 IOPS 上限;大量小块随机读取的吞吐看起来并不大,也足以形成排队。
这次排查中,比较有价值的信号是:
- 云平台的读 IOPS 持续贴近该规格的上限,CPU 没有同步升高。
- 某个容器的读量明显高于其他容器,且主要涉及反复访问的文件映射。
- 容器的
memory.events:max多次增长,oom_kill仍为零;文件页 refault 和 major fault 也在增长。 - 容器和管理操作开始超时,但没有找到足以证明磁盘硬件故障的日志。
这组证据更符合资源竞争。它也说明,仅凭“磁盘异常”这样的感受,很容易把容量、性能和硬件错误混为一谈。
没有 swap 仍然可能反复读盘
Linux 的内存里既有程序分配的匿名页,也有文件页,例如可执行文件、共享库和通过 mmap 访问的数据。
没有 swap 时,普通匿名页通常无法换出到磁盘。干净的文件页却可以被丢弃,因为磁盘上还有原件。下次访问这些页时,系统再把它们读回来。
如果容器的匿名内存已经占据大部分额度,剩下的空间装不下持续访问的文件页,就可能出现这样的循环:
1 | 容器接近内存上限 |
这就是文件页抖动。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 的容器里运行:
- 分配并触碰约 224 MiB 匿名内存。
- 对一个 64 MiB 文件建立只读映射,随机访问其中的页面。
- 保持匿名内存活跃,让文件页持续参与回收。
这组持续访问的数据无法同时装进容器额度,足以制造文件页抖动。
另一个独立探针读取不同文件:每次用 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 | 请对我明确指定的 Linux 服务做一次轻量巡检。 |
从这次排查里留下的习惯,是在检查资源用量的同时,检查服务是否还在完成工作;在检查单个容器的同时,检查它对共享资源和其他服务造成的影响。限额是否合适、故障能否清理、解除压力后能否恢复,都需要实际验证。