优雅停机开了,发版还是报 503
滚动发版的时候总有那么一小段时间,监控上跳出几个 503 或者 connection refused。流量不大,错误也不多,过一会儿自己就好了。
就是每次发版都来一次,看着膈应。
一个实例下线,其实牵着三个钟
想明白这件事的关键,是意识到下线过程里有三个互不相干的时间。
一是注册中心发现它没了,靠心跳过期,有个租约超时。
二是调用方知道它没了。每个调用方进程里都有一份服务列表的本地缓存,按自己的周期刷新。
三是进程真的退出,从收到 SIGTERM 到彻底结束。
503 就卡在第二和第三之间:进程已经退出了,调用方缓存里还留着它的地址,请求继续往一个不存在的端口上打。
这里有两层缓存很容易混为一谈,注册中心自己的状态,和每个调用方进程里的那份副本。就算注册中心第一时间知道了,调用方也得等下一次刷新才知道。你摘得再快,也快不过别人的刷新周期。
Eureka 那几层缓存加起来有多久
把默认值摊开看一遍就明白 503 从哪来了。
服务端这侧,Eureka 自己有两层缓存。注册表写进去之后先进 readWriteCacheMap,客户端读的却是 readOnlyCacheMap,两者之间靠 responseCacheUpdateIntervalMs 同步,默认 30 秒。另外心跳续约默认 30 秒一次,租约过期 90 秒,而清理过期实例的定时任务 evictionIntervalTimerInMs 默认 60 秒跑一次。
客户端这侧,registryFetchIntervalSeconds 默认 30 秒拉一次注册表。拉回来之后,负载均衡器还有自己的一层:Ribbon 的 ServerListRefreshInterval 默认 30 秒,换成 Spring Cloud LoadBalancer 则是 spring.cloud.loadbalancer.cache.ttl,默认 35 秒。
这几层是串起来叠加的。最坏情况下,一个已经死掉的实例能在别人的候选列表里待上一分半甚至更久。
# 把各层都压短一些,代价是注册中心压力上升
eureka:
server:
response-cache-update-interval-ms: 3000
eviction-interval-timer-in-ms: 5000
client:
registry-fetch-interval-seconds: 5
instance:
lease-renewal-interval-in-seconds: 5
lease-expiration-duration-in-seconds: 15
spring:
cloud:
loadbalancer:
cache:
ttl: 5s
Nacos 在这件事上好过不少,它是服务端主动推(UDP 推送加定时兜底),实例变化秒级就能到客户端。但主动摘除这一步仍然要做,靠心跳过期一样要等。
主动摘除有现成的端点
别等心跳超时,Spring Cloud Commons 提供了一个端点可以直接把自己摘掉:
POST /actuator/serviceregistry?status=DOWN
用之前得先放出来:
management:
endpoints:
web:
exposure:
include: health,serviceregistry
发版脚本在停容器之前先打这一下,比什么配置都管用。GET 同一个地址可以看当前状态,用来确认摘干净了没有。
graceful 只管了一半
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
这个配置干的事是:收到停机信号后不再接受新连接,把手上正在处理的请求做完再退出。
它解决的是"请求处理到一半被砍断"。
它解决不了"别人压根不知道我要走了,还在源源不断发新请求过来"。
两件事名字听起来像一回事,其实完全不沾边。我一开始也以为配上 graceful 就万事大吉。
正确的顺序是先说再走
- 主动从注册中心摘除,别等心跳超时
- 等一会儿。等的是调用方缓存刷新周期加一点余量,这段时间进程还活着,还照常处理请求
- 再发停机信号,让 graceful 把在途请求收尾
第 2 步最容易被省掉,因为它看起来什么也没干,就是在那儿干等。而它恰恰是消灭 503 的那一步。等多久取决于你的注册中心和客户端配置,宁可多等几秒。
在 Kubernetes 里对应成这样:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 20"]
terminationGracePeriodSeconds: 60
有个地方特别容易理解反:preStop 执行期间容器还在正常服务,SIGTERM 根本还没发出去。所以这个 sleep 不是浪费时间,它就是上面那个"等一会儿",是在等 Service 的 Endpoints 和各个调用方把你摘干净。
时间账要算清楚。terminationGracePeriodSeconds 是从发出 SIGTERM 之前就开始计的,preStop 的耗时算在里面。所以它必须大于 preStop 加上优雅停机的时长:20 秒 preStop 加 30 秒 graceful 是 50 秒,配 60 秒还剩一点余量。配成 30 秒的话,preStop 刚睡完就被 SIGKILL 了,前面铺垫全白做。
readiness 探针也要先失败,让 Endpoints 尽快把这个 Pod 摘出去。注意 readiness 失败不会掐断已有连接的处理,它只影响"新流量还发不发给你",这正好是我们要的。
management:
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
打开之后 Spring Boot 会暴露 /actuator/health/readiness,而且它在接到停机信号的第一时间就会转成 OUT_OF_SERVICE,不需要自己写逻辑。
几个容易漏的角落
长连接。 HTTP keep-alive、gRPC、数据库连接池,全是长连接。调用方的连接池里可能还攥着一条指向你的连接,它不会因为注册中心摘除就自己断开 —— 摘除影响的是"下次选谁",而复用现成连接压根不走选择这一步。
对策有两头。服务端把 server.tomcat.keep-alive-timeout 配得比发布窗口短,让连接自然老化掉;客户端侧确保连接池有空闲回收,OkHttp 的 ConnectionPool 默认空闲 5 分钟才清,对发布窗口来说太长了。
消息消费者。 它压根不走注册中心,但一样有下线问题:得先停止拉新消息,把手上这批处理完,最后才断开。顺序反了就会出现"拉下来一批没处理完就断线",然后靠重投恢复,于是你又需要幂等了。绕来绕去还是那件事。
定时任务。 停机瞬间正在跑的任务会被打断。Spring 的 ThreadPoolTaskScheduler 要配上 setWaitForTasksToCompleteOnShutdown(true) 和 setAwaitTerminationSeconds(...),否则 graceful 只等 web 请求,不等它。分布式调度要有超时接管,单机 @Scheduled 得保证任务本身可重入。
怎么证明真的修好了
别靠"发版时盯一眼监控有没有毛刺",正常流量太稀疏,看不出来。
用压测工具打一个低频但恒定的流量,然后完整走一遍发版流程,看有没有非 200:
wrk -t2 -c10 -d300s --latency http://svc/api/ping
# 或者更直观:每 50ms 一次,把非 200 的都打出来
while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' http://svc/api/ping)
[ "$code" != "200" ] && echo "$(date +%T.%3N) $code"
sleep 0.05
done
这个办法的好处是把一个"偶尔出现、不好复现"的问题,变成了"每次发版都能稳定观察"的问题。有毛刺就是没配对,一个都不该有。
顺带一提,那串时间戳还能告诉你毛刺出现在哪一秒 —— 是在 preStop 刚开始(说明摘除没生效),还是在 sleep 结束之后(说明等得不够久)。两种病因,改法完全不同。