🤖
AI审核中

SpringBoot优雅停机实战

原创 框架与微服务 19分钟 117浏览 1评论

一、502不是代码抛出来的,是交接时漏掉的

假设一个 Spring Boot 服务每次发版,监控上都会出现一小段尖刺:

upstream prematurely closed connection while reading response header from upstream
connect() failed (111: Connection refused) while connecting to upstream

网关返回 502,部分客户端报 Connection reset by peer。

但应用日志里没有异常,发版结束之后一切正常,也没有任何接口能稳定复现。

很多人的第一反应是在配置里加上一行:

server:
  shutdown: graceful

然后下一次发版,502 依然存在。

原因在于,发版期间的错误并不只来自“正在处理的请求被中断”。一次停机至少要回答三个问题:

旧实例从什么时候开始不再收到新请求?已经进来的请求能不能做完?进程到底有没有收到“该停了”的信号?

优雅停机只负责回答其中一个。

1. 一次停机其实是一条时间线

从外部看,“停掉一个实例”像一个动作;从内部看,它是一串有先后顺序的步骤:

graph LR
    A["发起停止"] --> B["摘除流量"]
    A --> C["收到 SIGTERM"]
    C --> D["拒绝新连接"]
    D --> E["等待请求完成"]
    E --> G["进程退出"]
    E -.->|"超过宽限期"| H["SIGKILL"]
    B -.->|"晚于拒绝新连接"| X["新请求 502"]

注意“摘除流量”和“收到 SIGTERM”在很多平台上是并行发生的。只要摘除流量这一步比应用关闭端口慢,哪怕应用本身停得再“优雅”,中间这段时间到达的请求也会失败。

2. 发版期间的错误,可以分成四类窗口

窗口 典型现象 修复方向
摘流量晚于关闭端口 502、连接被拒绝或重置 先摘流量,再发 SIGTERM
请求处理到一半被中断 502、upstream 提前关闭连接 优雅停机,并对齐超时
SIGTERM 没送到 JVM 超时后被强杀,日志无记录 启动命令使用 exec
长连接迟迟不结束 每次停机都拖满超时时间 关闭时主动结束长连接

排查时不要把它们混成一个“502 问题”。每一类窗口,对应的证据和修复位置都不同。

二、先做实验:同样一次 SIGTERM,差别在哪里

为了不凭印象下结论,下面用一个最小的 Spring Boot 应用做对照实验。

环境为 Spring Boot 2.7.18(内嵌 Tomcat 9.0.121)+ JDK 8,本机运行。应用只有几个接口:

  • /slow?ms=5000:睡眠指定毫秒后返回,模拟一个处理中的请求;
  • /fast:立即返回;
  • /ready:返回 Spring Boot 当前的就绪状态;
  • /sse:一个不会自然结束的 SSE 连接。

实验步骤统一为:先发起 /slow 请求,1 秒后向 JVM 发送 SIGTERM,再在 0.5 秒后发起新的请求。所有时间都以收到 SIGTERM 的时刻为 0。

1. immediate 与 graceful 的对照

观察项 shutdown: immediate shutdown: graceful
进行中的请求(还剩约 4 秒) 约 2.7 秒被断开,无响应 4.0 秒正常返回 200
SIGTERM 后 0.5 秒的新连接 连接被重置 连接被重置
SIGTERM 后 0.7 秒请求 /ready 连接被拒绝 连接被拒绝
复用空闲的 keep-alive 连接 — 0.6 秒时被重置
进程退出 约 2.8~2.9 秒 约 4.1 秒

把请求时长加到 20 秒再测一次:graceful 模式下,这个请求在 19.0 秒时完整返回 200,进程随后退出;immediate 模式下,请求依然在 2.7 秒左右被断开。

从这组结果可以得到一个很重要的结论:

优雅停机保护的是“已经进入应用的请求”,不保护“SIGTERM 之后才到达的请求”。

实验中,Tomcat 进入优雅停机后立即停止接收新连接,已经建立但处于空闲状态的 keep-alive 连接,在下一次复用时也被重置。Spring Boot 官方文档说明,Jetty、Reactor Netty 与 Tomcat 都在网络层停止接收新请求;Undertow 则会继续接收连接,但立即返回 503。无论哪一种,新请求都拿不到正常结果。

所以,只开优雅停机而不先摘流量,502 窗口仍然存在,只是从“处理中的请求失败”变成了“新请求失败”。

2. 就绪状态变了,但探针读不到

应用日志里能看到,收到 SIGTERM 后几乎同一毫秒,Spring Boot 就发布了就绪状态变化:

availability -> REFUSING_TRAFFIC
ContextClosedEvent
Commencing graceful shutdown. Waiting for active requests to complete

但实验中 0.7 秒时请求 /ready,得到的是连接被拒绝,而不是一个 REFUSING_TRAFFIC 的响应——端口已经不再接收新连接了。

这说明:

停机时的就绪状态变化,更多是给应用内部和同进程的组件看的;指望外部探针在 SIGTERM 之后“读到拒绝”再去摘流量,时间上来不及。

摘流量必须发生在应用关闭端口之前,而不是依赖应用在关闭过程中“通知”外部。

三、第一步:确认 SIGTERM 真的送到了 JVM

1. 启动脚本少了一个 exec

很多服务用脚本启动,比如:

#!/bin/bash
JAVA_OPTS="-Xms512m -Xmx512m"
java $JAVA_OPTS -jar /opt/app/app.jar

这时 shell 是父进程,JVM 是它的子进程。

在实验中,把同一个应用改成用这样的脚本启动,然后只向脚本进程发送 SIGTERM:

  • 脚本进程立即以退出码 -15 结束;
  • JVM 完全没有收到信号,进行中的请求照常返回;
  • 40 秒之后,JVM 依然在运行,成了一个没人管理的孤儿进程。

在容器里,这个脚本通常就是 PID 1。停止容器时,信号发给了脚本,脚本没有转发给 JVM,于是 JVM 一直等到宽限期结束被 SIGKILL——所有正在处理的请求一起中断,而且应用日志里不会留下任何关闭记录。

修复方式是让脚本最后一步用 exec 替换自身:

#!/bin/bash
set -euo pipefail

JAVA_OPTS="${JAVA_OPTS:--Xms512m -Xmx512m}"

# exec 之后,JVM 接管当前进程号,停止信号会直接送到 JVM
exec java $JAVA_OPTS -jar /opt/app/app.jar

2. 容器:使用 exec 形式的 ENTRYPOINT

Dockerfile 里的 shell 形式:

ENTRYPOINT java -jar /opt/app/app.jar

实际执行的是 /bin/sh -c "java -jar ...",与上面的包装脚本是同一个问题。应改为 exec 形式:

ENTRYPOINT ["java", "-jar", "/opt/app/app.jar"]

如果确实需要启动脚本,就在脚本最后使用 exec,并用 exec 形式调用这个脚本。

另外要记住默认宽限期:docker stop 默认只等 10 秒,就会发送 SIGKILL;Kubernetes 的 terminationGracePeriodSeconds 默认是 30 秒。这两个数字,都要和后面计算的停机预算对齐。

3. systemd:确认超时,并把 143 视为正常退出

systemd 默认使用 KillMode=control-group,停止服务时会向服务所在 cgroup 里的所有进程发送 SIGTERM,所以包装脚本的问题在 systemd 下没有容器里那么致命。但仍建议使用 exec,让 MainPID 就是 JVM。

一个典型的 unit:

[Service]
ExecStart=/opt/app/startup.sh
# 停机总预算:摘流量等待 + 应用停机超时 + 余量
TimeoutStopSec=60
# JVM 收到 SIGTERM 正常关闭后,退出码为 128 + 15 = 143
SuccessExitStatus=143

实验中 JVM 在 SIGTERM 后完成优雅停机,退出码确实是 143。如果不声明 SuccessExitStatus=143,每次正常停止,systemd 都会把服务标记为 failed,干扰告警判断。

验证方式不是看配置文件,而是看实际状态:

systemctl show myapp.service -p MainPID -p KillMode -p TimeoutStopUSec

# MainPID 应该就是 java 进程
ps -o pid,ppid,comm -p "$(systemctl show -p MainPID --value myapp.service)"

四、第二步:开启优雅停机,并算清楚超时

1. 配置本身很简单

server:
  shutdown: graceful

spring:
  lifecycle:
    # 每个关闭阶段最多等待多久,默认 30 秒
    timeout-per-shutdown-phase: 30s

server.shutdown=graceful 从 Spring Boot 2.3 开始提供;Spring Boot 3.4 起,内嵌 Web 服务器默认就启用优雅停机。即便使用的是新版本,也建议在配置中显式写出来,让停机行为不依赖默认值。

2. 超时不是“到点砍断”

timeout-per-shutdown-phase 很容易被理解成“请求最多再处理 30 秒”。实验结果并非如此。

把它设为 2s:

  • 还剩约 4 秒的请求:Spring 在 2 秒时打印 Graceful shutdown aborted with one or more requests still active,但这个请求仍然在 4.0 秒时正常返回了 200;
  • 还剩约 19 秒的请求:同样在 2 秒时放弃等待,随后 Tomcat 停止容器时又等待了一小段时间,请求在 4.2 秒时被断开。

也就是说,这个配置控制的是 Spring 生命周期为这一阶段等待多久,放弃等待之后,Web 服务器自身的停止流程还会有额外的时间。它不是请求处理时间的硬上限。

真正的硬上限来自外部:Kubernetes 的 terminationGracePeriodSeconds、systemd 的 TimeoutStopSec、docker stop -t。时间一到,就是 SIGKILL。

3. 用预算公式对齐所有超时

可以按下面的关系设置:

外部宽限期 ≥ 摘流量等待 + 应用停机超时 + 其余关闭步骤 + 余量

例如:摘流量等待 10 秒,Spring 停机超时 30 秒,线程池与连接关闭预留 5 秒,那么 Kubernetes 的 terminationGracePeriodSeconds 不应少于 45 秒。

反过来也成立:如果业务上最长的正常请求需要 60 秒,只把 Spring 超时改成 60 秒而不改外部宽限期,请求依然会在 30 秒时被 SIGKILL 截断。

超时要成组调整,不能只改其中一个。

五、第三步:先摘流量,再停进程

1. Kubernetes:用 preStop 等待端点摘除生效

删除 Pod 时,kubelet 会先执行 preStop,再向容器发送 SIGTERM。与此同时,Pod 被标记为 Terminating,从 Service 的可用端点中移除。

问题在于,端点的变化需要时间才能传播到 kube-proxy、Ingress 控制器和外部负载均衡。如果应用一收到 SIGTERM 就关闭端口,而网关还没来得及更新,就会出现前面实验里“新连接被拒绝”的窗口。

Spring Boot 官方文档给出的做法,是在 preStop 中短暂等待,让摘除先生效:

spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: app
      image: registry.example.com/app:1.0.0
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 10"]
      readinessProbe:
        httpGet:
          path: /actuator/health/readiness
          port: 8080
        periodSeconds: 5

几点需要注意:

  • terminationGracePeriodSeconds 包含了 preStop 的执行时间,所以要按上一节的公式加上这 10 秒;
  • exec 方式要求镜像中存在 sh 和 sleep,精简镜像可能没有,较新的 Kubernetes 版本也提供了原生的 sleep 动作,可按集群版本选择;
  • 10 秒只是示例,应根据实际网关的更新延迟确定。

2. Nginx + 多实例:重试能兜住什么

对于 Nginx 反向代理多个实例的部署,很多人会依赖 proxy_next_upstream:一个实例失败了,自动换下一个。

为了确认它的边界,在一台服务器上用独立的 Nginx 1.20.1 实例和两个后端做了验证,配置为:

upstream app {
    server 127.0.0.1:18481 max_fails=0;
    server 127.0.0.1:18482 max_fails=0;
}

server {
    listen 127.0.0.1:18480;

    location / {
        proxy_pass http://app;
        proxy_next_upstream error timeout;
        proxy_connect_timeout 1s;
    }
}

每种情况各发 10 次请求:

后端 A 的状态 GET POST
端口已关闭(连接被拒绝) 10 次全部 200 10 次全部 200
收到完整请求后,不回响应直接断开 10 次全部 200 502 与 200 交替出现,5 次失败

原因在于:连接被拒绝时,请求还没有发出去,Nginx 可以安全地换一个实例;而请求已经发给后端之后连接断开,对于 POST 这类非幂等方法,Nginx 默认不会重试——它无法判断后端是否已经执行了一半。这正是 proxy_next_upstream 的 non_idempotent 参数默认关闭的原因。

所以,重试只能兜住“还没发出去”的请求。滚动发布时,更稳妥的顺序是:

  1. 把即将停止的实例从 upstream 中摘除,执行 nginx -s reload(旧 worker 会处理完已有连接后退出);
  2. 等待已有请求处理完毕;
  3. 停止实例,启动新版本;
  4. 确认新实例健康后,再加回 upstream。

不要让“重试”承担“摘流量”的职责,尤其是写接口。

3. 单实例:没有无损,只能让失败变得可理解

只有一个实例时,重启期间一定存在不可用窗口,任何配置都不能消除它。

这时能做的是:缩短窗口(加快启动、减少启动时的同步初始化),并让失败“体面”一些——例如在 Nginx 中把重启期间的 502 替换为一个说明页:

error_page 502 = @restarting;

location @restarting {
    default_type "text/plain; charset=utf-8";
    add_header Retry-After 30 always;
    return 503 "服务正在更新,请稍后刷新";
}

503 + Retry-After 比一个裸的 502 更准确地描述了状态,也方便客户端和爬虫决定何时重试。

六、第四步:别让长连接和后台任务拖住停机

1. SSE 会让优雅停机等满超时

优雅停机会等待“所有进行中的请求”完成,而 SSE、长轮询这类连接,本身就被设计成不会主动结束。

实验中,保持一个 SSE 连接后发送 SIGTERM:

  • 默认情况下,停机一直等满 30 秒超时,打印 Graceful shutdown aborted,之后连接才被断开,进程在约 32 秒时退出;
  • 在收到 ContextClosedEvent 时主动结束所有 SSE 连接后,进程在 0.74 秒内退出。

前者看上去也“停下来了”,但每次发版都白白多等 30 秒;如果外部宽限期更短,还会直接变成 SIGKILL。

可以把 SSE 连接集中登记,并在应用关闭时统一结束:

import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class SseRegistry {

    private final Set<SseEmitter> emitters = ConcurrentHashMap.newKeySet();

    public SseEmitter open() {
        SseEmitter emitter = new SseEmitter(0L);
        emitters.add(emitter);
        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> emitters.remove(emitter));
        emitter.onError(ex -> emitters.remove(emitter));
        return emitter;
    }

    /**
     * 应用开始关闭时主动结束所有 SSE 连接,
     * 否则优雅停机会一直等到超时。
     */
    @EventListener(ContextClosedEvent.class)
    public void closeAll() {
        for (SseEmitter emitter : emitters) {
            emitter.complete();
        }
        emitters.clear();
    }
}

控制器只需要调用 sseRegistry.open() 返回连接。浏览器端的 EventSource 在连接结束后会自动重连,届时请求会被负载均衡分配到新的实例上。

WebSocket 同理:在关闭阶段以明确的关闭码结束会话,由客户端负责重连,而不是让服务端被动等到超时。

2. 线程池与异步任务

Web 服务器的优雅停机只覆盖 HTTP 请求。由 @Async、自定义线程池、消息消费者处理的任务,需要单独安排收尾。

以 Spring 的 ThreadPoolTaskExecutor 为例:

@Bean
public ThreadPoolTaskExecutor reportExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("report-");
    // 关闭时等待已提交的任务执行完,而不是直接中断
    executor.setWaitForTasksToCompleteOnShutdown(true);
    // 最多等待 20 秒,这段时间同样要计入外部宽限期
    executor.setAwaitTerminationSeconds(20);
    return executor;
}

需要注意两点:

  • 等待时间会叠加到停机总时长上,必须纳入前面的预算公式;
  • 如果任务来自消息队列,应先停止拉取新消息,再等待处理中的消息完成,否则关闭过程中还会不断有新任务进入。

3. 客户端要能处理连接被重置

实验中,已经建立的 keep-alive 空闲连接在停机开始时被关闭,客户端复用时得到的是 Connection reset。

即使服务端已经做好了摘流量和优雅停机,调用方的连接池里仍可能有指向旧实例的连接。对于幂等请求,调用方应当具备有限次数的重试;对于非幂等请求,则应依靠幂等键等机制保证重试安全,而不是简单地关掉重试。

七、验证:把“发版不报错”变成可重复的检查

优雅停机很难靠阅读配置确认,最可靠的是在发版过程中持续打流量,统计失败。

下面的脚本持续请求指定地址,按秒输出成功数和各类失败数,适合在一次滚动发布期间运行:

import collections
import http.client
import sys
import time
import urllib.parse

url = urllib.parse.urlparse(sys.argv[1])
duration = int(sys.argv[2]) if len(sys.argv) > 2 else 120

end = time.time() + duration
while time.time() < end:
    second = int(time.time())
    stats = collections.Counter()
    while int(time.time()) == second:
        try:
            conn = http.client.HTTPConnection(url.hostname, url.port or 80, timeout=5)
            conn.request("GET", url.path or "/")
            status = conn.getresponse().status
            conn.close()
            stats["ok" if status < 500 else f"http_{status}"] += 1
        except Exception as exc:
            stats[type(exc).__name__] += 1
        # 限制请求频率,避免验证脚本本身给网关制造压力
        time.sleep(0.05)
    print(time.strftime("%H:%M:%S", time.localtime(second)), dict(stats), flush=True)

执行:

python3 deploy_probe.py http://gateway.example.com/api/ping 180

然后在另一个终端触发发版。建议至少覆盖下面几类场景:

验证场景 期望结果
发版期间持续请求幂等接口 没有 502,没有连接被拒绝或重置
发版开始时存在一个长耗时请求 请求完整返回,而不是被中途断开
发版开始时存在 SSE 连接 连接被主动结束,进程在远小于超时的时间内退出
查看旧进程的退出过程 日志中有完整的关闭记录,退出码为 143
统计单个实例的停机耗时 明显小于外部宽限期,不触发 SIGKILL

如果只看“发版之后服务正常”,是验证不出上面这些问题的。

八、总结:优雅停机是一条链,不是一个开关

发版时的 502 很少来自某一行代码,而是来自停机链条上某个环节没有接上。

回到最开始的三个问题:

旧实例什么时候不再收到新请求?——取决于摘流量是否早于应用关闭端口。优雅停机不会延迟端口关闭,实验中 SIGTERM 后 0.5 秒的新连接就已经失败。

已经进来的请求能不能做完?——取决于 server.shutdown=graceful,以及 Spring 超时、线程池等待和外部宽限期是否成组对齐。Spring 的超时不是硬上限,外部的 SIGKILL 才是。

进程有没有收到信号?——取决于启动方式。少一个 exec,JVM 可能根本不知道自己要停机,最后被强制结束。

把这三件事分别确认清楚,再加上对 SSE、线程池和客户端重试的收尾,发版才能从“每次都有一小段 502”变成可预期、可验证的过程。

优雅停机不是在配置文件里多写一行,而是让流量、信号和超时在同一条时间线上对齐。

1 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

头像
  1 条评论
召田最帥boy   湖南省

谁还没被吓哭kuse