kubectl 更新容器镜像:Deployment 滚动更新与排查实践
2026/9/16 23:06:18 网站建设 项目流程

凌晨两点被群里叫起来,说新版本没生效,用户还在走老逻辑。登上去一看,kubectl get deploy里显示的镜像是新的,kubectl get pod里跑着的容器却是三天前那一版。这种场面我遇到过不止一次,问题往往不出在"改镜像"这条命令本身,而是改完之后镜像根本没被重新拉取,或者你改的对象压根没触发滚动更新。kubectl更新容器镜像这件事,表面看只有一行命令,实际上至少有五六条路子,每条路子的适用场景、副作用、回滚成本都不一样,随手挑一条用,出事是迟早的。

这篇东西我按自己的使用习惯来写:先把"改的到底是谁身上的哪个字段"讲清楚,再把set imageeditapplypatch这几条路一条条拆开,最后讲镜像没换成功时怎么顺着链路查下去,以及把这件事塞进 CI 流水线时要注意什么。适合已经能跑kubectl get pod、但对 Deployment 更新机制只有模糊印象的人看,也适合天天在流水线里改 tag 却总被"更新没生效"折磨的人对照着排查。

1. 先把"更新镜像"这件事拆开:你改的到底是哪个对象的哪个字段

1.1 镜像字段藏在三层嵌套里

新手最容易犯的错,是把kubectl set image当成一个"改 Pod 镜像"的命令。实际上绝大多数情况下你改的是 Deployment(或者 StatefulSet、DaemonSet、CronJob)的 Pod 模板,路径长这样:

Deployment └── spec └── template <- Pod 模板,改这里才会产生新的 ReplicaSet └── spec ├── initContainers[] │ └── image └── containers[] └── image

关键点在于,spec.template是"模板"。你把它改掉,Deployment 控制器会发现模板的哈希变了,于是创建一个新的 ReplicaSet,再由新旧两个 ReplicaSet 按滚动更新策略做替换。所以"更新镜像"本质上是一次模板变更,而不是给正在跑的容器换个皮。

顺带说一个冷知识:Pod 对象里的image字段其实是允许原地修改的少数几个字段之一(和activeDeadlineSecondstolerations之类一样属于可变更字段),你直接kubectl set image pod/xxx c=img:new也能生效,kubelet 会把容器重启一遍。但这条路基本不要走,因为 Pod 的所有者(ReplicaSet 之类)会按模板把差异纠回来,你等于是在跟自己较劲。除非你手动裸跑一个 Pod,否则一律改控制器对象。

1.2 更新镜像和"重启 Pod"是两件不同的事

这是第二个高频误解。很多人改完镜像没看到效果,第一反应是kubectl rollout restart,结果重启之后还是旧镜像,于是开始怀疑人生。

这两件事的关系是这样的:镜像如果变了,重启是自动发生的,不需要你额外操作;镜像如果没变(同一个 tag 覆盖推送这种),那你重启一百次,拉到的还是本地的旧层,因为imagePullPolicy决定了 kubelet 要不要去仓库看一眼。所以"要不要重启"这个问题根本不该出现,你该问的是"我的镜像引用到底变没变"。

1.3 动手前的三秒检查:上下文、命名空间、拉取策略

我现在的习惯是,在执行任何变更类命令之前,先敲三条只读命令确认现场。这不是仪式感,是真的踩过"改到测试集群以为在改生产"的坑。

kubectl config current-context kubectl config view --minify -o jsonpath='{.contexts[0].context.namespace}' kubectl get deploy <name> -o jsonpath='{.spec.template.spec.containers[*].image}{"\n"}' kubectl get deploy <name> -o jsonpath='{.spec.template.spec.containers[*].imagePullPolicy}{"\n"}'

第三条和第四条就是看当前镜像和拉取策略。如果imagePullPolicyIfNotPresent而你的 tag 又是固定值(比如v1.2.3被重复推送覆盖),那后面必然会出现"改了没生效",提前知道这点能省掉半小时排查。

2. kubectl set image:一行命令改镜像,以及它的边界在哪

2.1 语法里最容易写错的三个位置

标准写法:

kubectl set image deployment/<deploy-name> <container-name>=<new-image> -n <namespace>

三个位置各有各的坑。资源类型和名字写错会直接报 not found,这个好办;真正麻烦的是容器名。容器名不是 Deployment 的名字,是spec.template.spec.containers[].name那个值,两者经常不一样。忘了容器名怎么办:

kubectl get deploy <name> -o jsonpath='{range .spec.template.spec.containers[*]}{.name}{"\n"}{end}'

如果你的 Pod 里只有一个常规容器,可以用通配符省事:

kubectl set image deployment/web '*=registry.example.com/web:1.6.0'

*会把containers列表里的所有容器都改成同一个镜像——这在你写 sidecar 名字写到手酸的时候确实爽,但反过来想,它也意味着你 Terminal 上任何多容器 Pod 都会被无差别覆盖,我一般只在单容器场景用。

还有一点值得单独提醒:initContainer 的镜像我习惯单独确认一遍,不要指望一个*就万事大吉。改完之后用kubectl get deploy -o yaml把整个 Pod 模板翻一遍,比什么都靠谱。

2.2 先预览再落地:--dry-run 的正确用法

变更类命令我强烈建议养成"先 dry-run"的习惯。set image支持客户端预演:

kubectl set image deployment/web web=registry.example.com/web:1.6.0 \ --dry-run=client -o yaml | grep -A2 'image:'

它不会打请求到 API Server,只在本地把修改后的对象渲染出来给你看。确认容器名匹配上了、镜像 tag 没打错,再执行真命令。这个动作花 5 秒,能挡掉"容器名打错导致新起了一个同名以外的容器"这类不可逆的蠢事——虽然set image遇到不存在的容器名会报错,但你先看一遍总归更稳。

2.3 --record 已经指望不上了

老教程里经常让你加--record,说这样可以记录变更原因,方便kubectl rollout history里看到是谁改的。这个参数在新版本里已经废弃并移除了,加了要么报错要么无声地不生效。现在要留变更记录,正规做法是自己在 Pod 模板上打 annotation:

kubectl annotate deployment/web \ kubernetes.io/change-cause="release-1.6.0 by ci-1024" \ --overwrite

注意这个 annotation 打在 Deployment 上就行,不需要打进 Pod 模板。把变更来源、工单号、提交哈希写进去,出问题回溯时能救命。

实测下来,set image这条路的优点是快、可脚本化、变更面小(只动 image 字段);缺点是它只能改镜像,不能同时改镜像拉取策略、资源限制、环境变量。如果一次发版要动好几个字段,别硬凑set image,直接走apply

3. kubectl edit 与 kubectl apply:看得见改动的两条路

3.1 kubectl edit 适合救火,不适合长期依赖

kubectl edit deployment/web -n prod

它会用你的KUBE_EDITOR(没设就用 vi)拉一份实时对象给你改,存盘退出后把差异提交回 API Server。优点是所见即所得,容器名、tag、imagePullPolicy都在眼前,不容易写错字段路径。

它的风险也全在这里:你手里拿的是集群里当前的状态,不是 Git 仓库里的清单。如果这时候集群和仓库本来就漂移了,你基于漂移状态改一下再提交,漂移就被"固化"了。更别说手一抖删掉两行、或者把缩进弄乱,报错还好,怕的是改坏了别的地方你还浑然不觉。

我现在只在两种场景用它:一是半夜救火,需要立刻把镜像换回上一个稳定版本;二是排查字段到底长什么样,进去看一眼就退出(不存盘)。长期变更一律走文件。

3.2 从 YAML 文件改镜像再 apply 才是正路

标准动作:

# 1. 从集群导出当前清单(注意去掉运行时字段) kubectl get deployment web -o yaml > web.yaml # 2. 编辑 image 字段 $EDITOR web.yaml # 3. 回灌 kubectl apply -f web.yaml --dry-run=server # 先做服务端校验 kubectl apply -f web.yaml

这里有两点经验值得说。第一,导出后记得清掉metadata.resourceVersionstatusmetadata.uid这些运行时字段,虽然apply大多数时候能容忍它们,但混着放容易在后续 diff 时产生噪音。第二,--dry-run=server--dry-run=client的差别很关键:客户端预演只做语法和本地结构校验,服务端预演会把请求真的发到 API Server 做准入(admission)校验但不落库。真正能拦住问题的,是服务端预演。

如果你的清单是用 Helm 或 Kustomize 管理的,那就更不该手改渲染后的 YAML。Kustomize 有对应的命令直接改镜像引用:

kustomize edit set image registry.example.com/web=registry.example.com/web:1.6.0

它改的是kustomization.yaml里的 images 段,比全局搜索替换安全得多。

3.3 apply 时报 error validating 校验失败,按这个顺序查

网上关于apply校验错误的提问特别多,典型症状就是类似error: error validating "xxx.yaml"这种形式。这类报错看起来吓人,其实排查路径非常固定,我一般按下面这个顺序走。

排查顺序检查项典型现象处理方式
1YAML 语法与缩进error converting YAML to JSONkubectl apply --dry-run=client定位行号,检查缩进和制表符
2字段类型是否匹配cannot unmarshal string into Go value of type int32端口、副本数这类字段不要加引号
3apiVersion 与集群版本no matches for kindkubectl api-resources对比可用的组与版本
4自定义资源是否已注册no matches for kind "Xxx" in version先确认对应的 CRD 已安装并就绪
5旧的三路合并基线冲突field is immutable或字段被莫名重置清掉last-applied-configuration或改用服务端应用

第三条和第四条特别容易被忽略。很多清单是从网上抄的,版本比较老,而你的集群已经升级过,资源组名或版本号早就变了。第四条的典型场景是:你先把使用了某个自定义资源的清单灌进去,但负责提供这个 CRD 的组件还没装好或者还没就绪,apply自然就报校验失败。解决办法很简单,先装 CRD、等它 Ready,再灌业务清单。同样的道理,如果你在改镜像的时候顺手apply了一堆网络插件的清单,报错大概率是在 CRD 或者 RBAC 那一层,别死盯着 Deployment 看。

还有一条隐形坑:kubectl apply依赖last-applied-configuration这个 annotation 做三路合并。如果这个对象曾经被kubectl edit手改过、或者被别的控制器(比如同步工具、Operator)动过,合并结果可能和你想的不一样——你只改了一个 image,结果别的字段被回退成了旧值。遇到这种诡异现象,别怀疑人生,先看那个 annotation,必要时用kubectl apply --server-side --force-conflicts换成服务端应用,把字段所有权理清楚。

4. kubectl patch:脚本化和流水线里最趁手的一把刀

4.1 三种 patch 类型,用错了会打到别的容器上

kubectl patch支持三种写法,差别不是风格问题,是会出错的问题。

strategic merge patch(不显式指定 type 时的默认值是 strategic,对内置资源生效)。它的特点是列表按 merge key 合并,containers列表的 merge key 是name

kubectl patch deployment web -n prod --type=strategic -p \ '{"spec":{"template":{"spec":{"containers":[{"name":"web","image":"registry.example.com/web:1.6.0"}]}}}}'

注意我上面只写了nameimage两个键,其余字段都保留了——这就是 merge key 的好处,它是按容器名字匹配的,不会因为顺序变化打错目标。

JSON merge patch--type=merge)就不一样了,它遇到列表是整体替换。上面那条 patch 如果换成--type=merge,你的容器列表会被替换成一个只有 name 和 image 的残缺容器,其他字段全没了。这是个非常隐蔽的坑,容器能起来纯属运气好。

JSON patch--type=json)按数组下标操作:

kubectl patch deployment web --type=json -p \ '[{"op":"replace","path":"/spec/template/spec/containers/0/image","value":"registry.example.com/web:1.6.0"}]'

快是快,但containers/0这种下标绑定非常脆。别人往模板里加了个 sidecar 并且排在前面,你的 patch 就把 sidecar 的镜像改了,业务容器纹丝不动,报错都没有。除非你百分之百确定列表顺序不会变,否则别用下标。

4.2 镜像 tag 不变时,用 annotation 强制触发滚动

这是实战里使用频率最高的一个 patch 技巧。有些场景你不得不复用同一个 tag(比如内部约定用stable这种浮动 tag),这时候光改 image 是没用的,因为模板哈希没变。做法是往 Pod 模板上打一个时间戳 annotation:

kubectl patch deployment web -n prod -p \ "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"kubectl.kubernetes.io/restartedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}}}"

kubectl rollout restart deployment/web内部干的就是这件事,只是它把时间戳格式和字段名都替你想好了,所以能用这条内置命令就别手写 JSON。两者的区别是:restart不会改镜像(它假设镜像已经变过了,或者你就是要重拉),而 patch 可以一次把镜像和 annotation 都带上。

顺便说,restartedAt这种"改模板元数据来触发更新"的套路,本质上是利用了"模板哈希变化"这个机制,跟镜像本身没关系。理解这一点之后,你就能明白为什么给 Pod 模板加一个无害的 annotation 也能触发完整的一轮滚动更新。

4.3 patch 的幂等性和返回信息要看清

patch是幂等的:同样的 patch 打两次,第二次的结果和第一次一样,这点在重试逻辑里很省心。但它的返回信息值得每次看一眼,尤其是输出里带(no change)的时候——那说明你的 patch 根本没匹配上任何字段,常见原因是容器名写错了,或者 annotation 的路径层级少写了一层。

kubectl patch deployment web -p '{"spec":{"template":{"metadata":{"annotations":{"x":"1"}}}}}' # deployment.apps/web patched -> 真的改了 # deployment.apps/web patched (no change) -> 没匹配上,回去检查路径

在流水线里我习惯把这行输出抓下来做判断,出现(no change)就让流水线失败,而不是当作成功继续跑。因为"更新镜像"这件事最怕的就是静默成功。

5. 镜像没换成功?顺着这条链路往下查

5.1 头号嫌疑:tag 没变加上 IfNotPresent

前面埋了很久的伏笔现在揭晓。imagePullPolicy的默认规则是这样的:如果 tag 是latest或者你根本没写 tag(等同于 latest),默认是Always;如果写了具体 tag,默认是IfNotPresent

IfNotPresent的语义是:本节点上已经有这个镜像引用了,就不再向仓库请求。注意它判断的是镜像引用字符串,不是内容摘要。所以你把web:1.6.0这个 tag 重新推了一遍,节点上还留着旧的web:1.6.0,kubelet 会认为"我有这个镜像了",根本不去查仓库。结果就是你改了 Deployment、确实触发了滚动更新、Pod 也确实重建了,但跑的还是旧代码。

排查三板斧:

kubectl get pod <pod> -o jsonpath='{.spec.containers[*].image}{"\n"}' kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[*].imageID}{"\n"}' kubectl describe pod <pod> | grep -A3 'Image:'

第一条是"清单里声明了什么",第二条是"实际运行的镜像摘要是什么"。这两个对不上,说明拉取环节出了问题。三种解法按推荐程度排:换 tag(最干净,天然幂等)、换成 digest 引用(最严格)、imagePullPolicy改成Always(最省事但每次重建都要走一次仓库,会拖慢扩容和故障恢复,节点多的时候还会压仓库)。我自己的选择是:生产环境用 digest,预发用递增 tag,测试环境随便。

5.2 滚动更新卡住不动,问题通常不在镜像

kubectl rollout status deployment/web不返回,或者一直显示Waiting for deployment rollout to finish,这时候别急着去查镜像,先看这几个常见原因。

现象可能原因快速确认方式
新 Pod 一直是 Pending节点资源不足、亲和性、污点kubectl describe pod看 Events
新 Pod ImagePullBackOff镜像地址、私有仓库凭据检查imagePullSecrets与节点网络
新 Pod CrashLoopBackOff应用启动失败、配置缺失kubectl logs --previous
新 Pod Running 但一直不就绪就绪探针配置不当或依赖不可用kubectl describe pod看探针失败信息
老 Pod 不退出maxUnavailable: 0加探针不通过看 Deployment 的更新策略字段
滚动进度为零maxSurge: 0maxUnavailable: 0这是个非法组合,会被拒绝或卡死

maxSurge: 0maxUnavailable: 0同时设置是典型的自锁配置。前者限制不能多开 Pod,后者限制不能少 Pod,那新 Pod 永远没位置起,滚动就停在那里了。改镜像之前顺手看一眼更新策略,能避免很多"命令执行成功了但没动静"的诡异体验。

还有两个经常被忽略的因素。一是PDB(PodDisruptionBudget),它管的是主动驱逐,理论上不阻塞 Deployment 自己的滚动更新,但在节点资源紧张、Pod 被驱逐和滚动更新交织的场景下,会让你感觉"更新特别慢"。二是探针的 initialDelaySeconds 和 failureThreshold 乘起来太保守,一个 Pod 要等三分钟才 Ready,十副本滚动就是半小时,你会以为卡住了。

5.3 rollout status / history / undo 的正确配合

这三个命令我用得最频繁,值得单独说清楚:

kubectl rollout status deployment/web -n prod --timeout=300s kubectl rollout history deployment/web -n prod kubectl rollout history deployment/web -n prod --revision=3 kubectl rollout undo deployment/web -n prod --to-revision=3

history列出的每一行对应一个 ReplicaSet,能回滚多少个取决于spec.revisionHistoryLimit(默认保留 10 个)。所以别以为回滚是无限的,发版特别频繁的服务建议把这个值调到 20 以上。

undo --to-revision才是真正的"回滚到某一版",不带参数只回退到上一版。这里有个隐蔽的坑:回滚本身也是一次模板变更,它会创建一个新的 revision,history 里的编号是往前增长的,不是简单地把指针拨回去。理解这一点,你在排查"为什么回滚之后 revision 号变大了"时就不会困惑。

还有一个必须提醒的点:rolling update 的暂停与恢复kubectl rollout pause之后你连续改好几次镜像,最后resume,只会触发一次滚动。这在需要同时改镜像和配置的联调场景里非常好用,能避免中间态被观察到。

kubectl rollout pause deployment/web kubectl set image deployment/web web=registry.example.com/web:1.6.1 kubectl set env deployment/web FEATURE_X=on kubectl rollout resume deployment/web

5.4 用 digest 固定版本,顺手把镜像安全的基础动作做掉

容器镜像安全这件事,落到日常操作上其实很朴素:别用浮动 tag,别让同一个 tag 指向不同内容。最直接的做法就是引用 digest:

registry.example.com/web@sha256:7f8e...c1a2

digest 是内容寻址的,内容变一点,digest 就完全不同,根本不存在"同名 tag 被覆盖"这个问题。获取 digest 的方式,用容器运行时自带的工具或者专门的镜像检查命令都行,例如:

skopeo inspect docker://registry.example.com/web:1.6.0 | grep Digest

把 digest 写进 Deployment 之后,imagePullPolicyIfNotPresent反而是安全的,因为 digest 变了就意味着引用字符串变了,kubelet 一定会去拉。这也顺带解决了一个安全审计需求:你能精确回答"线上跑的是哪一次构建产物"。

配套的两个习惯,我也一并说说。一是私有仓库的凭据用imagePullSecrets引用一个只读权限的 Secret,不要图省事把长期有效的凭据写进节点级配置。二是发版用的 ServiceAccount 权限要收窄,只给它需要改的那几个 Deployment 的getpatchupdate权限,而不是一把管理员凭据走天下。这个后面在 CI 部分还会展开。

6. 把更新动作放进流水线:从 kubeconfig 到漂移控制

6.1 CI 里配置 kubeconfig 的三种方式与各自的坑

很多人第一次在流水线里跑kubectl会卡在认证上,报的无非是几个错:找不到配置文件、证书过期、权限不足。以 GitLab CI 为例,常见的做法有这么几种。

第一种是把 kubeconfig 内容作为一个受保护的变量存起来(base64 编码后存放更稳妥,避免换行被吃掉),在 job 里解码到临时文件,然后靠KUBECONFIG环境变量指过去:

echo "$KUBE_CONFIG_B64" | base64 -d > /tmp/kubeconfig export KUBECONFIG=/tmp/kubeconfig kubectl config current-context

第二种是走集群提供的令牌签发机制生成短期凭据,用完后自动失效,安全性明显更高,代价是配置步骤稍多。第三种是在集群侧部署一个常驻的同步组件,由它去拉取仓库变更,流水线根本不持有集群凭据。规模大一点、合规要求高一点的团队,基本都会走到第三种。

不管用哪种,有三个细节必须注意。一是KUBECONFIG指向的文件权限,CI 环境里经常是多用户共享的 runner,临时文件最好放在 job 私有目录里并在结束时清理。二是每个 job 都要确认当前 context 和 namespace,因为共享 runner 上可能残留环境变量,你以为是生产,其实打到了另一个集群。三是别用个人账号的凭据,个人凭据权限大、无法审计、人一离职就废,用专门的 ServiceAccount 加 RBAC 才是正路。

一个收窄权限的 RBAC 示例,思路是只允许它改指定 namespace 下的 Deployment:

apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: prod name: image-updater rules: - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list", "watch", "patch", "update"]

6.2 直接 set image 改集群,还是改仓库让同步工具去推

这是流水线设计里最值得想清楚的一个取舍,我把它做成表格对照着看:

方式变更来源优点风险
流水线直接set image/patch集群实时状态见效快,无需等同步周期与 Git 漂移,下次同步可能被回退
流水线改清单文件再apply文件变更可追溯、可评审需要管理文件仓库和合并冲突
流水线只改文件,由同步工具拉取文件 + 控制器单一事实来源,最可控链路长,出问题要跨系统排查

我踩过的坑是这样的:团队一边用同步工具盯着仓库,一边又在流水线里直接set image。结果是流水线改完之后,同步工具在下一个周期把集群状态按仓库内容纠了回去,镜像又变回旧版本。整个过程没有任何报错,只有用户投诉。所以选了一条路就别脚踏两条,要么流水线只改文件,要么关掉同步工具对这类资源的接管(用 ignoreDifferences 之类的机制)。

还有一个务实的小技巧:如果你们不得不在流水线里直接改集群(比如临时热修),至少把变更同步回仓库的清单,或者在 Deployment 上打一个带工单号的 annotation,给后面的人留一条线索。

6.3 灰度与回滚要跟更新动作一起设计

最后说一个容易被当成"后续再说"的问题。更新镜像这一步一旦自动化,触发频率会上来,灰度能力就变成了必需品。最简单的一套组合是:先rollout pause,改镜像、改配置,resume之后观察rollout status,出问题立刻undo --to-revision。再往前走一步,就是把副本数拆成稳定版和金丝雀版两个 Deployment,用 Service 的选择器或者流量层来做切分,镜像更新只发生在金丝雀那一边。

回滚能不能真的救你,取决于三个配置:revisionHistoryLimit是否够大、旧镜像是否还在仓库里没被清理、以及回滚所需的配置项是否也做了版本化。我见过最尴尬的情况是回滚命令执行成功,Pod 也起了,但因为旧镜像的 tag 已经在上周被清理工具删掉了,新拉起的 Pod 卡在 ImagePullBackOff,回滚变成了二次事故。镜像仓库的生命周期策略和 Deployment 的回滚窗口,这两个参数是要一起看的,不是各管各的。

回到我自己的日常习惯,现在固定这么一套:预发环境用set image加递增 tag 快速验证,生产环境一律走文件加同步工具的流程,所有生产镜像引用都用 digest,任何变更类命令之前先--dry-run看一眼,执行之后必须跟一条rollout status确认到底。这套动作不复杂,但确实把"改完没生效"这类问题从每周几次压到了几乎为零。真正省时间的从来不是那行命令,而是命令前后那几秒钟的确认动作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询