ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot优雅停机配置实战:从原理到K8s集群部署的完整指南

SpringBoot优雅停机配置实战:从原理到K8s集群部署的完整指南 1. 优雅停机这个让多少团队半夜救火的机制先聊聊我为什么想写这个话题。去年我们线上服务有一次发布流量刚起来运维那边反馈说新版本上去了但业务方投诉“请求超时”“订单状态没更新”。排查到最后发现问题不在新代码而是旧服务进程被强杀的时候还在处理中的请求直接断了。那一瞬间我就意识到很多团队根本没把“停机”当成一个需要设计的环节服务停得相当粗暴。SpringBoot的优雅停机机制就是从“进程收到停止指令”到“进程真正退出”之间给应用留出一段缓冲时间让它把手里的事处理完再走。它不是一个新概念Tomcat、Jetty这些容器早就支持但SpringBoot 2.3版本开始把这件事标准化了——通过一个配置项就能开启也把整个停机流程纳入了Spring容器的生命周期管理。这个机制解决的核心问题有三类第一正在进行中的HTTP请求不被强行中断避免客户端拿到连接重置的错误第二容器内部的线程池、连接池、消息消费者能有序释放不留半开的事务或未确认的消息第三微服务架构下服务实例从注册中心摘除后还能继续处理存量流量一段时间不让整个链路因为一个实例的退出而抖动。这篇文章适合谁看如果你正在维护SpringBoot项目尤其是线上流量不小、发布频繁、用了微服务或消息队列那优雅停机几乎是你必须补上的一课。哪怕你是刚接触SpringBoot不久的新人我也会把底层原理和配置细节讲透你照着做就能在自己的项目里落地。2. 停机为什么需要“优雅”先看暴力停机发生了什么2.1 直接kill进程的那些连锁反应很多团队的发布脚本至今还是kill -9或者直接从管理平台点“强制停止”。这里的核心问题不在“杀”这个动作本身而在于被杀时应用还处于什么状态。想象一个场景用户正在提交一个支付请求请求已经进了Tomcat的工作线程正在执行数据库操作可能还调了下游的库存服务。这时候进程被kill -9干掉操作系统会直接回收进程的所有资源已经建立的TCP连接被断开数据库事务因为没有机会提交或回滚最终由数据库连接超时来兜底。用户那边看到的是“连接被重置”而系统内部可能已经产生了一笔未完成的支付单、一次扣减了库存但没生成订单的脏数据。更隐蔽的问题是消息队列。如果你的应用从MQ里拉取了一批消息正在处理进程被强杀后这批消息既没有ack也没有重回队列重启后可能出现消息丢失或重复消费的极端情况。这些问题不会立刻暴露而是在某个凌晨突然给你捅出个大篓子。2.2 优雅停机的本质把“突然死亡”变成“处理完再走”优雅停机的本质是给应用一段“临终关怀”时间收到停机信号后先停止接收新请求然后把正在处理的任务做完最后释放资源、退出进程。整个过程像一家餐厅打烊不再放新客人进来但已经坐在餐桌上的客人要等他们吃完再关门。从操作系统层面看SpringBoot应用默认收到的是SIGTERM信号比如kill命令不带参数时发送的信号这个信号本身就能被JVM捕获并触发关闭钩子Shutdown Hook。SpringBoot的优雅停机机制做的事就是在这个关闭钩子里编排出一套完整的停机序列——让请求处理完、让线程池停下来、让容器销毁而不是默认的立即退出。这里有个关键点kill -9发送的是SIGKILL信号这个信号无法被捕获JVM连关闭钩子都不会执行所以任何优雅停机机制都对kill -9无效。这意味着如果你开启了优雅停机你的发布脚本、容器编排平台、Kubernetes的探针配置都必须确保发送的是SIGTERM而不是SIGKILL。2.3 优雅停机能保证什么不能保证什么先说能保证的正在处理中的HTTP请求有机会执行完成新请求会被拒绝通常返回503或连接拒绝Spring容器会执行销毁逻辑包括PreDestroy注解方法和DisposableBean接口但有几个边界你必须清楚。首先优雅停机不等于无限等待超过宽容时间后SpringBoot会强制关闭那些超时的请求照样会被中断。其次它保证的是“请求有机会完成”但如果你自己的业务代码里有长事务、长轮询、死循环或者对下游的同步调用迟迟不返回那停一次机可能拖垮整个发布节奏。最后优雅停机不解决分布式事务问题——跨服务的调用链在某个实例停机时该超时还是会超时该重试还是会重试。想明白这些边界你在设计优雅停机方案时就不会盲目乐观也会明白为什么生产环境里还需要配合其他手段一起用。3. SpringBoot优雅停机机制的整体设计与核心参数3.1 从Spring Boot 2.3开始的标准方案SpringBoot从2.3.0版本开始支持优雅停机核心配置就两行server.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30s第一行告诉SpringBoot启动优雅停机模式第二行设定每个停机阶段的超时时间。这里有个细节spring.lifecycle.timeout-per-shutdown-phase控制的是Spring容器每个生命周期阶段的超时时间而不是总的停机超时时间。如果你有多个阶段每个阶段都有单独的计时总停机时间可能是它们的累加。在SpringBoot 2.3之前大家普遍是通过自定义TomcatConnectorCustomizer来实现类似效果代码写起来啰嗦而且各个团队的实现五花八门。SpringBoot把这个能力内置之后统一了实现方式也让云原生场景下的容器生命周期管理变得顺畅很多。从2.3到3.x这个机制一直在完善。Spring Boot 3.x里增加了一些新的停机行为细节但核心配置和设计思路保持一致。如果你的项目用的还是2.1、2.2这种老版本建议升级到2.3以上再考虑优雅停机否则就得走自定义路线。3.2 各内置容器对优雅停机的支持差异SpringBoot支持Tomcat、Jetty、Undertow、Netty等容器它们对优雅停机的支持程度并不完全相同这点很容易被忽略。Tomcat从8.5版本开始就有优雅停机的原生能力SpringBoot的server.shutdowngraceful对Tomcat的适配最成熟。Jetty同样支持但它的实现细节和Tomcat不太一样主要体现在对连接器停止时机的控制上。Undertow也能做到但存在一些历史版本的边界问题。至于Netty主要用于WebFlux和响应式场景它也有自己的优雅停机机制但在SpringBoot 2.3里对响应式Web应用的优雅停机支持相比Servlet应用要晚一些才完善。如果你用的是默认的Tomcat那恭喜你大多数坑已经被前人踩平了。如果用了Jetty或Undertow建议你在测试环境专门验证一下停机行为不要想当然地认为“配置一样表现也一样”。3.3 开启优雅停机后停机流程到底是怎么走的当进程收到SIGTERM信号后SpringBoot内部的停机流程大致是这样的第一步Spring容器开始关闭发布ContextClosedEvent事件。第二步内嵌的Web服务器停止接收新请求——Tomcat的Connector会被暂停操作系统层面不再接受新的连接。第三步等待已经接收的请求处理完成这个等待受spring.lifecycle.timeout-per-shutdown-phase控制。第四步销毁Spring容器中的Bean执行PreDestroy方法。第五步关闭内嵌的Web服务器和其他资源进程退出。这里最有意思的是第二步到第三步的衔接。Tomcat处理优雅停机时不是简单地“停止接收新请求”而是通过设置acceptCount、停止心跳线程、等待工作线程池中的任务执行完成这套组合拳来实现的。在等待期间新连接会被拒绝已经建立的连接还可以继续发送请求并得到响应——当然前提是这些请求在超时时间内完成。如果你在停机过程中观察应用日志会看到类似这样的输出2024-01-15 10:00:00.123 INFO 12345 --- [SpringContextShutdownHook] o.s.b.w.e.tomcat.TomcatWebServer : Graceful shutdown of Tomcat started on port(s): 8080 (http) with hint(s): 30000ms 2024-01-15 10:00:29.876 INFO 12345 --- [SpringContextShutdownHook] o.s.b.w.e.tomcat.TomcatWebServer : Graceful shutdown of Tomcat completed注意时间戳Tomcat优雅停机真正耗时可能很接近超时阈值这背后通常是请求队列里有积压任务在排队。3.4 理解graceful-timeout的边界与计算逻辑graceful-timeout这个参数经常被误解。很多人以为设了30秒任何请求最多等30秒。实际上Tomcat在优雅停机时并不是用一个全局定时器去卡所有请求的总时长而是允许每个正在执行的请求最多运行一段时间同时等待线程池中的任务自然完成转换。我个人的经验是这个超时时间需要结合你的接口响应时间P99来设置。如果你们的接口P99是1秒P999是5秒那设30秒完全够用。但如果系统里有一个异步导出功能单个任务可能跑几分钟那你就要考虑是不是应该在停机前对这类任务做特殊处理而不是单纯地拉长超时时间。还有个容易踩的坑spring.lifecycle.timeout-per-shutdown-phase不是SpringBoot对Web服务器超时的唯一控制项。Tomcat环境下server.tomcat.connection-timeout、server.tomcat.keep-alive-timeout这些参数也会影响连接的行为。如果这些超时设置过短可能出现你明明配置了30秒的优雅停机时间但某些慢请求在10秒就被Tomcat掐断的情况。4. 核心实操从配置到验证的完整落地过程4.1 一步开启优雅停机配置文件与启动参数我先给出一个最小可用的配置然后逐个解释它的含义。server.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30s server.port8080在application.properties或application.yml里加上前两行就够了。如果你用application.ymlserver: shutdown: graceful port: 8080 spring: lifecycle: timeout-per-shutdown-phase: 30s就这么简单。但注意这只开启了HTTP层面的优雅停机。如果你想在停机时执行一些自定义的业务逻辑比如通知注册中心下线、清理本地缓存、关闭数据库连接池那还需要事件监听。对于SpringBoot 2.3之前的版本没有这两个配置项你需要自定义。我曾经维护过一个老项目用的SpringBoot 2.1.8当时优雅停机是自己写了一个TomcatConnectorCustomizer在停机时把Connector的pause()方法调用一下再配合线程池的shutdown()和awaitTermination()来实现。代码不难但每次SpringBoot升级都要重新验证比较烦。4.2 事件监听在停机时安全地执行清理逻辑SpringBoot优雅停机过程中Spring容器会发出ContextClosedEvent事件。默认情况下这个事件是同步触发的监听器执行完才继续后续流程。你可以在监听器里做资源清理、状态上报等操作。一个典型的监听器长这样Component public class ShutdownEventListener implements ApplicationListenerContextClosedEvent { private static final Logger log LoggerFactory.getLogger(ShutdownEventListener.class); Override public void onApplicationEvent(ContextClosedEvent event) { log.info(收到停机事件开始清理资源...); // 通知注册中心下线 // 关闭自定义线程池 // 清理本地缓存 cleanUp(); log.info(资源清理完成); } }有几个细节你需要知道。ContextClosedEvent在优雅停机时是由SpringContextShutdownHook线程触发的和Web请求处理线程不是同一个所以监听器里尽量不要调用依赖当前线程上下文的方法。另外PreDestroy注解的方法也会在这个阶段执行执行顺序上Spring会先执行DisposableBean的destroy()方法再执行PreDestroy方法最后才发布ContextClosedEvent事件。这意味着如果你的清理逻辑必须在某个PreDestroy之后执行用ContextClosedEvent监听器是比较保险的选择。但反过来如果你希望在Bean销毁之前做一些状态通知那就应该用PreDestroy。4.3 线程池优雅停机最大的隐藏坑HTTP请求的优雅停机只是冰山一角真正的坑埋在线程池里。SpringBoot默认的TaskExecutor用于Async异步任务在停机时的表现取决于你用的是哪个实现。如果是ThreadPoolTaskExecutor在容器销毁时会调用shutdown()方法但shutdown()只是不再接受新任务已经在队列里的任务还是会被执行。问题是如果队列里积压的任务非常多超过了优雅停机的总超时时间那这些任务很可能还没执行完就被强杀了。这就是我要强调的优雅停机不等同于异步任务全部处理完成。你设置的30秒超时对HTTP请求线程池和异步任务线程池是共享的还是独立的取决于容器实现。Tomcat下HTTP请求由Tomcat自己的工作线程池处理异步任务由你自定义的线程池处理它们是两个池子各自有各自的停机逻辑。我踩过的一个真实案例一次发布时消息队列里突然涌入了大量待处理消息消费线程池在处理这些消息时停机信号到了。HTTP请求很快都处理完了但消费线程池里的任务还在跑因为队列里堆积的消息太多30秒根本不够。最后的结果是服务停了消息处理到一半下次启动后消息重复消费业务侧出现了重复数据。后来我的解决办法是在消费逻辑里做幂等控制同时把消费线程池的超时时间单独控制不依赖SpringBoot的默认停机流程。4.4 验证优雅停机是否生效的两种方法配置写完之后一定要验证不要想当然认为加了配置就生效。我推荐两种验证方式一种是直接的一种是间接的。直接的验证方法启动SpringBoot应用先用curl发起一个慢请求比如curl http://localhost:8080/slow-api这个接口故意sleep 10秒。在慢请求执行期间打开另一个终端找到进程PID执行kill pid注意不是kill -9。观察应用日志应该能看到Tomcat开始优雅停机的日志并且慢请求最终返回成功。等进程完全退出后看curl的输出不应该有“Connection reset by peer”这类错误。间接的验证方法是在代码里临时加一个停机事件监听器在监听器里输出日志确认ContextClosedEvent确实被触发了。如果是Kubernetes环境你还需要检查Pod的terminationGracePeriodSeconds设置。这个值定义的是K8s在发送SIGTERM之后等待多久才发送SIGKILL。如果它小于SpringBoot的优雅停机总耗时那你的应用根本来不及优雅关闭就会被强杀。我的建议是K8s的terminationGracePeriodSeconds要比spring.lifecycle.timeout-per-shutdown-phase大一些留出至少10秒的余量。5. 从单机到集群生产环境的优雅停机组合拳5.1 负载均衡器层面先把流量摘干净再停机如果你只在SpringBoot层面配置了优雅停机在集群环境下依然会遇到问题。原因是负载均衡器Nginx、F5或云上的SLB如果还在往这个即将停机的实例转发新请求那这些请求就会进入“停机中”的服务大概率失败。正确的顺序应该是先从负载均衡器中摘除该实例的健康检查状态或者从注册中心下线。等待一小段时间让负载均衡器把存量连接调度完。再给应用发送SIGTERM让应用优雅停机。这个过程叫“摘流量 优雅停机”。在Spring Cloud场景下实例下线通常通过Eureka、Nacos或Consul来实现。你可以在ContextClosedEvent监听器里主动调用注册中心的下线接口但更稳妥的方式是由编排平台先摘流量再通知应用停机。Kubernetes在这方面做得比较成熟Pod进入Terminating状态后Endpoints对象会立即把该Pod从Service的后端列表中移除新请求不会被调度到这个Pod。但这里有个时间差因为K8s每个节点的kube-proxy同步Endpoints变更需要几秒钟所以如果你的应用在收到SIGTERM后立即停止接收新请求那这几秒内到达的请求依然可能失败。为了解决这个问题很多团队会给SpringBoot应用加一个“预热/预停止”的延迟逻辑收到SIGTERM后先不急着关Connector而是等几秒钟让流量调度彻底完成再进入真正的优雅停机流程。SpringBoot本身没有提供这个延迟参数但你可以用ApplicationListener配合CountDownLatch来实现。5.2 信号量控制停机前压住入口流量在集群规模不大、发布频繁的场景下我有一个比较好用的技巧用信号量控制入口流量。思路是在网关层或者Controller入口处加一个可开关的Semaphore或AtomicBoolean。正常情况下allowRequest()返回true请求照常通过。收到停机信号后把开关置为false此时新请求直接返回503或一个自定义的错误码不等SpringBoot容器层来处理。这样做的好处是新请求被拒绝的时机更早、更可控你可以在拒绝逻辑里加上“当前实例正在停机请稍后重试”的提示甚至可以在响应头里带上一个Retry-After。有些团队还会结合负载均衡策略让客户端在收到503后主动去请求其他实例进一步降低失败率。这个方案的代价是你需要在业务入口写一点额外的代码而且要考虑开关状态在集群环境下的一致性。不过对于中小规模团队这比引入复杂的服务网格方案要简单得多。5.3 停机过程中的链路追踪与日志优雅停机的过程往往伴随着异常流量排查时没有链路追踪会相当痛苦。我的建议是在停机前主动开启更详细的日志级别。比如在ContextClosedEvent监听器里动态修改某个Logger的级别为DEBUG这样停机过程中的线程状态、请求处理情况都会输出到日志里。停机结束后再恢复。如果你用的是Spring Cloud Sleuth或Micrometer Tracing那在优雅停机时给每个请求都带上traceId通过日志聚合平台查看“哪些请求在停机过程中处理完成、哪些超时”会非常直观。这里还有一个细节停机时的日志要注意别把敏感信息打出来。有些团队在停机清理逻辑里记录HTTP请求的完整URL和请求参数这在生产环境有安全风险。最好只记录方法名、耗时、状态码这些元信息。5.4 数据库连接池与消息消费者的停机顺序连接池是另一个容易踩坑的点。HikariCP在SpringBoot中默认配置的maximumPoolSize是10在停机时如果业务线程还在执行SQL语句连接池此时被强制关闭那这些SQL会直接抛异常。HikariCP和SpringBoot的集成中连接池的关闭是在PreDestroy阶段。如果你在ContextClosedEvent里做了额外的数据库操作而这个事件在连接池销毁之后才触发那就可能拿到一个已经关闭的DataSource。所以我的建议是不要在ContextClosedEvent里做任何数据库操作除非你确认连接池还活着。消息消费者的情况更复杂。如果是RabbitMQ的RabbitListener默认情况下容器停止时会等待正在处理的消息完成然后才停止消费。但Kafka的消费者不太一样Kafka的Consumer在close()时可能不会等待所有消息处理完成需要你额外设置shutdown-timeout之类的参数。这也是为什么很多Kafka消费者在停机时容易丢消息——不是SpringBoot没做优雅停机而是Kafka消费者自身的生命周期和Spring容器生命周期没有完美对齐。我实测下来的经验是如果业务对消息丢失零容忍就不要依赖容器层面的优雅停机而是自己在消费逻辑里做两阶段处理先停止拉取新消息再等待处理中的消息完成最后手动提交offset。这个过程通过自定义ContainerStoppingErrorHandler或KafkaListenerEndpointRegistry来控制。6. 常见问题与排查技巧实录6.1 明明配置了优雅停机为什么请求还是被中断这是最常见的问题。排查时按这个顺序走确认SpringBoot版本是否在2.3及以上低于2.3版本配置了server.shutdowngraceful也不会生效。确认进程收到的是SIGTERM而不是SIGKILL。很多发布平台默认发的是SIGKILL或者先发SIGTERM等几秒再补SIGKILL如果平台配置的等待时间太短效果等同于强杀。确认Kubernetes的terminationGracePeriodSeconds是否足够。确认Tomcat的KeepAlive连接是否也被纳入了优雅停机的等待范围。有些版本对KeepAlive连接的处理不够彻底导致连接虽然不接收新请求但旧连接也没有按时关闭。一个隐藏点如果你在代码里自己创建了ExecutorService但忘了在停机时调用shutdown()那SpringBoot的优雅停机无法感知这个线程池是否存在未完成任务。进程退出后这个线程池里的线程会被强杀。解决办法是给这种自定义线程池设置daemontrue或是在PreDestroy里手动关闭。6.2 优雅停机过程中Spring Cloud服务调用方的重试如何配合在微服务场景下A服务调用B服务如果B正在优雅停机A可能拿到连接失败或超时错误。这时候需要A服务配置重试策略。使用OpenFeign时可以配置Retryer。但重试要小心如果B已经摘除流量A的重试请求还会继续打到B反而加剧问题。所以更推荐的方案是B在摘流量后、停机前主动返回一个明确的错误码比如503A根据这个错误码快速切换到其他可用实例而不是盲目重试。如果你用的是Spring Cloud LoadBalancer或Ribbon还可以配置实例下线感知。例如通过心跳机制主动把B标记为DOWNA的负载均衡策略就不会再选中B。这个过程如果和优雅停机配合得好其实可以做到用户无感发布。6.3 停机时出现“线程卡死”现象怎么办有些应用在优雅停机时进程迟迟不退日志卡在“Graceful shutdown of Tomcat started”之后没有“completed”。这通常是某个请求长时间不返回或者某个线程池的任务一直执行不完。排查步骤用jstack pid打出线程快照看看哪些线程处于RUNNABLE状态但长时间没变化。重点看http-nio-*线程、task-*线程、SpringContextShutdownHook线程。如果发现某个线程堵塞在IO操作上比如调用外部服务没有设置超时那问题不在SpringBoot优雅停机本身而在你的代码没有给外部调用设置超时时间。我的习惯是给所有外部RPC调用、HTTP调用、数据库查询都设置明确的超时时间绝不让一个请求无限期等待。这样优雅停机才能可靠地按时完成。否则无论你把timeout-per-shutdown-phase设得多大总会有那么一两个请求把你的停机时间拖垮。6.4 优雅停机对本地开发与测试的影响本地开发时你可能经常用IDE直接停止应用。IDEA默认发送的是SIGTERM所以如果你在本地配置了优雅停机每次停止应用都要等待超时时间过去非常拖沓。我的做法是在application-local.yml本地专用配置里把spring.lifecycle.timeout-per-shutdown-phase设为1s甚至直接用server.shutdownimmediate。这样本地开发体验不受影响线上依然保持优雅停机。更好的做法是把这套优雅停机的验证流程写进CI/CD流水线。比如在集成测试阶段专门跑一个“启动服务 → 发起慢请求 → 发送SIGTERM→ 断言请求成功返回”的用例。这个用例能有效防止未来的代码改动把优雅停机机制破坏掉。这里有一个推荐的小技巧在测试时可以用timeout命令模拟慢请求接口# 先启动应用再执行慢请求 curl -s -o /dev/null -w %{http_code} %{time_total}s http://localhost:8080/slow-api # 记录PID PID$(jps | grep 你的应用名 | awk {print $1}) # 等2秒后发送优雅停机信号 sleep 2 kill $PID # 观察curl输出请求应该成功返回返回码为200正常情况下即使slow-api需要10秒才返回由于优雅停机它也会正常返回。如果返回连接重置或1006之类的错误说明你的优雅停机配置或环境有问题。7. 我这些年用优雅停机攒下的几点体会最后说几个零碎但很实用的经验。第一个是优雅停机不是一把万能伞它只保证“正在处理的请求有机会完成”不保证“所有想做的事都能做完”。所以我倾向于把优雅停机定位为“让故障恢复得更平滑”的手段而不是“零丢失、零异常”的银弹。真正要做到发布无损必须从流量调度、幂等设计、消息机制这几个层面同时下手。第二个是不要只盯着SpringBoot这一个层级的配置。发布平台上可能配置了系统级超时K8s里配置了Pod级终止等待时间运维脚本里可能还有自定义的连接检查逻辑。这些时间参数之间是层层嵌套的关系任何一个环节设置得不对都会让优雅停机功亏一篑。每次调整优雅停机相关参数时最好把整条链路的超时时间全部过一遍。第三个体会来自一次夜间发布我们把优雅停机时间从30秒缩短到了15秒之后一批慢SQL请求被掐断了。后来才知道那天的慢SQL是某个接口临时加了一个全表扫描查询正常时根本不会出现。这个案例让我意识到优雅停机时长的设定不是越短越好也不是越长越好而是要结合你线上接口的真实表现来持续调整。如果你打算在自己的项目里落地优雅停机我建议从小处开始先在一个低流量服务上开启配置验证日志输出和请求表现再逐步推广到核心服务。这个过程不复杂但值得认真测一遍。这算是我在运维线上服务这些年对优雅停机最真实的感受了。
返回列表