服务明明可以正常启动,容器却不断重启;Pod 看起来是 Running,流量却一直打不进去;一次滚动发布,旧实例已经开始退出,新实例还没有准备好。
很多 Kubernetes 上的"应用故障",最后都会指向同一组配置:
yaml
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
看起来配置很简单,但它背后至少涉及四个问题:
- 启动探针负责什么?
- 就绪探针什么时候应该失败?
- 存活探针失败后应该发生什么?
- 优雅退出时,Kubernetes 和应用如何协作?
四个概念只要混在一起,就可能出现"越检查越不稳定"的结果。
先分清三种探针的职责
Kubernetes 常见的三类探针经常被合成一个 /health 接口,但它们回答的问题完全不同。
Startup Probe:应用是否已经完成启动
适合启动慢、需要加载配置、预热模型或建立数据库连接的应用。
它在启动阶段检查应用是否完成初始化。对于启动时间波动较大的服务,可以避免 Liveness Probe 过早杀死应用。
Readiness Probe:应用是否准备好接收流量
它决定 Pod 是否应该进入 Service 的 Endpoint 列表。
失败不一定意味着进程坏了。它可能表示:
- 依赖暂时不可用;
- 正在加载缓存;
- 队列积压过重;
- 应用处于维护或降级状态;
- 正在等待数据库迁移完成。
Liveness Probe:应用是否仍然可以继续运行
它应该判断进程是否陷入不可恢复的状态,例如永久死锁、事件循环阻塞、内部状态损坏。
Liveness 失败通常会导致容器重启。这个动作代价很高,所以它不应该被用来判断数据库、Redis 或某个下游服务是否健康。
一句话总结:
text
Startup:能不能完成启动
Readiness:能不能接流量
Liveness:是否需要重启
三者混用时,最常见的结果就是:下游抖了一下,应用其实可以降级运行,却被 Liveness 判定为死亡,所有实例同时重启,故障进一步放大。
误区一:Liveness Probe 检查所有依赖
很多团队把 Liveness 接口写成这样:
ts
app.get("/health", async () => {
await database.ping();
await redis.ping();
await paymentService.ping();
return { status: "ok" };
});
这个接口用来做 Readiness 都有点激进,用来做 Liveness 则非常危险。
假设支付服务发生网络抖动:
text
支付服务不可用
-> 所有 Pod 的 Liveness 同时失败
-> Kubernetes 开始重启 Pod
-> 系统出现大量连接重建
-> 数据库和消息队列压力上升
-> 更多依赖失败
-> 更多 Pod 被重启
原本只是一个下游不可用,最后演变成整个服务不可用。
更合理的划分是:
text
/health/live
只检查进程是否可以响应、事件循环是否被长期阻塞
/health/ready
检查是否可以接收当前流量
可以包含关键依赖状态和降级判断
/health/startup
检查初始化流程是否完成
Liveness 最好使用尽可能少的本地检查,不要做外部网络调用。
如果应用必须依赖外部服务才能工作,可以让 Readiness 失败并停止接流量,而不是让 Liveness 重启所有实例。
误区二:Readiness 通过得太早,也可能太晚失败
Readiness 不只是"启动完成后返回 200"。
它还应该覆盖应用在运行过程中暂时无法服务的状态。
例如:
- 本地缓存为空,直接处理请求会产生大量回源;
- 数据库连接池正在重建;
- 服务正在滚动升级;
- 队列消费者已经落后太久,继续接流量没有意义;
- 应用处于只读降级模式,不满足核心请求要求。
一个典型的启动配置可以是:
yaml
startupProbe:
httpGet:
path: /health/startup
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: 8080
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
这些数字不是通用答案。关键是它们必须和应用的实际行为匹配:
text
启动最长需要多久?
一次探针响应通常需要多久?
依赖恢复需要多久?
故障时应该停止接流量,还是重启进程?
如果 periodSeconds、timeoutSeconds 和 failureThreshold 完全不匹配,就会出现:
- 应用还在启动,已经被 Liveness 杀死;
- 短暂网络抖动导致 Readiness 反复抖动;
- 探针超时比业务请求还短,正常负载被误判;
- 失败阈值太大,故障长时间无法被识别。
探针的参数应该来自观测数据,而不是复制别人的 YAML。
误区三:只有探针,没有优雅退出
滚动发布时,Pod 不会在收到终止信号后瞬间消失。
一个更合理的生命周期是:
text
Pod 被标记为 Terminating
-> 从 Service Endpoint 中摘除
-> 应用收到 SIGTERM
-> 停止接收新请求
-> 等待正在处理的请求结束
-> 关闭连接池和后台任务
-> 进程退出
如果应用收到 SIGTERM 后立刻退出,正在处理的请求可能失败;如果进程不退出,Kubernetes 最终会在 terminationGracePeriodSeconds 到期后强制结束。
因此,优雅退出至少要考虑:
- 先停止接收新流量,再处理存量请求;
- HTTP 服务停止监听;
- 消息消费者停止拉取新消息,等待当前消息处理完成;
- 数据库事务和外部调用有明确超时;
- 后台任务可以被取消或安全中断;
- 退出总时间小于
terminationGracePeriodSeconds。
一个 Node.js 服务的简化示例:
ts
let shuttingDown = false;
server.on("request", (req, res) => {
if (shuttingDown && req.url !== "/health/live") {
res.statusCode = 503;
res.end("shutting down");
return;
}
handleRequest(req, res);
});
process.on("SIGTERM", async () => {
shuttingDown = true;
await new Promise(resolve => server.close(resolve));
await consumer.stop();
await database.close();
process.exit(0);
});
真实实现还要处理强制退出兜底、连接超时和任务补偿,但方向是明确的:
探针决定 Pod 什么时候开始接流量,优雅退出决定 Pod 什么时候停止接流量。
缺少退出流程时,即使探针配置正确,滚动发布仍会出现 502 或请求中断。
误区四:把探针当成业务监控
探针只服务于 Pod 生命周期和流量管理,不能替代业务监控。
例如:
- 探针只能告诉你"某个实例能不能接流量";
- 监控可以告诉你"用户下单成功率是否下降";
- 探针失败会触发重启或摘流量;
- 业务监控失败可以触发告警、限流或降级。
如果把所有业务状态都塞进 Liveness,Kubernetes 会频繁重启容器;如果把所有外部依赖都塞进 Readiness,短暂抖动又会让整个服务从 Endpoint 中消失。
更合理的分工是:
text
Startup:启动成功即可
Readiness:当前是否有能力服务
Liveness:进程是否需要重启
Metrics:系统正在发生什么
Alert:人是否需要介入
探针应该回答"要不要采取 Kubernetes 动作",而不是"业务是否完全健康"。
一套更稳妥的落地方式
1. 先定义状态
写探针之前,先明确:
text
未启动:不能接流量,不能重启
启动中:不能接流量,不能重启
已就绪:可以接流量
暂时不可用:停止接流量,但不要重启
永久不可用:可以重启
正在退出:停止接新流量,处理存量请求
2. 让三种探针各司其职
yaml
startupProbe:
httpGet: { path: /health/startup, port: 8080 }
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
不要让三个接口返回完全相同的结果。
3. 用真实数据调整参数
至少采集:
- 应用启动耗时分布;
- 探针 P95 响应时间;
- 依赖恢复时间;
- 重启次数;
- Endpoint 摘除和恢复时间;
- 滚动发布期间的错误率。
4. 验证故障场景
只验证"正常时探针返回 200"远远不够。还应该验证:
- 启动过程中 Liveness 不会误杀;
- 下游不可用时 Readiness 能正确摘流量;
- 进程阻塞时 Liveness 能触发重启;
- 发布过程中存量请求不会被中断;
- 探针恢复后实例能重新进入 Endpoint。
最后检查这 8 项
- Startup、Readiness、Liveness 是否职责分离
- Liveness 是否避免检查外部依赖
- Readiness 是否反映真实接流能力
- 探针超时和阈值是否来自真实数据
- 慢启动服务是否使用 Startup Probe
- 应用收到 SIGTERM 后是否优雅退出
- 退出时间是否小于 terminationGracePeriodSeconds
- 是否用故障演练验证过摘流量和重启行为
K8s 探针不是"配置得越多越安全"。
它真正决定的是:什么时候接流量、什么时候停流量、什么时候重启进程。把这三个动作设计清楚,比单纯调整 failureThreshold 更重要。
你们的存活探针会检查数据库吗?有没有遇到过下游故障导致 Pod 集体重启?欢迎在评论区聊聊。
如果你也在优化 Kubernetes 上的服务稳定性,也欢迎在微信公众号搜索「长安米粒贵」继续交流。