ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot零停机更新实战:滚动摘流与优雅停机

Spring Boot零停机更新实战:滚动摘流与优雅停机 发版改到凌晨两点不是因为我们爱加班而是每次SpringBoot应用一重启几十秒的停机窗口都会让线上请求断崖式下跌。零停机更新这个词听起来像大厂专属实操下来其实单体SpringBoot项目也完全能做到核心就一句话把“停服更新”换成“滚动摘流 优雅停机”。这篇内容我会从方案选型、SpringBoot配置、Nginx流量切换、K8s容器环境配合到代码层优化把我在生产环境里验证过的整套思路和踩坑记录都写出来。适合被发版窗口折腾过的后端同学也适合刚接触SpringBoot、想提前了解生产发布机制的新手。1. 传统SpringBoot发布方式到底痛在哪1.1 每次重启都是一次线上事故很多团队发布SpringBoot应用的流程还是这样把新Jar包传到服务器kill掉旧进程等端口释放再启动新进程。整个过程看起来没什么问题但线上环境里会暴露出一堆连锁反应。先说说最直接的痛点kill之后到新进程启动完成之前应用端口是无人监听的。这个时间窗口里所有经过Nginx或网关进来的请求都会直接报错轻则502重则连接超时。单体SpringBoot应用如果启动要20秒、30秒那这个Gap就是20秒、30秒。流量稍微大一点全站监控告警能刷屏用户反馈“页面打不开”就会准时出现在客服群。还有更隐蔽的问题kill -9强杀进程等于直接切断所有正在处理的HTTP请求和数据库事务。用户可能正在提交一个订单请求已经在Controller里执行到一半这时候进程没了数据库事务要么回滚要么因为连接池里的连接被物理断开而留下一个异常状态。虽然加事务能保证一致性但前端拿不到响应用户只能刷新重试体验非常差。所以很多团队把发版时间定到半夜不是因为发布本身需要那么久而是为了避开流量高峰给自己留出“出错后回滚”的缓冲时间。这种做法本质上是用业务损失换安全窗口但遇到紧急修复、线上事故要立刻上线时根本等不到半夜。1.2 “零停机”的本质是管理流量而非管理进程想明白这件事我花了很长时间零停机更新不是让你把更新过程做得“不可见”而是让更新过程中随时有健康的实例在承接流量。整个过程可以拆成两个动作先把流量从旧实例上摘掉再重启旧实例。只要摘流量和重启之间存在时间差用户请求就会全部落在新实例或者尚未摘流的其他旧实例上用户自然感知不到更新发生。这个思路落地到SpringBoot单体项目里就是一套很标准的组合操作准备新版本Jar包在备用端口先启动新实例确认新实例健康后再把它加入负载均衡通过负载均衡把旧实例的流量摘掉等旧实例无流量后关闭进程。这套流程里SpringBoot本身能做的主要是优雅停机和健康检查两个部分而流量切换则需要Nginx、注册中心或者K8s配合。接下来我把每一步的原理和实操都拆开讲。2. 方案选型滚动发布、蓝绿发布还是灰度发布2.1 三种主流方案的对比与适用场景聊零停机更新绕不开术语。先把三种方案摆在一起对比大家心里有个数。方案原理优缺点适合场景滚动发布多个实例逐个更新始终保持有实例在服务成本低、操作简单但发布期间新旧版本共存单体SpringBoot集群最常用蓝绿发布保留老环境新环境整套部署通过负载均衡一键切换回滚极快但需要双倍资源资源充足、对稳定性要求极高的核心应用灰度发布只让部分流量进入新版本观察无异常后再全量风险可控但需要流量路由与权重控制能力大版本升级、接口不兼容的更新上面三种方案不是互斥的。灰度发布可以理解为滚动发布的精细化版本蓝绿发布也完全可以和滚动发布组合使用。对于大多数团队我建议先从滚动发布入手因为它的基础设施要求最低只要有一个Nginx和两台服务器就能跑起来。2.2 单体SpringBoot项目最容易落地的路径我见过很多团队一上来就上K8s还没搞明白Pod调度就被各种概念绕晕。其实单体SpringBoot做零停机更新最顺滑的路径是“Nginx轮询 SpringBoot优雅停机 健康检查接口”。Nginx做反向代理时upstream后面可以挂多个后端节点。发布时先把某个节点从upstream里暂时摘掉Nginx reload后流量就不再打到这个节点上这个节点处理完手头请求就可以安全退出。整个过程不涉及蓝绿环境切换也不需要额外引入注册中心成本几乎为零。如果你的项目已经上了K8s那路径会更简单滚动更新本身就是K8s的内置能力。但即便在K8s里SpringBoot侧的优雅停机配置依然不可或缺否则POD被删除那一刻正在处理的请求照样会被掐断。所以后面讲SpringBoot优雅停机是两种路径都要做的必修课。3. SpringBoot侧核心配置优雅停机3.1 优雅停机的原理与参数解析SpringBoot应用在收到停机信号时默认行为是立即关闭所有线程不管请求处理到哪一步。Spring Boot从2.3.0版本开始支持优雅停机核心配置只有两行server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s第一行告诉SpringBoot收到停机信号后先不要再接收新请求让正在处理的请求继续执行完成再关停。第二行是兜底熔断每个停机阶段最多等30秒超时后强制关闭避免线程池里有慢请求卡住导致进程永远退不掉。配置这个参数后SpringBoot处理停机信号的完整流程大概是这样的收到SIGTERM信号Spring容器开始进入关闭流程Web服务器Tomcat内嵌停止接收新连接等待正在处理的请求完成其他Spring Bean按依赖顺序执行销毁前逻辑所有阶段完成或超时后进程退出。这里要注意Tomcat内部还有一个连接池。即使配置了优雅停机如果有请求长期挂起比如某个接口在等一个永远不返回的第三方调用那么连接池里的线程会被一直占用直到timeout-per-shutdown-phase超时。所以这个30秒不是拍脑袋定的要根据你线上最慢接口的耗时间来评估。如果最慢接口耗时是25秒那超时时间至少要30秒以上否则请求会在停机过程中被切断。3.2 版本差异SpringBoot 2.3以下和SpringBoot 3.x怎么处理如果你的项目还在SpringBoot 2.2或者更早版本server.shutdown这个配置项是不存在的。老项目只能靠自定义停机钩子常见做法是注册一个ApplicationListener监听ContextClosedEvent在容器关闭前做清理Component public class GracefulShutdownListener implements ApplicationListenerContextClosedEvent { Override public void onApplicationEvent(ContextClosedEvent event) { // 这里做自定义清理比如通知注册中心下线、停止定时任务 System.out.println(容器开始关闭执行预热清理); } }但老版本这么做问题很多ContextClosedEvent触发时Tomcat的请求线程还在处理请求你无法控制“等请求完再断开”的时机经常出现监听器跑了但连接还是被强制断开的情况。所以老项目如果要做零停机更新我强烈建议优先升级到SpringBoot 2.3及以上哪怕只是升一个小版本也比自己造轮子省心。至于SpringBoot 3.x优雅停机的配置方式和2.3-2.7完全一样只是底层Servlet API变成了Jakarta命名空间不过server.shutdown配置不受影响。网上很多说“SpringBoot版本太高配了不生效”的帖子大多数情况是配置位置写错或者容器配置了SIGKILL信号导致优雅停机根本没机会执行我会在后面的问题排查章节展开。3.3 容器环境下K8s中的preStop与探针配合如果你在用DockerK8s部署光在application.yml里配置优雅停机还不够。K8s删除Pod时会向容器发送SIGTERM信号但K8s默认等待时间由terminationGracePeriodSeconds控制默认是30秒。如果你的优雅停机超时设置是60秒那K8s会在30秒后直接发SIGKILL把Pod杀掉优雅停机变成了强制停机。K8s环境的标准配置需要做两件事第一放大Pod的终止宽限期spec: terminationGracePeriodSeconds: 60第二在Pod生命周期里加preStop钩子让Pod在收到SIGTERM之前先做一次“流量摘除等待”。最简单的方式就是sleep几秒给K8s Service和Endpoint控制器足够时间把该Pod从负载均衡池里摘掉spec: containers: - name: app lifecycle: preStop: exec: command: [sh, -c, sleep 10]preStop的sleep很关键因为K8s向Pod发SIGTERM信号是即时的但Endpoints信息同步到各节点的kube-proxy和云负载均衡器需要时间。如果Pod关得太快流量还会往这个Pod上打请求就直接失败了。我还建议在Deployment里配置好readinessProbe健康检查接口指向SpringBoot的Actuator端点spec: containers: - name: app readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5这样K8s在滚动更新时会先等新Pod readiness探针通过再继续推进不会出现新Pod还没起来就切流量的问题。4. 基于Nginx的零停机更新实操流程4.1 场景假设与前置准备下面给一个可直接照抄的实操场景。假设你有两台服务器A和BNginx安装在其中一台或者独立部署在nginx服务器上upstream配置指向A和B两个节点。应用是标准SpringBoot单体两个节点各跑一份通过Nginx做负载均衡。发布前要准备的东西列一下新版本Jar包已经本地测试通过SSH终端能登录A、B和Nginx服务器线上数据库兼容新版本不需要执行破坏性变更确定发布时间窗口即便零停机也需要给自己留出操作时间。4.2 分步操作从启动新实例到摘除旧实例第一步先登录A服务器把新版本Jar包传到指定目录但不要覆盖旧Jar包而是用新文件名比如app-v2.jar。第二步在A服务器上启动新版本实例端口换成一个临时端口比如8081java -jar /data/app/app-v2.jar --server.port8081先启动到临时端口而不是直接替换8080是为了避免新旧进程抢端口。等A上的新实例启动完成后用curl验证健康curl http://127.0.0.1:8081/actuator/health看到返回up就说明新实例本身没问题。这时候A服务器上有两个SpringBoot进程在跑8080旧版本、8081新版本。第三步修改Nginx配置把A节点从upstream里摘掉。假设原配置是这样upstream backend { server 192.168.1.10:8080; server 192.168.1.11:8080; }先把A节点的状态改成down并且把B节点的权重保持正常upstream backend { server 192.168.1.10:8080 down; server 192.168.1.11:8080; }保存配置后执行nginx -s reload。注意Nginx reload不会中断现有连接正在处理中的请求不会断开只是新的请求不再往A节点分发。第四步观察A节点旧版本8080进程的访问日志确认流量已经归零。可以用tail持续观察tail -f /data/app/log/app.log | grep 8080如果等了2-3分钟日志不再增长说明Nginx已经把流量完全切到B节点和A节点的新版本8081端口。这里还没结束还需要把A节点的8081新实例加入upstream。修改Nginx配置upstream backend { server 192.168.1.10:8081; server 192.168.1.11:8080; }再次reload。这时候A节点的新版本正式接流量。第五步在A服务器上停止旧的8080进程。用kill发SIGTERM而不是kill -9kill $(lsof -t -i:8080)配合SpringBoot优雅停机配置旧进程会等待已接收的请求处理完成再退出。第六步重复以上步骤处理B服务器。等两台服务器都切到新版本后把Nginx配置里的8080端口全部替换成对应节点的新版本端口清理临时文件发布完成。4.3 几个关键细节上面这套流程有很多细节没处理好的话零停机也会翻车。端口切换上我习惯一个节点一个节点推进不要同时操作两台服务器。如果新版本有隐藏Bug单节点切换后可以先观察错误日志发现问题就再把流量切回旧节点至少还有一台旧的兜底。Nginx的upstream里如果用keepalive指令维护长连接摘除节点后已经建立的长连接不会立即失效。Nginx会尽量复用旧连接池所以你会发现down掉节点后有一段时间日志还在增长这是正常现象等连接池自然老化即可不用急着kill进程。另外新实例配置的健康检查接口要和Nginx的proxy_next_upstream区分开。不建议直接在Nginx里频繁探测health接口健康检查可以交给监控系统或K8s探针来做Nginx只负责转发否则Nginx日志会被探测请求刷爆。下面我把这一步的流程浓缩成表格方便做操作记录阶段操作关键验证点启动新实例A服务器8081端口启动新版Jar健康检查接口返回up摘除旧节点Nginx配置A节点8080状态downA节点旧版本日志停止增长接入新节点Nginx配置A节点8081状态正常curl测试A节点新版本接口正常关闭旧进程kill旧8080进程容器优雅停机日志输出进程退出完成切换重复上述步骤处理B服务器两台服务器均运行新版本5. 代码层面的进阶优化5.1 预下线逻辑让健康检查先“报假”K8s里preStop的sleep只是权宜之计更精准的做法是让应用在收到停机信号前主动向注册中心或负载均衡器报告“我不健康了”。SpringBoot结合Actuator可以在准备停机时把health状态改为DOWN从而让健康检查失败触发负载均衡器把节点摘掉。实现方式不复杂核心是注册一个自定义HealthIndicator用一个内存标记控制状态Component public class CustomHealthIndicator implements HealthIndicator { private volatile boolean ready true; public void setReady(boolean ready) { this.ready ready; } Override public Health health() { if (!ready) { return Health.down().withDetail(reason, prepare shutdown).build(); } return Health.up().build(); } }然后在停机前通过Spring的事件机制或者自定义Endpoint把ready标记设为false。这样即使Nginx不做主动摘流健康检查也会告诉下游“这个节点不行了”流量会被快速摘除。不过这套做法需要你的负载均衡器或注册中心真的会主动去探测health接口并把不健康节点踢出流量池。如果只是用Nginx做静态upstreamhealth接口变化并不会影响Nginx的转发行为还需要配合Nginx的主动健康检查模块或者直接把节点down掉这点要区分清楚。5.2 延迟注册解决“新实例刚启动就被打流量”的问题SpringBoot实例刚启动时内嵌Tomcat端口已经监听但Spring容器还在初始化很多Bean还没准备好。如果此时注册中心已经把实例注册进去流量就会打到这个半成品实例上轻则接口报错重则因为无法获取数据库连接池而雪崩。常见的解决方案是延迟注册。在注册中心比如Nacos或Eureka侧把新实例的注册延迟到应用完全就绪之后。SpringCloud的注册逻辑通常依赖健康状态可以让注册中心等待健康检查通过后再注册spring: cloud: service-registry: auto-registration: enabled: true更简单的做法是在应用启动类里做一次主动等待等本地健康检查通过后再把注册开关打开。例如通过一个ApplicationRunner延迟执行注册逻辑或者配置注册中心的元数据加入“预热时间”。K8s场景下readinessProbe天然支持这个逻辑Pod启动后探针会周期性探测SpringBoot的readiness端点连续成功后才把Pod标记为Ready加入Service的Endpoints。所以如果你已经在K8s里用了探针延迟注册其实是自动完成的。5.3 定时任务、消息队列与事务的停机边界优雅停机只保证HTTP请求能处理完但SpringBoot进程里还有其他线程尤其是Scheduled定时任务和消息消费者的线程。这些线程如果不做处理停机时可能出现两种问题定时任务重复执行多个实例同时在线时如果定时任务每台机器都跑发布期间会让任务多执行一轮导致重复发短信、重复跑批处理消息丢失或重复消费停机瞬间正好有消息在消费进程退了消息既没消费成功也没ack就会触发重新投递但新实例可能重复处理。对定时任务最理想的方案是引入分布式锁把“只有一台机器能跑任务”作为前提。没有分布式锁的话退而求其次的做法是停机前先停掉任务调度器让正在执行的任务自然结束。利用SmartLifecycle可以控制调度器的停止时机Component public class SchedulerLifecycle implements SmartLifecycle { private volatile boolean running false; Override public void start() { running true; } Override public void stop() { running false; // 停止调度器逻辑 } Override public boolean isRunning() { return running; } }SmartLifecycle会在Spring容器关闭时按顺序触发stop通过配置phase可以控制它在Web服务器停止之前还是之后执行。建议把定时任务的停止时机放在Web请求处理完之后避免正在处理的HTTP请求依赖定时任务的数据刷新。消息消费这块停机前要尽量让消费者停止拉取新消息同时把正在处理的消息完整消费掉。RabbitMQ的prefetch机制可以限制未ack消息数量ShutdownHook里可以等待channel关闭Kafka消费组则依赖再均衡机制进程退出后分区会自动转移到其他消费者重复消费只能靠消费端幂等来兜底。6. 常见问题与排查技巧实录6.1 server.shutdowngraceful配了却不生效这个坑我反复见过。配置优雅停机后发SIGTERM信号进程还是秒退。排查思路按下面几步走先确认SpringBoot版本是不是2.3及以上低版本根本没有这个配置项配置了也会被忽略。再看配置命名空间server.shutdown是放在application.yml的server节点下不是spring节点下位置错了会导致配置不生效。最后看容器是不是把SIGTERM转成了SIGKILL比如Dockerfile里如果用了exec形式的CMD会正确传递信号如果是shell形式很可能会把信号吞掉。Dockerfile建议使用如下格式ENTRYPOINT [java, -jar, /app.jar]而不是ENTRYPOINT java -jar /app.jar这两种写法对信号处理的影响非常大前者Java进程是1号进程能收到SIGTERM后者shell是1号进程Java只是子进程信号传递链容易断开。6.2 旧节点流量已经归零但kill之后还是一堆错误日志如果你确认Nginx已经把旧节点down掉访问日志也不再增长但kill进程时依然有请求错误多半是长连接或下游超时引起的重试流量。有两种情况一种是Nginx和SpringBoot之间配置了keepalive长连接down掉节点后之前建立的连接还挂在连接池里旧节点能收到的是连接池中已建立的连接上的残留请求。另一种是上游服务重试机制比如其他服务调你的接口第一次请求超时后自动重试在这段时间内把请求打了过来。排查时可以看进程的网络连接状态用netstat或ss查看8080端口是否还有established连接抓包看这些连接是从哪个客户端IP过来的。如果是Nginx IP就让Nginx连接池自然老化如果是应用服务需要考虑在下游调用方配置短超时或停止重试才能彻底规避。6.3 新版本启动正常但一到流量切换就出现大量超时这是典型的“实例就绪但业务未就绪”问题。SpringBoot的/actuator/health返回up只代表应用本身健康不代表依赖的资源都准备好了比如JPA的EntityManager可能还在懒加载、Redis连接池可能在第一次请求时才真正建立连接、本地缓存还没预热完。解决思路是在health里叠加业务就绪检查。定义一个BootstrapHealthIndicator启动时把必要资源初始化完成后才返回upComponent public class BootstrapHealthIndicator implements HealthIndicator { private final AtomicBoolean initialized new AtomicBoolean(false); public void markInitialized() { initialized.set(true); } Override public Health health() { return initialized.get() ? Health.up().build() : Health.down().build(); } }在ApplicationRunner里调用markInitialized这样Nginx或K8s探针只会在业务初始化完成后才切流量。这个方法很简单但对大型SpringBoot项目效果极其明显我建议每个项目都加上。6.4 常见问题速查表现象可能原因处理手段优雅停机配置不生效SpringBoot版本过低/配置位置错误升级到2.3检查server.shutdown位置进程被强制杀死K8s terminationGracePeriodSeconds不足调大终止宽限期与优雅停机超时对齐摘除节点后仍有流量Nginx keepalive连接池未老化等待或执行reload释放连接池新实例接口偶尔超时业务资源就绪慢health提前返回up自定义就绪检查延迟注册发版期间定时任务重复跑多实例同时执行任务引入分布式锁或控制停机顺序停机时消息被重复消费消费者未停止重复投递消费端幂等停机前停止消费者7. 个人实操体会与建议我这里写的一个小经验是零停机更新不要一上来就追求“全自动化”。我最早做这套方案时总想把Nginx配置、健康检查、发布脚本全部串成一条流水线结果第一版自动化脚本上线就出了问题某个节点健康检查还没通过就被自动接入了生产流量瞬间一批请求超时。后来我改成了“半自动化”模式所有重复性的准备动作用脚本完成但“流量切换”和“旧进程关闭”这两个高风险动作保留人工确认。确认的依据就是监控面板上的QPS曲线和日志实时输出。跑顺几十次之后我才逐步放开自动切换。还有一个技巧是发布前提前检查一下Nginx的错误日志。很多零停机方案翻车不是应用的问题而是Nginx负载均衡配置本身就有隐患比如某个节点挂了但upstream里还留着它导致部分请求持续打到一个已经离线的进程上。把基础环境清理干净后面再做的所有精细操作才有意义。最后分享一个我自己固定下来的发布前检查清单先看QPS基线再看错误率基线接着逐个节点执行“临时端口启动新版本 → 验证健康 → 摘流量 → 等日志归零 → 关旧进程”这套流程最后统一清理临时文件和旧Jar包并留好上一个版本的回滚包。按这个节奏做了几十次期间误操作过、超时过、长连接拖过后腿但整体上没有再因为发布让用户感受到明显的服务中断。对刚起步的团队我建议先照着本文第4章的Nginx流程手动跑通一遍再逐步叠加优雅停机配置、健康检查和延迟注册最后再谈自动化。零停机更新不是一个“开关”而是一套配合良好的工程习惯每加一块系统的韧性就多一分。
返回列表