每次发版都有几十个 502,终于找到原因了
我们服务是 2019 年 11 月上的 Kubernetes,1.16 版本,Deployment 配 4 个副本。从上线那天起,每次滚动发布,网关那边都会报几十个 502,持续时间一两秒。因为量不大、用户基本无感,一直没排。
直到 1 月初做活动,晚上八点发版,502 突然变成了 300 多个。我坐下来认真看了一次。
先看现象
网关是 Spring Cloud Gateway,错误日志清一色:
io.netty.channel.AbstractChannel$AnnotatedConnectException: Connection refused:
order-service/10.244.3.17:8080
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Connection refused 而不是 timeout,说明对端的端口已经没人监听了,pod 已经没了。
再看时间线。我把发布操作的时间和 502 出现的时间对了一下:
20:00:03 执行 kubectl set image,开始滚动更新
20:00:03 新 pod 创建,旧 pod 收到 SIGTERM
20:00:03 网关报第一个 502
20:00:05 502 停止(共 317 个)
20:00:31 新 pod Ready
关键点:502 集中在收到 SIGTERM 后的头两秒。这说明 K8s 一边在杀旧 pod,一边还在往旧 pod 转发流量。
根因是两个独立的 race
一、Spring Boot 2.2 收到 SIGTERM 就立刻关容器
我们用的是 Spring Boot 2.2.2.RELEASE。它注册的 shutdown hook 收到 SIGTERM 之后,会直接调用 Tomcat 的 stop(),Tomcat 立刻停掉 acceptor、关掉线程池。正在处理中的请求——比如那个要跑 1.5 秒的对账查询——直接被中断,客户端拿到连接重置。
这一点在 Boot 2.2 里没有开关可配,必须自己写 Connector 的暂停逻辑。
二、endpoint 摘除和发 SIGTERM 是并行的
K8s 删除 Pod 的流程里,这两件事同时开始:
- kubelet 给容器发 SIGTERM
- Endpoints controller 把该 Pod 的 IP 从 Service 的 endpoint 列表里摘掉
摘 endpoint 要经 API Server 再推给每个节点的 kube-proxy 去改 iptables,这段传播延迟通常在 1 到 3 秒。也就是说,Pod 已经开始关闭了,iptables 规则还没更新,新请求照样进来。
解决方案
一、升级到 Spring Boot 2.3 的优雅停机
2.3 加了这个能力,我是跟的 milestone 仓库试的(GA 还没发)。就两行配置:
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
配了 graceful 之后,收到 SIGTERM 时 Tomcat 会先调 Connector.pause(),停止接收新连接,但已经进来的请求继续处理。等所有在途请求结束(或者超过 30 秒的宽限期),才真正关闭。
日志里能看到这个阶段:
2020-01-20 20:12:03.412 INFO 1 --- [SpringContextShutdownHook]
o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2020-01-20 20:12:04.977 INFO 1 --- [tomcat-shutdown]
o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete
1.565 秒,正是当时最长那个请求的耗时。
如果还在 2.2 上,有个等价的土办法,自己接 Connector 然后 hold 住:
@Bean
public GracefulShutdownWrapper gracefulShutdown() {
return new GracefulShutdownWrapper();
}
// 实现 TomcatConnectorCustomizer + ApplicationListener<ContextClosedEvent>
// 在事件里 connector.pause() 然后 sleep 等待线程池清空
不如 2.3 自带的好用,2.3 那个还会等你配的 @PreDestroy、SmartLifecycle 都跑完。
二、用 preStop hook 把 endpoint 传播延迟吃掉
优雅停机只解决"在途请求",解决不了"新请求还在进来"。这个得靠 preStop:
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
preStop 会在发 SIGTERM 之前执行,并且是阻塞的。sleep 15 秒,足够 kube-proxy 把规则推完、网关把连接摘掉。这 15 秒里 Pod 还在正常服务,用户完全无感。
注意 terminationGracePeriodSeconds 要大于 preStop 时间加上停机宽限期,我给了 60 秒。否则 K8s 会在 30 秒时直接 SIGKILL,前面的设置全白搭。
健康检查探针怎么配
顺手把探针也理了一遍。2.3 里把 health 拆成了 liveness 和 readiness 两组:
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
show-details: never
probes:
enabled: true # 开启 /actuator/health/liveness 和 /readiness
探针配置:
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 5
failureThreshold: 3
这两个的区别必须搞清楚:
- liveness 失败就重启容器。所以它只应该检查"进程是不是彻底废了",比如死锁、OOM 后无法恢复。绝对不要把数据库、Redis 的检查放进 liveness,否则 DB 抖一下,全部 pod 一起重启,雪崩。
- readiness 失败只摘流量,不重启。依赖的下游、缓存预热状态,放这里。
我们最初的 initialDelaySeconds 是 10 秒,结果有个服务启动要 25 秒(要加载一堆规则到内存),liveness 在启动途中失败 3 次,pod 疯狂重启。后来统一改成 60 秒,宁可慢一点。
效果
1 月 20 号晚上八点半再发了一版,同样的流量:
| 指标 | 改之前 | 改之后 |
|---|---|---|
| 发布期间 502 数 | 317 | 0 |
| 单次滚动更新耗时 | 约 48 秒 | 约 72 秒 |
| 发布期间 P99 耗时 | 跳到 8.2 秒 | 245 ms,无明显波动 |
多花的 24 秒是 preStop 的 sleep,很划算。
下篇预告
这篇先把《Spring Boot 2.3 优雅停机与健康检查配置》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。