Kubernetes 节点故障恢复:从心跳检测、驱逐到 Pod 重建
节点失联后,为什么 Pod 没有立刻出现在另一台节点?新副本已经 Ready,旧 Pod 为什么还显示 Running 并带着删除时间?
这次在 zj 上暂停一个 kind worker,记录了从节点失联到替代副本就绪的过程:约 51 秒后 Node Ready 变为 Unknown,约 67 秒后新 Pod Ready。实验明确把 NoExecute 容忍设为 15 秒,所以这两个数字只能解释本次配置,不能当作所有 Kubernetes 集群的故障恢复时间。
先区分谁负责发现故障,谁负责补副本
节点上的 kubelet 向控制面报告状态,并通过 Lease 维持心跳。控制面长时间收不到有效报告时,会更新节点的健康判断。节点生命周期相关控制逻辑与污点驱逐随后影响该节点上的 Pod;Deployment、ReplicaSet 等工作负载控制器则负责维护期望副本。
节点 Ready 为 False 时,对应 node.kubernetes.io/not-ready;控制面无法确认节点状态、Ready 为 Unknown 时,对应 node.kubernetes.io/unreachable。对驱逐起作用的是这些污点的 NoExecute effect。Pod 的 tolerationSeconds 表示污点出现后还能容忍多久,不是从网线断开那一刻开始计时。节点检测已经消耗的时间,需要另外计算。
在本次版本中,taint-eviction-controller 根据 NoExecute 污点与容忍决定何时请求删除 Pod;ReplicaSet 控制器发现有效副本不足后创建替代 Pod;scheduler 和新节点的 kubelet 才接着完成调度与启动。旧节点恢复不是创建新副本的前置条件,因此 API 中可能同时出现正在删除的旧对象和已经就绪的新对象。相关职责见 Nodes 与按污点驱逐。
因此,“故障恢复用了多久”需要先定义终点:Node 变为 Unknown、Pod 进入删除流程、新副本 Running,还是新副本真的能够处理业务请求。本文以替代 Pod 的 Ready 为观测终点,不把它等同于任意业务系统的恢复完成。
心跳和驱逐为什么要分开计时
Lease 让 kubelet 可以用一个较小的对象持续报告存活,不必每次心跳都重写完整 Node 状态。v1.35.1 的 节点健康检查发现 Lease 的 renewTime 比先前更新后,把自己的 probeTimestamp 设为控制器当前时间,再用这个时间计算检测宽限期。这里判断的是“多久没有观察到更新”,而不是直接把节点写入的时间与控制面时钟相减。
检测超时只说明控制面无法确认节点存活,因此状态使用 Unknown。随后 taint-eviction-controller根据 NoExecute 污点和 Pod 容忍安排删除任务。它会保留已经安排的起始时间,避免同一个 Pod 的后续更新不断把驱逐往后推;相关污点消失时,也会取消尚未执行的任务。容忍时间因此提供了一段允许短暂故障恢复的等待,而不是另一套节点健康判断。
补副本还有一个容易忽略的设计。ReplicaSet 控制器创建 Pod 前会先记录 ExpectCreations,再发 API 请求,后续通过观察到的创建事件消减预期。否则,创建已成功但本地 Informer 缓存还没更新时,下轮同步可能再次看到“缺一个”,重复创建。manageReplicas还采用逐批增加的创建方式;某批创建失败后停止扩大发送,避免配额不足时一次制造大量相同失败。这些控制器协调细节说明,恢复时间除了容忍秒数,还包含状态传播与控制器处理。
实验只暂停一个 kind 节点容器
集群由一个 control-plane 和两个 worker 组成,版本为 kind v0.33.0、Kubernetes v1.35.1。先让一个单副本 Deployment 运行在 deep-dive-worker,再确保另一台 worker 可以接受新的调度。
为了让实验在较短时间内观察到驱逐,Pod 显式配置:
| |
脚本记录旧 Pod 的 UID 和 nodeName,再执行 docker pause deep-dive-worker。这会冻结节点容器中的进程,包括 kubelet 和业务进程,但不会停止 zj 的 Docker 服务,也不会关闭宿主机。
pause 能制造停止心跳的条件,却不等于拔掉真实服务器电源,更不等于只切断控制面网络。不同故障对旧业务进程是否继续执行的影响不同,后面判断双实例风险时必须保留这个区别。
实测时间线
| 相对暂停时间 | 观察到的状态 |
|---|---|
| 0 秒 | 原 Pod Ready,记录原 UID 和节点 |
| 约 51.00 秒 | 原节点的 Ready condition 变为 Unknown |
| 约 67.42 秒 | 不同 UID 的新 Pod 在 worker2 Ready |
| 恢复 worker 后 | 原节点重新 Ready,旧 Pod 最终消失 |
脚本每 2 秒检查一次条件,因此这些数字还包含采样间隔。镜像已经预加载,不能用这次新副本启动时间预测一个需要从远端拉取大镜像的业务。
这次约 67 秒可以拆成故障检测、污点容忍、控制器处理和新副本启动几段。表中记录了节点 Unknown 与新 Pod Ready 的采样时刻,没有单独测量每个控制器的处理耗时,不能把两者相减后全部归为 15 秒容忍时间。
为什么旧 Pod 与新 Pod 会同时出现在 API 中
新 Pod Ready 时,实验快照中仍然有旧 Pod:旧对象带着 deletionTimestamp,它最后上报的 phase 仍为 Running;新对象在 worker2,UID 不同。
旧节点已经暂停,不能继续及时报告容器状态。因此旧对象的 Running 不能证明旧进程此刻仍在正常服务,也不能证明它已经退出。这是控制面的最后已知信息。
这次旧进程因 docker pause 被冻结,没有继续处理业务。若实际故障只是节点与控制面网络断开,进程却仍能访问客户端或存储,情况就不同。此时在别处创建新副本可能带来同时写入的风险,需要应用成员协议、存储隔离或 fencing 保证安全。
有关“API 对象已经删除,但容器还在运行”的独立验证,见 Pod 删除与 Finalizer 实验。
新副本是新建,不是把旧 Pod 搬过去
本次前后记录的 UID 不同。这说明恢复由创建新对象完成,不是让原 Pod 换一个 nodeName 继续运行。新 Pod 需要重新启动进程,旧进程的内存、未落盘缓冲区和连接状态不会因为控制器补副本自动转移。
如果应用使用节点本地数据、具有特定节点亲和性,或者存储无法在目标节点使用,即使控制器已经决定补副本,新 Pod 也可能 Pending 或卡在挂载阶段。这时继续缩短容忍时间,不会解决后面的调度和存储约束。
有控制器管理的 Pod 和裸 Pod 也要分开判断。本文使用 Deployment,所以有维护副本数的控制器;不能据此假设删除任意裸 Pod 后都会自动出现替代对象。
现场按时间线查,而不是只数 Pod
| |
先确认节点健康判断是否已经变化,再检查 Pod 的 tolerations 和删除时间;已有替代对象时,再看它的调度、挂载、容器状态和 Ready 条件。把新旧 UID 一起记录,避免把同名或同一工作负载的不同实例混成一个生命周期。
实验代码与使用说明见 k8sdev/deep-dive-node-failure。