Featured image of post VPS 内核崩溃取证

VPS 内核崩溃取证

凌晨一点内核崩了,机器半死挂到早上七点才重启。从日志里一层层挖出根因:不是容器太多,是三个 interval=0 的健康检查撞上了云盘 IO 抖动

   
文章摘要
凌晨一点内核崩了,机器半死挂到早上七点。日志里挖出的根因不是"容器太多",而是三个容器配了 interval=0 的健康检查——检查完立刻开始下一轮,无限循环

早上打开微信,发现 AI 助手从凌晨开始就没有回应了。查过去,是跑在云上那台关键机器出了问题。

这篇文章记录完整的排查过程:怎么从"机器掉线"这个现象,一层层挖到"三个容器的健康检查配置"这个根因,以及中间踩过的判断错误。

一、现象:早上发现全挂

用户视角   早上发消息没反应
服务视角   模型网关、探针、自动化任务全部静默
机器视角   ping 通,SSH 连不上;或者干脆 ping 不通

第一反应是网络问题或者服务挂了。但看了机器状态就知道不对:

uptime    8 分钟
last      今天早上 07:39 重启

机器重启过。所以不是"服务挂了",是整台机器重来了一遍。

二、定性:真重启还是假死

这一步很关键——先分清是机器重启了,还是只是服务/网络断了。判据只有两个:

uptime                     # 很短 = 确实重启过
last reboot | head -3      # 历次重启时间

如果 uptime 显示跑了几天,那不是重启问题,去查服务层。这里是 8 分钟,所以确实重启过。

但有一个容易踩坑的地方:

重启时间  ≠  故障发生时间

last 显示 07:39 重启,但故障其实是 01:15 发生的。中间六小时机器处于"半死"状态——内核已经崩了,但没自动重启,SSH、网络、容器全部死掉,机器还"在那里"。

用户是早上才发现,这六小时完全是浪费的。

三、取证:读崩溃前的日志

系统的日志按"启动"分段,journalctl -b -1 读的是上一次启动的所有记录,最后几行就是死掉那个瞬间。

journalctl -b -1 -n 60 --no-pager

看到的东西是这样的:

01:15:25 kernel: INFO: task khugepaged:45 blocked for more than 120 seconds.
01:15:25 kernel: INFO: task runc:3531721 blocked for more than 120 seconds.
01:15:25 kernel: INFO: task runc:3531720 blocked for more than 120 seconds.
01:15:25 kernel: INFO: task runc:3531741 blocked for more than 120 seconds.
01:15:28 kernel: watchdog: BUG: soft lockup - CPU#2 stuck for 89s! [runc:3531939]
01:15:28 kernel: pc : d_alloc_parallel+0x174/0x3e0
01:15:28 kernel: Call trace:
01:15:28 kernel:  d_alloc_parallel+0x174/0x3e0
01:15:28 kernel:  __lookup_slow+0x64/0x158
01:15:28 kernel:  walk_component+0xe0/0x1a0
01:15:28 kernel:  link_path_walk.part.0.constprop.0+0x260/0x390
01:15:28 kernel:  path_openat+0xb0/0xf20
01:15:28 kernel:  do_filp_open+0xa4/0x160
01:15:28 kernel:  __arm64_sys_openat+0x6c/0xb8

两个关键信号:

1. 七个进程阻塞超过 120 秒

khugepaged 是内存管理的内核线程,runc 是 Docker 创建容器时的运行时。它们全部卡住,说明整个系统的 IO 路径已经堵死了。

2. soft lockup + 调用栈

soft lockup - CPU#2 stuck for 89s 表示 CPU 2 卡在内核态 89 秒没法调度。紧跟的调用栈指向具体位置:

openat → path_openat → walk_component → d_alloc_parallel

这一串是"打开文件时做路径查找"。卡在这里说明底层文件系统响应不过来,不是某个程序写错了。

四、追根:卡住的进程说明了什么

soft lockup 里方括号那个名字很重要:

[runc:3531939]

runc 只在创建或重启容器的时候才会被 Docker 调用。它在凌晨一点十五分活跃,说明那时候有容器在启动。

顺着查 docker 自己的日志:

journalctl -b -1 --since "2026-11-02 00:30" --until "2026-11-02 01:16" \
  | grep -iE "dockerd|containerd|health"

出来的是这个:

01:14:42 dockerd: stream copy error: reading from a closed fifo
01:14:43 dockerd: Health check for container 50fb9b78... error: timed out
01:15:23 dockerd: Health check ... timed out(802f5392)
01:15:23 dockerd: Health check ... timed out(424ea111)
01:15:23 dockerd: Health check ... timed out(986c0278)
01:15:23 dockerd: Health check ... timed out(11881187)
01:15:23 dockerd: Health check ... timed out(4edb3e96)
01:15:28 kernel: soft lockup - CPU#2 stuck for 89s! [runc]

五个容器的健康检查在同一秒集体超时,然后四秒后内核崩了。

到这里链条就清楚了:

故障级联:云盘抖动经健康检查与自动重启放大,最终卡死内核

① 云盘 IO 突然抖动(共享块存储,正常现象)
        ↓
② 多个容器健康检查同时超时
        ↓
③ dockerd 判定容器异常 → 调 runc 重建容器
        ↓
④ runc 在 openat(读容器配置和 rootfs)时撞上已堵住的 IO
        ↓
⑤ CPU 卡死 89 秒 → 内核 watchdog 触发 panic

五、这不是第一次

查一下系统保留了几次启动记录:

journalctl --list-boots
-2  2026-09-29 07:40 → 2026-10-21 13:27
-1  2026-10-22 00:36 → 2026-11-02 01:15
 0  2026-11-02 07:39 → 现在

上一次启动的结尾:

Oct 21 13:27:30 kernel:  invoke_syscall+0x78/0x108
Oct 21 13:27:30 kernel:  el0_64_sync+0x190/0x198
Oct 21 13:27:36 kernel: watchdog: BUG: soft lockup - CPU#3 stuck for 22s! [pgrep:262856]

同样的模式,同样的结局。区别只是那次卡住的是 pgrep(扫描进程),这次是 runc(创建容器)——两个进程做的是同一类事:遍历文件系统。

两次崩溃:进程不同,卡的是同一类操作

十一天内崩了两次,说明这是结构性问题,不是偶发。

六、真正的放大器:interval=0 的健康检查

一开始我给出的结论是"27 个容器导致磁盘 IO 压力大",被怼了回来:

不运行的容器为什么会导致磁盘 IO 故障呢

这个质疑是对的。空闲容器几乎不产生 IO,docker stats 里它们的 BlockIO 几乎不涨。拿"容器多"当结论是偷懒。

真正的放大器在健康检查的配置里。查一圈:

docker ps --format '{{.Names}}' | while read n; do
  echo "$n $(docker inspect -f \
    '{{.Config.Healthcheck.Interval}}|{{.Config.Healthcheck.Timeout}}|{{.Config.Healthcheck.Retries}}' $n)"
done

结果里有三个容器的 interval 是 0s:

容器A   interval 0s | timeout 0s  | retries 0
容器B   interval 0s | timeout 20s | retries 10
容器C   interval 0s | timeout 0s  | retries 0

Docker 里 interval=0 表示"上一轮检查结束后立刻开始下一轮"——没有间隔,无限循环。

这三个容器在后台不停 fork 进程、打开文件。平时 SSD 上感觉不到,但云盘 IO 一抖动,它们立刻从"无害"变成"压垮骆驼的最后一根稻草"。

而且 retries=0 意味着一次超时就判定 unhealthy,配合 restart: always 就是"判定失败 → 立刻重启 → 重启又要跑 runc → 继续往堵住的 IO 上加压"。

七、处置:三层改动

按照"先让下次不再半死,再谈根因"的顺序:

第一层:内核崩溃自动重启(零风险)

cat > /etc/sysctl.d/99-panic-reboot.conf <<'EOF'
kernel.panic = 10
kernel.panic_on_oops = 1
EOF
sysctl -p /etc/sysctl.d/99-panic-reboot.conf

默认 kernel.panic = 0,意思是崩溃后不重启、挂着。这就是为什么这次挂六小时没人知道。改成 10 秒后自动重启,机器自己会回来。

⚠️ 注意这里只改 panic,没有碰 softlockup_panic 和 hung_task_panic——那两个会让"卡顿 20 秒"直接判定死刑重启,正常的 IO 抖动也会被误杀,反而更容易把系统搞乱。

第二层:掐断重启风暴

for c in $(docker ps -q); do
  docker update --restart=on-failure:1 "$c"
done

restart: always 在雪崩时会反复重建容器,每重建一次就让 runc 干一堆文件操作。改成"失败一次就停"。

这个改动是热更新的,不重启容器。

第三层:停掉三个 interval=0 的容器

容器级的 healthcheck 不能在运行态改(docker update --no-healthcheck 这个参数不存在,Docker 27.x 也没有),只能重建容器。

确认不用的容器直接停最省事。停之前先备份数据库:

mkdir -p /root/backup/$(date +%F)
docker exec <db容器> sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" \
  --all-databases --single-transaction --routines --triggers' \
  > /root/backup/$(date +%F)/all-databases.sql
tail -2 /root/backup/$(date +%F)/all-databases.sql     # 要看到 Dump completed
tar czf /root/backup/$(date +%F)/mysql-volume.tar.gz \
  -C /var/lib/docker/volumes/<卷名>/_data .

只 docker stop,不 rm——数据卷原样保留,docker start 一条命令就能恢复。

八、量化:没有数据就不给阈值

用户问了个很实在的问题:

能量化 IO 的数据吗,看看阈值在哪

回答是:现在给不出来。

因为这台机器之前没采过 IO 数据。日志里只有内核自己喊的"阻塞 120 秒",拿不到当时的 %util、await、队列深度。硬猜一个数字就是编。

所以做了一套采样:

VPS 侧   每 5 分钟读 /proc/diskstats,算 util / await / 队列深度 / D 状态进程
         写 /var/log/io-watch.csv,7 天滚动
汇总侧   每小时拉数据算 max/avg,写进 Notion 一条
告警     超阈值发通知,正常静默

不装 sysstat,直接从 /proc/diskstats 算:

第 13 字段   IO 耗时(ms)    → %util = Δ(field13) / 间隔ms × 100
第 12 字段   在途 IO 数       → 队列深度
第 4/8 字段  读/写完成次数    → await = Δ(field13) / Δ(完成数)

阈值先按经验给了初值(util ≥ 80%、await ≥ 100ms、D 状态 ≥ 3),但明确标注这是初值,等两周数据攒够之后按实际分布重新算。

只观察,不自动处置——不加任何会导致自动重启的机制。

九、待续:两周后的验证

这篇文章写到这儿,有个结论还没法验证:

阈值到底定在哪才合理?

需要两周的真实数据才能回答:平时是多少、抖动到多少、崩溃前爬到多少。到时候会把实际分布(avg / p50 / p95 / max)拉出来,看初值是否偏松或偏紧。

如果这两周里又出现接近崩溃前的特征,说明监控起作用了;如果一直平稳,那说明三次改动确实压住了那套级联。

到时候补在这篇文章末尾。

十、这次学到的

「机器太多东西」不是根因。 空闲的容器、空闲的服务不产生负载。给出这种结论的人(包括之前的我)没有真正定位到机制。要找到具体在 fork / 开文件的进程和放大它的机制。

interval=0 是个隐蔽的陷阱。 它不报错、不告警,平时毫无存在感,只在系统已经很脆弱的时候补上最后一刀。配置健康检查的时候应该显式设定 interval。

崩溃时间和重启时间要分开说。 “早上七点重启"和"凌晨一点崩溃"是两个事实,混在一起会让用户以为问题发生在早上。

没有数据就不要给阈值。 拍一个数字出来看着专业,实际上是没有依据的猜测。承认"需要先采集"比给个假答案有用。

“确保能重启"和"卡顿就重启"是两回事。 前者是让 panic 后自动恢复,后者是让正常的 IO 抖动直接判死刑。前者零风险,后者会把系统搞得更乱。

晚来天欲雪,能饮一杯无