ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

K8s容器连环重启的隐形元凶:Major Page Fault原理与排障实践

2026/9/16 9:10:36 拓冰建站 浏览量
K8s容器连环重启的隐形元凶:Major Page Fault原理与排障实践 有一类故障你盯着监控面板看会觉得系统在说谎内存使用率还不到 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 podEvents 里没有任何 OOMKilled 的提示只有 Liveness 探针失败导致的容器重启或者说进程是“自己退出”的。让我印象最深的是 Grafana 面板。Memory Usage 只有 55% 左右曲线平滑没有冲顶。CPU Usage 却完全是另一副嘴脸像锯齿一样一浪一浪地顶到 100%然后又瞬间跌到 10% 以下。最诡异的是 Load Average1 分钟值能冲到 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 语言的mallocglibc 对较大的分配会走mmap系统调用。而mmap的本质只是创建了一个 VMAVirtual 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 和 soswap in/out以及waIO wait列。虽然 swap 数值不高但wa明显偏高说明系统的大量时间确实耗在等待块设备上。3.2 第二步从系统级和 cgroup 级指标确认“内存压力真实存在”进程级别确认之后我回到系统和 cgroup 层看看这种缺页是不是全局性的。系统级先看/proc/vmstatpgmajfault 81920321 pgscan 23420918 pgsteal 21227311pgmajfault是累计的主缺页次数pgscan和pgsteal是内存回收时扫描和偷取的页数。如果你看到 pgscan 和 pgsteal 在持续快速增长说明内核回收线程正在频繁工作page cache 正在被大量拆掉。然后看/proc/pressure/memory也就是 PSI 指标some avg1023.45 avg6018.21 avg30012.48 total880123456 full avg105.30 avg604.10 avg3002.60 total290123456PSI 是内核专门为“资源压力”设计的指标单位是百分比。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改为madviseecho 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 avg1023%1% 以下内存回收导致的停顿比例Pod RESTARTS54 次/小时0容器稳定性接口 p99 延迟3200ms120ms业务侧直观感受我实际测下来的结果是改完懒加载和限流之后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.limitIO 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当你在排查任何“运行时抖动”问题时如果这几个数字在快速跳动那基本可以断定问题出在内存回收和缺页路径上而不是业务逻辑。先把焦点拉回内核很多所谓的“死循环”谜题都会迎刃而解。