Graceful Shutdown:优雅停机与无损下线

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 太短收尾没跑完就被 SIGKILLterminationGracePeriodSeconds 调到 > 收尾耗时
没配 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,其实都是没做优雅停机埋下的雷。

上一篇 K8s HPA 实战:弹性伸缩与亲和性调度
下一篇 pre-commit 实战:用 Git 钩子拦截代码低级错误