jimyag's Blog

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 显式配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 只影响本实验 Pod;不要据此修改生产集群的默认容忍时间。
tolerations:
  - key: node.kubernetes.io/not-ready
    operator: Exists
    effect: NoExecute
    tolerationSeconds: 15 # 对此污点只容忍 15 秒。
  - key: node.kubernetes.io/unreachable
    operator: Exists
    effect: NoExecute
    tolerationSeconds: 15

脚本记录旧 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

1
2
3
4
kubectl --context kind-deep-dive get node deep-dive-worker -o yaml
kubectl --context kind-deep-dive -n kube-node-lease get lease deep-dive-worker -o yaml
kubectl --context kind-deep-dive -n dd-node-failure get pods -o wide
kubectl --context kind-deep-dive -n dd-node-failure get events --sort-by=.lastTimestamp

先确认节点健康判断是否已经变化,再检查 Pod 的 tolerations 和删除时间;已有替代对象时,再看它的调度、挂载、容器状态和 Ready 条件。把新旧 UID 一起记录,避免把同名或同一工作负载的不同实例混成一个生命周期。

实验代码与使用说明见 k8sdev/deep-dive-node-failure。

#Kubernetes #Kind #Troubleshooting