适用场景
某个 Deployment 使用 registry.example.com/payments/api:stable 这类会被覆盖的标签。流水线已经把新镜像推送到仓库,也执行了 kubectl rollout restart deployment/payments-api,但灰度流量中仍偶发旧接口行为;更棘手的是,同一 ReplicaSet 中不同 Pod 的日志还显示不同构建号。
本文以 Kubernetes Deployment 为例,说明如何区分“控制器确实创建了新 Pod”“节点重新解析了标签”和“容器实际运行的是哪个镜像 digest”,并给出可回滚的 digest 发布方案。示例要求具有目标命名空间的只读和更新 Deployment 权限;镜像仓库地址、digest 与构建号均为演示值。
先明确三个容易混淆的事实
kubectl rollout restart 的作用是修改 Pod 模板注解,从而创建新的 ReplicaSet 和 Pod;它并不把某个标签固定为新镜像。imagePullPolicy: IfNotPresent 会优先使用节点本地已存在的镜像;即使使用 Always,解决的也是“新建容器时重新向仓库解析标签”,不是可追溯性问题。
标签是可移动的名称,digest 才唯一标识镜像内容。若 stable 在发布期间从 digest A 被覆盖为 digest B,节点 A 可能启动 A、节点 B 可能启动 B;若某些旧 Pod 尚未终止,还会继续运行 A。排障时不要只比较 .spec.containers[].image,它往往仍只是 :stable。
现场取证:从模板、Pod 到 imageID
先取出控制器记录的镜像与拉取策略:
kubectl -n production get deploy payments-api \
-o jsonpath='{range .spec.template.spec.containers[*]}{.name}{"\t"}{.image}{"\t"}{.imagePullPolicy}{"\n"}{end}'
kubectl -n production rollout status deploy/payments-api --timeout=5m
第一条输出的是期望,例如 api registry.example.com/payments/api:stable IfNotPresent。第二条只证明本次滚动的副本已就绪,不能证明所有容器来自同一构建。若拉取策略为空,不要据标签自行猜测:Kubernetes 会在对象首次创建时写入默认值,之后改变标签并不会自动修改该字段。
接着同时读取每个 Pod 的镜像声明和运行时 imageID:
kubectl -n production get pods -l app=payments-api \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .status.containerStatuses[*]}{.name}{"\t"}{.image}{"\t"}{.imageID}{"\n"}{end}{end}'
典型输出中,.image 仍是标签,而 imageID 的末尾会包含运行时解析后的 @sha256:...。同一业务容器出现多个 digest,或存在没有 imageID 的未就绪容器时,都不应继续放量。imageID 的前缀会随 containerd、CRI-O 和私有仓库实现变化,所以脚本只应抽取 @sha256: 后的值,不能依赖固定前缀。
还应确认没有旧 ReplicaSet 遗留副本:
kubectl -n production get rs -l app=payments-api \
-o custom-columns=NAME:.metadata.name,DESIRED:.spec.replicas,CURRENT:.status.replicas,READY:.status.readyReplicas
kubectl -n production get deploy payments-api \
-o jsonpath='{.status.updatedReplicas}{" updated / "}{.status.readyReplicas}{" ready / "}{.status.replicas}{" total\n"}'
updatedReplicas 应等于期望副本数,readyReplicas 也应与总副本数一致。若旧 ReplicaSet 仍有期望副本,优先排查发布工具是否同时修改了 HPA、是否存在暂停的 Deployment,或是否手工扩容了旧 ReplicaSet;不要直接删除 Pod 来“碰运气”刷新镜像。
用小脚本把 digest 一致性变成发布门禁
下面的脚本接收 kubectl get pod -o json 的标准输入。它验证业务容器都已产生 imageID,并且全部等于流水线传入的预期 digest。真实流水线应把 digest 作为受保护的构建产物传入,不能从不可信日志拼接。
from __future__ import annotations
import json
import sys
from collections.abc import Iterable
def image_digest(image_id: str) -> str | None:
marker = "@sha256:"
if marker not in image_id:
return None
return "sha256:" + image_id.rsplit(marker, maxsplit=1)[1]
def verify_running_digests(pods: Iterable[dict[str, object]], container_name: str, expected: str) -> list[str]:
problems: list[str] = []
for pod in pods:
metadata = pod.get("metadata", {})
status = pod.get("status", {})
pod_name = metadata.get("name", "<unknown>") if isinstance(metadata, dict) else "<unknown>"
statuses = status.get("containerStatuses", []) if isinstance(status, dict) else []
matched = [item for item in statuses if isinstance(item, dict) and item.get("name") == container_name]
if len(matched) != 1:
problems.append(f"{pod_name}: 找不到唯一的容器状态")
continue
actual = image_digest(str(matched[0].get("imageID", "")))
if actual != expected:
problems.append(f"{pod_name}: 运行 digest 为 {actual or '缺失'},期望 {expected}")
return problems
if __name__ == "__main__":
expected_digest = sys.argv[1]
payload = json.load(sys.stdin)
errors = verify_running_digests(payload["items"], "api", expected_digest)
if errors:
raise SystemExit("\n".join(errors))
保存为流水线工作目录中的 verify_image_digest.py 后,在滚动完成并经过就绪检查时运行:
kubectl -n production get pods -l app=payments-api -o json |
python3 verify_image_digest.py \
sha256:8d92c7b1d1b88f7e4f5e0f70de0ad2a0d4e6e1c8a1234567890abcdef0123456
退出码为 0 才允许后续流量切换。脚本刻意把“没有容器状态”“缺少 digest”“digest 不一致”都视为失败;这避免在新 Pod 尚未启动完成时错误通过。它不替代可用性探针、接口冒烟测试或签名验证。
根治:发布不可变引用,而不是覆盖标签
推荐让构建流水线在推送后获取镜像 digest,并把发布清单写成不可变引用。以下值需要由实际镜像仓库返回,不要复制示例 digest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
namespace: production
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: payments-api
annotations:
release.example.com/build: "2026.10.29.1"
spec:
containers:
- name: api
image: registry.example.com/payments/api@sha256:8d92c7b1d1b88f7e4f5e0f70de0ad2a0d4e6e1c8a1234567890abcdef0123456
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /readyz
port: 8080
对于 digest 引用,IfNotPresent 是合理的:本地缓存命中同一 digest 不会改变实际代码,且无需重复下载已有层。重要的是 spec.template 中的 image 发生变化,这会生成新的 ReplicaSet;仅在仓库覆盖 :stable 后执行 restart 不能提供同样的确定性。
发布命令应保留变更记录,并等待控制器收敛:
kubectl -n production set image deploy/payments-api \
api=registry.example.com/payments/api@sha256:8d92c7b1d1b88f7e4f5e0f70de0ad2a0d4e6e1c8a1234567890abcdef0123456
kubectl -n production annotate deploy/payments-api \
release.example.com/build='2026.10.29.1' --overwrite
kubectl -n production rollout status deploy/payments-api --timeout=5m
set image 会修改 Deployment 的 Pod 模板;注解用于将代码版本、变更单或构建记录关联到这次发布,但不是镜像身份的可信来源。若集群有准入控制器把标签自动改写为 digest,应以实际创建后的 Pod 模板和 imageID 为准,并把这一改写纳入发布审计。
回滚不能只写“回滚版本号”
Digest 发布的回滚应明确指向上一条已验证的 digest。先保留每次成功发布的 manifest 或受版本控制的 Helm/Kustomize 输出;发生问题时,把镜像改回上一条 digest 并等待就绪,再运行同一份 digest 校验。
kubectl -n production set image deploy/payments-api \
api=registry.example.com/payments/api@sha256:4c5b3a291ee0d129ef60f4c3a9d0d2cce7771234567890abcdef0123456789
kubectl -n production rollout status deploy/payments-api --timeout=5m
kubectl rollout undo 可用于回退 Deployment 的历史 Pod 模板,但在清单被其他自动化工具持续回写、或标签被覆盖的环境中,不应把它当作唯一证据。回滚完成后仍须核对运行中的 imageID、就绪副本和业务冒烟结果。
防止复发的检查项
- 生产清单禁止
latest、stable、prod等可变标签作为唯一镜像身份;使用仓库/镜像@sha256:...。 - 在 CI 中记录“源代码提交 → 镜像 digest → 部署清单提交 → 运行 Pod digest”的链路,并在切流前做一致性校验。
- 不要把
imagePullPolicy: Always当作版本治理方案。它能要求新建容器重新解析名称,却无法给旧实例、回滚和审计提供不可变身份。 - 用 readiness、
maxUnavailable、副本冗余和业务冒烟测试控制滚动风险;digest 一致只能证明运行版本一致,不能证明功能正确。 - 若组织要求镜像来源可信,再叠加镜像签名、漏洞扫描和准入策略;这些能力与 digest 固定互补而非替代。
总结
“已重启”不是“已运行新代码”的证据。先用 Deployment 模板确认期望镜像和拉取策略,再用每个 Pod 的 imageID 确认实际 digest,最后把该检查纳入切流门禁。将 Deployment 改为 digest 引用后,发布和回滚都能精确指向同一份镜像内容,节点缓存与标签覆盖不再让版本状态漂移。
Discussion
评论