镜像引用策略 — 标签、摘要,以及叠在上面的东西
一句话总结
标签(tag)是可以移动的名称标记,摘要(digest)是内容的哈希,无法移动,而镜像策略几乎全部都是这一句话的推论。
为什么需要它
在部署事故中,有一类最难重现:昨天运行良好的东西,今天的表现不同了。清单没变,代码也没变,可就是不同。当原因出在镜像上时,通常是因为同一个标签指向了不同的内容。
Kubernetes 官方文档简短地点明了这一性质:标签可以被移动,指向另一个镜像,而摘要是固定的。摘要是镜像内容的哈希,不可变。所以 app:v1.2.3 不是承诺,而是惯例。注册表并不阻止把新镜像推送到同一个标签上(除非是启用了不可变标签设置的注册表)。
latest 是这个问题的极端。如果不写标签,Kubernetes 就把它视为 latest。此外还有一个事实:如果省略标签,或者用 :latest,又没有写 imagePullPolicy,这个值会自动变成 Always。也就是说,Pod 每次重启,都会去拉取那一刻注册表给出的内容。昨天和今天不同,原因就在这里。
工作原理
镜像引用由三部分构成。
registry.example.com/team/app:v1.2.3@sha256:1ff6c18f...
└── 레지스트리 ──┘└ 이름 ┘└ 태그 ┘└─── 다이제스트 ───┘
- 注册表主机省略时,会被解析为默认注册表。所以策略的第一条规则通常是“要明确写出主机”。
- 标签是人能读懂的名称标记。由小写字母、大写字母、数字和
_ . -构成,最长 128 个字符。 - 摘要是哈希算法加上值(
sha256:...)。它就是 OCI 镜像规范的 descriptor 所定义的那个值,内容只要有一个字节不同,值就会不同。
如果三者都写了,会怎样?文档给出了准确的回答。同时写了标签和摘要时,拉取时只使用摘要。所以就有了实际工作中使用的形式——保留标签供人阅读,而真实身份用摘要固定下来。
imagePullPolicy 的默认值也是由引用的形状决定的。
| 引用 | 没有写 imagePullPolicy 时 |
|---|---|
| 写了摘要 | IfNotPresent |
标签为 :latest |
Always |
| 没有标签 | Always |
| 其他标签 | IfNotPresent |
摘要保证什么,不保证什么。这里的误解很多。
保证的只有一件事:内容的一致性。同一个摘要拉取两次,得到的是相同的字节。无论从哪个注册表拉取,无论几年之后再拉取,都是一样的。
不保证的有三件事。
- 这个内容是安全的。包含有漏洞的库的镜像,摘要也是完好的。固定意味着“知道拉取的是什么”,而不是“拉取它是好事”。
- 是谁构建的。摘要里没有作者。攻击者构建的镜像也有摘要。
- 是怎样构建的。从哪个源码、哪条流水线、用什么命令构建的,哈希中并不包含。
补上后两点的,是签名(谁来担保)和来源证明(是怎样构建的)。SLSA 的 provenance 规范定义了后者的标准格式——把构建者、源码仓库与提交、构建参数,以签名文档的形式留下来。固定摘要是这些上层结构站立的地基。因为要签名的对象,最终指向的就是摘要。本实验环境中没有 cosign 之类的签名工具,所以签名和来源证明只作为概念来讲解。
施加策略的两个位置。
*内侧(准入)。*查看 Pod 规格中的镜像字符串。允许的注册表前缀、禁止 latest、要求摘要。这里有一个重要的陷阱。镜像字符串检查是字符串检查,所以很容易被绕过。如果把 registry.example.com 放进允许列表,只做前缀检查,registry.example.com.evil.net/x 就会通过。必须准确切出主机边界(/ 前面的部分)再做比较。另外,容器并不只存在于 containers 中——漏掉 initContainers 和 ephemeralContainers 的策略很常见。
*外侧(构建和仓库)。*Dockerfile 的 FROM、清单和 Helm Chart 中的镜像值、构建产物的镜像列表。准入是最后一道防线,而这里是人可以修复的位置。如果仓库里有 FROM alpine:3.20,每次构建都可能引入不同的基础镜像。把同样的规则挂在两侧,开发者在 PR 中就能知道,而集群则拦住万一漏出去的。
在现场相遇的样子
第一,标签被覆盖的那天。CI 把 app:v1.4.0 推送了两次。集群中的 Pod 没有变化,但那天重启的 Pod,启动时用的是新的内容。于是在同一个 Deployment 中,每个 Pod 运行的是不同的代码,而原因只有比较摘要才能看到。用 kubectl get pod -o jsonpath 取出 status.containerStatuses[].imageID,就能得到实际拉取的摘要。
第二,回滚不了的回滚。只用标签部署的团队,决定“退回到上一个版本”,结果那个标签已经被覆盖了。要退回的目标消失了。如果是用摘要部署的,退回的地址就还在。
第三,固定摘要的代价。固定不是免费的。基础镜像的安全补丁不会自动跟进,所以必须同时有定期升级所固定的值的流程。没有这个流程,几个月之后就会变成“重现得很完美,但全都有漏洞”的状态。固定与更新是一对。
第四,允许列表的漏洞。用前缀比较编写的策略,放过了名称相似的外部主机,这样的事故屡屡发生。编写规则时,一定要同时放入必须通过的样本和必须被拦住的样本,尤其要加入名称相似的攻击样本。
参考文档
- Kubernetes 镜像:https://kubernetes.io/docs/concepts/containers/images/
- OCI 镜像规范的 descriptor: https://github.com/opencontainers/image-spec/blob/main/descriptor.md
- OCI 分发规范:https://github.com/opencontainers/distribution-spec/blob/main/spec.md
- SLSA provenance: https://slsa.dev/spec/v1.0/provenance
- 准入控制器:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
下一项实验要做什么
亲手拆解镜像引用,并用策略检查。从离线镜像包中取出清单和摘要,确认标签和摘要各自指向什么,再把同样的内容复制为另一个标签,看到摘要保持不变。然后编写允许的注册表、禁止 latest、要求摘要这三条规则,放入用名称相似的主机绕过前缀比较的样本,以及只有 initContainers 中存在违规的样本,亲自找出规则的漏洞并补上。