
节点失联后，为什么 Pod 没有立刻出现在另一台节点？新副本已经 Ready，旧 Pod 为什么还显示 Running 并带着删除时间？

这次在 `zj` 上暂停一个 kind worker，记录了从节点失联到替代副本就绪的过程：约 51 秒后 Node Ready 变为 Unknown，约 67 秒后新 Pod Ready。实验明确把 NoExecute 容忍设为 15 秒，所以这两个数字只能解释本次配置，不能当作所有 Kubernetes 集群的故障恢复时间。

<!--more-->

## 先区分谁负责发现故障，谁负责补副本

节点上的 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](https://kubernetes.io/docs/concepts/architecture/nodes/#node-controller) 与[按污点驱逐](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/#taint-based-evictions)。

因此，“故障恢复用了多久”需要先定义终点：Node 变为 Unknown、Pod 进入删除流程、新副本 Running，还是新副本真的能够处理业务请求。本文以替代 Pod 的 Ready 为观测终点，不把它等同于任意业务系统的恢复完成。

## 心跳和驱逐为什么要分开计时

Lease 让 kubelet 可以用一个较小的对象持续报告存活，不必每次心跳都重写完整 Node 状态。v1.35.1 的 [节点健康检查](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/nodelifecycle/node_lifecycle_controller.go#L926)发现 Lease 的 `renewTime` 比先前更新后，把自己的 `probeTimestamp` 设为控制器当前时间，再用这个时间计算检测宽限期。这里判断的是“多久没有观察到更新”，而不是直接把节点写入的时间与控制面时钟相减。

检测超时只说明控制面无法确认节点存活，因此状态使用 Unknown。随后 [taint-eviction-controller](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/tainteviction/taint_eviction.go#L451)根据 NoExecute 污点和 Pod 容忍安排删除任务。它会保留已经安排的起始时间，避免同一个 Pod 的后续更新不断把驱逐往后推；相关污点消失时，也会取消尚未执行的任务。容忍时间因此提供了一段允许短暂故障恢复的等待，而不是另一套节点健康判断。

补副本还有一个容易忽略的设计。ReplicaSet 控制器创建 Pod 前会先记录 `ExpectCreations`，再发 API 请求，后续通过观察到的创建事件消减预期。否则，创建已成功但本地 Informer 缓存还没更新时，下轮同步可能再次看到“缺一个”，重复创建。[`manageReplicas`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/controller/replicaset/replica_set.go#L601)还采用逐批增加的创建方式；某批创建失败后停止扩大发送，避免配额不足时一次制造大量相同失败。这些控制器协调细节说明，恢复时间除了容忍秒数，还包含状态传播与控制器处理。

## 实验只暂停一个 kind 节点容器

集群由一个 control-plane 和两个 worker 组成，版本为 kind v0.33.0、Kubernetes v1.35.1。先让一个单副本 Deployment 运行在 `deep-dive-worker`，再确保另一台 worker 可以接受新的调度。

为了让实验在较短时间内观察到驱逐，Pod 显式配置：

```yaml
# 只影响本实验 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 实验](/posts/kubernetes-pod-terminating/)。

## 新副本是新建，不是把旧 Pod 搬过去

本次前后记录的 UID 不同。这说明恢复由创建新对象完成，不是让原 Pod 换一个 `nodeName` 继续运行。新 Pod 需要重新启动进程，旧进程的内存、未落盘缓冲区和连接状态不会因为控制器补副本自动转移。

如果应用使用节点本地数据、具有特定节点亲和性，或者存储无法在目标节点使用，即使控制器已经决定补副本，新 Pod 也可能 Pending 或卡在挂载阶段。这时继续缩短容忍时间，不会解决后面的调度和存储约束。

有控制器管理的 Pod 和裸 Pod 也要分开判断。本文使用 Deployment，所以有维护副本数的控制器；不能据此假设删除任意裸 Pod 后都会自动出现替代对象。

## 现场按时间线查，而不是只数 Pod

```sh
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](https://github.com/jimyag/k8sdev/tree/8f9bfbbd44ac061702310f332ee949fcdf0a213d/deep-dive-node-failure)。

