搬运并适配自国外社区真实案例(ClickHouse Cloud 在 GKE 上的一次 8 个月内核级排障)。原文出处见文末。
TL;DR
症状 :某几个 Pod 的 CPU 被打满到配额上限并被 throttle,内存贴着 cgroup 上限却从不 OOM ,进程完全不前进;最诡异的是 ps aux、perf、top 这些诊断工具自己也卡死 ,只有重启 Pod 能恢复。根因 :内核在处理缺页(page fault)时持有进程的 mmap_lock 去执行 memcg 内存回收 (shrink_lruvec),而高并发线程数(1000+)叠加公平调度让持锁线程被 __cond_resched() 反复让出 CPU,导致这个读锁被持有几分钟到几小时 ;期间所有需要 mmap_lock 的操作(包括 ps aux 读 /proc/<pid>/cmdline)全部阻塞。解决 :开启 MGLRU(echo y > /sys/kernel/mm/lru_gen/enabled)或升级到已修复该锁顺序的内核。
一句话记住这个案例:CPU 全烧在内核态回收上,扫了 4.27GB 只回收 32 个页------不足以触发 OOM,但足以让整台机器看起来像死机。
现象
环境:GKE + Container-Optimized OS(COS),ClickHouse Cloud 生产集群,Pod 配额多核 + 内存限额(本例 4 核 / 4GB 复现环境)。
可观测到的症状组合(原文描述 + 复现环境实测):
text
# 1. CPU 打满并被 throttle,且完全在内核态
$ pidstat 1
Average: UID PID %usr %system %guest %wait %CPU CPU Command
Average: 101 66 0.00 3000.00 0.00 0.00 3000.00 39 clickhouse-serv
# 2. 任务时钟全在内核,缺页率却低到离谱(10 秒只 90 次)
$ perf stat -p $PID -- sleep 10
304675.80 msec task-clock # 29.130 CPUs utilized
97934 context-switches # 0.321 K/sec
90 page-faults # 0.000 K/sec
# 3. 内存贴着 cgroup 上限,但没有 OOM(cgroup v1 路径)
==> memory.failcnt <== 31
==> memory.limit_in_bytes <== 4294967296
==> memory.usage_in_bytes <== 4294967296
==> memory.max_usage_in_bytes <== 4294967296
==> memory.stat <==
cache 4001792000 rss 128729088 mapped_file 4001792000
inactive_anon 128729088 active_anon 0
inactive_file 2411884544 active_file 1589776384
四个反直觉点,值得先记住,后面每一条都会在排查里起作用:
ps aux自己会挂------不只是变慢,是整条命令卡住不返回;perf大多数时候跑不起来------没有输出、没有报错,终端就停在那里;- 内存没超限、没 OOM,但进程一步都走不动;
- 只在 GCP 上出现,同一套服务在 AWS、Azure 上从未复现。
同时,第一次上 gdb 抓全线程栈时看到的画面完全把人带偏:线程数超过 1000,大量线程在等临界区(但不是死锁,因为总有人在持锁干活),而且大量栈帧指向 libunwind------看起来像"异常处理开销爆炸"。方向就此错了一整轮。
排查过程
第一回合:按"线程争用 + 栈回溯开销"找,失败
线索来自 gdb 的全线程 backtrace:线程数异常多、栈回溯占比高。团队据此优化了 ClickHouse 内部符号缓存(减少全局锁争用),无改善。
为什么这一轮必然失败 :gdb(以及所有基于信号的采样器)只能看到用户态栈。当 100% 的 CPU 实际消耗在内核函数里时,用户态栈提供的信息与真实瓶颈几乎无关。这一条是本案最重要的方法论教训。
第二回合:排查历史嫌疑犯 mincore,被排除
两个月前有过一次性能退化,根因是 libunwind 用 mincore 校验地址有效性,而 mincore 需要 mmap_lock,高线程数下争用严重。当时通过去掉 mincore 修掉了,并全局关闭了 profiler。
本轮用 bpftrace 直接统计 mincore 的调用次数与耗时:
bash
# 统计 mincore 调用延迟分布
tracepoint:syscalls:sys_enter_mincore /pid==$PID/ { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_mincore /pid==$PID/ && @start[tid]/ {
@latency_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
结果:这次一次 mincore 调用都没有 。嫌疑犯排除------但注意,它和真正的根因共享同一个关键资源(mmap_lock),这个细节在后面回看时才连起来。
第三回合:唯一一次成功的 perf,把方向彻底掰正
perf top 十次里九次不工作。终于成功的那一次,输出是这样的:
text
PerfTop: 8062 irqs/sec kernel:100.0% exact: 0.0% lost: 0/0 [4000Hz cpu-clock]
-------------------------------------------------------------------------------
64.28% [kernel] [k] shrink_lruvec
28.81% [kernel] [k] lru_note_cost
2.91% [kernel] [k] shrink_node
1.84% [kernel] [k] _raw_spin_unlock_irqrestore
0.38% [kernel] [k] count_shadow_nodes
0.33% [kernel] [k] shrink_page_list
kernel:100.0% 是决定性信息:CPU 不是被业务代码吃掉的,而是被内核内存回收(shrink_lruvec)吃掉的 。这也解释了为什么 gdb 的栈那么没用------它压根看不到这些内核帧。
第四回合:做 runbook,等下一次故障
问题偶发、随机 Pod、无法按需复现。团队的做法值得抄:按 Brendan Gregg 的 60 秒分析法准备一份 runbook(dmesg、bpftrace 内核栈、pidstat、mpstat、perf、sar、iostat),故障一出现就按固定顺序抓数据。同时给 on-call 一个 10 秒判据:
bash
# 出现问题时,先看 %system 是否异常
pidstat 1 3
第五回合:用 vmscan tracepoint 量化"回收有多慢"
bash
# 回收开始/结束配对,输出耗时直方图与回收页数
tracepoint:vmscan:mm_vmscan_memcg_reclaim_begin /pid == $PID/ {
@memcg_begin[tid] = nsecs;
printf("begin t=%d order=%d gfp=%ld\n", (nsecs-@epoch)/1000000, args.order, args.gfp_flags);
}
tracepoint:vmscan:mm_vmscan_memcg_reclaim_end /pid == $PID/ && @memcg_begin[tid] > 0/ {
$elapsed = nsecs - @memcg_begin[tid];
@mm_vmscan_memcg_reclaim_ns = hist($elapsed);
printf("end t=%d nr_reclaimed=%ld elapsed_ns=%ld\n",
(nsecs-@epoch)/1000000, args.nr_reclaimed, $elapsed);
@memcg_begin[tid] = 0;
}
真实输出读起来非常刺眼:单次回收耗时 1 至 4 秒,而回收到的页只有 4 到 60 个。直方图长这样(单位纳秒):
text
@mm_vmscan_memcg_reclaim_ns:
[512M, 1G) 354 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1G, 2G) 403 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2G, 4G) 300 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[4G, 8G) 139 |@@@@@@@@@@@@@@@@@@@@@|
[8G, 16G) 29 |@@@|
"扫描几个 GB 的内存,只回收几十页"------这个比例本身就是活锁的指纹:回收工作一直在做,却几乎不产生成果,同时把 CPU 全部吃光。
第六回合:云厂商的第一次回复是"没异常"
向云厂商支持的第一次沟通并不顺利:提交 sosreport 后得到"没有异常,就是内存不足被 OOM kill"。这与观测不符(Pod 从没重启过 OOM)。推动事情往前走的那一步是:给出一个 100% 可复现的用例。
第七回合:写一个 100% 复现的 C++ 用例,问题才真正被承认
复现程序(作者公开了原始代码,见文末出处)跑在 4 核 / 4GB 的容器里,分两阶段:
- 前 30 秒 :起 1000 个线程,各写 1000 个小文件(合计约 4GB),然后
mmap并按顺序持续遍历这些文件页; - 30 秒之后 :再起 1000 个线程,分配匿名内存并随机访问,触发大量缺页。
第二阶段一起来,现场三个症状同时出现,与生产完全一致:
text
1. ps aux 全系统卡死
2. perf 完全失效
3. 进程不再前进,但 CPU 全被占满
strace ps aux 给出了突破口:它卡在读取 /proc/<pid>/cmdline。查 procfs 内核代码可知,读取目标进程的这些信息需要获取该进程的 mmap_lock ------而这个锁已经被持有太久(实测 10 秒到 2 小时不等)。perf 同样需要读取进程信息,所以一起挂。
第八回合:量化持锁时长与"锁里在干什么"
bash
# 1) 持锁时长分布(只打印超过 50ms 的)
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ { @start[tid] = nsecs; }
tracepoint:mmap_lock:mmap_lock_released /pid == $1/ && @start[tid] > 0/ {
$us = (nsecs - @start[tid]) / 1000;
if ($us > 50000) { printf("hold %d us tid=%d comm=%s\n", $us, tid, comm); }
@hold_us = hist($us);
delete(@start[tid]);
}
# 2) 只在"持锁期间"采样内核栈,找出锁里在跑什么
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ { @is_holding[tid] = 1; }
tracepoint:mmap_lock:mmap_lock_released /pid == $1/ && @is_holding[tid] == 1/ { @is_holding[tid] = 0; }
profile:hz:99 /pid == $1/ && @is_holding[tid] == 1/ { @under_lock[kstack, comm] = count(); }
第 1 个脚本抓到一条 230818460 us(230 秒 )的持锁记录;第 2 个脚本证明锁里跑的是 shrink_lruvec(内存回收),调用路径来自缺页:
text
@[
do_user_addr_fault+727
exc_page_fault+120
asm_exc_page_fault+34
]: 1
第九回合:单次临界区的完整账本(本案最硬的一份证据)
自定义 bpftrace 脚本按"每次 acquire→release 周期"汇总所有指标,拿到的账本:
text
=== [READ] mmap_lock hold stats in TID 1891547 COMM r_memory ===
number of context switches: 2569
number of preemptions: 0 ← 从未被强制抢占
__cond_resched() calls: 2255178 ← 主动让出 CPU 225 万次
shrink_lruvec() calls: 13
shrink_list() calls: 34172
nr_scanned: 1093267 ← 扫描了 109 万页(约 4.27 GB)
nr_reclaimed: 32 ← 只回收了 32 页
nr_activate1: 564142 ← 56 万页被"晋升"回活跃链
nr_ref_keep: 529093 ← 52 万页被判定"还被引用,先留着"
runtime: 3699226 us ← 真正占用的 CPU 时间:3.7 秒
duration: 563997395 us ← 墙钟耗时:564 秒
翻译成人话:这个线程拿着锁,花了 564 秒墙钟时间去扫描 4.27GB 内存,真正烧掉的 CPU 只有 3.7 秒------其余时间都在和约 150 个线程抢那 4 个 CPU 核的调度。
而且它是主动 让出的:__cond_resched() 被调用了 225 万次,抢占次数为 0。这正是高线程数把这个 bug 放大的地方------线程越多,持锁者被让出 CPU 后重新被调度的间隔越长,锁的持有时间就被拉得越长。
根因:page fault 路径持有 mmap_lock 执行 memcg 回收
先把 Linux 页面回收的最少必要背景讲清楚:
| 概念 | 要点 |
|---|---|
| LRU 双链 | 匿名页与文件页各维护 active / inactive 两条链 |
| accessed bit | 硬件在 PTE 上置位,内核只能在自己的扫描过程中读取并清零 |
| 两次扫描才能回收 | 页先被放进 inactive 链,下次扫描若仍未访问才可回收 |
| cgroup 没有异步回收 | kswapd 是为系统级 压力服务的,cgroup 超限只能走同步回收------在申请内存的进程上下文里做,申请者阻塞等待 |
| 内存贴着上限 | 于是每一次内存分配都触发一次同步回收 |
根因链条完整表述如下:
text
进程缺页
└─► asm_exc_page_fault → do_user_addr_fault → handle_mm_fault → filemap_fault
└─► page_cache_ra_unbounded(预读)
└─► add_to_page_cache_lru → __mem_cgroup_charge
└─► charge_memcg → try_charge_memcg
└─► try_to_free_mem_cgroup_pages ← 需要内存,触发同步回收
└─► do_try_to_free_pages → shrink_node → shrink_lruvec
└─► vmscan:扫描 inactive/active 链
❗ 以上全程持有进程的 mmap_read_lock(本应只保护地址映射表)
三个机制叠加,形成活锁(livelock):
- 锁用在了错误的范围上 :
mmap_lock是为保护地址空间映射而存在的,把"回收几 GB 内存"这种长耗时工作放进它的临界区,本身就是设计缺陷。 - 回收效率趋近于零 :cgroup 内所有页几乎都处于"刚被访问过"的状态(active/inactive 都被标记),扫描必须扫完整条链才能判定,于是 4.27GB 扫出 32 个可回收页。
- 不触发 OOM 但烧光 CPU :回收"成功"了(回收到了 32 页),所以内核没有理由启动 OOM killer;CPU 却 100% 消耗在内核态扫描上,进程无法前进------这就是"没死但也没活着"的状态。
补充解释最初那个"为什么只有 GCP"的疑问:同一内核版本在不同发行版上行为不同。实测矩阵:
| 环境 | 内核 | 是否复现 |
|---|---|---|
| AWS EKS Amazon Linux 2 | 5.10 | ❌ 无 |
| AWS EKS Amazon Linux 2023 | 6.1 | ❌ 无 |
| GCP COS 101 | 5.10 | ✅ 有 |
| GCP COS 105 | 5.15 | ✅ 有 |
| GCP COS 109 | 6.1 | ✅ 有 |
| GCP COS 113 | 6.1 | ❌ 无(已含修复) |
| Azure Linux | 5.15 | ⚠️ 用例可触发,生产未观测到 |
结论:不要用"内核版本号"判断是否中招,要用"发行版 + 补丁 + 配置"的组合判断。
解决方案
按推荐程度排序:
方案 1:开启 MGLRU(Multi-Gen LRU)------本案唯一"当天生效"的手段
bash
# 前提:内核编译时带 CONFIG_LRU_GEN;生效不需要重启
echo y > /sys/kernel/mm/lru_gen/enabled
cat /sys/kernel/mm/lru_gen/enabled
# 0x0007
原文实测:对整个 GCP 机队开启后,该问题一周内不再出现,且未观测到性能退化 。参考 内核官方文档。
方案 2:升级到已修复该锁顺序的发行版
新内核的行为差异是根本性的:回收前先释放 mmap_lock 。对应到环境矩阵,就是 COS 113(6.1)不再复现。注意在托管 K8s 里你往往无法单独升级内核,只能跟随节点池的系统版本升级------这也是本案拖了 8 个月的现实原因之一。
方案 3:降低单进程并发线程数(缓解,不是根治)
复现用例说明:1000 线程 + 4 核是放大器。线程数越多,__cond_resched() 让权重调度造成的锁持有时间越长。减少同时访问文件映射内存的线程数,能让锁持有时间从"分钟级"降到"秒级"------不解决问题,但能让服务活着。
方案 4:外部 liveness 检测 + 自动重启(兜底)
原文最终就是靠这个收尾的。团队明确拒绝把"无响应就重启"当成修复(那是把问题扫到地毯下),但承认在无法升级内核的托管环境里,它是可接受的服务可用性保障:
bash
# cgroup v2 环境用 PSI 观测"回收是否卡住"(压力指标比 CPU 更能反映真相)
cat /proc/pressure/memory
# some avg10=45.22 avg60=38.10 avg300=12.55 total=12345678
判断逻辑:some avg10 长期高位 + 应用存活探针失败 → 触发重启。不要把 PSI 单一指标当判据,要与应用层探测组合。
方案 5:别用 gdb 的栈单独下结论(工具纪律)
gdb(信号采样)永远看不到内核帧。当 CPU 100% 在内核态时,仅凭它的栈会指向完全错误的方向。内核态问题必须用内核态工具:perf、bpftrace、/proc/pressure、/proc/vmstat。
一个必须知道的后续:修好之后又冒出第二个 bug
开启 MGLRU 后同类告警再次出现,但这次现场不同了:
text
$ perf top
55.47% [kernel] [k] _raw_spin_unlock_irq
12.05% [kernel] [k] _raw_spin_unlock_irqrestore
关键差别:ps aux、perf 都正常工作了(因为不再有长时间持锁),但它仍然全在内核态烧 CPU。栈变成:
text
_raw_spin_unlock_irq → evict_folios → shrink_lruvec → shrink_node
→ do_try_to_free_pages → try_to_free_mem_cgroup_pages
→ try_charge_memcg → charge_memcg → __mem_cgroup_charge
→ __filemap_add_folio → filemap_fault → handle_mm_fault → exc_page_fault
根因是新机制自己的锁:MGLRU 的 evict_folios 需要获取 每个 cgroup / NUMA 节点一把的 lru_lock (保护 struct lruvec,定义在内核 include/linux/mmzone.h)。当 100+ 个线程各自独立做回收时,这把自旋锁 被高频争用------top 里 3 秒内有至少 138 个活跃线程,火焰图中 84% 的栈含 evict_folios。
这个后续给出了两条工程结论:
- "打开 MGLRU"不是万能药:它解决了上一个 bug(长时间持锁),但代价是把压力搬到了自旋锁上;好在它把"服务看起来死机"降级成"CPU 高但工具可用"。
- 同类问题在 MGLRU 下仍然存在 ,且官方 issue(COS 105 慢回收)长期无修复活动。剩下的选择只有两个:升级内核,或做活锁检测 + 自动重启。
如何自检你是否中招
一行判据(最先跑这条):
bash
# 内核态 CPU 是否异常(正常服务不应长期接近 100% system)
pidstat 1 3 | awk 'NR<=3 || $5+0 > 50 {print}'
一段持续观测脚本(放到节点上跑,故障时自动留证):
bash
#!/usr/bin/env bash
# memcg_livelock_watch.sh ------ 内存回收活锁取证
PID="${1:?usage: $0 <pid>}"
OUT="/tmp/livelock-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT"
echo "== 1. 内核态 CPU 与缺页率(低缺页 + 高 system = 可疑)"
perf stat -p "$PID" -- sleep 10 2>&1 | tee "$OUT/perf-stat.txt"
echo "== 2. 回收频率与页数(高频回收 + 个位数回收页 = 活锁指纹)"
timeout 20 bpftrace -e '
tracepoint:vmscan:mm_vmscan_memcg_reclaim_end /pid == '"$PID"'/ {
@reclaimed = hist(args.nr_reclaimed);
@count = count();
} interval:s:5 { print(@count, @reclaimed); clear(@count, @reclaimed); }' 2>&1 | tee "$OUT/reclaim.txt"
echo "== 3. 内存压力(PSI,比 CPU 更能说明回收是否卡住)"
cat /proc/pressure/memory | tee "$OUT/psi.txt"
echo "== 4. cgroup 内存账本(贴着上限 = 每次分配都触发回收)"
CG=$(cat /proc/$PID/cgroup | grep -oE '[0-9]+:[^:]*:[^ ]*' | head -1 | cut -d: -f3)
for f in memory.max memory.current memory.stat memory.pressure; do
echo "--- $f"; cat "/sys/fs/cgroup$CG/$f" 2>/dev/null
done | tee "$OUT/memcg.txt"
echo "== 5. MGLRU 是否开启(0x0000 = 关闭)"
cat /sys/kernel/mm/lru_gen/enabled 2>/dev/null || echo "未编译 CONFIG_LRU_GEN"
echo "取证目录:$OUT"
读法 :第 1 项出现"高 %system + 低 page-faults"、第 2 项出现"大量次回收但每次数十个页"、第 3 项 PSI 高位、第 4 项 memory.current ≈ memory.max、第 5 项显示未开启------五条同时命中,基本可以判定为同一类 memcg 回收活锁。此时先用 ps aux 是否能正常返回做快速确认(能返回说明不在长时间持锁路径上)。
启示
- 工具会给你错误的方向,而且不会报错。
gdb/ 信号采样看不到内核栈;当 CPU 100% 在内核态时,用户态栈越"像问题",越说明你在错路上。先确认"CPU 花在用户态还是内核态",再决定用哪套工具。 - "没有 OOM"不等于"内存没问题"。 回收 32 个页足以让内核认为"回收成功了",从而既不启动 OOM killer,又把 CPU 全烧掉。内存问题的表现形式可以是"CPU 100% 但进程不前进"。
- 内存回收是"别人的进程"替你付账。 同步回收发生在申请内存的那个进程的上下文里,所以肇事者与受害者往往不是同一个 Pod;排查时不要只盯着"哪个进程吃内存",要盯"谁在缺页路径上被迫回收"。
- 同一内核版本 ≠ 同一行为。 本例 5.10/5.15/6.1 在 AWS 与 GCP 上表现相反。内核判断必须落实到"发行版 fork + 补丁 + 配置",不能靠版本号。
- 一个可复现用例是推动外部依赖方认账的唯一硬通货。 云厂商第一次回复是"没有异常";转折点是那份 100% 复现的 C++ 用例。排障的产出物不只是修复,还包括能把责任边界说清楚的证据。
- 修好一个活动锁,可能只是把压力搬到另一把锁上。 MGLRU 修复了
mmap_lock长持锁,却暴露出lru_lock自旋争用。工程上必须接受"内核 bug 修不完",并为不可升级的环境准备兜底(liveness + 重启)。
原始出处
- ClickHouse Cloud 工程博客:The case of the vanishing CPU: A Linux kernel debugging story(Sergei Trifonov,含 perf/bpftrace 原始输出、环境矩阵与后续 MGLRU 二次问题)
- 复现用例(作者开源):serxa/stress_memcg
- 内核官方文档:Multi-Gen LRU(开启方式与限制)
- 内核官方文档:Multi-Gen LRU 管理接口 / PSI:Pressure Stall Information
- 云厂商公开 issue:Google Issue Tracker 363324206(COS 105 页面回收缓慢问题)
- 内核源码位置(原文引用):
mm/vmscan.c(shrink_lruvec/evict_folios)、include/linux/mmzone.h(struct lruvec与lru_lock)、mm/memcontrol.c(try_charge_memcg/try_to_free_mem_cgroup_pages)、mm/memory.c(handle_mm_fault)
本文首发于 CSDN 专栏《运维漏洞指南》。