Skip to content

同一个镜像 tag,两个节点上跑的不是同一个镜像 ​

一个服务有三个副本,镜像都是 app:1.0。其中一个副本的行为和另外两个不一样:同样的请求,它返回旧的结果。重启这个副本没有用;把它赶到另一个节点上,问题消失了。

三个副本的 Pod 定义一模一样,差别在节点上:app:1.0 这个名字在不同节点上对应着不同的镜像。镜像的 tag 是仓库里一个可以改写的指针,并不标识内容;而节点默认的行为是「本地已经有这个名字的镜像就不再去拉」。两件事加在一起,同名不同物就成了可能。

本文在一个三节点的集群(Kubernetes 1.37.0)上把这个过程复现出来,再说明拉取策略各自的代价,以及为什么交付流程里要传递的是摘要而不是 tag。实验见文末。

一、复现 ​

实验的步骤:构建内容为 A 的镜像,推送为 app:1.0,在节点 1 上启动 Pod;再构建内容为 B 的镜像,用同一个 tag 推送一次;然后在节点 2 上按同样的镜像名启动 Pod。每个 Pod 记录它实际运行的内容,以及 Pod 状态里 imageID 字段的摘要。

镜像仓库tag app:1.0 → 摘要 B之前指向摘要 A,被同名推送覆盖节点 1:之前拉过 app:1.0节点上的镜像app:1.0 = 摘要 APod(app:1.0)运行内容 A节点 2:第一次用这个镜像节点上的镜像app:1.0 = 摘要 BPod(app:1.0)运行内容 B没有:拉到 B本地已有这个 tag:不拉取在节点 1 上删除 Pod 重建,仍然是 A;imagePullPolicy: Always 会重新解析 tag,但仓库不可用时 Pod 起不来按摘要部署(app@sha256:…)时两个节点一致,tag 之后怎么移动都不影响
图 1 · tag 是仓库里一个可以改写的指针,摘要才是镜像内容的身份。app:1.0 第一次指向内容 A,后来被重新推送成内容 B。节点 1 上已经有 app:1.0,默认的拉取策略发现本地有这个名字就直接用;节点 2 上没有,去仓库拉到的是 B。同一个 Deployment、同一个镜像名,两个节点上跑着两个版本,从 Pod 的定义里看不出任何差别。
步骤节点镜像引用实际运行kubelet 的动作
首次部署节点 1app:1.0内容 A拉取
tag 被重新推送之后节点 2app:1.0内容 B拉取
同一时刻节点 1(原来的 Pod)app:1.0内容 A—
删除后重建节点 1app:1.0内容 A镜像已在节点上,不拉取

第四行对应开头的现象:重启解决不了问题。节点 1 上已经有一个叫 app:1.0 的镜像,kubelet 的事件是 Container image "…/app:1.0" already present on machine,它没有去问仓库这个 tag 现在指向哪里。

触发这种情况不需要有人故意覆盖 tag。常见的来源:

  • 流水线对同一个版本号重新构建并推送(修了个小问题、重跑了一次失败的任务)。
  • 用分支名、环境名、stable 这类本来就会移动的 tag 部署。
  • 扩容或节点替换:新节点第一次拉取,拿到的是 tag 当前指向的内容,和老节点上缓存的不同。

最后一种最隐蔽:tag 被覆盖时什么也没发生,几周之后一次自动扩容才把差异带出来。

二、拉取策略各自的代价 ​

容器的 imagePullPolicy 决定 kubelet 启动容器前要不要联系仓库。不写时的默认值由镜像引用的形式决定。实验里读回的生效值:

镜像引用默认策略
app:1.0(具体的 tag)IfNotPresent
app:latest(或不写 tag)Always
app@sha256:…(摘要)IfNotPresent

IfNotPresent:本地有同名镜像就用。启动快,不依赖仓库,但 tag 被改写后各节点可能不一致,如上一节。

Always:每次启动容器都向仓库解析这个 tag 当前的摘要,本地已有该摘要的内容就不重复下载。实验里节点 1 上用 Always 启动,得到了内容 B。它有两个代价:

  • 它消除的是「节点缓存与仓库不一致」,没有消除「tag 会移动」。同一个 Deployment 的副本在不同时间启动,仍然可能解析到不同的内容,只是变成了以仓库为准。
  • 启动依赖仓库可用。实验里把仓库停掉之后:
策略仓库不可用时在节点 1 上创建 Pod
Always容器状态 ErrImagePull,起不来(镜像其实就在节点上)
IfNotPresent正常启动

仓库故障时,节点重启、Pod 被驱逐后重建、自动扩容都会受影响。

还有一个副作用:Always 拉取之后,节点上 app:1.0 这个名字被更新为指向 B。实验里随后用默认策略在节点 1 上再启动一个 Pod,得到的也是 B。也就是说,一个节点上「这个 tag 是什么」会被任何一次拉取悄悄改掉。

三、摘要才是镜像的身份 ​

镜像的摘要(digest)是内容的哈希,内容变了摘要就变。用摘要引用镜像时,两个节点的结果一致:

节点镜像引用实际运行kubelet 的动作
节点 1app@sha256:<摘要 A>内容 A镜像已在节点上
节点 2app@sha256:<摘要 A>内容 A拉取

这时 tag app:1.0 在仓库里已经指向 B,不影响结果。按摘要部署同时得到了两种策略的好处:内容确定,并且可以放心使用 IfNotPresent,不依赖仓库在线。

确认一个正在运行的 Pod 到底是哪个镜像,看状态而不是定义:

bash
kubectl get pod <名字> -o jsonpath='{.status.containerStatuses[*].imageID}'

Pod 定义里的 image 是「要求的名字」,状态里的 imageID 才是「实际运行的内容」。排查「同一个版本行为不同」时,先比较各副本的 imageID。

四、测试通过的和上线的是不是同一个 ​

把上面的问题往前推一步:流水线里「测试通过」的那个东西,和最后部署到生产的那个东西,是同一个吗?

如果每个环境各自从源码构建一次镜像,答案是否定的。实验里用同一份 Dockerfile 和同样的参数、不使用缓存连续构建两次,得到的镜像 ID 不同。实际的构建里差异来源更多:基础镜像的 tag 在两次构建之间移动了、依赖的版本范围解析到了新版本、构建时下载的内容变了。两个内容不同的镜像可以有完全相同的版本号。

由此得到交付流程里的几条做法:

  • 只构建一次。 一次提交对应一次构建,产出一个镜像;后续的测试、预发、生产各环节使用的都是它,环境之间的差别放在配置里,不放在镜像里。
  • 在环节之间传递摘要。 构建完成后记录摘要,测试报告、审批记录、部署清单里引用的都是摘要。tag 可以保留,用来给人看。
  • 部署清单里写摘要。 image: registry.example.com/app:1.0@sha256:… 的写法可以同时保留可读的 tag 与确定的摘要(生效的是摘要)。
  • 让 tag 不可变。 多数镜像仓库支持把仓库或项目设置为 tag 不可覆盖,第二次推送同名 tag 会被拒绝。这能挡住第一节里的无意覆盖,但挡不住「每个环境各构建一次」。
  • 基础镜像同样按摘要固定。 FROM 后面写摘要,升级基础镜像成为一次看得见的变更。

回滚也依赖这一点:回滚的目标是「上一个确认过的摘要」。按 tag 回滚时,如果那个 tag 已经被覆盖,回到的就不是原来的版本。数据结构变更对回滚的限制是另一个问题,见 能重新部署旧版本,不等于能回滚。


配套实验

  • codesphere-labs/kubernetes/image-tag-identity:kind 三节点集群(Kubernetes 1.37.0、containerd 2.3.4)与本地镜像仓库:同一个 tag 重新推送后各节点按 tag、按 Always、按摘要启动的 Pod 实际运行的内容与摘要,仓库停止时两种拉取策略的结果,以及重复构建的镜像 ID(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权