Skip to content

优雅上下线全景:从注册摘流到 K8s 探针的无损发布

提出问题

微服务发布期间最常见的线上事故:发布瞬间 5xx 飙升,几秒后恢复。运营同学看到监控报警,等你去查,流量已经恢复正常了。这种"幽灵错误"比持续故障更恶心——你很难复现,也很难定位,但每次发布都会报一次。

根因是两个时序错位:

  1. 下线时:进程被 kill 了,但注册中心还没摘掉它,流量还在往里打
  2. 上线时:新进程刚启动,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+)

startupProbelivenessProbe 的区别是: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 个 Pod
  • maxUnavailable: 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% 左右,然后恢复。

排查过程

  1. 查了 K8s 事件,发现 preStop 没配,旧 Pod 收到 SIGTERM 后立即关闭端口
  2. 加了 preStop sleep 10,5xx 降到 1% 左右,但没完全消失
  3. 再查发现 readiness 探针返回 200 太快了——应用启动后 2 秒就 readiness 了,但 JIT 和连接池还没热
  4. 在 readiness 前加了一个 15 秒的预热期,5xx 降到 0.1% 以下
  5. 最后发现 Nacos 实例摘除有个 3 秒的推送延迟,配合 K8s 的 preStop 一起解决了

结论:三个环节缺一不可——注册中心摘流、preStop 等待、启动预热。任何一个环节没做,发布期间就会丢请求。

总结

优雅上下线不是单个配置能解决的,是注册中心、应用框架、K8s 调度三层协作的结果:

环节做什么配置点
下线摘流先 deregister 再等同步NamingService.deregisterInstance() + sleep
优雅停机排空存量请求,不直接 killserver.shutdown=graceful + SmartLifecycle
K8s 探针控制流量进入/退出的时机preStop + readinessProbe + startupProbe
启动预热冷启动保护,逐步放流量自定义 HealthIndicator + 慢启动负载均衡
滚动参数控制更新速率和容量maxUnavailable + maxSurge

下次发布前拿这个清单挨个检查一遍,比出问题了再回滚实在。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1