如果你在一个稍微上点规模的k8s集群里干过两年运维,你一定经历过这种深夜:改了一行镜像tag,敲下kubectl apply,然后盯着终端等结果。滚动更新正常的话,十几秒后Pod全部Ready,一切平静;不正常的话,你等来的是一个永远Pending的新版本,或者一批瞬间被删光的旧Pod。Deployment的两种更新策略——Recreate和RollingUpdate,就是这两种命运的分水岭。
这篇文章不讲安装,不讲网络插件,专门把Deployment工作负载的更新策略拆开揉碎。我会从两种策略的运行机制讲起,再到参数怎么配、发布怎么盯、排错怎么入手,最后给出一套可以直接抄的配置模板。无论你是刚接触K8s的运维新手,还是被滚动更新卡住过几次的"老兵",都能从中找到点有用的东西。
1. 两种更新策略的运行逻辑:先搞清楚它们到底在干什么
很多教程一上来就丢两个YAML片段,让你"这么配",却不解释背后发生了什么。结果就是你配了Recreate,但不知道它为什么会有一段服务不可用的窗口;你配了RollingUpdate,但不知道为什么发布时Pod数量会忽多忽少。这章先把运行机制讲清楚。
1.1 Recreate:先杀后建,一步到位
Recreate策略的配置非常简单,在Deployment的spec下写:
apiVersion: apps/v1 kind: Deployment metadata: name: my-app spec: replicas: 3 strategy: type: Recreate selector: matchLabels: app: my-app template: spec: containers: - name: app image: my-app:v2就这一个type字段,没有其他可调参数。它的执行流程是:Deployment controller检测到模板变化后,先创建一个新的ReplicaSet(初始副本数为0),然后把旧ReplicaSet一次性缩容到0,等旧Pod全部清理完毕,再把新ReplicaSet扩容到replicas指定的数量。
这个过程有一个非常关键的特征:旧Pod全部消失、新Pod还没有Ready的这段时间,这个Deployment管理的服务是零可用实例的。如果Deployment后面挂着Service,请求会持续失败,直到新Pod全部启动并进入Ready状态。用装修来类比,Recreate就是推倒整个旧房子,重新打地基盖新房,施工期间一家人得住酒店。
有一个新手常踩的误解:以为Recreate也能配rollingUpdate参数,比如同时写maxSurge。实际上K8s会直接忽略strategy里多余的rollingUpdate字段,不会报错,但也不生效。我见过有人配了老半天发现发布行为没变,最后才意识到参数被忽略了。Recreate就是"全杀全建",没有任何中间态。
1.2 RollingUpdate:边撤边补,滚动前进
RollingUpdate是Deployment的默认策略。就算你什么都不写,K8s也会默认按RollingUpdate来发布。它默认的配置是maxSurge=25%、maxUnavailable=25%,这组参数后面会详细说。
RollingUpdate的整体流程可以概括为:新建一个ReplicaSet,按照maxSurge的限制分批扩容新Pod,新Pod Ready后,按照maxUnavailable的限制分批缩容旧Pod,不断循环,直到新Pod数量达到期望副本数、旧Pod全部清零。
用装修来类比,RollingUpdate就是"边住人边施工":不整体推倒房子,而是每次只封一层楼,等新的房间装好能住人了,再封下一层。任何时刻都保证大部分住户还在家里,服务整体不中断。
但这里有个容易忽略的点:滚动更新过程中,新旧两个版本的Pod是同时存在的。如果你的应用做不到新旧版本在短时间内共存,这个"平滑"就会变成"事故"。这一点在第5章会展开讲,它直接决定了你该选哪种策略。
1.3 关键差异一页纸
把两个策略的核心差异整理成一张表,后面所有章节都围绕这张表展开:
| 对比维度 | Recreate | RollingUpdate |
|---|---|---|
| 下线与上线顺序 | 先全量删旧,再全量建新 | 逐批替换,新旧并存 |
| 服务空窗期 | 有,且窗口较长 | 基本没有(配置错误除外) |
| 新老版本是否共存 | 否,任意时刻只有一个版本运行 | 是,滚动期间两个版本同时在线 |
| 发布速度 | 切换动作快,但空窗期内无法服务 | 取决于副本数和参数,通常也可控 |
| 回滚速度 | 慢,需要再次全量切换 | 快,undo后旧RS立即扩容 |
| 资源峰值需求 | 低,先删后建不超配 | 可能超过副本数(因为有maxSurge) |
| 典型场景 | 有状态、必须全量切换 | 无状态、多副本API |
这张表是起点,不是结论。Recreate的"快"只是切换动作快,真正影响用户体验的是那段空窗期;RollingUpdate的"无空窗"也不是白来的,需要你在参数和就绪探针上付出代价。
2. 什么时候该用Recreate:停机不是原罪,盲目滚动才是
只要聊到Recreate,大部分人的第一反应是"会导致停机,不好"。但如果你反过来想一步:有些工作负载根本不允许新旧版本同时存在,这时候Recreate反而是唯一正确的选择。停机不可怕,可怕的是在错误的场景里盲目追求"无感发布"。
2.1 哪些场景必须接受Recreate
第一类,数据库表结构变更 + 新老代码不兼容。比如应用v1启动时会往表里写一个旧格式的字段,v2把字段约束改了。如果v1和v2同时跑,v1可能写出旧格式数据,v2读不了,甚至两个版本互相覆盖字段,数据直接脏掉。这种时候你只能让旧代码彻底下线,全量切到新版本。
第二类,单副本的工作负载。有些服务设计上就只有一个实例,比如分布式锁、定时任务、特殊协议的服务端,副本数永远是1。副本数为1时,滚动更新本质上也是"先停旧再启新",因为集群里不能同时有两个Pod抢同一份资源。与其依赖maxUnavailable的默认值去赌它不卡住,不如直接写Recreate,把"这个服务就是有停机窗口"这件事摊在明面上。
第三类,强依赖本地存储或独占硬件的工作负载。比如Pod绑定了特定节点、特定磁盘目录,或者应用启动时会初始化本地文件索引,两个版本同时跑会互相覆盖文件。这时候滚动更新不仅带不来平滑,反而制造数据损坏的风险。Recreate的全量切换能保证文件系统层面永远只有一个版本在动。
2.2 什么时候别用Recreate
反过来看,下面这些场景用Recreate就是给自己埋雷:
第一,Deployment后面挂着Service,且客户端对可用性有要求。一次Recreate就是一次全量故障,哪怕只持续几十秒,监控告警也能响成一片。第二,你有快速回滚的需求。Recreate的回滚流程是把新Pod全部杀掉、旧Pod全部重新拉起,新版本如果本身启动崩溃,这个回滚过程会变成双倍折磨:新Pod反复Crash要被挨个清理,旧Pod又要重新调度。第三,集群资源充足、Pod启动速度快,滚动更新能把停机降到接近零,你就没必要主动制造一个可见的故障窗口。
我判断一个场景能不能用Recreate,通常只问一句话:如果这个服务在发布过程中出现1分钟无可用实例,用户能不能接受?能接受,才考虑Recreate;不能接受,就绕开它。
2.3 一次Recreate故障回滚的全过程复盘
用一个真实感很强的案例帮你建立体感。假设你有一个10副本的支付回调服务,因为需求紧急,把镜像从v1改成v2,用了Recreate。执行kubectl apply之后,更新瞬间发生的事情是:
- controller创建新RS,副本数为0;
- 旧RS从10缩到0,10个旧Pod全部进入Terminating,大约2秒后彻底消失;
- 新RS从0扩到10,但v2镜像有一个环境变量配置错误,容器启动后直接退出,进入CrashLoopBackOff;
- 10个Pod全部CrashLoopBackOff,服务完全不可用,此时kubectl get deploy看到的可用副本数是0;
- 你发现不对,执行kubectl rollout undo。但undo同样触发一次更新,Recreate再执行一遍:删除10个CrashLoopBackOff的Pod,再创建10个v1 Pod;
- 清理异常Pod、重新拉取镜像、逐个调度,整个过程平均多花3到8分钟。
如果当初用的是RollingUpdate,新Pod同样CrashLoopBackOff,但旧Pod不会一上来就被删光。滚动会卡在等待新Pod就绪的状态,你有充足时间执行undo,旧RS会立刻重新扩到10副本,服务通常在一两分钟内恢复。这就是两种策略在故障场景下的核心差别:Recreate把"发布问题"放大成"长时间全量故障"。
我不是说Recreate不能用,而是要强调:它适合用在"确认新版本没问题、且不担心停机"的场合。如果你们的发布流程依赖人工判断要不要回滚,Recreate会放大每一次失误的后果。
3. RollingUpdate参数调优:maxSurge和maxUnavailable的搭配艺术
RollingUpdate的可调参数就两个:maxSurge和maxUnavailable。很多资料一笔带过,但实际踩坑时你会发现,它们就是整个滚动过程的心脏。参数配错了,滚动更新要么慢到让人怀疑人生,要么悄无声息地把流量导到没准备好的Pod上。
3.1 默认参数到底在说什么
maxSurge:更新过程中,新RS可以比期望副本数多出的Pod数量上限。它和期望副本数相加,就是集群里这个Deployment最多能同时存在的Pod总数。
maxUnavailable:更新过程中,允许不可用的副本数量上限。它表示"当前可用的Pod数比期望副本数最少可以少几个"。
两个参数都支持绝对值(比如1)和百分比(比如25%)。使用百分比时,以Deployment的期望副本数为基数计算,结果向上取整。举个例子:副本数为10,maxSurge=25%时,计算得2.5,向上取整为3,也就是说发布过程中总Pod数最多可以到13;maxUnavailable=25%时同样向上取整为3,也就是最低允许7个Pod可用。
这里有个常被忽略的细节——取整方向是向上(ceil),不是向下。有些网上资料写"25%对副本数10就是2个",那是旧说法,实际源码里算的是ceil。副本数小的服务尤其明显:副本数为2时,25%算出来是0.5,向上取整是1,不是0。别小看这个差异,它决定了小副本数服务的发布节奏。
还有一个硬性约束:maxSurge和maxUnavailable不能同时为0。因为如果两个都是0,控制器找不到任何可以动的Pod,滚动更新只能卡死。所以你要是想在"完全不超量、也完全不少量"的状态下发布,这个需求本身在Deployment层面就不成立,必须二选一放开一个。
3.2 四组常见搭配的真实效果
我在实际项目里见到的配置基本可以归成四类,各自对应的节奏和风险如下:
| 配置组合 | 滚动节奏 | 容量影响 | 适合场景 |
|---|---|---|---|
| maxSurge=25%, maxUnavailable=25% | 快,每批可同时扩缩多个Pod | 发布期间总Pod数偶尔超出期望,也可能短暂低于期望 | 常规无状态应用,副本数较多 |
| maxSurge=1, maxUnavailable=0 | 慢,每批只多一个新Pod,等Ready后再缩一个旧Pod | 可用副本数始终不低于期望,总Pod偶尔多1 | 对外核心服务,可用性优先 |
| maxSurge=0, maxUnavailable=1 | 慢且可用数会短暂下降,每批先缩旧再扩新 | 总Pod数不超过期望,但可用数会短暂少1 | 集群资源严格受限,宁可短暂降级也不超配 |
| maxSurge=100%, maxUnavailable=0 | 先全量拉起新Pod,全部Ready后一次性缩旧 | 更新期间总Pod数是期望的2倍,可用数始终满额 | 资源充足,希望尽量接近蓝绿发布 |
第四种组合值得单独说说。很多团队嘴上说"蓝绿发布",其实Deployment原生就能做到类似效果:把maxSurge设成100%,maxUnavailable设为0。控制器会先创建一批与期望副本数相同的新Pod,全部Ready后才缩容旧Pod,发布过程几乎无感。缺点是短时间内需要双倍资源配额,而且新旧版本仍会短暂并存——如果数据库不兼容新老两版,这个方案照样翻车。
3.3 手工推演一个10副本的发布过程
光看表格还不够直观,我们完整推演一遍控制器的工作节奏。
场景:replicas=10,maxSurge=1,maxUnavailable=0,镜像从v1升v2。
初始状态:旧RS=10个v1 Pod,新RS=0,总Pod数=10,可用Pod数=10。
第一步,控制器判断"总Pod数不能超过11(10+1)",于是把新RS从0扩到1。此时总Pod数=11,可用Pod数=10,因为新Pod还没Ready。
第二步,新Pod就绪。控制器发现"可用Pod数不能低于10",现在新Pod可用了,于是把旧RS从10缩到9。总Pod数回落到10,可用Pod数=10(1新+9旧)。
第三步,总Pod数=10,小于11,可以继续扩新RS,新RS从1扩到2。等新Pod Ready后,旧RS从9缩到8。如此循环,直到旧RS=0,新RS=10。
如果每个Pod从创建到Ready需要30秒,那么这轮发布的节奏是"等一个新Pod Ready + 缩一个旧Pod",总共要替换10个Pod,总时长接近5分钟。如果换成默认的maxSurge=25%和maxUnavailable=25%,发布并行度提高到一次能扩3个新Pod、缩3个旧Pod,总时长能压缩到两三分钟。
还有一个非常容易忽略的细节:控制器判断"新Pod是否Ready"用的是Pod的就绪状态,不是Running状态。如果Pod模板里没有配置readinessProbe,那么容器一旦启动,K8s默认把它标记为Ready,滚动节奏会比你以为的快很多。这种快是虚假的,因为流量已经被引流到一个还没初始化完成的进程上了。
4. 发布过程中的监控与排错:如何判断更新是真成功还是假成功
策略配好了,发布动作执行了,接下来怎么判断这次发布是真的成功还是"假成功"?我见过太多人只盯着Pod状态,看到Running就以为万事大吉,结果流量已经打到没有就绪的Pod上,一堆5xx才知道出事了。这章讲发布过程中的监控命令和排错链路。
4.1 rollout命令的正确打开方式
先把手里的工具用利索。和发布相关的kubectl rollout命令就这么几个,但用法上有讲究:
- kubectl rollout status deployment/my-app:阻塞等待发布完成。成功会输出"deployment 'my-app' successfully rolled out";超时或失败返回非零退出码,CI/CD脚本里经常用它判断发布结果。
- kubectl rollout history deployment/my-app:查看发布历史。注意Deployment默认只保留最近10个版本,可以设置spec.revisionHistoryLimit来调整。
- kubectl rollout undo deployment/my-app:回滚到上一个版本,加--to-revision=3可以回滚到指定版本。这个命令非常快,本质是让旧RS重新扩起来。
- kubectl rollout pause deployment/my-app和kubectl rollout resume deployment/my-app:暂停和恢复滚动,通常用于金丝雀发布流程。
我见过不少发布脚本是这么写的:kubectl apply之后sleep 60,然后检查Pod。这个做法有两个问题:一是60秒未必够,Pod启动慢一点,你检查的时候看到的还是旧状态;二是如果Pod一启动就CrashLoopBackOff,你sleep完再查已经晚了。正确做法是apply之后立刻用rollout status阻塞等待,并配一个合理的timeout:
kubectl apply -f deployment.yaml kubectl rollout status deployment/my-app --timeout=300s这条命令返回非零时,脚本立即失败并触发告警,而不是傻等60秒再去读一个过期的状态。
4.2 就绪探针没配置,滚动更新就是空中楼阁
这是我认为关于RollingUpdate最重要的一条经验,值得单独拿出来讲。
RollingUpdate的参数计算依赖"可用Pod"这个概念,而"可用"的定义是:Pod处于Running状态,并且通过readinessProbe的检查。如果一个Deployment的Pod模板里没有配置readinessProbe,K8s会在容器进程启动后立刻把Pod标记为Ready。于是会发生什么?
新Pod刚启动1毫秒,K8s就认为它可以接流量;Service的Endpoints立刻把它加进负载均衡;Deployment controller也认为它Ready了,继续缩旧Pod。整个滚动更新的"平滑"被压缩成了"新Pod一启动即Ready、旧Pod快速淘汰",看起来发布极快,实际上流量全打到还没初始化完成的进程上。
配置readinessProbe的姿势也很讲究,初始延迟(initialDelaySeconds)要大于应用的实际启动时间。如果配小了,探针会在应用准备好之前连续失败,K8s永远不把Pod标记为Ready,滚动更新就会卡死。一般经验是:initialDelaySeconds设置成"进程启动到能对外服务"的预估时间再加点余量,periodSeconds默认10秒即可:
readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 10如果你发现滚动更新"永远卡住",第一个要查的往往不是maxSurge和maxUnavailable,而是新Pod的readinessProbe是不是一直在失败。新Pod不Ready,控制器只能等着。
4.3 卡住时的完整排查链路
发布卡住是K8s运维最常撞上的排错场景。我建议按下面这条链路走,能省下不少瞎查的时间。
第一步,看Deployment整体状态:
kubectl get deploy my-app -o wide kubectl describe deploy my-appdescribe输出的Conditions里,重点看Available、Progressing、ReplicaFailure这三项。如果Progressing的Reason是ProgressDeadlineExceeded,说明这次滚动更新超过了ProgressDeadlineSeconds(默认600秒),控制器已经放弃推进。这是最常见的"发布卡住"信号,也是排错时最先要确认的一件事。
第二步,看新旧ReplicaSet:
kubectl get rs -l app=my-app你会看到两个RS:旧RS的DESIRED已经缩到很小,新RS的DESIRED还没达到replicas。哪个RS的Pod Ready数一直不增长,问题基本就锁定在哪个版本上。
第三步,看Pod状态和事件:
kubectl get pods -l app=my-app -o wide kubectl describe pod <新Pod名称>重点看事件里的几个关键词:Pulling说明镜像拉取慢;Created/Started反复出现说明容器在重启;FailedScheduling说明资源不足;CrashLoopBackOff说明新版本镜像自身有问题。事件里如果频繁出现Insufficient memory/cpu或FailedScheduling,那是maxSurge放太多新Pod、集群容量扛不住了;如果是CrashLoopBackOff或Readiness probe failed,那和新版本本身有关,你调参数也救不回来。
这套链路走完,90%的发布卡住都能定位。剩下10%通常是集群层面的网络问题、镜像仓库不可达这类外部因素,建议直接查节点状态和kubelet日志。
5. 实战中的那些坑:我的经验和建议
前面几章把两种策略的机制、参数和排错都讲了,最后这章是我的实战经验合集。这些坑我基本都踩过,写出来帮你避一避。
5.1 别迷信maxSurge=0:资源省了,问题多了
有些团队为了"发布时不多占资源",把maxSurge设成0,maxUnavailable设成1。我一开始也试过,后来发现它带来两个隐藏问题。
一是发布速度感人。10副本的服务,每轮只能缩1个旧Pod、等1个新Pod Ready,全部替换完可能要十几分钟。如果你们的发布窗口只有5分钟,这个方案根本来不及收尾。
二是可用性会短暂受损。每批缩容一个旧Pod的瞬间,可用Pod数从10降到9,如果服务流量峰值本来就贴着容量上限,这次短暂降级可能直接触发限流告警。更麻烦的是,如果HPA正在扩容,它看到Pod数变少又会往上加,两个控制器互相打架,集群里会出现奇怪的扩缩容抖动。
我的建议是:maxSurge=0只适合集群资源精确受限、且服务能容忍短暂降级的场景。正常情况下,哪怕只是maxSurge=1、maxUnavailable=1,都比maxSurge=0稳得多。
5.2 滚动更新也要配合PodDisruptionBudget
滚动更新是Deployment主动删除旧Pod,这个动作本身不受PDB(PodDisruptionBudget)约束。PDB限制的是驱逐(eviction)类的自愿中断,比如节点维护时的kubectl drain、集群autoscaler缩容时的Pod驱逐。
但为什么说滚动更新也要配PDB?因为生产环境里,发布和节点维护经常同时发生。你正在滚动更新,集群autoscaler因为节点使用率低缩容了一台机器,驱逐了这台节点上的Pod——这些Pod可能恰好是滚动更新里还没Ready的新Pod,或者是被缩容到一半的旧Pod。如果没有PDB,就会出现"明明replicas=10,可用Pod却一度只剩1个"的极限场景。配了PDB,驱逐会被延迟,避免雪上加霜。
apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: my-app-pdb spec: minAvailable: 2 selector: matchLabels: app: my-app顺带纠正一个常见误解:有人说"滚动更新已经保证了可用副本数,PDB可有可无"。这话不对。滚动更新保护的是maxUnavailable范围内的可用性,PDB保护的是非发布操作(节点驱逐等)下的最小存活数,两者保护维度不同,都要有。
5.3 新老版本并存的兼容性,是RollingUpdate的隐藏前提
RollingUpdate最大的特点是滚动期间新旧Pod同时存在。如果应用做不到"新老兼容",那不管参数怎么调,总有一部分流量会打在旧Pod上,或者新旧Pod访问同一个数据库时产生行为差异。
我遇到过最典型的场景是:新版本引入了数据库字段,旧版本代码不认识这个字段,启动时直接报错,整个滚动更新还没开始就失败一大片。另一个场景是WebSocket长连接服务,老连接由旧Pod维持,新连接被路由到新Pod,如果协议不兼容,客户端瞬间断线。这些问题都不是调maxSurge能解决的,需要在发布策略之外做兼容设计,或者干脆放弃滚动更新,改用Recreate。
所以我每次评审发布方案时都会先问一句:新旧两个版本能同时在集群里跑几分钟吗?答案是不能,就别用RollingUpdate。
5.4 一套可以抄的Deployment更新策略模板
最后分享我的配置习惯。面对不同工作负载,我会用几套模板,基本覆盖绝大多数场景。
常规无状态API服务,优先保证可用副本数不下降:
spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: spec: containers: - name: app image: my-app:latest readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 10资源严格受限、可以接受短暂降级的场景:
spec: strategy: type: RollingUpdate rollingUpdate: maxSurge: 0 maxUnavailable: 1必须全量切换、有状态或新老不兼容的场景,直接上Recreate:
spec: strategy: type: RecreatemaxSurge=1、maxUnavailable=0之所以是我的默认选择,是因为它对大多数Web服务足够保守:可用副本数永远不低于期望值,总Pod数最多多一两个,几乎不浪费资源,发布速度虽然在所有组合中最慢,但可用性最稳。如果团队刚接手一个集群,对业务代码质量没把握,从这套开始是最不容易出错的。
最后再提醒一个我踩过的坑:不要把这些strategy配置硬编码在Deployment里。我的做法是把更新策略和readinessProbe都做成参数,通过Helm Chart的values.yaml控制。dev环境为了迭代快直接用Recreate,prod环境用maxSurge=1、maxUnavailable=0。CI流水线里apply之后立刻跑rollout status --timeout=300s,失败就自动undo。这一套流程跑顺之后,发布问题基本不用再半夜爬起来处理。