同一个镜像 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 字段的摘要。
app:1.0 第一次指向内容 A,后来被重新推送成内容 B。节点 1 上已经有 app:1.0,默认的拉取策略发现本地有这个名字就直接用;节点 2 上没有,去仓库拉到的是 B。同一个 Deployment、同一个镜像名,两个节点上跑着两个版本,从 Pod 的定义里看不出任何差别。| 步骤 | 节点 | 镜像引用 | 实际运行 | kubelet 的动作 |
|---|---|---|---|---|
| 首次部署 | 节点 1 | app:1.0 | 内容 A | 拉取 |
| tag 被重新推送之后 | 节点 2 | app:1.0 | 内容 B | 拉取 |
| 同一时刻 | 节点 1(原来的 Pod) | app:1.0 | 内容 A | — |
| 删除后重建 | 节点 1 | app: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 的动作 |
|---|---|---|---|
| 节点 1 | app@sha256:<摘要 A> | 内容 A | 镜像已在节点上 |
| 节点 2 | app@sha256:<摘要 A> | 内容 A | 拉取 |
这时 tag app:1.0 在仓库里已经指向 B,不影响结果。按摘要部署同时得到了两种策略的好处:内容确定,并且可以放心使用 IfNotPresent,不依赖仓库在线。
确认一个正在运行的 Pod 到底是哪个镜像,看状态而不是定义:
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(验证记录)
参考资料