Administrator
发布于 2022-04-23 / 2745 阅读
50

Kubernetes 中 Java 应用的优雅上下线

滚动发布时,监控里出现了 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,得在 @PreDestroyshutdown()awaitTermination,否则任务会被直接丢。
  • 注册中心注销。如果用 Nacos 做服务发现,应用下线要主动调 deregister,不然 Nacos 还以为实例活着,内部 RPC(Dubbo/Feign)还会把请求发过来。我们加了 @PreDestroynacosNamingService.deregisterInstance(...),且等 5 秒再退。

留个问题

关于《Kubernetes 中 Java 应用的优雅上下线》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考