Administrator
发布于 2020-01-25 / 18577 阅读
100

Spring Boot 2.3 优雅停机与健康检查配置

每次发版都有几十个 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 那个还会等你配的 @PreDestroySmartLifecycle 都跑完。

二、用 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 数3170
单次滚动更新耗时约 48 秒约 72 秒
发布期间 P99 耗时跳到 8.2 秒245 ms,无明显波动

多花的 24 秒是 preStop 的 sleep,很划算。

下篇预告

这篇先把《Spring Boot 2.3 优雅停机与健康检查配置》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考