故障档案

监控全绿。52 个实例已经死了几小时。

节点 kubelet 虚假 Ready,但容器运行时 hang 住导致 52 个用户实例网关集体失效,数小时无告警。

凌晨 01:30 左右,某节点(default-9d23b)的监控曲线呈现完整的断崖。CPU 从 70% 跌至 0%,内存从 37% 跌至 3%,该节点上 52 个用户实例的容器集体消失。

但——

K8s 控制面全绿。零条告警。kubelet 仍在向 API server 汇报该节点 Ready。容器卡在 ContainerCreatingTerminating,既创建不出来也删不掉。

52 个网关同时失效,系统无声无息。直到数小时后收到用户反馈"连不上",才发现已经故障了好几个小时。

矛盾。监控看起来节点宕了,但 K8s 说它 Ready。宿主机断崖,用户实例无法启动,但没有告警。这才是真正的问题——故障本身可恢复,但看不见的故障无法自愈。

监控数据在说什么

查共享存储(SFS Turbo)的监控。01:30 时刻写 IOPS 出现尖峰:2800 次/秒,写吞吐 70MB/s。直观看起来是存储压力导致了节点卡死——节点在做大量 I/O 操作,触发了限流或延迟,进程全部卡住。

但关键数据打了脸。SFS Turbo 的规格是 30K IOPS / 2GB·s⁻¹。实际使用率:

  • IOPS:2800 ÷ 30000 = 9%
  • 吞吐:70MB/s ÷ 2GB/s = 3.4%

服务端远未饱和。容量用量也只有 14%。这不可能是存储容量不足或聚合性能饱和。所以问题不在存储服务端。在哪呢?节点侧。具体说,该节点的 NFS 客户端(内核网络栈的 NFS 模块)或它到 SFS 的网络链路在 01:30 时 hang 住了。

NFS hard 挂载下的连锁反应

当 NFS 客户端因网络问题或 SFS 暂时无响应而卡死时,所有试图访问这个挂载点的进程会被阻塞在 I/O 操作上,进入 D 状态(disk sleep,不可中断睡眠)。D 状态的进程无法被信号杀死,也无法被超时打断——内核会一直等待 I/O 完成。这个行为在网络稳定的数据中心是安全的,但在任何抖动的环境里都是致命的。

D 状态的进程很特殊——无法被信号杀死,无法被超时打断,内核会无限期等待 I/O 完成。即便网络彻底断了,进程仍在死等。

这时宿主机上发生了什么:

  • 容器的启动脚本卡在读配置文件
  • containerd 的生命周期管理调用卡在存储操作
  • kubelet 的 pod 创建/删除操作卡在与 containerd 的 gRPC 通信

但有个奇特的例外:kubelet 的心跳协程——那个定期向 API server 汇报节点状态的 goroutine——不依赖文件系统操作。它继续活着。每 10 秒一次,向 API server 汇报该节点 Ready。

从 K8s 的角度看,这个节点一切正常。有心跳,有回应,不存在问题。

而实际上,任何与 containerd 交互的操作都已无响应。容器创建失败。现有容器无法优雅终止。网关进程要么无法启动(配置文件在 NFS 上读不到),要么启动后立即因存储操作超时而崩溃。

52 个实例,52 个网关,集体失效。外界完全看不见。

为什么 NFS hang 导致全节点瘫痪

该节点上的 NFS 挂载点是共享存储卷的唯一入口。一旦这条路被 hang 住,整个节点上所有容器的存储操作(读配置、写日志、挂载 subPath)都会卡死,进而拖累 containerd 的生命周期管理。最终的结果就是:整个节点上的所有容器无法启动、无法优雅停止、无法查询状态,只能处于永久的"创建中"或"删除中"状态,直到人工介入。

这是一个级联故障的典型案例:底层的存储客户端出问题 → 容器运行时无法工作 → 整个节点失效 → 上层的 Kubernetes 控制面却仍然说一切正常(因为 kubelet 心跳进程不依赖存储)。

根因与修复思路

NFS 挂载使用了默认参数 hardhard 的含义:当 NFS 连接中断时,客户端无限重试 I/O 操作,永不超时。这在网络完全可靠的数据中心是安全的。在任何有抖动的网络里,都是定时炸弹。

修复:改为 soft,timeo=100,retrans=3,intr

  • soft:超时后放弃重试,返回 I/O 错误给应用
  • timeo=100:I/O 操作超时 100ms(原默认 600ms,太长)
  • retrans=3:放弃前最多重试 3 次(原默认无限)
  • intr:允许信号中断 I/O 操作

改了以后,当网络或 NFS 客户端出问题时,I/O 操作在 100ms 后返回错误。容器报错退出。关键是:进程不进入 D 状态,宿主机不冻死。ReplicaSet 自动重启容器,或调度器把实例分配到健康节点。故障隔离在单容器,不演变成整个节点瘫痪。

但这需要滚动重启所有使用该 NFS 卷的实例。一次性代价,可以接受。

kubelet 的 Ready 状态检测只验证两件事:kubelet 进程活着 + API server 能收到心跳。它验证容器运行时是否真的可用、节点的存储链路是否畅通、能否实际创建和销毁容器。当 NFS 客户端 hang 住时,这三个全部失败。但 Ready 信号一点都不知道。这是监控最大的盲点。

防回归:健康守卫机制

应该补充一个独立的节点健康守卫机制,定期(每 60 秒)验证:

  1. 该节点是否有异常堆积的 ContainerCreatingTerminating pod(超过 5 分钟仍未完成)
  2. 从该节点的各 pod 内部进行网关探测(HTTP 请求实例网关的 /panel/ 端点),检查网关是否可达
  3. 对于发现无响应的节点,进行重试确认,避免误判

一旦发现节点虽 Ready 但存在大量卡住的 pod 或网关大范围失效,立即触发告警。告警应该同时推送到监控系统和人工值班渠道(钉钉、邮件或短信)。

后续可以进一步自动化:确认节点真的故障后,自动隔离该节点(cordon),然后驱散它上面的所有实例到健康节点。但这是较高风险的自愈动作,应该先在告警阶段验证机制的准确性,再考虑自动化。

另外,应该在监控告警规则里补上对宿主机级别异常的检测:CPU / 内存 / 网络指标的断崖式下跌通常表示故障,应该立即告警,而不是等待用户反馈。

现场处理

  1. 人工 cordon 该节点,禁止新 pod 调度
  2. 强制删除所有卡住的 pod(kubectl delete pod --grace-period=0 --force
  3. 这些 pod 被 ReplicaSet 自动重新调度到健康节点
  4. 原节点标记待重启

52 个实例逐渐恢复在线。重启时间取决于宿主机厂商的故障排查流程,不受我控制。

最后的教训

故障本身不致命。52 个实例暂时离线是可接受的代价(虽然用户感受很差),可以人工隔离、迁移、恢复。

致命的是不可见加无自愈

K8s 控制面的 Ready 只代表 kubelet 心跳协程还活着,不代表容器运行时真的可用。当节点侧 NFS 客户端 hang 住时,这个事实在数小时内完全对外界不可见。系统既没自动隔离故障节点,也没自动驱散实例。直到人类察觉到用户反馈,才开始行动。

后续系统设计缺三不可:

  1. 检测:定期验证节点运行时的真实可用性,不只依赖心跳信号
  2. 告警:一旦检测到节点虽 Ready 但运行时卡死,立即告警
  3. 自愈:故障确认后,自动隔离节点、驱散实例、释放资源

没有检测,问题永远看不见。没有告警,人无法及时知道。没有自愈,即使知道了也要等待人工干预,浪费宝贵的恢复时间。这次故障的代价,最终是整个系统的可见性和自愈能力。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。