Spring Boot 健康检查、就绪探针和优雅下线为什么经常让服务状态看起来不对?
很多“探针不准”的现场,真正错的不是某个接口返回值,而是三块钟没对齐:应用内部状态、平台探针结果、流量真正摘除的时机。把这三段时间线摆平,问题通常比单改阈值清楚得多。
我第一次真正把 health、readiness 和优雅下线分清,不是在看配置文档的时候,而是在一次滚动发布里盯着一台本该平滑退出的 Pod。
19:32:04,应用收到 SIGTERM。
19:32:05,/actuator/health/readiness 已经返回 OUT_OF_SERVICE。
19:32:07,应用开始拒绝新任务,准备清空内部队列。
19:32:11,Ingress 访问日志里还能看到 37 个新请求继续打进来。
19:32:18,preStop 结束。
19:32:20,JVM 开始退出,留下 9 个被截断的请求。
如果只看其中任意一层,这次事故都能得出一个似是而非的结论:
- readiness 都已经下线了,为什么还接到流量?
- 应用明明还活着,为什么请求已经开始失败?
- 明明配了优雅下线,为什么还是有人被断在半路?
这类问题之所以总显得别扭,是因为它们表面上都像“服务状态不对”,实际上混在一起的是三件不同的事:
- 进程是不是还活着。
- 实例此刻该不该接新流量。
- 外围流量系统有没有真的停止把请求送过来。
这三件事只要有一件没对齐,现场看起来就会像探针失灵。
先分清你修的是哪一种错位
我通常先把现场归到下面四类之一,而不是一上来改 probe:
| 现场现象 | 真正错位的层次 | 第一份该补的证据 |
|---|---|---|
| 服务还活着,但已经不该再接新请求 | readiness 语义和流量摘除没对齐 | readiness 变化时间、Ingress / LB 停止转发时间 |
| 服务明明只是短暂抖动,却被反复重启 | liveness 过早介入了可恢复问题 | liveness 失败原因、线程栈、是否存在真实卡死 |
| Pod 已经收到下线信号,但仍有新流量进入 | 外围摘流量慢于应用退出 | preStop、readiness、访问日志、连接关闭时间 |
| 实例还没准备好就被接上流量 | 启动期和运行期语义混用 | 首次 ready 时间、startup 阶段日志、探针配置 |
这张表的重要性在于:它把“状态不对”拆成了不同语义。否则很容易把所有问题都叫成“health 不准”。
这类现场最值钱的是三块钟
我一般会强行把问题画成三条时间线:
| 钟表 | 它记录什么 | 常见错觉 |
|---|---|---|
| 应用内部钟 | 线程池、连接池、正在处理的请求、关闭钩子 | 应用说自己准备好了,外面就一定能立刻感知到 |
| 平台探针钟 | liveness、readiness 的成功/失败时刻 | probe 状态变化就等于流量已经切换 |
| 流量系统钟 | Ingress / Service / LB 何时真正停止转发 | 只要 preStop sleep 足够长,一切都会自动对齐 |
很多“readiness 都已经 false 了,怎么还来流量”的现场,本质上就是后两块钟没对齐:探针已经翻转,但上游摘流量还没完成。
而“明明接口已经开始大面积超时,readiness 还一直是绿的”,通常又是另一种错位:应用内部早就没有接流量余量,readiness 却还在回答一个过于宽松的问题。
readiness 里到底该放什么
readiness 最常被滥用的地方,是让它同时承担三种职责:
- 判断进程是否存活。
- 判断实例是否适合接流量。
- 判断依赖是否一切完美。
这三件事混在一起,状态一定会难看。
我更愿意这样理解:
liveness 回答的是:这个进程是不是已经卡死到不该继续活着
它不该因为短暂下游波动、瞬时线程池紧张、数据库偶发慢查询就把进程杀掉。否则你得到的不是自愈,而是在故障里不断重启。
readiness 回答的是:此刻再给我新请求,成功率还有没有把握
它更该关心的是:
- 关键工作线程是否还有余量。
- 请求入口是否已进入主动拒绝或排队过长状态。
- 下线中的实例是否已经明确不再接新流量。
它不该机械地把所有依赖健康都串进去。某个非核心下游临时抖一下,不代表整个实例此刻必须从流量池里消失。
优雅下线回答的是:既然不再接新流量,旧请求有没有被留出收尾窗口
这一步经常被误会成“配一个等待时间就行”。实际上真正要核对的是:
- 应用什么时候宣布自己 not ready。
- 上游什么时候真的不再送新请求。
- 应用准备退出时,还有多少在途请求。
- server / 线程池 / 连接池 的收尾窗口有没有覆盖住在途请求的尾巴。
一次下线现场里,哪些证据最能说明问题
还是拿开头那次发布来说,我最后看的不是更多文档,而是下面这张时间线:
| 时间 | 事件 | 说明 |
|---|---|---|
| 19:32:04 | Pod 收到 SIGTERM | 开始进入退出窗口 |
| 19:32:05 | readiness 变为 OUT_OF_SERVICE | 应用内部已经声明不接新流量 |
| 19:32:08 | 业务线程池仍有 23 个活动请求 | 老请求还没收完 |
| 19:32:11 | Ingress 还在路由新请求 | 外围摘流量慢了一拍 |
| 19:32:17 | 新请求数降到 0 | 真正完成摘流量 |
| 19:32:20 | 进程退出 | 给老请求的收尾窗口只剩几秒 |
问题很清楚:不是 readiness 配错了,而是readiness 翻转和外围摘流量之间还有 6 秒空窗,而应用退出窗口又不够长。
如果只盯着 /actuator/health 的返回值,是看不到这件事的。
真正容易踩坑的两种配置方式
把可恢复的抖动直接接进 liveness
比如:
- 某个下游偶发超时。
- 瞬时线程池排队。
- 某个连接池短时间打满。
这些情况更适合触发降级、限流或 readiness 降级,不适合直接让 liveness 杀进程。否则你会得到“问题还没恢复,实例已经先被重启”的更糟现场。
把 readiness 设计成“依赖全绿才接流量”
这听起来很稳,实际上很容易让实例在轻微依赖抖动时来回上下线。
更稳的做法通常是:readiness 盯住接新流量是否还安全,而不是盯住“所有依赖是否完美”。如果实例还能靠降级、缓存、熔断维持主要路径,贸然摘流量反而可能把压力挤到别的实例上。
别只改阈值,先做一次真实下线演练
这类问题最怕在配置上空谈。真正有效的是做一台实例的可观测演练:
- 让单个 Pod 进入下线流程。
- 记录 readiness 翻转时间。
- 记录 Ingress / LB 最后一次把新请求送进来的时间。
- 记录应用内最后一个请求处理完成的时间。
- 再看进程退出时间有没有覆盖住这段尾巴。
如果你跑完一轮演练,看到的是“readiness 已经 false,但 5 到 10 秒内仍有新流量”,那就该回头看流量系统收敛时间,而不是继续在 probe 阈值里兜圈。
如果你看到的是“readiness 一直不掉,但线程、错误率和超时已经明显失控”,那就该回头改 readiness 语义本身,而不是只管优雅下线。
哪些情况该切去别的文章
- 问题发生在启动阶段,实例还没 ready 就被判死,去看没配 startupProbe,为什么慢启动会被当成启动失败?。
- 高负载下 readiness 自己就在抖,去看高负载下 readiness 一直抖,为什么实例会在接流量和摘流量之间来回震荡?。
- 你现在还没分清线程池、连接池和慢请求谁先出问题,去看Spring Boot Actuator、线程池、连接池、慢请求这些运行态观测点,应该怎么串起来看?。
探针问题之所以难看,往往不是因为某个字段不会配,而是因为我们拿一块钟去解释三件不同的事。
把“应用内部状态”“平台探针状态”“流量真正摘除的时刻”这三块钟对齐之后,很多原本像玄学的问题,会突然变成一条很普通的时间线问题。