SpringBoot优雅停机实战:从SIGTERM到K8s滚动发布全解析

发布时间:2026/10/8 15:05:26
SpringBoot优雅停机实战:从SIGTERM到K8s滚动发布全解析 凌晨十二点盯着发布流水线一条kill -15发下去业务群里瞬间冒出好几条“接口报错了”“刚才提交的订单没返回”。这个场景是我对 SpringBoot 停机机制最初的记忆。默认情况下SpringBoot 收到 SIGTERM 并不代表它会等手头的事干完而是“几乎立刻”把 Web 容器和 Spring 容器拆掉所有在途请求直接被 Reset。SpringBoot 优雅停机机制是解决这类问题的标准方案它从 Spring Boot 2.3 开始由官方原生支持。这篇文章我会带你搞清楚优雅停机到底在哪一层生效、两行配置背后发生了什么事、实测中请求表现差多少、以及真正上线时最容易忽略的注册中心和容器编排坑。适合正在做服务发布、弹性伸缩、压测验证的 Java 开发者收藏。1. 从最惨的一次发布事故说起默认停机到底停掉了什么1.1 kill -15 并不温柔JVM 钩子与 Web 容器的同步关闭很多人有个误解以为给 Java 进程发kill -15SIGTERM之后JVM 会“把当前请求处理完再退出”。这个说法只说对了一半。JVM 确实会执行 ShutdownHookSpring Boot 也注册了自己的SpringApplicationShutdownHook但问题在于默认停机模式下Spring 容器关闭 WebServer 时并不会等待活跃请求。你可以把那次事故的现场还原一下。Tomcat 收到 stop 指令后Connector 直接进入关闭流程还在执行中的 Servlet 线程会被打断正在读取响应体的 Nginx 突然收到一个空的 FIN 包客户端表现就是 Connection reset by peer。如果你在写订单接口可能已经完成了数据库事务提交但响应没送到客户端调用方就会选择重试于是重复下单、重复扣库存、对账不平全都来了。1.2 粗暴关停的典型故障清单这类问题在线上有非常固定的模式我整理了一张表基本覆盖了绝大部分事故场景故障现象真实原因涉及模块客户端提示 Connection resetTomcat 在请求进行中直接关闭连接Web 容器下游收到 502/504Nginx 在 Upstream 节点关闭瞬间转发请求失败负载均衡消息重复消费消费到一半进程退出offset 未提交Kafka/RocketMQ事务已提交但响应丢失业务逻辑完成响应未写回客户端Servlet 线程定时任务重复执行集群多实例同时停机任务调度没做抢占任务调度注册中心出现异常实例服务端进程已死但心跳还没过期Eureka/Nacos这些故障有个共同点都不是代码逻辑错误而是进程退出顺序问题。业务代码跑得好好的纯粹因为停机姿势不对把“正常完成的请求”变成“客户端感知到的失败请求”这才是最亏的。1.3 先分清三个概念JVM ShutdownHook、Actuator Shutdown 与优雅停机网上搜“SpringBoot 优雅停机”经常看到两种做法混在一起容易踩坑。第一种是 JVM 层面的 ShutdownHook代码里Runtime.getRuntime().addShutdownHook(new Thread(...))它只保证“JVM 退出时我能执行一段代码”但改变不了 Web 容器已经在关闭的事实。你用这个方式做“通知注册中心摘流”因为顺序不对往往还没来得及发请求容器已经断了。第二种是 Actuator 的/actuator/shutdown端点需要手动打开并 POST 触发。它本质上也是触发ApplicationContext.close()在 Spring Boot 2.3 之前它是实现“相对优雅”停机的主要手段因为你可以控制触发时机不让它依赖操作系统信号。第三种才是真正的“优雅停机Graceful Shutdown”也就是从 2.3 开始官方内置的能力。它做的事情很明确先停止接收新请求然后等待所有在途请求处理完成或超时最后再关闭 WebServer 和 Spring 容器。后面我会从配置开始完整拆解。2. 先别急着加配置版本、容器与两行核心参数的前置判断2.1 Spring Boot 版本是分水岭低于 2.3 就没有原生方案Spring Boot 2.3.0.RELEASE 的 Release Notes 明确写了为内嵌 Web 服务器增加优雅停机支持。所以如果你项目还在 2.1、2.2对不起server.shutdowngraceful这个配置是无效的日志里连个警告都不会有。我见过有人把server.shutdowngraceful加到 2.2 项目上发版之后以为万事大吉结果压测一打就露馅。所以第一步永远是看pom.xml里的父版本parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent如果你日常用的是 Spring Boot 3.x那当然更没问题这套机制一直延续下来了。如果是老项目暂时升不动版本至少要做到给负载均衡或注册中心留出摘流时间再手动触发/actuator/shutdown不要裸着发 SIGTERM。2.2 核心配置只有两行但第二行含义经常被读错在application.properties或 yaml里加上server.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30s第一行很简单把默认的immediate改成graceful告诉 Spring Boot 走优雅停机流程。第二行spring.lifecycle.timeout-per-shutdown-phase写的默认值是 30s。注意关键词是per-shutdown-phase也就是“每个关闭阶段”的超时时间不是“整个停机过程”的总超时。Spring 容器里不同 Lifecycle 组件按 phase 分批关闭理论上有几个 phase就可能累加几个 30s。这一点后面讲事件链的时候还会展开先记住不要把它理解成“最多等 30 秒就强制退出”。2.3 官方支持矩阵四个内嵌容器全都支持但成熟度有差异Spring Boot 官方文档对优雅停机的描述是支持所有四个内嵌 Web 服务器Tomcat、Jetty、Reactor Netty、UndertowServlet 和响应式应用都可以。但做底层适配的人都知道一个能力“支持”和“支持得好”是两回事。Tomcat 的实现最完整从 9.0.33 开始提供了相对成熟的 Connector pause 线程池等待机制Jetty 和 Reactor Netty 也能做到“停止接收新请求等待在途请求完成”Undertow 也能用但我在网上见过一些版本差异导致的边界问题如果你不是必须用 Undertow建议在生产环境优先用 Tomcat省心。容器是否支持实际表现Tomcat 9.0.33完整支持pause Connector等待线程池任务完成日志清晰Jetty 9.4支持等待在途请求但部分版本需要额外注意连接超时Reactor Netty支持WebFlux 场景可用2.3 起支持等待事件循环中请求Undertow支持能用但社区反馈的边界问题相对多2.4 开启前检查下自己的环境这里给一个快速自查清单照着过一遍再上线项目spring-boot-starter-web或spring-boot-starter-webflux版本是否 ≥ 2.3。是否使用内嵌容器如果打成 war 丢外置 Tomcat这套配置不直接生效。确认停机时是kill -15或docker stop、systemctl stop触发而不是kill -9。有没有在代码里自定义 ShutdownHook如果自定义 Hook 里做了“等待 xx 秒”之类的逻辑要测试它与 Spring 优雅停机的先后顺序别让两段等待互相打架。3. 停机时到底在等什么SIGTERM 到容器关闭的完整事件链3.1 事件链路拆解SIGTERM → JVM Hook → Spring 容器关闭优雅停机不是魔法它只是在正确的位置插入了等待逻辑。进程收到kill -15后JVM 开始执行已注册的 ShutdownHook。Spring Boot 的SpringApplicationShutdownHook在这个时机被触发它会调用ApplicationContext.close()。close()内部会先触发ContextClosedEvent然后通过DefaultLifecycleProcessor去 stop 所有 Lifecycle 组件。关键就在这里Spring Boot 把内嵌 WebServer 的优雅停机封装成了一个SmartLifecycle它会在 Spring 容器关闭时先执行。这个 Lifecycle 做的事是设置 Server 不再接收新请求Tomcat 表现为停止 accept 新连接。等待活跃请求执行完成。超过timeout-per-shutdown-phase的请求不再等待直接强制关闭。等它结束后WebServer 才真正销毁Spring Bean 才开始挨个销毁。这条链路保证了“先等业务收完尾再拆房子”。3.2 三层等待容器层、线程池层、业务资源层实际等的东西可以分成三层看。第一层是 Web 容器层。Tomcat 的 Connector 进入 paused 状态后不再 accept 新连接但已经建立连接上正在执行请求的线程不会被打断。第二层是线程池层。Tomcat 内部维护了一个执行 Servlet 的工作线程池优雅停机时它会等线程池里所有任务执行完。如果你的某个接口里定义了 10 秒Thread.sleep它就是这里被等的重点对象。第三层是业务资源层。Spring 容器真正 close 时HikariCP 数据源、消息连接池这些 Bean 才开始逐个关闭。HikariCP 关闭时也会等待租出去的连接归还。这就是为什么“Web 容器等待”和“数据源等待”是两个不同的时间窗口别只用接口耗时去预算停机总时长。3.3 为什么日志里只见 “Commencing graceful shutdown” 却迟迟不结束开启优雅停机后收到 SIGTERM控制台会输出类似这样的日志o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete很多第一次接优雅停机的人看到这行日志然后发现进程迟迟不退会慌。其实这是正常现象。它正在等待在途请求。等所有请求处理完会看到o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete之后 Spring 容器继续关闭剩下的 Bean。整个停机的标准日志顺序应该是Commencing graceful shutdown. Waiting for active requests to complete 这里停顿时间取决于在途请求 Graceful shutdown complete Closing org.springframework.context.annotation.AnnotationConfigApplicationContext ...如果你始终看不到第二行日志大概率是某个请求永远不返回比如 WebSocket 连接、SSE 长连接、卡死的任务最后只能等超时被强制切断。这也是生产环境最容易出问题的点看似在“优雅等待”实际在“无限等待”。3.4 timeout-per-shutdown-phase 的真实累计逻辑前面说了这个参数是“每阶段超时”。Spring 的DefaultLifecycleProcessor会把所有 Lifecycle 分组每个 phase 单独计时。如果你既用了 WebServer 的优雅停机又自定义了几个 SmartLifecycle还接了消息监听容器等等那总停机时间可能超过 30s。举个实际例子phase 大的 Lifecycle 先关闭如果它在 30s 内没结束后面哪怕还有很多 Bean 没关也会被强制打断继续往下走。所以设置这个参数时最好把你业务里“最长的一个慢请求耗时 数据源连接归还时间 自定义清理时间”都估算进去。宁愿给 60s也不要卡在 30s 边缘。4. 用压测说话优雅停机前后的请求行为对比4.1 测试环境与压测准备配置说再多不如自己跑一轮压测直观。下面是我在本地搭的一组最小验证环境Spring Boot 2.7 项目 Tomcat一个慢接口模拟“在处理中的业务”。RestController public class GracefulController { GetMapping(/slow) public String slow() throws InterruptedException { System.out.println(请求进入 Thread.currentThread().getName() 时间 System.currentTimeMillis()); TimeUnit.SECONDS.sleep(10); System.out.println(请求完成 Thread.currentThread().getName() 时间 System.currentTimeMillis()); return ok; } }压测用 ab 就够了不用上 JMeterab -n 500 -c 50 http://127.0.0.1:8080/slow这个命令的意思是总共 500 个请求50 并发每个请求都要花 10 秒。按下命令后间隔几秒再开一个终端执行kill -15 $(jps | grep -i app | awk {print $1})然后观察 ab 的统计结果和控制台日志。4.2 未开启优雅停机kill 之后响应直接被掐断先把server.shutdowngraceful注释掉用默认的immediate跑一轮。kill 之后控制台立刻开始关闭容器正在执行的接口线程直接被中断。ab 端的表现非常明显大量Failed requests。客户端侧报Connection reset by peer。不管接口逻辑后面还有没有收尾工作一律不执行。最终输出的Complete requests远小于 500。我在实际压测中看到过更夸张的情况有些请求已经进入 Tomcat 工作线程但还没来得及打印“请求进入”进程就关了。这类请求就是“连业务代码都没跑完就被掐断”的典型。4.3 开启优雅停机新请求被拒绝在途请求正常收尾然后把两行配置加上重新启动并压测。中途 kill -15重点关注几个表现第一控制台输出Commencing graceful shutdown之后原本已经在跑的接口线程继续打印“请求完成”没有中断异常。第二ab 的Complete requests和Failed requests不再难看。已经进入 Tomcat 的请求基本都能正常拿到响应。第三停机期间新进来的请求会失败因为容器已经不再 accept。你会在 Tomcat 日志里看到连接被拒绝的痕迹这是预期行为。所以优雅停机的核心价值不是“所有请求都不失败”而是“已经进入的请求别被半路打死”。至于新请求本来就应该靠前面的负载均衡摘流解决这属于第 5 章要讨论的问题。4.4 超时场景超过等待上限的请求最终怎么收场再验证一个边界如果请求耗时超过timeout-per-shutdown-phase会怎样把spring.lifecycle.timeout-per-shutdown-phase改成3s然后接口 sleep 10 秒压测中途 kill。你会发现3 秒之后日志里出现容器强制关闭的动作。那个还在 sleep 的请求被中断客户端同样会收到连接重置。所以优雅停机不是“无限等”它是有上限的。上限到了该断还是断。这个测试特别适合用来跟业务方对齐预期停机窗口内新请求会失败超长请求会失败只有“正常长度”的在途请求能被保护。4.5 生产视角看待压测结果跑完这些对比我建议把结论写进发布手册发布前先确认 Kafka 消费位移、消息推送、文件上传这类长任务耗时不超预算。压测结论要让运维、QA、后端三方都看到别只停留在“我这边加了配置”。把压测中的Graceful shutdown complete日志出现时间点记录下来作为每次发布前的参考基线。5. 单机优雅不解决流量分配注册中心、Nginx、Docker 和 K8s 的配合5.1 最容易被忽视的坑负载均衡还在往停机节点甩流量单机优雅停机只解决“请求已经进来了”的情况。但发布的时候前面挂的 Nginx、Gateway、注册中心客户端并不知道这个节点正在停机它们仍会把新请求转发过来。这个瞬间连接被拒、超时、报错还是会重演。我见过一个团队做出来的停机顺序是反的先 kill 进程等容器优雅停完了再去 Nginx 摘节点。摘流事件发生在进程死亡之后前面的用户流量已经遭殃了。正确的顺序应该是先摘流再停机。摘流手段根据你的流量入口不同有几种做法下面分开说。5.2 手动摘流Nginx、Eureka、Nacos、Consul 分别怎么处理如果你们上游是 Nginx最直接的健康检查方案是Spring Boot 暴露一个健康检查接口停机前先把该接口状态改成 DOWNNginx 通过max_fails或主动健康检查摘掉这个 upstream 节点。比较粗暴但有效的做法是在停机前用脚本调 Nginx 接口直接标记 down。如果用的是注册中心更标准化的做法是调用服务治理 API 提前反注册注册中心摘流方式说明Nacosnacos-client的deregisterInstance主动摘除后客户端拉取新列表需要时间Eureka发心跳把状态置为 OUT_OF_SERVICE配合 eureka 客户端缓存刷新Consul服务实例标记为 maintenance 或反注册同样存在传播延迟无论哪种方式都要记住一个现实注册中心的摘流是异步传播的客户端可能存在缓存延迟。所以不要“反注册完立刻杀进程”要留一个缓冲窗口比如再等 5~10 秒等流量真的淡出再停。5.3 Docker stop 的 10 秒绞刑默认 STOP_TIMEOUT 与 30 秒优雅超时的冲突这块是网上抱怨最多、也最隐蔽的坑。用 Docker 跑 SpringBoot 时docker stop默认只等 10 秒超过 10 秒直接SIGKILL。可是spring.lifecycle.timeout-per-shutdown-phase默认是 30s。如果你的停机时间超过 10sDocker 会毫不客气地把进程干掉优雅停机形同虚设。解决办法有三个# 运行时指定 docker run --stop-timeout 60 your-image # compose 文件 services: app: stop_grace_period: 60s# 更推荐的还是在 Dockerfile 里明确信号 STOPSIGNAL SIGTERM注意这行STOPSIGNAL SIGTERM有些基础镜像或启动脚本会对信号做二次转发如果默认不是SIGTERM优雅停机可能根本没被触发。Dockerfile 里写清楚就没有这个歧义。5.4 Kubernetes 滚动发布readiness 探针与 preStop 的最佳姿势K8s 场景跟 Docker 又不一样因为 K8s 控制的是 Pod 生命周期它默认给了terminationGracePeriodSeconds: 30s这个窗口要大于你预估的优雅停机时间。推荐的做法是用 readiness 探针配合 preStop hookspec: terminationGracePeriodSeconds: 60 containers: - name: app readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 lifecycle: preStop: exec: command: [/bin/sh, -c, sleep 5]这里面的逻辑是K8s 在停止 Pod 前会先把该 Pod 从 Service Endpoint 中摘掉但摘掉动作的生效也要时间preStop里 sleep 几秒等于给了 K8s 一个缓冲。sleep 结束K8s 才发 SIGTERM此时流量已经基本不来了Spring Boot 才开始优雅停机。这样组合起来才能真正做到“先摘流再等请求最后杀进程”。5.5 多实例同时停机的雪崩问题分批发布最后一个集群层面的坑如果你有多个实例发布系统把实例 A、B、C 同时 kill那它们同时在优雅停机同时在等各自的重请求完成。等它们全部重启完中间出现的空闲窗口可能直接把整个服务的容量打穿。稳妥做法是分批发布先把流量摘掉一部分停一个、起一个。滚动发布有天然的顺序但如果你是自己写脚本发布一定要手动控制并发数。这个经验我在线上吃过亏优雅停机修好了“单机请求被掐断”却差点因为“全部实例同时停机”导致服务不可用。6. 给业务留足收尾时间事件监听、Lifecycle 顺序与上线检查清单6.1 ContextClosedEvent别在 Web 容器停止后再做异步收尾优雅停机等于给了你一个“业务收尾”的时间窗口。但很多团队把收尾逻辑写在PreDestroy或自定义 ShutdownHook 里顺序非常不可控。更可控的方式是监听ContextClosedEvent。这个事件在 Spring 容器开始关闭时发出此时 Bean 还都活着你可以在这时候通知消息队列暂停消费、把服务状态标记为“停止接收流量”、保存内存中的批次数据等。Component public class ShutdownListener { EventListener(ContextClosedEvent.class) public void onShutdown(ContextClosedEvent event) { // 1. 暂停消息消费 // 2. 触发缓存刷新或持久化 // 3. 标记健康检查为 DOWN System.out.println(容器开始关闭执行业务收尾); } }但要注意这个回调里不要做耗时太长的操作它会被计入整个关闭流程超时一样会被强杀。6.2 SmartLifecycle 与 phase 控制注册中心为什么必须放在高 phase如果你需要在 Web 容器停止之前就把注册中心摘掉更规范的做法是实现SmartLifecycle并给一个较大的 phase。Spring 的规则是停止时数字越大越先关闭。所以摘流逻辑应该用 Integer.MAX_VALUE 附近的 phase确保它排在 WebServer 优雅停机之前。Component public class DeregisterLifecycle implements SmartLifecycle { private volatile boolean running; Override public void start() { this.running true; } Override public void stop() { // 在这里调用 Nacos / Eureka / Consul 摘流 System.out.println(先从注册中心摘除实例); this.running false; } Override public boolean isRunning() { return this.running; } Override public int getPhase() { return Integer.MAX_VALUE; } }这个模式比在ContextClosedEvent里写更严谨因为它跟 Spring 的关闭顺序是同一套机制不用担心某个 Bean 提前销毁导致调用失败。不过也要注意摘流后必须在注册中心传播延迟里再拖几秒所以我一般会在stop()里加一个轻量 sleep别一摘完就立刻进入下一步。6.3 定时任务、线程池、数据库连接池在停机期的真实表现如果你项目里有Scheduled定时任务默认情况下它们跑在单线程调度器里。优雅停机过程中这些任务不会自动取消如果某个任务正在刷新缓存或跑批量任务它也会被容器关闭打断。我的建议是给这些异步任务显式指定一个线程池并在关闭时主动 shutdown。这样能保证优雅停机时任务执行到安全边界才退出。数据库连接池这块HikariCP 在容器关闭时会等待租借出去的连接归还但默认的等待时间并不长。如果某个慢 SQL 在停机瞬间还没跑完Hikari 的连接会被强制释放事务可能回滚。所以你真正要评估的是“业务慢请求的最差耗时”而不是平均值。6.4 上线前照着抄的停机检查清单最后给一份可以直接抄进发布手册的检查清单确认 Spring Boot ≥ 2.3配置了server.shutdowngraceful且把timeout-per-shutdown-phase按最坏情况调大。用kill -15压测一次确认出现Commencing graceful shutdown和Graceful shutdown complete两行日志。Docker 场景检查stop-timeout或stop_grace_periodK8s 场景检查terminationGracePeriodSeconds与 preStop。确认注册中心摘流在进程退出前完成并留了缓冲时间。明确新请求在停机窗口内注定会失败由负载均衡/网关做兜底。针对长连接和长期占用线程的请求WebSocket、SSE、文件上传单独确认超时策略。我个人的体会是优雅停机不是一个让“所有请求零失败”的方案它是一个让“该成功的请求能成功”的方案。真正要啃的硬骨头在停机前的那几步流量调度上。把代码里的等待时间、容器参数里的超时时间、K8s 里的优雅终止时间这三件事对齐线上发布才算真的稳健。