早上打开微信,发现 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 抖动直接判死刑。前者零风险,后者会把系统搞得更乱。
