一、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 参数默认关闭的原因。
所以,重试只能兜住“还没发出去”的请求。滚动发布时,更稳妥的顺序是:
- 把即将停止的实例从
upstream中摘除,执行nginx -s reload(旧 worker 会处理完已有连接后退出); - 等待已有请求处理完毕;
- 停止实例,启动新版本;
- 确认新实例健康后,再加回
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