K8s发布三板斧:滚动更新、优雅停机与回滚实战指南
凌晨两点,老张在群里发了一条消息:订单服务刚发完版本,接口大片超时,新Pod起来了但请求全挂在那一动不动,他想回滚却不知道点哪儿。我问他preStop钩子配了没,优雅停机验证过没,他反问我那是什么玩意儿。那一刻我意识到,很多团队的容器化发布其实还停留在“镜像能推到仓库、Pod能拉起来”的初级阶段。至于滚动更新、优雅停机、回滚这三板斧,属于标题刷过、文档看过、实战零经验的典型状态。
这篇文章就围绕滚动更新、优雅停机、回滚三个关键词展开。它们不是三个独立按钮,而是一条完整的发布生命周期管理链路。适合正在从“裸奔发布”走向“规范发布”的团队,也适合刚接手K8s集群、被线上故障教育过的朋友。我会把我实际部署、排障、回滚过程中总结的参数配置、踩坑案例、验证方法全部写出来,保证你看完能直接照着一套组合拳打下去。
1. 容器化发布,先想清楚要解决什么问题
1.1 一次发布引发的“半小时事故”
先复盘老张那次事故,典型得不能再典型。
他们的服务在K8s里跑,副本数就2个,Deployment用的是默认滚动更新策略,也就是maxSurge=25%、maxUnavailable=25%。问题就出在这套默认值上:2个副本乘以25%向上取整,新的Pod最多可以超额起1个,旧的Pod也允许同时挂掉1个。发布一触发,旧Pod先被杀掉一个,新Pod开始拉镜像、初始化。
看起来没问题对吧?两个致命细节同时炸了。
第一,应用完全没有处理SIGTERM信号。K8s发SIGTERM给容器主进程,应用不理会,等于白白等完30秒的默认宽限期,然后被SIGKILL强杀。正在处理中的请求全部断掉,数据库连接池、Redis连接全部瞬间失联,下游依赖一堆报错。第二,他们没有配置readinessProbe,新Pod一启动,容器还没准备好就被Service纳入负载均衡端点,流量打进一个还在初始化中的进程,直接超时。结果就是集群里旧Pod被杀、新Pod在拉镜像,中间出现一个真空窗口,所有请求朝剩余的那个旧Pod猛打,它扛不住,504一片。
这个事故里面,滚动更新机制在执行,但发布质量完全裸奔。问题不是“要不要用滚动更新”,而是怎么在滚动更新、优雅停机、失败回滚之间形成一套真正可用的组合策略。
1.2 不可变基础设施带来的新变量
以前发布是登录服务器、拉代码、改配置、重启进程,每台机器都可能因为历史遗留问题变成不一样的“雪人”。容器化之后,镜像是不可变单元,构建一次,跑哪都一样,环境漂移被彻底消灭。
但别高兴太早,容器化把老问题消灭的同时,也把发布问题从一个“部署动作”变成了“编排过程”。传统发布你只需要管一台机器的启停顺序,容器发布你要管的是一群Pod的调度节奏、新旧版本共存期、流量摘除时序、失败后如何恢复。这就像以前你骑自行车,刹车坏了顶多摔一跤;现在你开的是自动挡汽车,刹车系统由一堆传感器和ECU协同控制,任何一个信号没对齐,事故级别都不一样。
所以容器化发布的核心命题不是“怎么把镜像部署上去”,而是“在一组Pod不断变化的过程中,如何保证服务始终可用、流量始终平滑、故障随时可控”。这就是滚动更新、优雅停机、回滚三板斧存在的意义。
2. 滚动更新:默认策略不是万能的
2.1 先看懂Deployment的滚动更新模型
滚动更新的底层逻辑是Deployment控制ReplicaSet,ReplicaSet控制Pod。你每次改Deployment的镜像版本,K8s不会直接修改现有Pod,而是创建一个新的ReplicaSet,用新的ReplicaSet扩容,旧的ReplicaSet缩容,两个ReplicaSet的Pod在一段时间内共存,最终新版本完全替代旧版本。
这里有个反直觉的点:kubectl rollout undo和滚动更新表面上都在操作Deployment,但滚动的过程并不是“切开关”,而是“逐步换血”。每个阶段的Pod数量由两个关键参数决定:
maxUnavailable:滚动过程中允许有多少个Pod处于不可用状态,可以写百分比,也可以写整数。默认25%向上取整。maxSurge:滚动过程中允许有多少个额外Pod可以超出期望副本数,同样可以是百分比或整数。默认25%向上取整。
拿10个副本举例,默认参数下maxUnavailable=3、maxSurge=3,意味着滚动过程中最少要有7个Pod可用,最多可以同时存在13个Pod。maxSurge给了新版本“抢跑”的空间,maxUnavailable给了旧版本“退场”的余量。这套组合适合对瞬时容量要求不高、资源池宽裕的场景。
但如果你直接把默认参数用在核心交易链路,会发现发布窗口内服务能力是波动的。副本数本来就不多,再允许不可用Pod数量占四分之一,一个发布动作就可能让服务打对折。老张那个事故,本质就是默认参数在低副本场景下放大了发布风险。
2.2 maxUnavailable和maxSurge的调参艺术
我给团队定的推荐标准分三档,已经实测过几套环境,表现稳定。
| 服务类型 | maxUnavailable | maxSurge | 说明 |
|---|---|---|---|
| 核心交易链路、可用性极高 | 0 | 1 | 严格一个就绪再替换下一个,发布慢但最稳 |
| 普通无状态API服务 | 0 | 25% | 不允许旧实例先死,允许一定超额缓冲 |
| 资源紧张、集群余量少的服务 | 1 | 0 | 只能删一个补一个,注意maxSurge为0时必须有maxUnavailable余量 |
最稳妥的组合是maxUnavailable: 0配maxSurge: 1,配合minReadySeconds。它的逻辑很简单:新Pod必须确认就绪并稳定运行一段时间后,才允许杀掉一个旧Pod,每次只动一个,像流水线一样往后推。8个副本的发布耗时会长一些,但整个过程服务容量完全不缩水,适合订单、支付、登录这类对可用性极其敏感的服务。
参数怎么写?看这个实际用的yaml片段:
minReadySeconds: 10的意思是Pod变成Ready状态后,还要稳定运行10秒才被计入可用副本。这个参数专门用来拦截“刚就绪就崩”的假健康Pod,别小看这10秒,它能过滤掉不少启动式崩溃问题。
progressDeadlineSeconds: 600则是给整个滚动更新一个截止时间。如果600秒内Deployment还没完成发布,控制器会把Deployment标记为Progressing=False,并进入失败暂停状态。这个机制很重要,它让发布失败变成一个可以被系统识别和干预的事件,而不是永远卡在某个中间状态。
2.3 滚动更新最容易踩的三个坑
第一个坑:会话保持失效。
滚动更新期间,旧Pod在缩容,新Pod在扩容。如果你的服务把session存在本地内存里,用户刚在旧Pod上登录,下一秒请求被路由到新Pod,session直接丢失,表现为“莫名其妙的登录态消失”。解决思路不是去K8s层面硬凑,而是把session挪到Redis、数据库这类共享存储,让会话归属从“某个Pod”变成“整个服务”。
第二个坑:新旧版本共存期的兼容问题。
滚动更新的核心特征是新旧版本同时运行。如果新版本的接口改了协议,或者数据库表结构做了变更,老版本代码调用新接口、操作新表结构,极可能报错。我见过一个团队发布时改了用户表的字段长度,新版本写入没问题,但还没缩容完的旧Pod拿到的数据超长,直接反序列化失败。发布前必须先确认:接口是否向后兼容、数据库迁移是否平滑、消息队列的消息格式是否兼容。不能向后兼容的变更,就别硬走滚动更新,换蓝绿发布隔离新旧版本更合适。
第三个坑:资源风暴。
maxSurge越大,瞬时新增的Pod越多。线上集群剩余资源不多时,一个默认参数的发布就可能触发节点资源不足,Pod调度不上去,滚动更新直接卡在原地。我之前在一个资源余量只有10%的集群上发一个大服务,maxSurge配了50%,瞬间多出十几个Pod,节点磁盘IO被打满,连未发布的Pod也一起遭殃。后面把所有服务的maxSurge统一调小,再配合ResourceQuota限制单Namespace的资源池上限,资源风暴才算基本控制住。
3. 优雅停机:让每个容器都体面离场
3.1 K8s终止Pod的真实流程
滚动更新缩容旧Pod时,Kubelet并不是直接把容器“咔嚓”一下杀掉。完整的终止流程是这样的:
- 用户或控制器发起Pod删除请求,API Server为Pod打上删除标记,并记录一个宽限时间戳,默认宽限期30秒。
- Pod状态变成Terminating,同时如果Pod挂到了Service后面,控制面会把该Pod从Endpoint和EndpointSlice中摘除。
- Kubelet收到终止任务后,如果Pod配置了
preStop钩子,会在业务容器里执行这个钩子。 - 等到preStop执行完毕,或者宽限期快耗尽时,Kubelet向容器主进程发送SIGTERM信号。
- 进程收到SIGTERM后,有机会做清理工作:停止接收新请求、处理完存量请求、关闭连接池、退出循环。
- 如果宽限期结束后进程还没退出,Kubelet会发送SIGKILL强制杀掉容器。
理解这个流程,你就能回答一个关键问题:为什么很多服务明明配置了滚动更新,发布时还是一堆连接错误?因为进程根本不会主动响应SIGTERM。
3.2 preStop钩子与端点摘除的时序博弈
这是优雅停机里最容易被忽视的细节。
前面提到了,Pod进入Terminating时,服务端点会将其摘除。但摘除是“控制面逻辑”,真正把网络流量从Pod上切走,还要等各节点的负载均衡规则刷新,这个刷新在iptables和IPVS模式下需要时间,加上网络传播延迟,可能在几十毫秒到几秒之间波动。
如果在摘除动作还没完全生效时就给进程发SIGTERM,应用已经开始关闭监听,但链路层还在往这个Pod转发流量,结果就是一批请求被送到一个“正在优雅关闭”的进程上,连接拒绝或超时。
解决办法很朴素:在preStop钩子里先sleep一小段时间,给流量摘除留出缓冲窗口。不要用精确的规则刷新时间,用足够保守的固定值。
sleep 15这15秒不是拍脑袋定的。我们压测下来的数据是:在常见的云厂商K8s集群上,从Endpoint摘除到各节点规则全部刷新完成,通常在3-8秒内。取15秒留足余量,再用terminationGracePeriodSeconds: 60整体兜底。如果15秒没有flow了,进程再收到SIGTERM已经是干净状态。
很多团队把preStop只当成“让应用多活一会儿”,这是误解。它真正的作用是协调“流量摘除”和“进程退出”这两个异步动作,让它们有序衔接。先摘流量,后关服务,顺序错了就是事故。
3.3 应用层优雅关闭的正确姿势
preStop只负责时间缓冲,真正优雅的收尾靠的是应用自己对SIGTERM的处理。不同语言框架做法不一样,但核心逻辑相同:停止接收新请求、处理完正在处理的请求、关闭外部依赖连接、退出进程。
Go语言的HTTP服务可以这样写:
http.Server.Shutdown做的事情是先关闭监听套接字,不再接受新连接,然后等待已有的活跃请求处理完成。如果等待超过我们设置的20秒context超时,则强制关闭。这20秒就是应用层的“优雅期限”,它必须小于K8s宽限期里剩余的时间。
Java Spring Boot在2.3版本之后内置了优雅关闭支持,非常简单:
Node.js、Python等服务同理,关键是注册SIGTERM处理器,别让进程默认行为接管。
另外一个经常被漏掉的点:消息队列消费者。消费者进程收到SIGTERM后,应该先停止拉取新消息,等待正在处理的那一批消息完成提交offset或ack,再退出应用。否则就会出现“进程退了,消息其实处理了一半,但offset没提交,重平衡后消息重复消费”的老问题。我们的处理方式是:先把消费线程从队列中摘掉,再等待工作线程池中的任务执行完毕,最后关闭连接。
3.4 优雅停机验证方法
配置写完了,不能只在文档里“看起来没问题”。优雅停机必须实测,而且是发布流程里最值得做的验证实验。
我用过一套很直观的验证方法,在测试环境里持续打流量,然后手动删除一个Pod,观察请求日志的断点:
把压测结果收集起来,看有没有返回错误的请求。正常情况下,配合正确的优雅停机,压测过程里只会出现极少数连接建立阶段的失败,甚至完全无失败。如果出现一大片502、504、EOF错误,说明preStop时间不够,或应用层Shutdown逻辑不完整。
另一个细节是看日志时序。优雅关闭的日志应该是:先收到SIGTERM,打印“开始停止接收新请求”,然后“等待存量请求完成”,最后“关闭连接池,退出”。如果日志直接是进程被强杀后的系统信息,那说明进程压根没处理信号。
4. 回滚:线上事故的最后一根救命稻草
4.1 回滚命令五分钟上手
发布翻车了,最快的止血手段就是回滚。K8s里操作不复杂:
有一点要讲清楚:rollout undo并不是“魔法般地恢复之前那个ReplicaSet”,而是用当前Deployment模板里记录的旧版本信息创建一个新的ReplicaSet,重新走一遍滚动更新流程。所以回滚本身也是一次发布,同样受滚动更新参数约束,同样会触发preStop和优雅停机。
还有一个容易忽略的点:如果发布过程中卡在progressDeadlineSeconds超时,Deployment会进入暂停状态,这时候直接rollout undo可能报错。解决办法是先kubectl rollout resume deployment/order-service,恢复编排再回滚。
回滚命令本身不难,难的是搞清楚“能不能回滚”和“回滚到哪一版”。
4.2 回滚前必须想清楚的三件事
第一件事:数据库迁移是否已经执行。
这是回滚最大的盲区。如果新版本发布时执行了数据库schema迁移,加了列、改了约束、删了索引导,而代码回滚到旧版本后,旧代码操作的是新schema,行为完全不可控。最典型的是新版本给用户表加了一个NOT NULL的新列,旧代码插入数据不带这个列,数据库直接拒绝。
这类数据库变更从根本上就是不可逆的,回滚代码不等于回滚数据。做法是发布前评估schema变更,把数据库迁移和应用发布拆成两步,或者让新旧代码都兼容中间状态的schema。如果已经发生不可逆迁移且代码必须回滚,那要准备好数据修复脚本,而不是指望rollout undo一键解决。
第二件事:配置是否跟着版本走。
Deployment的Pod模板里通常引用了ConfigMap和Secret。如果你在新版本发布前改了ConfigMap,旧镜像回滚后加载的还是新配置,新配置可能包含旧代码不认识的内容,结果是代码版本回去了,配置却停留在新版本,形成“代码与配置错位”。所以回滚时要把ConfigMap和Secret也一并回滚到与目标镜像匹配的版本,这里的坑非常隐蔽,我至少见过两次线上回滚后反而更严重的案例。
第三件事:目标镜像是否还存在。
镜像仓库不是无限保留的。如果你们的CI/CD流水线有定期清理策略,回滚目标版本对应的镜像可能已经被删除了。特别是那些用latest标签的团队,昨天发布的latest今天已经被新镜像覆盖,想回滚昨天根本没得滚。所以在发布记录里除了tag,最好还记录image digest,回滚前确认目标digest在仓库里仍然可用。
4.3 镜像管理与版本策略决定回滚成败
回滚能不能成功,一半取决于发布前的镜像管理策略。
我一直推荐三个硬性规范:
- 永远不要给线上服务用
latest标签。latest是一个漂移标签,同一个标签在不同时间可能指向完全不同的镜像,回滚时根本不知道拉下来的是什么。 - 镜像tag使用“构建时间戳+Git短哈希”的组合,例如
order-service:20240610_1430_abc123f。可读性好,能一眼看出是哪次代码提交构建的产物。 - 镜像仓库保留策略至少保留最近N个版本,N的取值要大于滚动更新历史记录的保留版本数,确保任何一个可回滚的目标都存在。
我在团队里还强制要求发布系统记录每次发布的镜像digest,因为两个不同tag可能指向同一个镜像,但digest是唯一的。回滚前比对digest,能确认要回滚的版本和当时验证过的产出一模一样。
4.4 别把本地代码回滚的习惯带到线上
这里想专门聊一个场景,因为实在太常见了。
很多开发同学在VSCode里回滚代码是肌肉记忆:改坏了,右键git revert,或者直接git checkout -- .,屏幕上红红绿绿一片,很快就回到上一个能跑的状态。这个习惯带到线上容器发布时,会产生一个理解错位:本地回滚操作的是“源代码”,线上容器回滚操作的是“制品版本”。你在VSCode里把代码revert回上个提交,线上Pod跑的镜像还是新的,代码变了镜像没变,相当于白干。
正确的链条应该是:本地回滚代码 -> 触发CI重新构建 -> 生成新镜像 -> 推送到镜像仓库 -> 更新Deployment触发发布。这套链条走完,线上的代码才真正变了。
而K8s的kubectl rollout undo则是另一条路:它直接跳过代码构建,把已经构建好的旧镜像重新拉起来跑。这两个操作经常被混为一谈,但一个是“重新造一个旧版本”,一个是“把仓库里已有的旧版本重新部署”。你要根据事故级别选择走哪条路:如果只是新版本代码有bug,立刻rollout undo;如果发现旧版本也有隐患需要修复,那只能走回滚代码+重新构建发布。
5. 一次完整的发布、优雅停机与回滚演练
5.1 准备一个干净的示例环境
理论说再多,都不如自己动手验一遍。我建议你在测试集群里跑一个完整的演练,环境只要一个K8s集群就行,minikube、k3s或者云端托管集群都可以。
先准备两个镜像版本,比如用nginx模拟,但给它加上优雅关闭逻辑更贴近真实。我这里用一个简单的order-service示例,Deployment配置直接融合前面讲到的滚动更新参数和优雅停机配置:
这个配置里面有readinessProbe、preStop、gracePeriod,已经是一个具备优雅停机基础的部署单元。
5.2 滚动更新发布v2
模拟一次正常发布,把v1.0.0升级到v1.1.0:
由于配置了maxSurge: 1、maxUnavailable: 0,你会看到Pod数量从4变成5,多出来的那个是新版本Pod,等它通过readinessProbe并稳定10秒后,旧Pod开始逐个缩容。整个过程Service始终有4个可用Pod提供服务。
注意观察扩缩容顺序:先增后减,而不是先杀后拉。这是maxUnavailable: 0对核心服务最重要的保护。
5.3 优雅停机实测
发布完成后,回到压测终端,持续向Service发请求,手动删除其中一个Pod:
在Pod进入Terminating状态后,观察请求返回情况。配置了preStop的Pod会有约15秒的终端摘除缓冲期,期间新请求不会再路由到该Pod,已经建立的连接会被处理完。压测结果应该是干干净净的200,偶发极少数失败属于可接受范围,但如果失败率超过1%,就要检查preStop时间和应用Shutdown逻辑。
同时观察Pod生命周期:
你会看到Terminating状态持续约15秒,随后进程收到SIGTERM并迅速退出,整个删除过程在宽限期内完成,没有出现强制杀。
5.4 故障模拟与快速回滚
现在模拟最坏情况:v1.2.0版本发布后,接口直接500,readinessProbe开始失败。由于maxUnavailable: 0,新的错误Pod数量超过maxSurge限制后,滚动更新会停滞,不会继续替换旧Pod,这个机制本身就在限制事故范围。
发现故障后,执行回滚:
回滚动作同样走滚动更新,旧的v1.1.0镜像重新被拉起来,一直等到所有Pod恢复Ready,服务流量完全正常。整个回滚时间取决于镜像大小和拉取速度,通常在几分钟内完成。
我建议每个团队在季度里至少做一次这样的模拟演练,把发布、故障注入、回滚整套流程走一遍。平时练过,事故发生时手指才有肌肉记忆。
6. 我的三板斧经验总结
这三板斧用下来的个人体会,可能比那些架构画得更重要。
发布不是一个“换镜像”的瞬态操作,而是一条完整的生命周期链路:从确认目标版本,到滚动替换,到优雅退出,再到失败恢复。任何一个环节缺失,都可能把一次发布变成一次事故。
先说优雅停机,这是整条链路的根基。滚动更新管的是“流量在多个版本之间怎么切换”,但如果不先把进程的退出行为处理好,切换的时候该断的请求照样断。我见过把滚动更新参数调得很漂亮,但应用不处理SIGTERM,发布时该掉链子还是掉链子。先把优雅停机做好,再谈其他策略。
再说回滚,它永远是兜底的最后一道防线,但防线必须提前筑好。镜像tag规范、仓库保留策略、配置版本对齐、数据库迁移评估,这几件事任何一件没做好,回滚按钮按下去之后可能比不按还危险。建议团队把回滚操作纳入发布checklist的一部分,每次发版前确认三件事:目标版本镜像还在、配置随版本可回退、数据库变更不会阻塞旧代码。
最后说一个很隐蔽但很关键的经验:发布后的观察窗口不要一过就撤。滚动更新完成后,新版本Pod可能在一段时间后才暴露问题,比如内存缓慢泄漏、慢SQL逐渐拖垮接口。我现在的习惯是发布结束后至少人工盯指标15分钟,配合告警兜底,确认曲线没有异常才开始做下一件事。
实际操作中,这“三板斧”也不是万能的。对核心链路,金丝雀发布和蓝绿发布在隔离性上比滚动更新更强,如果你有足够的发布环境资源,可以叠加使用。但如果现在团队连滚动更新、优雅停机、回滚这三项都还没做扎实,我建议先别追求高级玩法,把基础组合拳打好,线上事故至少能少一半。发布这件事,稳比快重要,可恢复比“完美”重要。