主题
优雅上下线全景:从注册摘流到 K8s 探针的无损发布
提出问题
微服务发布期间最常见的线上事故:发布瞬间 5xx 飙升,几秒后恢复。运营同学看到监控报警,等你去查,流量已经恢复正常了。这种"幽灵错误"比持续故障更恶心——你很难复现,也很难定位,但每次发布都会报一次。
根因是两个时序错位:
- 下线时:进程被 kill 了,但注册中心还没摘掉它,流量还在往里打
- 上线时:新进程刚启动,JIT 还没跑、连接池还没热,流量已经来了
场景复现:你有一个 Spring Boot 应用跑在 K8s 上,一共 6 个 Pod。滚动更新时,第 1 个 Pod 被回收,SIGTERM 发出,应用立即关闭了 Netty 端口,但 Nacos 还没收到心跳超时,网关还在往这个 Pod 转发请求。本来 6 个 Pod 分担流量,瞬间变成 5 个扛,但好在这 5 个还能扛住。真正出问题的是第 6 个 Pod 被替换时——第 6 个的旧 Pod 被杀,新 Pod 还没就绪,流量全部涌向剩下的 5 个,其中 1 个瞬间过载,雪崩了。
完美解决这个问题,需要搞清楚下线和上线各自的时序依赖,以及为什么"sleep 30s"这种土办法确实有效。
一、下线时序:先摘流再停机
一台实例下线,要确保它不再接收新请求,同时处理完已经在处理中的请求。分两步:
第一步:从注册中心摘除
注册中心(Nacos / Consul / Eureka)都有心跳机制,默认 5-30 秒不汇报心跳才摘除实例。这意味着如果你直接 kill 进程,有 5-30 秒的时间窗口内,注册中心认为这个实例还活着,照样把流量路由给它。
正确的做法是先调用 API 将实例状态设为 DOWN 或删除实例:
java
// Nacos 主动摘除实例
NamingService namingService = NamingFactory.createNamingService("127.0.0.1:8848");
namingService.deregisterInstance("service-name", "192.168.1.100", 8080);但这里有个陷阱:Nacos 的 deregister 是异步的。你调用完 API 立刻退出,可能摘除还没生效。所以很多团队在 shutdown 钩子里先等几秒——这就是"sleep 30s"的真实用途:不是乱等,是在等注册中心同步。
第二步:排空存量请求
摘除流量之后,需要处理已经在处理的请求。Spring Boot 2.3+ 提供了优雅停机:
yaml
# application.yml
server:
shutdown: graceful # 开启优雅停机
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 最多等 30s开启后,收到 SIGTERM 不再立即关闭 Tomcat/Undertow,而是停止接受新请求,等待已开始的请求处理完(或超时)。内部实现是 SmartLifecycle 接口的 stop() 方法。
但要注意:Spring 的优雅停机只管理自身的 Tomcat 线程池。如果你的应用有:
- 正在执行的异步任务(
@Async+ThreadPoolTaskExecutor) - MQ 消费者正在处理消息
- 数据库长事务还没提交
这些都需要自己注册 SmartLifecycle 或者 shutdown hook 来排空。一个常见的反例:RabbitMQ 消费者在 shutdown 时被强制中断,消息被丢弃(没有 ack),重启后消费不到这批消息,需要人工补偿。
时序图
mermaid
sequenceDiagram
participant K8s as K8s
participant App as 应用
participant Registry as 注册中心
participant Gateway as 网关/客户端
Note over App: 开始下线
K8s->>App: SIGTERM
App->>Registry: deregister/push DOWN
Registry->>Gateway: 推送变更(异步,延迟0-5s)
Note over App: 等待注册中心同步(1-5s)
App->>App: 关闭端口,停止接受新请求
App->>App: 等待存量请求处理完(max 30s)
App->>App: 关闭连接池、MQ消费者
App-->>K8s: 进程退出二、K8s 侧的探针时序
K8s 的滚动更新在 Pod 级别操作,但它的探针和优雅停机的配合经常被搞错。
preStop Hook 是关键
K8s 执行滚动更新时,先创建新 Pod,等新 Pod 就绪后,再向旧 Pod 发送 SIGTERM。但默认情况下,旧 Pod 收到 SIGTERM 的同时,K8s 就从 Service 的 Endpoint 列表里把它移除了——这一步是 K8s 做的,与注册中心无关。
问题在于:K8s 的 Endpoint 移除和 SIGTERM 几乎是同时发生的,但 Endpoint 移除需要 etcd 传播到 kube-proxy,再更新各节点的 iptables/IPVS 规则。这个传播延迟可能是 1-5 秒。如果旧 Pod 在收到 SIGTERM 后 0 秒就关闭端口,这 1-5 秒内新旧 Pod 都没就绪,流量就断了。
所以需要 preStop Hook:
yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
image: app:v2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
terminationGracePeriodSeconds: 60这个 sleep 10 就是给 K8s 的 Endpoint 传播留出时间窗口。preStop 执行期间,Pod 状态是 Terminating,但 Endpoint 还没移除,新请求能继续进来。10 秒后 K8s 再发 SIGTERM,此时应用才真正开始优雅停机。
readiness 和 liveness 的配合
很多人搞混这两个探针的作用:
- readiness:告诉 K8s "这个 Pod 可以接收流量了"。readiness 失败 → 从 Service Endpoint 移除
- liveness:告诉 K8s "这个 Pod 还活着"。liveness 失败 → 重启 Pod
上线的时机用 readiness 控制。应用启动时,readiness 返回 503,K8s 不会把流量打到它。等应用完成初始化+预热后再返回 200。
Spring Boot Actuator 提供了现成的端点:
yaml
management:
endpoint:
health:
probes:
enabled: true
health:
readinessstate:
enabled: true
livenessstate:
enabled: true启用后,/actuator/health/readiness 和 /actuator/health/liveness 会分别返回应用的状态。Spring Boot 在 ApplicationReadyEvent 之后才把 readiness 设为 UP,天然保证了"启动完再放流量"。
三、上线预热:为什么 readiness 探针还不够
readiness 探针只保证"进程启动了",但不能保证"服务能力就绪了"。一个典型场景:
Spring Boot 应用启动后,readiness 返回 200,K8s 开始把流量打进来。但此时:
- JIT 编译器还没温热点方法,响应时间可能是正常情况下的 5-10 倍
- 数据库连接池刚刚初始化,连接是冷连接,需要慢启动
- 本地缓存还是空的,每一个请求都要穿透到 DB
- 如果依赖 Redis/MQ,连接还没建好
多个 Pod 同时滚动更新,新 Pod 全部处于"冷启动"状态,流量涌进来,延迟飙升,下游超时,雪崩。这就是为什么发布时的 5xx 经常出现在滚动更新快结束时——最后几个新 Pod 全部都在"冷"的状态。
解决方案:让新 Pod 先"暖"一下再放流量。
方式一:启动延迟放流量
在 readiness 探针上做文章,等应用启动后额外等一段时间:
java
@Component
public class WarmupHealthIndicator implements HealthIndicator {
private volatile boolean warmupComplete = false;
public WarmupHealthIndicator() {
new Thread(() -> {
try {
// 预热:先跑一遍关键路径的代码
warmUpJit();
warmUpConnectionPool();
warmUpLocalCache();
Thread.sleep(5000); // 再等 5s 确保稳定
warmupComplete = true;
} catch (Exception e) {
warmupComplete = true; // 预热失败也要放流量,不能一直卡住
}
}).start();
}
@Override
public Health health() {
return warmupComplete ? Health.up().build() : Health.down().build();
}
}方式二:K8s 的 startupProbe(JDK 17+/K8s 1.18+)
startupProbe 和 livenessProbe 的区别是:startupProbe 成功后 liveness 才开始检查。适合启动慢的应用:
yaml
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30 # 给最多 150s 启动方式三:慢启动负载均衡
网关层做慢启动(slow start),新 Pod 刚上线时只分配少量请求,逐步增加:
yaml
# Spring Cloud LoadBalancer 配置
spring:
cloud:
loadbalancer:
slow-start:
enabled: true
duration: 30s # 30 秒内从 0 逐步增加到满权重四、滚动更新参数调优
K8s 滚动更新有两个关键参数:
yaml
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # 更新过程中最多允许 1 个 Pod 不可用
maxSurge: 1 # 最多允许比预期多 1 个 PodmaxUnavailable: 1:确保至少有 5 个 Pod 在服务(6 - 1),避免同时杀掉太多实例maxSurge: 1:允许先起一个新 Pod 再杀一个旧 Pod,减少容量波动
对于 6 副本的服务,maxUnavailable: 1 + maxSurge: 1 意味着更新期间最多有 7 个 Pod(6+1),最少 5 个在服务,容量波动最小。
但如果服务本身扛不住 5/6 的流量(比如高峰期 6 副本刚好打满),那就需要 maxSurge: 2 或更高,先起足量的新 Pod 再杀旧的。代价是期间资源消耗翻倍,node 需要预留足够资源。
事故复盘:一个发布 5xx 的排查过程
下面是一个真实案例的简化复盘:
现象:某服务每次滚动更新,都会有 30-60 秒的 5xx 错误率上升,从 0% 跳到 5% 左右,然后恢复。
排查过程:
- 查了 K8s 事件,发现 preStop 没配,旧 Pod 收到 SIGTERM 后立即关闭端口
- 加了 preStop sleep 10,5xx 降到 1% 左右,但没完全消失
- 再查发现 readiness 探针返回 200 太快了——应用启动后 2 秒就 readiness 了,但 JIT 和连接池还没热
- 在 readiness 前加了一个 15 秒的预热期,5xx 降到 0.1% 以下
- 最后发现 Nacos 实例摘除有个 3 秒的推送延迟,配合 K8s 的 preStop 一起解决了
结论:三个环节缺一不可——注册中心摘流、preStop 等待、启动预热。任何一个环节没做,发布期间就会丢请求。
总结
优雅上下线不是单个配置能解决的,是注册中心、应用框架、K8s 调度三层协作的结果:
| 环节 | 做什么 | 配置点 |
|---|---|---|
| 下线摘流 | 先 deregister 再等同步 | NamingService.deregisterInstance() + sleep |
| 优雅停机 | 排空存量请求,不直接 kill | server.shutdown=graceful + SmartLifecycle |
| K8s 探针 | 控制流量进入/退出的时机 | preStop + readinessProbe + startupProbe |
| 启动预热 | 冷启动保护,逐步放流量 | 自定义 HealthIndicator + 慢启动负载均衡 |
| 滚动参数 | 控制更新速率和容量 | maxUnavailable + maxSurge |
下次发布前拿这个清单挨个检查一遍,比出问题了再回滚实在。