流水线全绿,跑的还是上一版:一次 apply 把镜像打回旧 tag

改完接口,流水线五步全绿,Pod 也 Ready,行为还是旧的。Pod 请求的 tag 是对的,实际跑的 digest 却是三天前的——清单里写死的 image,每次 apply 都把 set image 撤销掉。

改掉一个接口的返回字段,提交、等流水线、看日志,五步全绿。二十分钟后测试同学在群里说:还是老样子,接口返回的字段跟昨天一模一样。

我第一反应是缓存。网关缓存关掉、浏览器硬刷新、直接拿 Pod IP 打接口,返回还是旧的。这才去看集群:kubectl get pod 显示的是 Running 1/1,AGE 六分钟,事件里 rollout 也成功了。直到我把 Pod 的 image 和 imageID 一起打出来,才看见问题——Pod 请求的引用是当天的新 tag,实际跑的 digest 是三天前那个。

这件事在此之前已经出现过两次。每次我手动补一条 kubectl set image 就好,好了也就不追了。第三次之后我把流水线五个步骤的命令逐条抄下来对顺序,才明白为什么"再执行一遍就好了"的直觉是错的:真正让它好的,是我手动补的那一条,而流水线自己每一次都在把我手动补的东西撤销掉。

一、先钉死"跑的是哪个镜像"

排查镜像问题,image 字段不够用。它只是"请求的引用",可能是 tag,也可能被节点上的缓存解析成任意一个 digest。真正要看的两个东西是 imageID(实际运行的 digest)和 rollout history(这次 rollout 属于哪次变更)。

NS=default
kubectl -n $NS get pod -l app=api \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\n"}{end}'
kubectl -n $NS get pod -l app=api \
  -o jsonpath='{.items[0].status.containerStatuses[0].imageID}'
kubectl -n $NS rollout history deploy/api

输出是:

api-7d9c8f5b6-4xk2p    registry.example.com/app:20261110-3
docker-pullable://registry.example.com/app@sha256:4f52a927...

第一行看上去完全正确,tag 是当天第三次构建。第二行的 digest 拿回仓库一查,对应的是三天前那次构建。也就是说,APIServer 里记录的 Pod 模板是新的,节点上真正起来的容器是旧的——但 digest 和 tag 不一致这件事,只会在节点没拉新镜像的时候发生,而不是"模板没更新"。

我一开始把它当成 imagePullPolicy 的问题,翻了三个节点的容器运行时,镜像确实都在本地,且 IfNotPresent 下"本地有就不拉"。可 tag 是新造的,本地不该有。这个方向很快就走不通了,因为 digest 的创建时间早于 tag 的创建时间,说明拉到的是别人推的、同名不同内容的镜像,不是缓存。

二、把流水线的五个步骤摊开

流水线不复杂,五步:

步骤 命令 我原以为它干什么 实际副作用
build docker build -t $REG/app:$(date +%Y%m%d-%H%M) 出镜像 无
push docker push $REG/app:$TAG 推到仓库 无
apply kubectl apply -f 18-apps.yaml 保证 Service/Deployment 存在 把清单里的 image 写回 Deployment
setimage kubectl set image deploy/api api=$REG/app:$TAG 换成新镜像 只改 live 对象
rollout kubectl rollout status deploy/api 等就绪 等的是上一步的结果

第三行和第四行的顺序,是这件事的全部原因。18-apps.yaml 是迁移初期为了省事合成的一份清单,Deployment、Service、ConfigMap 全在里面,而 Deployment 里的 image 字段写的是当时手工填的 tag,之后没人再动过。

kubectl apply 不是"把文件覆盖到集群",它是三路合并:本地文件、last-applied-configuration 注解、集群里的 live 对象。对 image 这种标量字段,本地文件里有值就取文件值。kubectl set image 改的是 live 对象,不改那个注解,所以下一次 apply,文件里的旧 tag 又变成"期望状态",Deployment 触发一次 rollout,把三天前的镜像拉起来。

流水线当然全绿:apply 成功、setimage 成功、rollout 成功。每个步骤都完成了自己的语义,只是合起来的效果等于没发版。

三、验证这个猜测只花了一条命令

看 Deployment 上的 last-applied 注解,如果里面还是旧 tag,猜测就成立:

kubectl -n $NS get deploy api \
  -o jsonpath='{.metadata.annotations.kubectl\.kubernetes\.io/last-applied-configuration}' \
  | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["spec"]["template"]["spec"]["containers"][0]["image"])'

打出来的就是三天前的 tag。更直接的证据是事件:每次流水线跑完,Deployment 的 ReplicaSet 数量都会多一个,而新旧两个 ReplicaSet 的镜像 digest 不一样——如果只是"没拉新镜像",ReplicaSet 不该变。

顺带说一个容易误判的分支:如果这个 Deployment 是早期用 create 建的、没有 last-applied 注解,apply 会退化成二路合并,本地文件直接覆盖 live,覆盖得更彻底,连副本数、探针都可能被打回。所以"apply 是幂等的"这句话,只在清单与集群人工改动都不冲突时成立。

四、改成两份清单,并且只 apply 该 apply 的

改法不复杂,但要改的是约定,不是命令:

  • 17-services.yaml:Service、Ingress、ConfigMap、Secret,每次发布都 apply,这些对象天然幂等,重复写不会造成滚动。
  • 18-deployments.yaml:只放 Deployment,只在空集群引导时 apply 一次,之后任何发布都不许再碰它。
  • 镜像一律用 digest 引用,不用可变 tag:registry.example.com/app@sha256:...。
# 发布只做这两件事,Service 类对象幂等 apply,Deployment 只 set image
kubectl apply -f 17-services.yaml
kubectl -n $NS set image deploy/api api=$REG/app@$DIGEST
kubectl -n $NS rollout status deploy/api --timeout=420s

再加一道守卫,防止有人手滑把整份 apply 又打开:

if ! kubectl -n $NS get deploy api >/dev/null 2>&1; then
  echo "Deployment/api 不存在,这是空集群引导场景,请手工 apply 18-deployments.yaml" >&2
  exit 1
fi

这段守卫的价值不在于防止命令报错,而在于把"引导"和"发布"两个语义显式分开。之前之所以会合成一份清单,就是因为没人区分这两件事。

五、部署后自证:让流水线自己证明跑的是新镜像

rollout 成功只代表"Pod 起来了",不代表"起来的是这次构建的产物"。加一步比对 digest,成本一条命令,能挡住后面所有同源问题:

EXPECTED=$(kubectl -n $NS get deploy api -o jsonpath='{.spec.template.spec.containers[0].image}' | sed 's/.*@sha256://')
ACTUAL=$(kubectl -n $NS get pod -l app=api -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' | sed 's/.*@sha256://')
[ "$EXPECTED" = "$ACTUAL" ] || { echo "运行镜像与期望不一致: $EXPECTED != $ACTUAL"; exit 1; }

同时把 smoke 从"curl 首页返回 200"改成"Pod Ready=True 且日志里出现本次构建写入的版本号"。首页 200 是最没有信息量的断言,旧镜像同样返回 200。

六、同一类问题的另外两个面孔

改完这份清单之后,我在同一个流水线里又遇到两个同源的现象,一并记下来:

一是 ConfigMap 改了不生效。ConfigMap 变更不会触发 Deployment 滚动,kubectl apply 完必须补 kubectl rollout restart deploy/api,否则新配置要等到下一次重建才加载。

二是固定 tag 加 IfNotPresent 的假成功。有人为了"发布快",把镜像 tag 固定成 latest,节点上有旧的就直接复用,kubectl get pod 一切正常,行为却是旧的。这类问题用 digest 引用可以一次性消灭,因为 digest 变了节点必然会拉。

小结

  • image 是请求的引用,imageID 才是实际运行的东西;排镜像问题先看 digest,不要只看 tag。
  • kubectl apply 是三路合并,清单里写死的 image 会覆盖 set image 的结果,而且每一步都返回成功。
  • 清单拆两份:Service 类每次 apply,Deployment 只在引导时 apply,发布走 set image。
  • 镜像用 digest 引用,配合 rollout 后断言 Pod 的 digest 与期望一致,比"首页 200"有用得多。
  • ConfigMap 变更不触发滚动,apply 之后必须补一次 rollout restart。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。