容器 14:构建缓存为什么不能等同于可复现性
输入改变与缓存边界
在 BuildKit 中,一个步骤的缓存依赖指令、输入内容及相关构建状态。更改被 COPY 的源码通常会影响后续依赖节点;修改上下文中一个被 .dockerignore 排除的文件不应改变对应 COPY 的输入。命中缓存只是重用已有结果,不证明两台构建机独立构建会得到字节相同的 tar、时间戳或上游包;要谈可复现还要固定基础镜像 digest、依赖、平台、环境与产物字节。
同一行 Dockerfile 也可能有多种输入
缓存检查先确定“这个步骤依赖了什么”:COPY probe_app.py 会消费构建 context 中经过忽略规则筛选后的脚本字节,后续以它为输入的步骤才能从新内容出发。更改仅存在于另一个目录、没有被 Dockerfile 步骤读取的文件,即使它就在本机工作区,也不能由“文件修改时间变了”推出对应缓存必须失效。反过来,基础 FROM 指向可变 tag 而解析到新 digest,即使应用源码没有变化,后续图中的依赖也可能改变。为此每轮要记录构建 context、.dockerignore、选定基础镜像 digest、构建参数、目标平台和 builder 版本,而不是只贴“命中缓存/未命中缓存”两个字。
缓存命中只支持这一次构建器复用了被它判定为等效的已有结果。假设两个开发者分别在各自机器构建:一台使用旧缓存,另一台使用新解析的 base,最终 digest 不同并不与第一台“缓存命中”矛盾。即使两台都重新运行 RUN,上游包仓库响应、环境变量、时钟和压缩器版本也可能影响输出字节。要声称字节级可复现,必须在已明确的边界内取两次独立输出,直接比较 manifest/config/layer digest 及原始 bytes;日志里同一个命令行、相近的镜像大小或程序两次都返回 200 都不够。另一方面,digest 不同也不必然是程序逻辑变了,应追查哪个 descriptor 的字节首先发生差异。
为什么普通 ARG 不是密钥入口
若把真实口令写到 ARG TOKEN=...、ENV 或 COPY,即便后一层 rm 删除,旧构建层、历史信息和构建日志都可能保存明文;清理最终 merged 路径不能擦掉先前上传的 blob。Docker 官方 Build secrets 文档提供 secret mount,使构建阶段可读取被授权的 secret 文件而不把它当作普通输入层持久保存。这里 Dockerfile.secret 只运行 test -s /run/secrets/synthetic:它没有打印 secret,也没有把文件复制到最终 /app;但不能由此断言任意更复杂的 RUN 命令都不会把 secret 写到输出文件或标准输出。教学仅使用实验专用的合成字符串,源码中不包含可发布的凭据。
--mount=type=secret,id=synthetic,required=true 表明该 RUN 步骤要求有名为 synthetic 的 secret,缺失则步骤应失败;失败属于构建阶段,不意味着已运行的应用出现权限异常。向 Docker CLI 提供 secret 时记录它来自受控本机临时文件、作用域仅限这次构建,不把字符串写在 shell 命令行选项里供历史记录传播;私有日志中也不输出真实凭据。示例 Dockerfile.secret 仅验证文件非空,所以替换另一个非空合成 secret 的内容即使真的重新执行了 test -s,对该命令的逻辑结果仍然相同。不能借最后镜像不变反推出“肯定是缓存命中了”,更不能推断应用已使用了新 secret。
secret 值变化可能没有缓存失效
Docker Build cache invalidation 文档明确:secret 内容本身不参与 build cache 的计算,仅换值不会自动使相关层失效;secret 的 ID、挂载路径等参数则会影响依赖。因此“给构建机换了新的凭据”不等于旧步骤一定重跑。若这一步只是 test -s,复用已有成功结果在本示例甚至不改变最终脚本的行为;若真实构建步骤用旧凭据从远端拉依赖,则静默缓存命中可能掩盖凭据已经变更。需要强制重新执行时,应在受控范围改变明确的非秘密缓存输入,例如合成的可审计 build arg;仍不得把实际 secret 字节放进 ARG 作为缓存失效技巧。
比较 secret 行为需要将“步骤是否执行”“步骤能否读取这次提供的文件”“最终制品是否包含合成字符串”拆开。可以准备两个不同且仅供本次实验的合成字符串 S1、S2,分别构建并保存 BuildKit 输出;在独立轮次禁用或改变缓存条件时观察 required=true 的错误路径;最后按选中的 manifest digest 拉取最终 layers/config,在它们的字节、docker history 及构建日志里寻找的是合成串。即使这些地方都没找到,也只能在本次已检查的范围内说明“未发现明文”,不证明构建器临时目录、系统审计记录、远端 cache exporter 乃至任何第三方插件都没有副本。清理只有在确认对象都属于实验时才能移除,不能删除共享构建缓存以求一个干净对照。
版本和平台会改变你以为稳定的输入
两份看似相同的 Dockerfile 如果通过默认 PYTHON_IMAGE=python:3.12-slim 解析,某次上游 tag 更新会引入新解释器和不同基础层。先锁定目标平台的 base digest,再把它作为 13、14 两篇共用的构建参数,才能将源码、secret、cache 变化归到同一条实验链。若在 x86_64 构建后换 ARM 节点,multi-platform index 选择的 manifest 不同,最终字节不同本来就是平台条件变化,不属于“同输入不可复现”的反例;真实跨平台话题留给 15。内容寻址能检测字节变化,但决定什么是“同输入”仍需人工列出外部依赖、网络访问和工具版本。
一个可以安全制造的失败是调用 builder 时完全不给 required=true secret,只在私有目录使用自己的 tag 和没有真实凭据的 Dockerfile。保存 docker build 退出码与指向 RUN 的报错,核对它未覆盖上一轮成功镜像使用的 digest;之后再提供合成 secret 重跑,不能因为同名 tag 原先存在就误以为失败轮次产生了新镜像。另一种失败是把 probe_app.py 改成错误 Python 语法:若 secret 检查在 COPY 前而 RUN 能命中缓存,它可能不能发现源码错误,而真正的语法检查在不同构建阶段才出现。构建图的顺序与步骤职责都应逐条观察,不把“密钥读取成功”说成“构建安全、功能正常”。
密钥不能用 ARG 或普通 COPY 塞入构建层再删除:历史层可能保留内容。源码包中的 Dockerfile.secret 采用 RUN --mount=type=secret,id=synthetic,required=true 只检查密钥文件非空;实验只能用在专用 VM 临时生成的合成字符串。构建输出后检查制品层、配置与日志是否泄露合成字符串,同时检查 BuildKit 对 secret 值变化时的缓存行为;“没有在最终层检索到”也不等于日志、缓存、构建器环境没有其他泄露点。不能对真实凭据做可发布博客实验。
正例对照先固定基础 digest,再仅修改 probe_app.py 并记录步骤缓存/生成 digest;边界例改变 secret 内容但不改变公开构建输入,缓存命中不能证明使用了新的密钥。失败案例不给必需的 secret,预期构建步骤失败而不是启动阶段失败。当前云端无 BuildKit,所有缓存、密钥与构建结果 NOT_RUN,入口注明版本及检查边界。
本机能静态核对的只是 Dockerfile 里的 RUN --mount 语法、required=true、独立归档的哈希以及共用探针文件,不会凭空出现两个构建的最终 digest。需要真实 BuildKit 的 VM 时,每轮同时记录环境(builder 版本、架构、基础 digest)、完整构建命令、输入变动、是否命中缓存、HTTP/文件输出、制品字节差异和只属于本轮的清理结果。缺其中一列时只承认已核验部分,严格把云端缓存/secret 运行状态留为 NOT_RUN。尤其不要将静态模式检查或 Hexo 页面生成误记为对合成密钥的泄露扫描已通过。
对同一份构建输入做四轮判定
第一轮保留完整 context,预热该 builder 的缓存并保存所有步骤标识;第二轮只修改 probe_app.py 的一个可观察行为,查看受影响的 COPY 和依赖该 COPY 的后续步骤是否按选定目标重算。第三轮恢复应用源文件,仅修改被 .dockerignore 排除的自建测试文件,判断该步骤的输入字节实际上是否改变;如果重新传送整个 context 的耗时变了,不能因此说最终镜像肯定变了。第四轮只替换 secret 的合成内容,核对缓存复用与最终 digest;即使第四轮结果完全相同,也只说明当前规则下没有由这个变动导致的可见输出差异。不同轮次之间保留每条命令和摘要,不用“改了文件”和“构建很快”这样的主观描述代替实际比较。
再给一轮独立缺 secret 的失败案例:不能复用上一轮镜像名就去检查 docker image inspect 的旧 ID,必须保留这次 docker build 返回非零的原始错误和结束时间,明确标记没有新制品。随后清理本次写下的合成密钥文件,核对它不再存在,清理在归档中生成的独立标签与临时容器;不清空其他人用的全局 builder cache。若清理失败,把仍存在的标签、文件路径和错误码记录下来,不能填“已完全回收”。这些验证全部依赖当前云端没有的构建器,所以只是 VM 的补跑设计。
若要验证合成字符串没有混入制品,还应按 manifest 实际引用的全部最终层逐个检查压缩 blob 解压内容,不能只在最终合并文件树里运行 grep:被后续层删除的字符串仍可能留在历史层。检查 image config、构建历史和本次暴露出来的日志时要对指定的合成标记逐字节搜索;没有找到仅说明这些被检查的产物在本轮没有命中标记。真实密钥绝不进入这套可公开传阅的实验材料,因而不存在需要把生产 token 上传到博客再来“验证不泄露”的步骤。
判断是否达到本篇目标时,同时索取构建缓存证据、两次最终制品字节对照和合成标记的有限范围扫描结果。少了任何一项,都只能把另外两项分别记下,不能用一份成功日志覆盖三种不同的验收问题。
| 现象 | 观察对象 | 不能推出 |
|---|---|---|
| 缓存命中 | 构建图步骤及输入 | 跨机器字节复现 |
| 镜像中找不到 secret | 层、配置、构建日志 | 所有构建器日志都安全 |
| secret 缺失 | BuildKit RUN 报错 | 应用进程出错 |
练习
- 先预测改变一个未被
COPY的文件对构建缓存的影响,再对照同一 Dockerfile 在干净与预热缓存下的日志,解释构建上下文和步骤输入区别。 - 先预测换掉合成 secret 的内容是否必然让相关步骤重跑,记录构建器版本、步骤行为、最终摘要,再核对官方文档的缓存边界。
系列总目录:从进程隔离到运行时与编排。




