用哈希命名并不等于构建可复现
一句话总结
用哈希给制品命名,与让同一份源码产出同样的字节,是两回事。前者是把标签贴准确,后者是把构建变成“输入相同则输出相同的函数”。只贴了标签就宣称可复现的流水线非常多。
为什么需要它
“用源码哈希打了标签,所以可复现”这种话经常听到,却很少被证明过。把同一个提交构建两次,直接比较两个制品的 SHA-256,往往是不同的。然而名字却是一样的。名字相同而内容不同的制品,会同时破坏三样东西:缓存在说谎,晋级模型崩塌,事故复盘时没有人能回答“当时发出去的到底是不是这个”。
可复现性实际换来的有三点。第一,缓存变得可信。 只有“相同的键就得到相同的结果”这个前提成立,才能把缓存命中当作“可以跳过构建”的依据。第二,可以重新构建来核实制品的来源。 用同样的源码和流程重新构建,字节一致的话,不需要签名就能核对该制品确实出自那份源码。第三,出事故时能把当时的制品重新造出来。 即使它已从仓库中被删除,只要源码和流程还在,就能再得到同样的字节。
非确定性从哪里进来
来源几乎是固定的。遇到新语言、新工具,这份清单也不会有太大变化。
- 构建时间。 把构建时间写进制品里,每次都会不同。压缩文件头、归档中文件的修改时间,甚至生成的源码中的注释都包括在内。
- 文件顺序。 遍历目录的顺序由文件系统决定,而这个顺序是没有保证的。装入归档的次序不同,字节就不同。
- uid/gid 和权限。 构建账号不同,归档里的所有者信息就不同。umask 不同,权限位就不同。
- 区域设置与排序。 排序结果随
LC_ALL而变,而排序结果会反映到列表文件或归档顺序里。 - 绝对路径。 构建目录的路径如果进入了制品,仅仅是工作区名称不同,结果也会不同。
- gzip 头。 gzip 默认会把原始文件名和时间写进文件头。
- 并行执行顺序。 如果多个任务不分先后地往同一个结果文件里追加内容,每次的顺序都会不同。
如何固定
核心约定是 SOURCE_DATE_EPOCH。官方文档把这个变量定义为“某个对象(通常是源代码)的最后修改时间,以自 Unix 纪元以来的秒数表示”。也就是说,构建工具在需要写入时间时,约定用这个值取代当前时间。这个值通常取自该提交的时间。
归档尤其要小心。官方文档推荐的形式如下。
export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
tar --sort=name \
--mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
-cf product.tar build
gzip -6 -n < product.tar > product.tar.gz # -n 은 원본 이름과 시각을 빼고 압축한다
--sort=name 固定文件顺序,--mtime 固定时间,--owner/--group/--numeric-owner 固定所有者,--pax-option 去掉混入 PAX 头的 atime/ctime。这个组合需要 GNU tar 1.28 及以上。并且判定不靠眼睛,而靠哈希。构建两次,看 sha256sum 是否相同——除此之外,没有别的方法能确认可复现性。
摘要保证什么,不保证什么
OCI 镜像规范把摘要定义为“针对字节的抗碰撞哈希”,并写明它使内容寻址成为可能。也就是说,只要通过安全的途径拿到摘要,即使内容来自不可信的地方,也可以重新计算哈希并比对,从而确认它没有被篡改。格式是 알고리즘:인코딩된값(占位符依次为算法与编码后的值),规范建议来自不可信来源的内容在使用之前必须用摘要验证。
这一特性也直接固化在存储格式里。OCI 镜像布局规范要求,放在 blobs/<알고리즘>/<인코딩된값>(占位符依次为算法与编码后的值)的内容,必须与摘要 알고리즘:인코딩된값 一致。也就是说,文件名指向的不是位置,而是内容本身。布局中还必须同时存在 oci-layout 以及充当入口点的 index.json。
这里必须把界线划准确。摘要所保证的,只到“我收到的字节确实就是那些字节”为止。至于这些字节出自哪份源码、经过什么流程,它完全没有说明。 要主张这种联系,就必须另外记录构建以什么为输入、经历了什么流程,这正是来源证明(provenance)所做的事。可复现构建与它相互配对,因为它使这个主张任何人都可以重新构建来验证。
在现场相遇的样子
- 清掉缓存之后,制品哈希变了。这说明缓存一直在混入结果,这期间的测试,测的是缓存造出来的东西。
- 同一个提交,CI 上构建的和手工构建的,大小差了几个字节。通常是归档的文件顺序或所有者信息。
- 为了回退而重新构建旧提交,得到的却是与从前不同的东西。这是没有被固定的输入在此期间发生了变动。
参考
- SOURCE_DATE_EPOCH 规范:https://reproducible-builds.org/docs/source-date-epoch/
- 归档的非确定性:https://reproducible-builds.org/docs/archives/
- tar(1):https://man7.org/linux/man-pages/man1/tar.1.html
- OCI 描述符(摘要):https://github.com/opencontainers/image-spec/blob/main/descriptor.md
- OCI 镜像布局:https://github.com/opencontainers/image-spec/blob/main/image-layout.md
- SLSA 来源证明:https://slsa.dev/spec/v1.0/provenance
下一项实验要做什么
只用 shell、tar、sha256sum,亲手核对同一份源码是否会产出相同的字节。先在不采取任何措施的情况下把归档构建两次,确认哈希出现分歧,然后逐一固定时间、顺序、所有者和 gzip 头,同时用眼睛追踪每一项让多少个字节发生了变动。最后整理成一个从提交时间提取 SOURCE_DATE_EPOCH 的构建脚本,并以运行两次的结果哈希是否相同来判定。这个 Pod 中无法启动容器,所以镜像方面的做法是用 skopeo 读取现成的 oci-archive 的摘要来进行核对。