有一类故障,你盯着监控面板看会觉得系统在说谎:内存使用率还不到 60%,Limit 也没触顶,容器里没有 OOMKilled 记录,可 Pod 偏偏在连环重启。CPU 一会儿打满一会儿掉下来,日志里全是线程卡住、健康检查超时,看起来就像进程陷入了一场没有出口的“死循环”。我前前后后排查过好几起这样的问题,最后无一例外,线索都指向了同一个内核机制——Linux 的 Major Page Fault。
这篇文章就基于其中一次比较典型的 K8s 环境故障来做复盘。我会先还原那种“内存一切正常但 Pod 疯狂抖动”的现场,再拆解 Major Page Fault 的原理,然后给出完整的排障命令链路,最后聊修复方案和长期防范。适合正在被容器异常重启困扰的运维、SRE,也适合写后端服务时总遇到“无缘无故卡死”的开发者参考。
1. 先看现象:为什么“内存不报警”是最迷惑人的地方
1.1 一次典型的连环重启故障现场
那是一个 2c4g 的 Pod,跑的是一个 Java 写的在线接口服务。现象从下午开始:kubectl get pods里的 RESTARTS 字段开始跳动,一小时能从个位数涨到几十次。点进去看kubectl describe pod,Events 里没有任何 OOMKilled 的提示,只有 Liveness 探针失败导致的容器重启,或者说进程是“自己退出”的。
让我印象最深的是 Grafana 面板。Memory Usage 只有 55% 左右,曲线平滑,没有冲顶。CPU Usage 却完全是另一副嘴脸,像锯齿一样一浪一浪地顶到 100%,然后又瞬间跌到 10% 以下。最诡异的是 Load Average,1 分钟值能冲到 8 以上,但 5 分钟和 15 分钟的值低得多——说明这不是一个持续高负载问题,而是周期性的、非常剧烈的性能抖动。
当时几个同事的第一反应都是“业务是不是有死循环”。 Java 服务嘛,先抓线程栈,jstack 出来的结果却让人更困惑:线程大部分停在java.io相关的读操作上,有的停在mmap相关调用上,没有明显的业务死循环热点。GC 日志也算正常,Full GC 有,但不频繁,而且每次停顿也就是几十毫秒。
这事情怪就怪在,你以为的“异常表现”和“系统指标”完全对不上号。
1.2 三个最容易误导人的“正常指标”
我后来总结,这类故障之所以难查,主要是下面三个指标让人产生了错误的安全感。
第一个是“内存使用率不高”。很多人的直觉是:只要没有触顶,内存就没问题。但 Linux 的内存模型不是这样的。cgroup 的内存限制(memory.limit_in_bytes)限制的是进程已经占用的物理页上限,而真正导致卡顿的,往往是内存分配的过程中发生的缺页异常。换句话说,问题不是“内存不够”,而是“内存分配路径上踩了坑”。
第二个是“CPU 使用率看起来在跑”。CPU 高,未必代表业务代码在高效执行。Major Page Fault 发生时,进程可能在内核态处理缺页,也可能在 D 状态(不可中断睡眠)等磁盘 IO——但调度器还是会把 CPU 时间记在这个进程头上,或者让其它任务把那颗 CPU 抢走。所以 CPU 高只能说明 CPU 没闲着,不代表你的业务逻辑在做实质进展。
第三个是“没有 OOM 记录”。容器被 OOMKilled 会留下很明显的事件,但这次事件里 Pod 重启是因为健康检查失败,或者进程主动崩溃,和传统意义的“内存爆掉”完全是两码事。 OOM 只是内存问题的冰山一角,页面回收、缺页抖动、IO 等待这些“慢性病”反而更容易被忽视。
1.3 一开始我踩过的两条死胡同
第一次遇到这个问题时,我的排查顺序其实很乱。先怀疑业务死循环,花了一个多小时做 thread dump 分析,没有结论;然后又怀疑宿主机被其他 Pod 打爆了,去看了节点 CPU 和网络,也看不出什么大问题。中间甚至怀疑过是不是健康检查参数设置得不合理,调大了 timeout 之后确实能多撑一阵子,但没根本解决问题,反而把故障周期拉长了。
真正让我转变思路的,是看了/proc/vmstat里一个字段:
pgmajfault 81920321这个数字在几秒内涨了十几万。到了这一步,我才反应过来,之前所有“内存正常”的判断都是站在用户态视角,而真正的矛盾发生在了内核的缺页处理路径上。
2. 揭开 Major Page Fault 的面纱:为什么一次缺页能把进程“卡死”
2.1 Minor 和 Major:同样是缺页,代价差三四个数量级
先讲清楚什么是 page fault。
现代操作系统都基于虚拟内存。进程访问地址时,CPU 通过页表把虚拟地址翻译成物理地址。如果页表里没有对应的映射,CPU 就会触发一次缺页异常(page fault),然后由内核的缺页异常处理函数来把页面准备好。
缺页异常分两类。一类是 Minor Page Fault(次要缺页),意思是页面其实已经在物理内存里了,只是当前进程的页表里没有映射,内核只需要建一下映射关系就行,耗时通常是微秒级。另一类就是 Major Page Fault(主缺页),意思是目标页面根本不在物理内存里,需要从磁盘、SSD 这类块设备上重新读进来。一次磁盘 IO 的耗时是毫秒级,内存里寻址是纳秒级,两者差了三四个数量级。
我用一个生活化的比喻来解释:Minor Fault 相当于你打开抽屉发现资料就在里面,只是没有索引卡片,你顺手补一张就行;Major Fault 相当于资料压根不在抽屉里,得跑去楼下档案室调阅,等电梯、翻柜子、办手续,一次要几分钟。如果整个办公室的人都在等档案室调资料,那这栋楼的工作节奏就全乱了。
2.2 虚拟内存的“懒加载”机制:malloc 和 mmap 都只是画饼
这里有一个很多人都没意识到的核心细节:进程申请内存时,内核并不会真的立刻给物理页。
比如 C 语言的malloc,glibc 对较大的分配会走mmap系统调用。而mmap的本质只是创建了一个 VMA(Virtual Memory Area)区域,在页表里留下一个“未映射”的标记。真正把物理页分配出来、建立映射,是你在首次读或写这块内存时,由缺页异常完成的。文件映射也是同理,mmap一个大文件时,文件内容不会立刻加载到内存,而是等你访问文件中的某个页时,内核才通过filemap_fault从磁盘把对应页读进来。
Kubernetes 里很多服务的初始化流程就是“重灾区”:启动时mmap一个大配置文件、加载索引、初始化堆外缓存,这些操作看起来瞬间完成,因为内核只是在“画饼”。一旦某个线程真的去读那些映射区域,成千上万个缺页在一瞬间集中爆发,磁盘 IO 队列直接被打满,进程表现就是“死循环一般卡住”。
这也解释了为什么“内存不报警”——内存使用率统计的是已经被映射并实际占用的物理页。而 Major Fault 发生在“程序想用内存,但物理页还没准备好”的那一刻,跟你已经用了多少内存并没有直接关系。
2.3 为什么从一次缺页变成“连环死循环”:thrashing 的链条
前面说的还只是单次缺页的代价。真正致命的是,当大量线程同时发生缺页时,会把一次偶尔的卡顿放大成系统级的抖动。
我梳理一下当时的恶化链条:
- 线程 A 访问了一个未驻留的页,陷入 D 状态等待磁盘 IO。
- 线程 A 手里可能还握着业务锁。线程 B 拿不到锁,被唤醒后尝试抢锁失败,进入睡眠或自旋。
- 线程 C、D、E 也在做类似的工作,又各自触发新的缺页。内核的 mmap_lock 是全局锁,多个线程同时做内存映射操作时还要互相等待。
- 调度器发现 CPU 出现了空闲,把更多线程调度上来运行,而这些线程同样在访问未驻留页面,于是缺页数量进一步放大。
- 系统开始出现内存回收(page reclaim),把一些很久没用的页面换出去或回收掉,结果很快又被程序重新读取,再次触发缺页。
到第 5 步,系统就进入了典型的 thrashing(颠簸)状态。内存明明还有余量,但系统把大量时间花在了“换入换出”上,有效指令完成率低得可怜。从外部看,进程 CPU 使用率高、状态一直不完结、日志进展停滞,就像一个死循环。其实它不是死循环,而是“活锁式抖动”——每一个线程都在跑,但都在等不同的缺页完成。
2.4 最容易触发 Major Fault 的典型场景
根据我的排查经验,下面几类场景最容易踩中这个坑:
- mmap 大文件并随机访问。比如服务启动时用 mmap 加载一个几百 MB 的词典或索引,然后业务请求又随机命中这些区域,一旦 page cache 里的页被回收,后续每个请求都可能触发一次磁盘读。
- JVM / Redis 等运行时的“懒初始化”。JVM 启动时会分配大块堆内存,但如果用了大页、堆外内存、或者 GC 后进行了内存整理,某些内存区域在首次访问时会集中缺页。
- 冷启动 / 缓存失效。缓存过期后,大量请求同时回源读文件或数据库,数据还得重新读入 page cache,这时候 major fault 会非常密集。
- 宿主机内存压力引起 page cache 被回收。即使你的容器内存使用率不高,宿主机如果整体内存吃紧,内核会优先回收 page cache。回收之后再读取数据,就全部退化成 Major Fault。
3. 完整排障链路:从 0 到抓到真凶的每一步
3.1 第一步:用 pidstat 锁定进程特征,majflt 不会说谎
碰到这种“看起来像死循环”的场景,我最先做的事情是找到那个反复重启的进程 PID,然后直接看它的缺页统计。
pidstat -r -p PID 1-r显示缺页和内存信息,1表示每秒采样一次。输出里有两个关键列:minflt/s和majflt/s。
正常情况下,一个稳定的 Java 服务majflt/s应该是 0,偶尔是 1、2。而当时这个进程采样出来,majflt/s长期保持在 100 以上,最高冲到几千。看到这个数字,基本可以断定:进程的卡顿不是业务逻辑问题,而是每秒钟有大量线程嵌入到磁盘缺页读取上。
同时我配合vmstat 1看了一眼系统层面的 si 和 so(swap in/out),以及wa(IO wait)列。虽然 swap 数值不高,但wa明显偏高,说明系统的大量时间确实耗在等待块设备上。
3.2 第二步:从系统级和 cgroup 级指标确认“内存压力真实存在”
进程级别确认之后,我回到系统和 cgroup 层,看看这种缺页是不是全局性的。
系统级先看/proc/vmstat:
pgmajfault 81920321 pgscan 23420918 pgsteal 21227311pgmajfault是累计的主缺页次数,pgscan和pgsteal是内存回收时扫描和偷取的页数。如果你看到 pgscan 和 pgsteal 在持续快速增长,说明内核回收线程正在频繁工作,page cache 正在被大量拆掉。
然后看/proc/pressure/memory,也就是 PSI 指标:
some avg10=23.45 avg60=18.21 avg300=12.48 total=880123456 full avg10=5.30 avg60=4.10 avg300=2.60 total=290123456PSI 是内核专门为“资源压力”设计的指标,单位是百分比。some表示“至少有一个任务因为内存回收而停顿”的时间占比;full表示“所有任务都因内存压力停顿”的时间占比。这里some的 avg10 到了 23% 左右,说明机器上每 10 秒就有约 2 成多的时间有任务在等内存;这已经是很明确的压力信号了。
之后我再进入 Pod 对应的 cgroup 目录,看更细的统计:
cat /sys/fs/cgroup/kubepods/burstable/pod-xxx/memory.stat重点看这几个字段:pgfault、pgmajfault、workingset_refault。workingset_refault尤其关键,它专门统计“之前被回收的页面再次被访问”的次数,也就是典型的 cache 抖动。这个数字也很大,说明确实有一部分页面在反复被回收、反复被换入。
3.3 第三步:用 perf 定位缺页发生在哪些路径上
确认主缺页很多之后,我还需要回答一个问题:这些缺页发生在什么代码路径上?是匿名内存(堆),还是文件映射?这决定了后续修代码的方向。
我先用perf record -g -p PID -a sleep 30抓了 30 秒的调用栈,然后用perf report看热点。正常情况下业务热点应该在 Java 的Interpreter、Compiled Method或者 JIT 代码里。但那次排障看到的热点几乎全部在内核函数上:
- do_mpage_readpage - mpage_readpages - filemap_fault - do_fault - handle_mm_fault - __do_page_fault这段调用链的含义很明确:缺页异常走到了filemap_fault,走的是文件映射路径,而不是匿名内存路径。也就是说,程序不是“堆爆了”,而是在频繁读取文件映射区域的时候,页面没有驻留在 page cache 里。
如果你想进一步知道是哪个用户态的库或文件触发的,可以配合 ftrace 或者 bpftrace 跟踪。我当时的做法是用bpftrace跟踪filemap_fault的入口,把用户态栈也打出来:
bpftrace -e 'kprobe:filemap_fault { @[comm, ustack] = count(); }'抓下来之后发现热点集中在某个负责加载词库的 Native 库调用上,和 perf 的结果互相印证。到了这一步,真凶基本浮出水面。
3.4 第四步:宿主机视角交叉验证,别让邻居 Pod 背锅
最后一步是把视角从容器拉到宿主机,因为容器里的内存压力很有可能是宿主机整体内存紧张传导下来的。
我检查了三样东西:
- 宿主机空闲内存:
free -g看 available 是否长期贴着 0。 - 块设备延迟:用
iostat -x 1看%util和await。如果await超过几十毫秒,说明磁盘本身已经过载。 - 同节点其他 Pod 的 IO 使用:通过 cgroup 的
io.stat对比。
结果发现宿主机 available 内存只剩不到 5%,其他几个大内存 Pod 把 page cache 几乎挤干了。也就是说,即使我们的容器自己的内存使用率不高,宿主机整体内存压力也会把我们的文件 cache 回收掉,导致每次读文件都变成 Major Fault。这是整个证据链里非常重要的一环,也是很多 K8s 用户特别容易忽略的问题。
4. 修复方案:治标参数、治本改造、以及验证闭环
4.1 先止血:内核参数和运行时调整
排障到了这个阶段,第一步不是大改代码,而是先把抖动止住。
如果你的服务确实使用了大量文件映射,且访问模式是随机读、对延迟敏感,那么值得评估一下透明大页(THP)的影响。THP 在内核把缺页处理路径拉长,因为分配一个 2MB 的巨型页比分配 4KB 基础页更耗时,还更容易触发内存 compaction。在大部分容器场景下,我倾向于把 THP 策略从always改为madvise:
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled echo advise > /sys/kernel/mm/transparent_hugepage/defrag这样只有显式调用madvise(MADV_HUGEPAGE)的映射才会使用大页,普通匿名内存和文件映射不受影响。
另一个可以按需调整的是vm.page_cluster。这个参数决定了内核一次缺页时预读多少个页。默认值是 3,也就是一次读 2 的 3 次方等于 8 个页。如果程序访问模式是稀疏随机读,预读 8 个页反而浪费带宽、放大延迟;可以尝试调成 0,让内核一次只读一个页。注意,这个参数的收益要实测验证,并不是所有场景都适合调低。
在 JVM 场景,还可以考虑以下几个运行时选项:
-XX:+AlwaysPreTouch -XX:+UseTransparentHugePagesAlwaysPreTouch的意思是 JVM 启动时把所有堆内存先“碰”一遍,让物理页在启动阶段就分配好,而不是等到业务流量来了之后才一边触发缺页一边跑。代价是启动时间变长,但对降低运行时 Major Fault 有明显帮助。
在 K8s 里如果改不了宿主机参数,也可以通过加环境变量、启动参数来绕开。
4.2 业务代码层面的根因修复
止血之后必须治本。当时我们最终在代码里做了什么?主要是三点:
第一,把“启动时 mmap 大文件 + 运行时随机读”的模式改成分片懒加载。具体做法是切分索引文件,只把热数据常驻内存,冷数据通过预取线程提前拉进 page cache。本质是减少运行期对磁盘的突发依赖。
第二,对确定要反复访问的大文件,调用madvise设置顺序读和预读提示:
madvise(addr, len, MADV_SEQUENTIAL);这样内核能更激进地提前读入数据,减少业务线程触发缺页的等待时间。
第三,如果页面确实需要长期驻留且对延迟极其敏感,可以考虑mlock锁页。但这并不是默认推荐方案,因为锁页会让物理页无法被回收,等于强行占内存,容器和宿主机都容易踩到配置上限。用之前一定要算好内存预算。
在服务架构层面,我们还做了一件事:给回源读文件的路径加了并发控制和限流。这样一来,即使 page cache 被清空,同一时刻只会有一小部分线程在触发缺页,其余请求排队等待,而不是所有线程同时去砸磁盘。
4.3 cgroup 与 Kubernetes 侧的隔离和限流
前面说过,宿主机内存压力是页面被回收的一大来源。所以 K8s 侧的配置也值得审视。
首先是 requests 和 limits 的比例。如果两个值相差太大,宿主机超卖严重,页面回收就会很频繁。按我们经验,对延迟敏感的服务,requests/limits 不要超过 1:2。另一点是,如果诊断出问题来自磁盘 IO,可以考虑用 cgroup v2 的io.weight给关键服务更高的磁盘调度权重,必要时用io.max限制邻居 Pod 的读写带宽。
/sys/fs/cgroup/kubepods/burstable/pod-xxx/io.weight # 设置权重 /sys/fs/cgroup/kubepods/burstable/pod-xxx/io.max # 设置带宽上限K8s 在 cgroup v2 的节点上会默认把这两个文件映射好,但具体配置还是要通过 kubelet 参数或者一些扩展来完成,不同发行版差异不小。做之前记得先确认节点内核版本和 kubelet 的 cgroup driver 是否已切到 systemd + cgroupfs v2。
最后是监控告警。很多团队只在内存使用率超过 90% 才告警,这种阈值对于本类故障毫无意义。建议在 Prometheus 里同时采集以下指标并设置告警:
- 容器或进程的
majflt速率(可通过/proc/[pid]/stat的字段计算,或通过node_exporter的process_major_page_faults_total获取)。 - 主机的
/proc/pressure/memory的 PSI 指标,尤其关注some和full的 avg10。 - cgroup 的
memory.stat中的pgmajfault和workingset_refault。
4.4 验证闭环:修没修好不能靠感觉
修复之后,最关键的一步是做好前后对比,不然你根本不知道是“改好了”还是“碰巧没发作”。
我建议在同样的流量场景下,分别采集修复前后的数据,至少对比以下几项:
| 指标 | 修复前 | 修复后 | 说明 |
|---|---|---|---|
| majflt/s | 平均 300+ | 平均 5 以下 | 主缺页速率,核心指标 |
| PSI memory some avg10 | 23% | 1% 以下 | 内存回收导致的停顿比例 |
| Pod RESTARTS | 54 次/小时 | 0 | 容器稳定性 |
| 接口 p99 延迟 | 3200ms | 120ms | 业务侧直观感受 |
我实际测下来的结果是,改完懒加载和限流之后,majflt/s基本归零,PSI 也掉到了个位数以下。Pod 重启次数直接降为 0,接口延迟恢复到了正常水位。这时候才算是真正的闭环。
5. 复盘与长期防范:这类故障的辨识特征和我的几点习惯
5.1 一张可以保存的“辨识特征表”
这类故障排查多了之后,我养成了一个习惯:遇到“卡死”类问题,不急着猜原因,先把现象整理成一张特征表。
| 现象特征 | 可能原因 | 关键验证指标 |
|---|---|---|
| CPU 高,线程栈在业务代码循环执行 | 业务死循环 | jstack / perf 用户态热点 |
| CPU 高,线程栈大量停在内核缺页/文件读取路径 | Major Page Fault 抖动 | pidstat majflt/s、perf 内核栈 |
| 内存未触顶但容器频繁重启 | cgroup OOM / 健康检查超时 / 页面回收抖动 | dmesg、kubectl events、PSI |
| 内存接近触顶且伴随 swap | 内存容量不足 | free、si/so、memory.limit |
| IO util 高 + 应用延迟飙升 | 磁盘或邻居 Pod 抢占 | iostat await、cgroup io.stat |
这张表不一定覆盖所有场景,但能帮你最快速度判断“该往哪个方向查”,而不是在业务代码里反复挖坟。
5.2 监控补位:别只看内存使用率
我见过太多集群的监控面板只有 CPU、内存、网络这三件套,这对于捕获 Major Page Fault 引发的抖动完全不够。要真正提前发现这类问题,监控面板里至少要有下面几个维度:
- 进程级缺页速率。
pidstat -r输出里的 majflt/s 要采集,或者用 node_exporter 暴露的process_major_page_faults_total。 - PSI 指标。
/proc/pressure/memory要么直接看,要么通过prometheus_psi类的 exporter 采集。这个指标对“页面回收导致停顿”非常灵敏。 - 块设备延迟。
iostat -x的await、%util,配合容器的 IO 限制一起看。
5.3 我的一些个人习惯变化
在这几次排障之后,我个人的工作习惯也改了。
第一,看到同事的代码里出现“一次性 mmap 大文件”、“启动时把整个索引加载进堆外内存”这类写法,我会条件反射式地追问:这个文件多大?映射之后的数据访问模式是顺序还是随机?有没有预热的机制?这些问题的答案往往已经决定了 fault 的频率,而不是等到线上出了问题再来查。
第二,做压测的时候,我不再只看吞吐和 p99。每次压测都会同步记录 pidstat 里的 majflt/s 和 PSI 的 some/full 曲线。如果流量上升时缺页速率呈指数型上涨,这个系统距离崩溃就不远了,哪怕 CPU 还没有打满。
第三,遇到看起来“完全不合常理”的故障,我学会了冷静回到内核指标本身。上层应用再花哨,最终的卡顿都会体现在内核的调度、中断、缺页这些底层机制上。多掌握几个/proc下的统计文件,排查问题时等于多了一双眼睛。
另外还有一个很实用的小技巧:直接在宿主机上跑一个 watch 命令,可以非常快速地感知全局缺页压力:
watch -n1 'grep -E "pgmajfault|pgscan|pgsteal" /proc/vmstat'当你在排查任何“运行时抖动”问题时,如果这几个数字在快速跳动,那基本可以断定问题出在内存回收和缺页路径上,而不是业务逻辑。先把焦点拉回内核,很多所谓的“死循环”谜题都会迎刃而解。