
Pod 仍然显示 Running，Service 却不再把新流量发给它；另一个 Pod 则不断重启。两者可能都打印过 `Unhealthy`，但处理方向完全不同：前者可能只是暂时没有准备好接请求，后者可能已经被 kubelet 判定需要重新启动。

要解释这些现象，不能只看“探针失败”四个字，还要确认失败的是 startup、readiness 还是 liveness，以及失败触发了什么动作。

<!--more-->

## 三个探针对应三个判断

| 探针 | 要回答的问题 | 失败后最直接的影响 |
| --- | --- | --- |
| startupProbe | 应用是否完成启动 | 达到失败阈值后终止容器；成功前不执行该容器的 readiness/liveness |
| readinessProbe | 当前是否适合接收流量 | 容器变为未就绪，不因这个结果单独重启 |
| livenessProbe | 当前进程是否需要通过重启恢复 | 达到失败阈值后终止容器，再按重启策略处理 |

kubelet 为配置的探针执行周期检查，并按连续成功或失败次数判断是否达到阈值。startup 成功前，readiness 和 liveness 不接管该容器；readiness 的结果用于容器及 Pod 的就绪状态；liveness 和 startup 达到失败阈值则进入容器终止与重启处理。官方定义见 [Liveness、Readiness 和 Startup Probes](https://kubernetes.io/docs/concepts/workloads/pods/probes/)，执行逻辑见 [v1.35.1 的 probe worker](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/prober/worker.go)。

readiness 影响流量还要经过一条控制链：kubelet 更新 Pod 状态，EndpointSlice 控制器更新 Service 对应端点，kube-proxy 或其他 Service 数据面再更新转发规则。探针本身不拦截应用端口，也不检查每个业务请求。本文使用未设置 `publishNotReadyAddresses` 的普通 Service；直接访问 Pod IP，或使用不同的服务发现策略，不一定遵循同样的就绪筛选。

探针检测程序可能是 HTTP 请求、命令或其他受支持的机制，但“检测什么”仍然由应用决定。把三个探针都指向同一个无条件返回 200 的接口，只能说明那个接口还会响应。

## 实验怎样控制故障

本次通过 SSH 在 `zj` 上运行 kind v0.33.0、Kubernetes v1.35.1。一个 Python HTTP 服务提供三个路径：

- `/startup`：只有 `/state/started` 文件存在才返回 200。
- `/ready`：存在 `/state/not-ready` 时返回 503。
- `/live`：存在 `/state/not-live` 时返回 503。

`/state` 挂载 `emptyDir`，所以容器重启后文件仍然保留。每次实验通过创建或删除文件改变一个条件，其他接口保持不变；这比让应用随机报错更容易确认因果。

关键配置如下：

```yaml
# 以下字段放在应用容器中；三个接口分别控制三个独立状态。
startupProbe:
  httpGet:
    path: /startup
    port: 8080
  periodSeconds: 2 # 周期性检查启动条件。
  failureThreshold: 60 # 留出实验操作时间，避免启动阶段过早被终止。
readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  periodSeconds: 2
  failureThreshold: 2 # 连续失败后标记未就绪。
livenessProbe:
  httpGet:
    path: /live
    port: 8080
  periodSeconds: 2
  failureThreshold: 2 # 连续失败后终止容器。
```

这是为了缩短反馈时间的实验参数，不是生产环境的通用建议。实际阈值应覆盖正常启动时间和短暂波动；探测周期、单次超时、连续失败阈值和终止宽限期共同影响最终恢复时间。

## 启动没有完成，进程也可以已经 Running

实验先不创建 `started` 文件。HTTP 服务进程已经启动，但 startupProbe 返回 503，事件记录 `Startup probe failed`，容器状态中的 `started` 为 false。

这说明 Running 只描述运行阶段，并不证明应用启动逻辑已经完成。此时增大 readiness 的初始延迟并不能替代明确的启动判断。

创建标记后，startupProbe 成功，容器开始接受后续的 readiness/liveness 检查：

```sh
kubectl --context kind-deep-dive -n dd-probes exec app -- touch /state/started
kubectl --context kind-deep-dive -n dd-probes wait --for=condition=Ready pod/app --timeout=60s
```

startupProbe 不是每次请求前的检查。当前容器实例启动成功后，它的职责已经完成；容器重新启动时才需要重新通过启动判断。

## readiness 失败：退出流量集合，但进程继续运行

实验创建 `not-ready` 文件后，容器的 `ready` 变为 false，但 `restartCount` 仍然是 0。对应 Service 的 EndpointSlice 也观察到 `ready=false`。

```sh
kubectl --context kind-deep-dive -n dd-probes exec app -- touch /state/not-ready
kubectl --context kind-deep-dive -n dd-probes get pod app -o yaml
kubectl --context kind-deep-dive -n dd-probes get endpointslice -o yaml
```

删除标记后，同一个容器恢复 Ready，无需重启。对于暂时无法接新请求、但仍能自行恢复的情况，这种行为通常更符合预期。

EndpointSlice 更新也不等于所有连接瞬间断开。已有连接和应用中的请求有自己的生命周期，具体讨论见 [Service 连接排空文章](/posts/kubernetes-service-production-practice/)。探针不负责替应用完成事务、关闭连接或刷新缓冲区。

## liveness 失败：重启是否真的能够恢复

接着让 `/live` 返回 503。连续失败后，实验观察到 `restartCount` 增加，新容器启动。由于 `emptyDir` 保留，`not-live` 文件不会因为容器重启自动消失；脚本随后删除标记，才让应用稳定恢复。

这个安排也说明了 liveness 设计中最重要的问题：如果故障原因没有被重启消除，重启只会重复相同的失败。数据库不可用、配置错误、权限不足，都不一定能通过重启当前进程解决。

是否把数据库连接纳入探针，应该从失败动作反推。数据库短暂断开时，若应用还能重连，就不能仅因为数据库不可达而认定当前进程必须重启；readiness 是否失败，则取决于应用是否仍能提供可接受的服务。把所有下游依赖同时纳入所有副本的 liveness，还可能在下游故障时让上游一起反复重启。

## 连续失败计数怎样避免一次抖动就改变状态

v1.35.1 的 [probe worker](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/prober/worker.go#L215)维护当前结果和连续出现次数。新结果与上一次一致时累加，变化时从 1 开始；达到相应阈值才发布结果。因此 `failureThreshold: 3` 表示连续三次失败，不是任意时间窗口内累计三次失败，也不是失败率达到某个比例。中间出现成功会打断这串失败计数。

发布给 results manager 的结果以实际 container ID 为键。worker 发现容器 ID 改变后，会移除旧 ID 的结果，为新实例处理初始结果，并解除暂停；代码还对 kubelet 自身重启后的初始结果设置做了特殊判断，避免立即把原本就绪的运行中容器标记为失败。这里不能把“换了 container ID”概括为“所有内部计数都清零”，连续计数与已发布结果是不同状态。

还有一处处理专门照顾终止过程：liveness 达到失败阈值，或 startup 达到成功、失败阈值后，worker 会进入 `onHold` 并把连续计数清零，等待新的 container ID，不继续反复执行检查。源码注释指出，尤其要避免对正在停止的容器持续执行 exec 探针。readiness 失败后则继续探测，否则它就无法在应用恢复时把结果改回成功。

所以三个探针虽然复用检查方式，却有不同的状态转换。本文 readiness 实验中进程不重启而端点就绪性改变，liveness 实验中 restart count 增长，正好对应这两条处理路径。阈值可以过滤短暂抖动，但无法修复一个把“外部依赖故障”误判成“必须重启本进程”的检查。

## 看到 Unhealthy 后看什么

先读事件中的探针名称和错误内容，再看容器的 `ready`、`started`、`restartCount`、`lastState`。如果重启次数没有增加，应该先解释未就绪；如果确实重启了，需要核对退出原因，不能仅因为存在 liveness 就把所有重启归因于探针。

```sh
kubectl --context kind-deep-dive -n dd-probes describe pod app
kubectl --context kind-deep-dive -n dd-probes logs app --previous
```

实验代码与使用说明见 [k8sdev/deep-dive-probes](https://github.com/jimyag/k8sdev/tree/8f9bfbbd44ac061702310f332ee949fcdf0a213d/deep-dive-probes)。

