滚动发布时,监控里出现了 0.3% 的 5xx 毛刺
4 月一次常规滚动更新,发布过程中 Grafana 的 5xx 曲线冒出一排小毛刺,单个实例发布期间大约丢了 0.3% 的请求。量不大,但每次发布都有,说明是上下线姿势不对,不是偶发。
我们的 Java 服务跑在 K8s 上,Spring Boot 2.6。我花了两天把优雅上下线彻底调通,这篇把探针、preStop 和滚动更新的配合讲清楚。
先分清就绪探针和存活探针
很多人把这两个搞混,其实职责完全不同:
| 探针 | 失败后果 | 用途 |
|---|---|---|
| livenessProbe(存活) | 杀掉容器重启 | 判断进程是否假死 |
| readinessProbe(就绪) | 从 Service 摘流量,但不杀 | 判断能否接新请求 |
滚动更新出错,几乎都出在就绪探针没用好。我们看原来的配置:
livenessProbe:
httpGet: { path: /actuator/health, port: 8080 }
initialDelaySeconds: 30
readinessProbe:
httpGet: { path: /actuator/health, port: 8080 }
initialDelaySeconds: 30
问题在于:就绪和存活用了同一个 /health。应用启动后连接池还没建好、缓存还没预热,但 /health 已经返回 200,K8s 立刻把流量打进来,前几百毫秒的请求全在等慢资源,超时变 5xx。
上线过程:新实例为什么被提前灌流量
滚动更新的真实顺序是这样的:
1. 启动新 Pod
2. 等 readinessProbe 连续成功 → 加入 Service Endpoints(开始接流量)
3. 终止旧 Pod:发 SIGTERM
4. 等 terminationGracePeriodSeconds
5. 强杀
毛刺来自第 2 步:新 Pod 还没预热完就接了流量。解法是把就绪探针拆出来,只有在应用真正准备好时才返回 200:
readinessProbe:
httpGet:
path: /actuator/health/readiness # 拆出专门的就绪检查
port: 8080
initialDelaySeconds: 20
periodSeconds: 5
failureThreshold: 3
Spring Boot 2.6 的 health/readiness 端点只在 ApplicationAvailability 状态为 READY 时返回 200。我们在启动完成时显式翻状态:
@EventListener(ApplicationReadyEvent.class)
public void onReady(ApplicationReadyEvent e) {
availabilityProvider.get().setReadinessState(ReadinessState.ACCEPTING_TRAFFIC);
}
上线后新实例接流量前的预热时间从"立刻"变成"平均 18 秒",发布期 5xx 毛刺消失。
下线过程:SIGTERM 之后请求去哪了
下线才是真正的坑。旧 Pod 收到 SIGTERM 后,K8s 会同时从 Service 摘除它,但这两件事不是原子的:摘除 Endpoints 是异步传播到 kube-proxy 的,有秒级延迟。这段时间里,旧 Pod 已从 Service 摘掉(不该接新流量),但老的转发规则还在,请求还会被路由过来,而应用可能已经开始关线程池了——这就是那 0.3% 5xx 的来源。
正确的无损下线要三件事一起做:
- preStop 钩子先 sleep 几秒,等 Endpoints 传播完,确保不再有新流量进来。
- 应用监听 SIGTERM,先拒绝
/readiness(立刻摘流量),再等在途请求处理完。 terminationGracePeriodSeconds要大于"sleep + 在途请求最大耗时"。
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"] # 等 Endpoints 传播,挡掉新流量
terminationGracePeriodSeconds: 40
应用侧,Spring Boot 2.6 在收到 SIGTERM 时会触发 ContextClosedEvent,我们先把就绪状态翻成 REFUSING_TRAFFIC,再 await 等待 Web Ubuntu 服务器排空:
@EventListener(ContextClosedEvent.class)
public void onShutdown(ContextClosedEvent e) {
availabilityProvider.get().setReadinessState(ReadinessState.REFUSING_TRAFFIC);
// 等待 Tomcat 在途请求处理完,最多等 20s
try { Thread.sleep(20000); } catch (InterruptedException ignored) {}
}
时序对一下:preStop sleep 15 秒(Endpoints 传播完,无新流量)→ 应用睡 20 秒(在途请求清空)→ 总 35 秒 < grace 40 秒,容器被正常退出,没有强杀。
滚动更新参数也要配套
光调探针不够,Deployment 的滚动策略决定了"同时动几个":
strategy:
rollingUpdate:
maxSurge: 1 # 最多比期望多 1 个 Pod
maxUnavailable: 0 # 发布期间不允许少 Pod(保证容量)
maxUnavailable: 0 保证发布全程容量不缩水,避免因为下线一个旧 Pod、新 Pod 还没就绪,导致总容量掉一截、把剩下的实例压垮。我们压过:12 副本、maxUnavailable: 0 / maxSurge: 1,发布期间集群总 QPS 能力始终 ≥ 12 副本,没出现容量塌陷。
两个容易忽略的点
- JVM 的优雅停机。Tomcat 默认有
awaitility等待请求,但我们的线程池是自定义ThreadPoolTaskExecutor,得在@PreDestroy里shutdown()并awaitTermination,否则任务会被直接丢。 - 注册中心注销。如果用 Nacos 做服务发现,应用下线要主动调
deregister,不然 Nacos 还以为实例活着,内部 RPC(Dubbo/Feign)还会把请求发过来。我们加了@PreDestroy里nacosNamingService.deregisterInstance(...),且等 5 秒再退。
留个问题
关于《Kubernetes 中 Java 应用的优雅上下线》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。