开篇先交代一下背景:这是我自己维护的 K8s 实战系列第四篇,上一篇聊了 Pod 和控制器的基础关系,这一篇专门把 Deployment 单独拎出来讲透。标题里写了两个关键词:滚动更新、回滚。很多刚接触 K8s 的朋友整天把“滚动更新”挂在嘴边,但真正碰到线上版本发布的时候,要么是参数没调明白导致更新被卡住,要么是更新完发现问题想回退结果回滚不干净。这篇我会直接用实际操作带你过一遍从创建 Deployment、发起滚动更新、观察发布状态,到版本回滚的完整链路,同时把里面最容易被忽略的细节和可复现的避坑经验一并整理出来。
这篇文章适合这么几类人:刚学完 K8s 基础、准备在生产环境尝试 Deployment 的运维和开发;已经在用 Deployment 但被更新卡住、回滚失败折磨过的人;以及想系统理解 K8s 工作负载里“声明式发布”到底怎么回事的读者。我会尽量用口语化的方式把原理和操作串在一起讲,避免上来就丢一堆抽象概念。
1. 为什么线上一定要用 Deployment 管工作负载
1.1 Deployment 在 K8s 工作负载里的位置
K8s 里管理 Pod 的控制器有好几种,常见的有 Deployment、StatefulSet、DaemonSet、Job、CronJob。很多人一开始分不清,觉得反正都是管 Pod 的,随便用一个不就行了。这种想法在测试环境可能没啥问题,一旦上生产就会踩坑。
Deployment 适合管理的,是无状态服务。什么叫无状态?就是任何一个 Pod 实例挂了、被删了、被重新调度了,对整体服务没有影响,数据不落在本地,实例之间不要求固定网络标识,谁替代谁都可以。Web 后端、API 网关、前端静态页面的 Nginx、各种 Worker 组件,这些都属于典型无状态服务。
Deployment 的核心能力可以概括为三点:声明式 Pod 管理、滚动更新、快速回滚。这三个能力正好覆盖了线上服务日常发布和故障恢复的核心场景。StatefulSet 虽然也能做滚动更新,但它面向的是有状态应用,比如数据库、消息队列,对 Pod 顺序、持久化存储、网络标识都有额外约束。DaemonSet 则是保证每个节点上跑一个副本,更适合日志采集、节点监控这类组件。你在选控制器的时候,第一步先判断应用有没有状态,再决定用哪个,而不是一上来就复制一个 Deployment 的模板改改名字就完事。
1.2 “滚动更新+回滚”到底解决了什么问题
回到标题里的关键词。滚动更新解决的核心问题是:在发新版的时候,尽量不让服务中断。传统的发布方式很简单粗暴:先把所有旧版本进程停掉,再启动新版本进程。对于用户量小、内部系统、维护窗口内可以接受短暂宕机的场景,这种停机发布也能忍。但线上流量稍微大一点,停个几十秒,用户那边可能就有超时告警、报错反馈,严重的话就直接影响收入了。
滚动更新的思路是“分批替换”。我一次只更新一小部分实例,比如一共 10 个副本,先更新 2 个,等这 2 个新版本正常对外提供服务了,再更新下一批。整个过程旧版本和新版本短暂共存,但只要探针配置得当,请求不会打到不健康的 Pod 上,用户基本感知不到发布动作。
那回滚解决的是什么问题呢?降低发布故障的恢复成本。没有 Deployment 的情况下,如果新版本有 bug,你想回到旧版本,得重新打包旧镜像、重新执行发布流程,运气不好还要改一堆配置。有 Deployment 之后,每一次发布的版本信息都记录在控制器里,一条命令就能回到上一个版本或者任意历史版本,几秒钟搞定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与前置检查
2.1 集群版本与配套工具
开始实操之前,先把环境说清楚。我这边用的 K8s 版本是 v1.28,其实滚动更新和回滚这俩功能非常基础,v1.10 之后的版本都能稳定使用,只是细节上略有差异,比如 Deployment 的 progressDeadlineSeconds 行为、kubectl rollout 命令的展示格式,不同版本会有些区别。
你本机需要装好 kubectl,并且能连上集群。验证方式很简单:
bash复制kubectl version --client
kubectl get nodes
如果 kubectl get nodes 能看到节点处于 Ready 状态,说明控制面和节点通信正常。如果你是本地用 minikube 或者 kind 搭的单节点集群,也可以跟着操作,除了资源配额相关的测试会和真正多节点的集群有差异之外,其他行为完全一致。
2.2 实战演练用的服务选择
为了把滚动更新和回滚的效果演示清楚,我这边选了一个非常轻量的 HTTP 服务镜像。这里不引入公司内部镜像,就用一个能返回版本信息的 Nginx 镜像来模拟,避免占用太大篇幅。
我的做法是给 Nginx 挂一个自定义首页文件,首页内容里写清楚版本号。这样滚动更新的效果非常直观:更新前 curl 返回的是 version 1.0.0,更新过程中新旧版本并存时部分请求返回 1.0.0 部分返回 1.1.0,更新完成后所有请求都返回 1.1.0。这个可观测性比盯着 Pod 状态要直接得多。
构建镜像的步骤就不细讲了,Dockerfile 大概长这样:
dockerfile复制FROM nginx:1.25-alpine
RUN echo "version 1.0.0" > /usr/share/nginx/html/index.html
分别构建出 myapp:1.0.0 和 myapp:1.1.0 两个镜像,推送到仓库里即可。测试环境没有仓库的话,直接在每个节点上 docker pull 本地导入也行,只要保证后续 Deployment 能拉取到对应镜像。
提示:如果用的是 minikube,可以在 minikube 节点上直接
docker build并设置imagePullPolicy: IfNotPresent,避免因本地镜像库不通导致ImagePullBackOff。
3. Deployment 创建与滚动更新完整实操
3.1 编写 Deployment 的 YAML
直接上一个我常用的最小可用配置,然后逐行拆解:
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: default
labels:
app: myapp
spec:
replicas: 5
selector:
matchLabels:
app: myapp
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: registry.example.com/myapp:1.0.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 3
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 15
periodSeconds: 5
创建并确认状态:
bash复制kubectl apply -f deployment.yaml
kubectl get deployment myapp
kubectl get pods -l app=myapp
这里有一个非常容易被忽略的点:selector.matchLabels 是不可变字段。一旦 Deployment 创建成功,selector 里面的 label 就不能再改了,改了之后 apply 会报错,只能删掉重建。所以第一个版本就要考虑好 Pod 的 label 命名规范,我当时就因为这个字段踩过坑,后来规范成了 app 加 tier 的组合方式。
再解释一下 strategy 这一段,这是滚动更新行为的关键。maxSurge: 1 代表更新过程中允许超出期望副本数的最多 Pod 数量。期望副本数是 5,那更新最激进的时候最多可以存在 6 个 Pod,也就是先多启动一个新版本 Pod,再删一个旧版本 Pod。maxUnavailable: 0 代表更新过程中不允许出现低于期望副本数的可用 Pod 数量。两个配置合在一起的效果是:新 Pod 先起来并 Ready,然后旧 Pod 才被删除,能做到最小化的服务不可用时间。
你这样理解:maxUnavailable: 0 是安全底线,maxSurge: 1 是并行度调节阀。如果想要更快发布,可以调成 maxSurge: 2 甚至更高,但前提是集群有足够的资源余量。
3.2 发起滚动更新的完整过程解析
现在我把镜像从 1.0.0 升级到 1.1.0,用两种方式都可以:
bash复制kubectl set image deployment/myapp myapp=registry.example.com/myapp:1.1.0
或者直接改 YAML:
bash复制kubectl edit deployment myapp
我个人推荐在真实项目里用 Git 管理 YAML,改完直接 kubectl apply -f deployment.yaml,这样变更可审计、可追溯。临时测试的话 kubectl set image 比较方便。
执行完更新之后,立刻查看滚动过程:
bash复制kubectl rollout status deployment/myapp
这个命令会阻塞直到滚动更新完成或者超时。如果你想观察中间状态,开两个终端,一个执行滚动更新,另一个持续观察:
bash复制kubectl get pods -l app=myapp -w
kubectl describe deployment myapp
实际观察到的 Pod 变化顺序大概是这样的:
- 一开始 5 个旧版本 Pod 全部
Running且Ready - 新建了 1 个新版本 Pod,等待它进入
Ready - 新 Pod
Ready后,删除 1 个旧版本 Pod - 再次新建 1 个新版本 Pod,等待进入
Ready - 重复上述过程,直到 5 个 Pod 全部变成新版本
所以你会看到 Pod 数量在更新过程中短暂变成 6,但永远不会低于 5(因为我们设了 maxUnavailable: 0)。这就是“滚动”二字最直观的体现。
这时候再用 curl 验证业务效果,你会发现在滚动更新期间,请求结果可能有时是 version 1.0.0,有时是 version 1.1.0,但不会出现请求失败。这取决于 Service 是否已经把后端 Endpoint 更新为新 Pod 的 IP。它可能会指向旧 Pod 或者新 Pod,只要两边行为兼容,就没有问题。
3.3 滚动更新参数调优与资源考量
刚才的例子用的是 maxSurge: 1、maxUnavailable: 0,这个配置对于大多数内部 Web 服务都够用。但在真实生产环境里,有几个参数值得根据业务场景调优。
maxSurge 和 maxUnavailable 可以组合出三种典型策略。第一种是“保守发布”,maxSurge: 1、maxUnavailable: 0。每次只多跑一个新 Pod,旧 Pod 确认无误后才删。这种方式发布速度慢,但资源占用最低,适合资源紧张、不能有 Pod 低于期望数的场景。
第二种是“快速发布”,maxSurge: 25%、maxUnavailable: 25%。允许缺失一部分旧 Pod,也允许临时跑更多的新 Pod,整体发布速度会快很多,但需要确保集群节点资源有足够余量。如果集群节点的 CPU 和内存本来就处于高水位,快速发布很容易触发节点压力,导致 Pod 调度失败。
第三种是“不可用容忍”,maxSurge: 0、maxUnavailable: 1。它会先摘掉一个旧 Pod,再启动一个新 Pod。资源非常节省,但更新期间会出现短暂的可用 Pod 数量减少。如果应用有多副本且负载均衡层有重试机制,影响不大;如果只有一个副本、且没有其他容灾手段,千万不能用这个组合。
还有一个参数和滚动更新关系很大:progressDeadlineSeconds。它用来设置更新任务的处理超时时间,默认是 600 秒。如果滚动更新在 10 分钟内没有完成,Deployment 会被标记为 Progressing 状态异常。实际排查问题的时候,我建议把它显式配置到 YAML 里,比如:
yaml复制spec:
progressDeadlineSeconds: 300
这样如果滚动更新卡住了,kubectl describe deployment 的 Conditions 里会出现 ProgressDeadlineExceeded 的信息,排查思路就清晰很多。不配置的话,默认值还是 600 秒,条件里不会第一时间体现出来,容易让人误以为还在正常更新中。
4. Deployment 回滚操作完整实战
4.1 查看历史发布版本
滚动更新的好处不只是发布平滑,更关键的是回滚有据可依。K8s 会为 Deployment 的每次配置变更生成一个 ReplicaSet,这个 ReplicaSet 就是某个时间点的完整副本集定义。查看历史版本的方式:
bash复制kubectl rollout history deployment/myapp
输出大概是这样的:
code复制REVISION CHANGE-CAUSE
1 <none>
2 <none>
默认情况下,CHANGE-CAUSE 是空白的。如果你希望历史记录里能看出每次改了什么,在 kubectl apply 的时候加上注解:
bash复制kubectl apply -f deployment.yaml --record=true
不过在新版本 kubectl 里 --record 已经被标记为废弃,我现在的做法是在 YAML 的 metadata.annotations 里手动加入变更说明:
yaml复制metadata:
annotations:
kubernetes.io/change-cause: "update image to 1.1.0"
这样每次查看历史版本的时候,就能看到明确的变更描述,线上回滚的时候一眼就知道要回哪个版本。团队协作时这个习惯非常重要,不然时间久了,谁都不记得某个 version 对应的镜像内容是什么。
再看看某个指定版本的详细信息:
bash复制kubectl rollout history deployment/myapp --revision=1
这会输出对应 ReplicaSet 的完整 Pod 模板,包括镜像地址、环境变量、参数等。通过对比版本 1 和版本 2 的模板,可以确认这次发布到底改了什么。
4.2 回滚到上一个版本与指定版本
回滚操作的命令非常简洁,但要理解了再用,别背出肌肉记忆。
回滚到上一个版本:
bash复制kubectl rollout undo deployment/myapp
回滚到指定版本:
bash复制kubectl rollout undo deployment/myapp --to-revision=1
执行完 undo 之后,Deployment 会立刻创建一个新的 ReplicaSet,把镜像配置改回旧版本,然后按照同样的滚动更新策略去替换当前的新版本 Pod。注意这里说的是“创建新的 ReplicaSet”,而不是复用旧的。从 rollout history 看,REVISION 会增加一个版本号,比如从 2 变成 3。这个机制的优点是你回滚之后,历史记录还是完整的,不会覆盖掉之前出问题的版本,方便后续分析。
回滚过程中同样可以用 kubectl rollout status 观察进度。如果是紧急回滚,还可以在 undo 的同时调整 maxSurge 和 maxUnavailable 来加速,比如临时把 maxSurge 调大,回滚完再恢复原值。不过线上操作我建议一步一步来,先把回滚发起来,观察新 Pod 状态正常再调整,别一次改一堆配置。
4.3 回滚失败场景与应急处理
回滚不可能百分百成功。最常见的失败原因是“回滚到的目标版本也有问题”,比如旧版本依赖的数据库表结构已经变了,旧代码根本起不来。这时候就算回滚命令执行成功,Pod 也会陷入 CrashLoopBackOff。
遇到这种情况,我的处理思路是按优先级来。第一步,先暂停滚动发布,让现场停止变化:
bash复制kubectl rollout pause deployment/myapp
暂停之后,Deployment 不会再创建新的 Pod,也不会删除旧的 Pod。但要注意,系统不会帮你把现有 Pod 自动恢复到某一边,你需要手动干预。第二步,快速确认哪一个版本的镜像能正常工作,可以用 kubectl run 启动一个临时 Pod 去验证镜像能不能启动、接口能不能通。第三步,根据验证结果选择回滚或者继续发布。
还有另一种情况:回滚过程中卡住了。一般是目标版本的 Pod 一直无法 Ready,因为镜像拉取失败、探针失败、资源不足都有可能。这时候先看事件:
bash复制kubectl describe rs -l app=myapp
kubectl get events --sort-by=.lastTimestamp | tail -50
常见原因是镜像地址错了或者镜像 tag 不存在。有些人回滚时直接写 --to-revision=1,但这个 revision 对应的镜像可能已经在仓库里被覆盖或者清理了,拉取时就会报 ImagePullBackOff。解决方式是把镜像重新打到正确的 tag 并推送,或者手动修改 Deployment 的镜像地址指向可用版本。
注意:
kubectl rollout undo不会跳过 Deployment 的滚动更新策略。如果你的策略是maxUnavailable: 0,回滚时会严格保证可用 Pod 数量,那么回滚速度会偏慢。线上紧急回滚时如果急于恢复服务,可以先临时把maxUnavailable调成 1 或者 2,再执行 undo,速度会有明显提升。
5. 滚动更新与回滚的常见坑和排查思路
5.1 探针配置不当导致滚动更新永远卡住
这是我在实际项目里遇到最多的一个问题:滚动更新开始时,新 Pod 一直处于未就绪状态。最典型的迹象是,执行完 set image 之后,过了很久 rollout status 还在等,新 Pod 反复被创建,然后被删除,旧 Pod 什么都不动。看起来像是卡死了,其实控制器在等你新 Pod 变成 Ready。
原因多数是 readinessProbe 配置得不对。比如我见过有人把探针的 path 配成 /healthz,但应用根本没实现这个接口,返回 404,探针永远失败。还有一种情况是 initialDelaySeconds 太短,应用启动慢,探针在应用还没监听端口的时候就打了过去,连续失败几次之后 Pod 就被认为不健康了。
排查方式很简单:
bash复制kubectl logs <new-pod-name>
kubectl describe pod <new-pod-name>
看探针相关的 Events,重点看 Readiness probe failed 后面跟的具体错误。探针细节上我也给出一个实用的配置习惯:initialDelaySeconds 设置成应用估算启动时间再加 3~5 秒,periodSeconds 设置成 5 或者 10,failureThreshold 设置成 3,不要为了加快更新速度而把探针调得过于激进。
5.2 镜像拉取策略引发的回滚不干净问题
热词里有一条挺有意思:“回滚时回滚不干净”。这个现象在 K8s 里同样存在,而且一个非常典型的原因是 imagePullPolicy 的设置。
默认情况下,如果镜像 tag 是 latest,imagePullPolicy 会强制为 Always,也就是说每次 Pod 重建都会尝试拉取最新镜像。假如你的回滚目标版本镜像 tag 也叫 latest,但仓库里的 latest 已经被覆盖成别的内容,你回滚之后拉到的可能并不是你真正想要的“旧版本”。这就是回滚不干净的来源之一。
所以我的建议非常明确:生产环境永远不要用 latest 作为镜像 tag。每次发布必须用唯一的版本号,比如 1.0.0、1.1.0、20250118-commitid 这种格式。回滚时只要指定对应的唯一 tag,就能保证拉到的内容和你预期的一致。
如果确实用了同一个 tag 来覆盖发布,回滚前需要检查节点上的本地镜像缓存。因为 imagePullPolicy: IfNotPresent 配合本地已有镜像,可能压根不会重新拉取,Pod 直接用了旧缓存。这时候你以为是新版本,实际还是旧内容,反过来也是一种“回滚不干净”。
5.3 资源配额与 PDB 对滚动更新的影响
滚动更新能不能顺利跑完,不仅取决于 Deployment 自身的配置,还和集群资源密切相关。
先看 resources.requests。如果新版本的 Pod 定义了 CPU 和内存 requests,但集群节点的剩余资源不够,新 Pod 会一直处于 Pending 状态,滚动更新卡住。典型表现是:kubectl get pods 里新 Pod 一直是 Pending,kubectl describe 显示 Insufficient cpu 或 Insufficient memory。这时候表面上看是滚动更新的问题,实际是资源调度失败。解决思路是多加节点、清理僵尸 Pod、或者调低新版本的资源申请,但要评估好容量,别把节点打爆。
再看 PodDisruptionBudget,简称 PDB。它是用来保护应用在主动节点维护时最多可以容忍多少 Pod 不可用的策略。PDB 的存在会影响滚动更新的进度。比如你定义了 minAvailable: 4,但 Deployment 的副本数是 5,节点维护时已经有一个 Pod 被驱逐了,剩下的可用 Pod 是 4。这时候滚动更新想删除一个旧 Pod 就会违反 PDB 的限制,导致删除卡住,更新无法继续。
排查方法:
bash复制kubectl get pdb
kubectl describe pdb <name>
PDB 的 events 里会明确提示因为干扰预算无法驱逐 Pod。遇到这种情况,要么临时调高 maxUnavailable(但这有风险),要么等节点维护完成释放资源后再继续滚动,要么调整 PDB 的配置。这里要格外小心,不要随便清理 PDB,尤其是数据库之类的有状态服务,PDB 是保护它们安全的重要机制。
5.4 并发发布与版本覆盖的注意事项
还有一个操作层面的坑:并发发布。假设团队里有两个人同时对同一个 Deployment 执行 kubectl set image,一个改成 1.1.0,一个改成 1.2.0,最终结果取决于谁后写到 etcd。这个操作本身不会报错,但很容易造成混乱。
我给团队的规范是:Deployment 的变更只能走 GitOps 流程,所有人改 YAML,走代码评审,然后统一由 CI/CD 系统执行 kubectl apply。这样并发冲突在代码评审阶段就能发现,而不是等容器都起不来了才去排查。
另外,Deployment 更新之后,旧的 ReplicaSet 会保留但缩容到 0,这是为了支持回滚。这些缩容到 0 的 ReplicaSet 会占用少量 etcd 存储,数量太多时会影响集群性能。K8s 默认的 revisionHistoryLimit 是 10,也就是最多保留 10 个历史版本。可以根据自己的回滚需求调整这个值,比如:
yaml复制spec:
revisionHistoryLimit: 5
但不要设为 0,设成 0 之后就无法回滚了,并不推荐。保留 5~10 个版本够绝大多数场景用。
6. 实际操作中的经验总结与建议
写到这里,其实该讲的命令、参数、坑都已经铺开了。最后分享一段我在实际项目里踩过几次坑之后的习惯流程,你可以直接参考。
我每次发布新版本,不管测试还是生产,都会强制走这么几步:
第一,发布前先确认当前版本没问题。执行 kubectl rollout history deployment/<name>,记录当前 REVISION 号,最好把对应的镜像 tag 也记一下。这是回滚的锚点,如果你连“当前正常的是哪个版本”都没搞清楚,后面所有操作都是盲猜。
第二,发布时不要直接在旧版本的 Deployment 上改多个字段,尤其是不要同时改镜像和环境变量。每次变更尽量只改一个变量,这样回滚时语义是清晰的,不会出现“镜像回滚了但环境变量还是新版”这种分裂状态。
第三,发布后不要立刻离开,花 3 到 5 分钟观察新 Pod 的情况,还要看业务指标的曲线,比如请求成功率、延迟、错误日志。K8s 的 Pod 状态只是“进程还活着”,不代表业务真的没问题。我遇到过一种情况:新版本镜像启动正常、探针也通过,可是内部有 bug,导致大量请求返回 500。如果这时候人已经走了,故障就会在几小时后才暴露。有条件的可以把关键业务指标接出来,发布期间看告警和看板,做到心中有数。
第四,回滚时不要只关注 Deployment 本身。如果这次发布同时改了 ConfigMap 或者 Secret,回滚 Deployment 并不会自动回滚这些资源。需要把相关的配置一起回退,否则会出现镜像回滚到旧版、但配置还指向新版的错位情况。这也是很多人说“回滚不干净”的另一个常见原因。
Deployment 的滚动更新和回滚看似简单,但要把这套机制在线上用好,你需要理解它背后的 ReplicaSet 模型、探针机制、资源约束和配置漂移问题。建议你拿到一个小型服务,亲自把一个版本从 1.0 滚到 1.1,再从 1.1 滚回 1.0,把这个流程重复几遍,直到你对 Pod 数量变化、版本历史记录和异常排查都有手感为止。这个基本功扎实了,再复杂的发布系统也能驾驭。
