Graceful Shutdown(优雅停机)是线上服务发布与扩缩容时最容易被忽略、却最能避免事故的一环。当 Kubernetes 滚动更新、手动重启或自动扩缩容要下线一个实例时,如果进程被直接强杀,正在处理的请求会瞬间 500、长连接被粗暴断开、消息消费只做了一半——用户看到的就是「刷新一下又好了」的灵异故障。优雅停机的核心只有三步:先停止接收新流量,再把在途请求处理完,最后才退出进程,做到对调用方零感知的无损下线。本文从进程信号讲到 Spring Boot 与 K8s 的配合,给出可直接套用的配置清单,帮你把「重启一次服务」从事故源头变成无感操作。
一、为什么需要优雅停机
每一次发布、扩缩容、节点驱逐、手动重启,本质上都是「让一个运行中的实例下线」。默认情况下,操作系统或编排系统会先发 SIGTERM,等一小段时间(Docker 默认 10 秒、K8s 默认 30 秒)后直接 SIGKILL 强杀。如果应用没接住这个信号,后果很具体:① 在途请求失败,前端弹出 500;② 数据库连接被 abruptly 切断,事务只提交了一半;③ 消息消费中断,这条消息既没 ack 也没 nack,重启后可能重复消费;④ 注册中心没及时摘除,网关仍把流量转发到一个正在死去的实例上。这些问题的共同点是:它们都发生在「进程退出」这一瞬间,而优雅停机就是把这一瞬间拉长、变可控。
反过来看,做好了优雅停机的服务,发布时可以做到「用户毫无感知」:旧 Pod 先被从负载均衡摘除,等几秒让存量连接自然排空,再真正退出;新 Pod 起来并健康后才接流量。这正是K8s HPA 滚动伸缩和GitHub Actions 零停机发布能平滑生效的前提——如果下线不优雅,再聪明的扩缩容策略也会把流量甩进黑洞。
二、进程信号:SIGTERM 与 SIGKILL 的区别
优雅停机的第一性原则是:进程必须「接到通知、有机会收尾」。SIGTERM 是可捕获的终止信号,进程可以注册处理函数、做完清理再退出;SIGKILL(信号 9)则不可捕获、不可忽略,内核直接夺走进程生命,没有任何收尾机会。下面这张表是两者的核心差异:
| 维度 | SIGTERM(15) | SIGKILL(9) |
|---|---|---|
| 是否可捕获 | 是,可注册处理函数 | 否,内核直接终止 |
| 能否收尾清理 | 能(关连接、落盘、反注册) | 不能,数据可能损坏 |
| 典型发送方 | kubectl delete / docker stop / systemd | 超时后的兜底强杀 |
| 使用建议 | 默认用它下线实例 | 仅在卡死时手动 kill -9 |
在容器场景里,docker stop 默认先发 SIGTERM,等待 10 秒后再 SIGKILL;你也可以在 Dockerfile 里显式声明停止信号,让镜像明确告诉运行时「请用 SIGTERM 礼貌地叫我下班」:
# Dockerfile
FROM eclipse-temurin:17-jre
STOPSIGNAL SIGTERM
COPY app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
重点是:永远用 kill -15 而不是 kill -9 下线服务。后者跳过了所有清理逻辑,是把事故概率直接拉满的做法。
三、应用层优雅停机:以 Spring Boot 为例
Spring Boot 2.3+ 内置了 Web 服务器优雅停机开关。开启后,容器收到 SIGTERM 会先停止接受新请求,把已在处理的请求跑完再关。配置只需两行:
# application.yml
server:
shutdown: graceful # 优雅停机:跑完在途请求再关
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 单个生命周期阶段最多等待 30s
management:
endpoint:
shutdown:
enabled: false # 切勿暴露 /actuator/shutdown POST 端点
其中 timeout-per-shutdown-phase 是兜底超时:超过这个时间仍有请求没跑完,才会被强制中断。如果你的业务请求普遍偏长(比如报表导出),要把它调大到覆盖 P99 耗时,否则长请求仍会被砍。对于非 Web 的收尾逻辑,比如停掉自定义线程池、关闭 Kafka 监听,可实现 DisposableBean 或在 Bean 上标注 @PreDestroy,Spring 会在关机阶段按依赖顺序调用它们:
@Component
public class OrderConsumer {
@PreDestroy
public void destroy() {
// 停止拉取新消息,等待已接收消息处理完再退出
kafkaListenerContainer.stop();
log.info("消费者已停止,等待在途消息处理完毕");
}
}
四、容器层:Kubernetes 如何摘流量
光应用层优雅还不够,K8s 才是「摘流」的真正执行者。一个滚动更新下线的 Pod,生命周期是这样的:Endpoint 控制器先根据 readinessProbe 把 Pod 从 Service 的 Endpoint 列表移除 → 流量不再转发过来 → 再给容器发 SIGTERM → 等 terminationGracePeriodSeconds 后强杀。所以readinessProbe 是优雅停机的总开关,没配它,K8s 就不知道「什么时候算没流量了」。配套配置如下(详见K8s Pod/Deployment/Service 入门):
# deployment.yml
spec:
template:
spec:
terminationGracePeriodSeconds: 40 # 给足优雅停机时间(> 应用收尾耗时)
containers:
- name: app
image: order-service:1.4.0
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 8"] # 先等注册中心/网关完成摘流
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
这里有两个关键数字:preStop 里的 sleep 8 是在 SIGTERM 之前先等几秒,让注册中心(Nacos/Eureka)的心跳过期、网关真正摘掉这个实例;terminationGracePeriodSeconds: 40 必须明显大于「sleep + 应用优雅停机耗时」,否则 K8s 会在收尾完成前就 SIGKILL。 readiness 探测还能被Prometheus + Grafana采集成「实例是否可接流量」的看板,发布时盯着它从 1 变 0,心里就有底。
五、网关与注册中心层:先把流量挡在外面
应用和 K8s 之间还隔着一层:服务注册中心和反向代理。Nginx 反向代理如果没配优雅摘流,仍可能在后端已死的瞬间把请求转发过去。更稳妥的做法是让应用在 preStop 时主动从注册中心下线,再 sleep 等注册中心把本实例剔除、并等上游(网关/调用方)刷新地址列表的缓存周期过去,最后才退出。Spring Cloud 应用可以这样操作:
# preStop 命令:先摘注册中心,再等缓存过期,最后退出由 SIGTERM 接管
curl -X POST "http://localhost:8080/actuator/service-registry?status=DOWN"
sleep 12 # 大于注册中心心跳周期 + 调用方缓存 TTL,确保无人再连我
对于没有注册中心、直接用 Nginx upstream 的场景,可以在下线前通过 nginx -s reload 临时把该节点权重调 0,或配合 Consul Template / 控制面动态摘流。原则只有一条:退出之前,必须确认「已经没有人会再把新请求发给我」。
六、连接池与消息:别留半截尾巴
数据库和消息是优雅停机最容易被遗忘的角落。HikariCP 等连接池在 JVM 关闭钩子里会自然关闭连接,但要确保「关闭发生在请求处理完之后、而非之中」——这正是 server.shutdown=graceful 保证的顺序。对于消费端,MySQL 主从架构下的长事务、Kafka 的 at-least-once 投递,都要求消费者在退出前先 pause() 停止拉新消息、等 onMessage 里的业务逻辑跑完再 ack。下面这个 Python 信号处理的例子,展示了不依赖框架时如何自己接住 SIGTERM:
# Python: 捕获 SIGTERM 做优雅退出
import signal, sys, time
def graceful(signum, frame):
print("收到 SIGTERM,停止接新任务并等待在途完成...")
while has_inflight_requests():
time.sleep(0.5) # 等存量任务自然跑完
sys.exit(0)
signal.signal(signal.SIGTERM, graceful)
# 主循环持续接任务;收到信号后不再 accept 新连接
无论什么语言,模式都一致:收到 SIGTERM → 立刻关「入口」(不再 accept / 不再 poll)→ 等「存量」清空 → 退出。把这套逻辑写进启动脚本,比事后排查「为什么每次发布都有几条失败请求」省心得多。
七、五个高频坑
下面这些坑,几乎每个团队都踩过至少一遍。对照这张表自检,能挡掉八成发布事故:
| 坑 | 现象 | 解法 |
|---|---|---|
| 用 kill -9 强杀 | 请求批量 500、事务中断 | 永远用 kill -15(SIGTERM) |
| gracePeriod 太短 | 收尾没跑完就被 SIGKILL | terminationGracePeriodSeconds 调到 > 收尾耗时 |
| 没配 readinessProbe | 流量仍转发到将死实例 | 配就绪探针,让 K8s 自己摘 Endpoint |
| 注册中心反注册慢 | 下线后还被调用方连 | preStop 主动下线 + sleep > 缓存 TTL |
| 定时/消费没停止 | 任务跑到一半进程没了 | @PreDestroy 显式 pause/stop |
八、上线前检查清单
把下面四项变成发布流水线的卡点(可以接进CI/CD的发布前检查),每次上线前逐项确认:
| 检查项 | 达标信号 |
|---|---|
| 应用接住 SIGTERM | 日志出现「开始优雅停机」并正常退出 |
| readinessProbe 就绪 | 下线时 Endpoint 立即移除本实例 |
| 注册中心已摘流 | preStop 后调用方 0 新请求到达 |
| 在途请求跑完 | 监控无新增 5xx,RT 平稳回落 |
验证手段也很简单:在低峰期对单个 Pod 执行 kubectl delete pod xxx,同时盯着访问日志和 5xx 曲线。如果日志里旧 Pod 的请求数平滑归零、没有报错尖峰,说明优雅停机链路真正生效了。
九、小结
优雅停机不是高级特性,而是「把下线这件事做对」的基本功。三层配合缺一不可:应用层接住 SIGTERM、跑完在途请求(Spring Boot 一行 server.shutdown=graceful);容器层用 readinessProbe 摘流量、用 terminationGracePeriodSeconds 给足时间;注册中心/网关层在退出前主动反注册、等缓存过期。三者串起来,发布和扩缩容就能对用户完全无感。今天下班前,挑一个最核心的服务,按上面的清单跑一次 kubectl delete pod 验证——你会发现,很多「偶发」的 500,其实都是没做优雅停机埋下的雷。




