拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot优雅停机完全指南:从配置到K8s落地

我记得很清楚第一次在发布窗口里遇到这类问题是一个订单服务的发版时刻。当时半小时前刚上过线主流程走完测试回归一切正常。结果发布后马上就有客服反馈用户在提交订单那一刻页面转圈随后报“网络异常”。去查日志发现请求到达了后端但响应没写完进程就退出了。典型症状就是停机瞬间正在处理中的请求被强行撂挑子。这个问题背后的机制就是SpringBoot的优雅停机Graceful Shutdown。如果你正在负责一个SpringBoot服务的发布、扩容缩容、滚动更新或者只是面试被问到“SpringBoot如何优雅停机”这篇文章都值得你看完。我会把默认停机为什么粗暴、优雅停机怎么开启、底层到底执行了什么、验证方法、生产环境K8s下的落地细节以及我踩过的几个坑完整拆开讲。1. 为什么说“默认停机”是个隐患先看一次真实的发版事故1.1 事故现场的典型表现那次事故的表现非常经典。服务用K8s滚动发布新Pod起来、老Pod被标记为Terminating然后进程收到SIGTERM信号Java进程开始退出。问题在于老Pod退出时正在处理的线程被直接中断。订单提交接口从网关进来Controller已经拿到了请求正在调用下游支付、写数据库这时候进程关闭Tomcat工作线程被强制终止底层Socket连接被关闭。客户端那边看到的就是响应超时、连接重置用户以为没提交成功就会重复点击带来重复订单的风险。等我把启动日志和关闭日志放在一起对比发现整个JVM退出过程极快——从收到SIGTERM到进程消失往往不到1秒。这么短的时间根本来不及让正在跑的HTTP请求把响应写回去。1.2 JVM的ShutdownHook只能保证“有机会执行”但不保证“等待”很多人以为SpringBoot应用天然支持优雅停机因为在Spring容器启动后会调用Runtime.getRuntime().addShutdownHook(...)注册一个钩子。当JVM收到SIGTERM信号时ShutdownHook确实会执行Spring容器会走close()方法销毁Bean、关闭连接池、释放资源。但这里有个致命区别ShutdownHook保证的是“关机会执行清理逻辑”不是“等所有请求处理完再关”。默认情况下SpringBoot的内嵌Web服务器Tomcat/Netty在收到停机信号时不会等待正在处理的请求。Tomcat连接器直接进入关闭流程工作线程正在执行的Servlet被粗暴打断业务代码里的try-finally也许能执行一部分但返回响应已经不可能了。这就像一栋楼断电消防广播刚响电梯直接掉了里面的人根本没时间走出来。1.3 业务上哪些请求最受伤不是所有请求都需要优雅停机但以下几类接口如果赶上停机窗口影响最明显写操作接口订单提交、支付回调、库存扣减。响应没落地用户不知道是否成功重试可能造成重复数据。长耗时接口导出报表、批量计算、AI推理、文件上传下载。这些请求本身就要几十秒发布窗口一到就断体验极差。异步任务提交接口请求把任务丢进线程池就返回但线程池里的任务还在执行进程退出直接把任务杀了。丢失的业务数据查都查不到。理解了这些你就明白为什么“能停但不优雅”会成为生产事故的导火索。2. 开启优雅停机一个配置项背后的版本分水岭2.1 从“一刀切”到“优雅”的配置差异SpringBoot从2.3.0开始正式提供了优雅停机配置。不需要引入额外依赖只要在application.yml加上两行server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s第一行server.shutdowngraceful表示启用Web服务器层面的优雅停机第二行是每个生命周期阶段的超时时间默认就是30秒如果接口耗时平均较长可以调大。这里说的“优雅”核心行为是进程收到SIGTERM后不再接收新的HTTP请求但已经在处理的请求会继续执行完直到所有请求处理完毕或者超时才进入下一阶段关闭。在没有这个配置的旧版本里手动模拟也能实现部分效果但代价是写一堆代码去实现ApplicationListenerContextClosedEvent、自定义Lifecycle去督促Tomcat等待而且很容易出边界问题。我的建议很简单如果项目还在SpringBoot 2.2或更早升级到2.3再谈优雅停机省力且可靠。2.2 两个最容易混淆的配置项server.shutdown和spring.lifecycle.timeout-per-shutdown-phase经常有人只配其中一个。只配server.shutdowngraceful不配timeout会使用默认30秒超时。绝大多数据场景够用了。只配spring.lifecycle.timeout-per-shutdown-phase不配server.shutdowngraceful相当于给每个phase设置了超时时间但Web服务器的停机模式还是默认的immediate该掐断还是掐断并不会变得优雅。所以两个配置是配合关系server.shutdowngraceful开启Web容器等待请求完成。spring.lifecycle.timeout-per-shutdown-phase控制整个停机阶段的总预算超过预算就会强杀。2.3 旧项目没有优雅停机配置怎么办如果你的项目锁定在SpringBoot 2.2以前不能升级可以试试自定义WebServerFactoryCustomizer在Tomcat停机时手动设置ProtocolHandler。但我不建议在生产环境做这种“逆向补丁”因为Tomcat不同版本内部API差异大十分脆弱。相比之下升级SpringBoot带来的收益远超改这几十行代码的成本。3. 优雅停机底层链路SIGTERM进来以后发生了什么3.1 信号 → JVM ShutdownHook → Spring容器关闭先看一下信号是怎么流动的。在Linux环境下kill pid默认发送SIGTERM编号15。JVM收到SIGTERM后会启动ShutdownHook线程Spring容器注册的钩子随之被触发最终调用ApplicationContext的close()方法。在这个关闭动作里Spring的LifecycleProcessor会按顺序停止所有注册到容器中的Lifecycle组件。而SpringBoot在启动Web服务器时注册了一个特殊的SmartLifecycle组件专门负责优雅停机。它做的事情按顺序大致是标记Web服务器进入“停止接收新请求”状态。等待当前正在处理的活动请求全部完成或者等待超时。活动请求清零后继续发起后真正的容器销毁执行PreDestroy、销毁连接池、关闭线程池等。这个顺序很关键——先等请求跑完再销毁业务用到的Bean和资源。所以Controller里正在调用的Service、Mapper不会在请求执行到一半时突然消失。3.2 Tomcat的等待逻辑活动请求计数与超时Tomcat内部的实现是通过ProtocolHandler跟踪工作线程处理请求的数量。开启graceful后连接器将不再接受新请求同时对存量请求做归零等待。这里有个非常值得注意的点Tomcat等待的是“请求处理完毕”而不是“空闲连接关闭”。如果客户端开启了Keep-Alive连接还挂着但没有新请求这不算活动请求不会阻塞停机。如果正在处理一个下载大文件的请求那必须等这个文件流写完或者等超时时间到达。实际日志里会出现类似o.s.b.w.embedded.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete随后所有请求都结束后输出o.s.b.w.embedded.tomcat.GracefulShutdown : Shutdown complete如果超时了应用会放弃等待强制关闭。3.3 Netty与WebFlux的差异如果你用的是SpringBoot WebFlux而不是SpringMVC底层Web服务器是Netty优雅停机的实现同样走server.shutdowngraceful行为上也是停止接受新请求、等待存量请求完成。但因为WebFlux是响应式模型请求处理不像Servlet那样占用一个工作线程而是基于事件循环。实际关闭时Netty的处理更快等待时间通常更短。但注意WebFlux下如果用了阻塞调用、WebClient、流式响应停机的等待语义会更复杂最好也留足timeout配置。3.4 PreDestroy和SmartLifecycle的先后顺序很多人误以为PreDestroy方法在收到信号后立刻执行。其实在优雅停机配置下PreDestroy是在Web服务器等活动请求处理完成后才触发的。这决定了你不能在PreDestroy里做“强制等待HTTP请求结束”这类操作——因为那个时候请求已经处理完了。反而应该在PreDestroy里做业务资源清理比如关闭数据库连接池、反注册中间件、上报停机指标。如果你有自定义组件需要在停机时执行特殊逻辑可以实现SmartLifecycle。它的getPhase()返回值决定了停止顺序。SpringBoot内置的优雅停机组件phase是Integer.MAX_VALUE最早执行。你自定义组件如果希望靠后执行可以把phase设成Integer.MAX_VALUE - 1这样会排在Web服务器优雅停机之后容器销毁之前。4. 动手验证优雅停机是否生效用日志和curl说话配置写完别急着上生产先在测试环境做一次带流量的停机演练。我的验证方法非常朴素下一个慢接口然后给进程发SIGTERM看请求能不能正常返回。4.1 准备一个模拟慢请求接口新建一个Controller故意让请求睡上20秒RestController public class ShutdownTestController { GetMapping(/slow) public String slow() throws InterruptedException { Thread.sleep(20000); return done; } }正常启动应用后先把请求发出去curl --max-time 60 http://localhost:8080/slow请求会在第20秒返回。在请求执行后的第3秒左右另开一个终端杀死进程kill -TERM $(pgrep -f your-app.jar)注意用-TERM也就是SIGTERM。如果用-9任何优雅停机都不会生效。4.2 看日志里有没有特征输出观察应用日志。如果配置正确你应该能在进程退出前看到Commencing graceful shutdown. Waiting for active requests to complete然后等slow接口的20秒sleep结束后再看到类似于Shutdown complete同时curl命令正常返回done退出码为0。这个结果就说明进程在收到停机信号后没有立刻把连接切断而是等业务代码跑完了。4.3 对比未开启优雅停机的结果把server.shutdown改回immediate重复上面的步骤。你会看到应用日志里没有Commencing graceful shutdown。curl大概率在kill瞬间报错curl: (52) Empty reply from server或者Connection reset by peer。进程秒退。两种模式的差异非常直观演示给团队看的时候这种对比最容易说服人。4.4 验证时容易忽略的细节如果你用IDE启动应用然后点IDE的Stop按钮行为可能和直接发SIGTERM不同。有的IDE在Stop时会直接强制终止JVM不一定走ShutdownHook。验证时请用命令行启动或者用kill -TERM。如果应用注册了多个服务端口比如management端口、gRPC端口要注意优雅停机只覆盖SpringBoot WebServer管理的端口其他端口需要单独处理。压测工具发压时建议用单连接并发来模拟“存量请求”避免压测机本身的重试掩盖掉停机表现。5. 生产环境落地K8s、线程池与消息消费者5.1 K8s滚动发布必须处理的preStop与终止周期在传统VM部署时用systemd发SIGTERM就够了。但K8s环境下Pod删除不是一个瞬间动作而是有完整时间线Pod进入Terminating状态。Endpoint Controller把Pod从Service的Endpoint列表中移除但网络规则/负载均衡规则生效有延迟。Kubelet执行preStop Hook如果配置了。Kubelet向容器主进程发送SIGTERM。Kubelet等待terminationGracePeriodSeconds默认30秒超时后发送SIGKILL。所以生产环境要算好一道数学题preStop sleep时间 应用优雅停机等待时间 terminationGracePeriodSeconds。最常见的做法是给Pod加一个preStop sleep比如spec: terminationGracePeriodSeconds: 60 containers: - name: app lifecycle: preStop: exec: command: [sh, -c, sleep 10]这个sleep的作用是给K8s的网络规则一点时间把节点摘掉让新请求不要再打过来。但千万别把sleep设置太长——如果sleep了30秒应用只剩30秒做优雅停机一旦接口耗时超过30秒就会被Kill掉。我一般这么规划terminationGracePeriodSeconds设为60秒。preStop sleep设置为10秒。spring.lifecycle.timeout-per-shutdown-phase设置为30秒到45秒。预留10秒左右给JVM安全退出。这样即使有长请求也有足够的预算走完优雅停机。还有一个容易被忽略的坑Dockerfile启动命令别用shell包装。# 不推荐 ENTRYPOINT [sh, -c, java -jar app.jar] # 推荐 ENTRYPOINT [java, -jar, app.jar]原因很简单docker stop只会向容器PID 1发送SIGTERM。如果PID 1是shellshell不一定把信号转发给Java进程。到时候你从K8s里看到的是Pod已经Terminating但Java进程迟迟不退。用java -jar直接作为ENTRYPOINT保证Java进程就是PID 1才能正常收到SIGTERM。5.2 业务线程池shutdown()的真正语义HTTP请求处理完不代表业务异步任务都结束了。很多服务里都有自己创建的ExecutorService如果不在停机时妥善处理进程一样会把这些任务掐断。只调用executor.shutdown()语义是“不再接受新任务已经在队列中的任务继续执行”。但问题是它不会等待任务完成就返回。如果你想等必须手动awaitTerminationexecutor.shutdown(); try { if (!executor.awaitTermination(20, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }把这段逻辑放进SmartLifecycle注意设置好phaseComponent public class AppTaskExecutorShutdown implements SmartLifecycle { private final ExecutorService executor Executors.newFixedThreadPool(8); private volatile boolean running; Override public void start() { running true; } Override public void stop() { executor.shutdown(); try { if (!executor.awaitTermination(20, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } running false; } Override public boolean isRunning() { return running; } Override public int getPhase() { return Integer.MAX_VALUE - 1; } }这里把phase设为Integer.MAX_VALUE - 1让它排在Web服务器优雅停机之后执行。因为HTTP请求可能正在往线程池里提交任务如果线程池先关了请求执行中又拿不到线程反而制造新问题。如果你用的是Spring的Async也推荐在定义ThreadPoolTaskExecutor时开启等待Bean(taskExecutor) public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix(async-); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; }setWaitForTasksToCompleteOnShutdown(true)配合setAwaitTerminationSeconds(30)相当于给线程池开启了它自己的“优雅停机”。5.3 定时任务与消息消费者要自己负责存量任务Spring的Scheduled定时任务默认在容器关闭时不会等你正在执行的任务跑完。如果你有定时扫描类的任务最好在任务方法里做好标记或者确保任务幂等。KafkaListener这类消息监听容器在Spring Boot管理下通常实现了SmartLifecycle停机时会取消订阅并等待处理中的消息完成但还是要确认你的ack模式。如果是手动ack一定要在业务处理成功后再确认否则进程强杀后消息会重新投递重试风暴也够喝一壶。6. 排坑记录配置了优雅停机却没生效的排查链路6.1 场景复现有次同事反馈“我明明配了server.shutdowngraceful为什么 kill 之后请求还是断了”我登录到测试环境翻出他的配置看着也没毛病。于是给他了一套排查方法这里完整还原一下。6.2 第一步看日志里有没有“Commencing graceful shutdown”所有SpringBoot优雅停机只要走的是内置逻辑一定会有特征日志。如果没有说明连触发链路都没进去。我用一段简单的命令现场实测kill -TERM pid然后立刻看日志尾部。同事那边日志里确实没有Commencing graceful shutdown倒是Spring容器的关闭日志一路往下走进程直接没了。这说明要么配置没生效要么信号没传到Java进程。6.3 第二步确认配置是否被外部覆盖SpringBoot配置是有优先级顺序的。有时候application.yml里写了server.shutdowngraceful但启动参数里有--server.shutdownimmediate或者环境变量、配置中心把值覆盖了最终生效的还是immediate。排查命令curl -s http://localhost:8080/actuator/env | grep -A 2 server.shutdown或者直接用Spring Boot Actuator的configprops端点查看最终值。如果不方便开Actuator可以在启动类里临时加一行日志System.out.println(environment.getProperty(server.shutdown));打印出来看一眼最直接。6.4 第三步检查是不是kill -9、shell包装、IDE停止这一步最容易被忽略。同事当时是在IDEA里直接点红色Stop按钮这压根不是SIGTERM而是IDEA自带的强制终止逻辑完全绕过了ShutdownHook。所以验证优雅停机务必用命令行启动 kill -TERM。还有一种情况启动命令如果是sh -c java -jar app.jar信号发给了shell而不是Java进程同样不会触发优雅停机。用ps -ef看清楚进程树确认PID 1就是Java。6.5 附加actuator/shutdown端点与优雅停机无关很多人会把SpringBoot Actuator的shutdown端点和优雅停机搞混。management: endpoint: shutdown: enabled: true这个端点暴露的是HTTP关闭入口默认通过JMX如果通过HTTP调用底层走的是ApplicationContext.close()。它本身不决定关闭是否优雅。只有配合了server.shutdowngraceful从该入口触发关闭才会走优雅停机流程。而且生产环境我强烈不建议开启HTTP shutdown端点。它是没有鉴权的软件攻击面任何人只要访问到路径就能把服务停了。你要优雅停机靠K8s发SIGTERM就够没必要再开一个HTTP入口。6.6 一个隐蔽坑非Web应用配置优雅停机不生效如果项目不是Web项目比如纯后台任务、非Web的MQ消费者server.shutdowngraceful对它是没有意义的因为优雅停机机制依赖内嵌Web服务器。对这类应用你需要自己通过SmartLifecycle管理好线程池、连接池的关闭顺序而不是指望一个Web配置解决所有问题。7. 个人经验补充优雅停机是一个系统配合问题最后再分享几点我自己的体会。优雅停机不是SpringBoot一个配置就能解决所有问题它是“信号 → 容器 → 线程池 → 中间件 → 基础设施”整套链路的配合。你把SpringBoot配置好了不等于线程池里的任务、消息队列的消费、K8s的preStop都完美了。每次发布前最好把发布流程的每个环节都盘一遍。我第一次完整配置优雅停机后在测试环境演练时发现虽然HTTP请求能正常返回了但ESB企业服务总线那侧还有个异步回调线程池进程退出后被强杀丢了几个回调消息。这个场景才能让你意识到停机演练不能只跑一个慢接口要尽量覆盖业务的真实异步链路。另一个心得是设置超时时间要参考接口的P99耗时而不是凭感觉。如果线上接口最长有40秒的导出任务spring.lifecycle.timeout-per-shutdown-phase至少要设到50秒同时配合K8s的terminationGracePeriodSeconds统一计算。宁可让发布多等几秒也不要让用户在发版窗口看到超时。如果想把停机过程看得更细可以临时加一行日志配置logging: level: org.springframework.boot.web.embedded.tomcat: DEBUG org.apache.catalina: DEBUG能清晰看到Tomcat在等待哪些请求、是否触发了超时强制关闭。上线稳定后记得去掉不然日志量太大。我个人的习惯是把优雅停机作为服务上线前的“标准检查项”固定下来和健康检查、监控告警一样重要。每次改动发布流程先在测试环境做一轮带流量的停机演练再上生产。这套流程跑顺以后发布窗口再也没有出现过“发版即超时”的用户投诉。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门