TT Lab
开始
学习 学习路径 课程

CI/CD 流水线

同一份源码构建两次,摘要却不一样

在 TT Lab 中继续学习

目标

在字节层面找出构建产物的非确定性,用 SOURCE_DATE_EPOCH 和 tar 选项让两次构建连字节都相同,然后建立守住这一点的检查脚本和门禁。

为什么重要

把哈希用作名字,和这个哈希可复现,是两个不同的问题。同一个提交构建两次,产物的摘要却不同,那么就没有人能证明已部署的字节出自那份源码——出事故时无处可回,缓存每次都落空,签名只能保证“这些字节”,却无法保证“这份源码”。非确定性几乎总是来自几个常见的位置:文件的修改时间、压缩头中刻入的时间和名称、目录的读取顺序、构建用户的 uid 和 gid,以及写进产物里的构建时间。把这些位置一个个钉死,构建就成了输入的函数,从那时起,一个摘要就成了指向整份源码的名字。

步骤

  1. 在 /root/repro/src 创建源码树——src/app.py、src/lib/util.py、src/conf/app.conf、src/README.md,以及 src/VERSION(内容是一行 1.4.0)。接着创建 /root/repro/naive-build.sh <출력파일> [소스디렉터리](占位符依次为输出文件与源目录)。源目录的默认值是 /root/repro/src,先把源码复制到临时暂存目录,再用 tar -czf <출력파일> -C <스테이징> .(占位符依次为输出文件与暂存目录)打包。赋予执行权限,间隔 1 秒以上运行两次,生成 /root/repro/out/naive-1.tar.gz 和 /root/repro/out/naive-2.tar.gz,然后在 /root/repro 下把 sha256sum out/naive-1.tar.gz out/naive-2.tar.gz 的输出原样保存到 /root/repro/naive.sha256。两个值必须不同。
  2. 留下两个产物为什么不同的证据。第一,把 diff <(tar --full-time -tvf /root/repro/out/naive-1.tar.gz) <(tar --full-time -tvf /root/repro/out/naive-2.tar.gz) 的输出原样保存到 /root/repro/tar-diff.txt(不能为空)。第二,确认 gzip 头——把 gzip -dc /root/repro/out/naive-1.tar.gz 的结果保存到 /root/repro/out/inner-1.tar,然后对同一个文件用两种方式压缩,生成 /root/repro/out/hdr-keep.gz(保留原始名称和时间的默认压缩)和 /root/repro/out/hdr-none.gz(去掉名称和时间的压缩)。第三,在 /root/repro/gzip-header.txt 中按 <파일이름> <FLG> <MTIME>(占位符依次为文件名、FLG、MTIME)的形式写两行。文件名依次为 hdr-keep.gz、hdr-none.gz,FLG 写第 4 个字节(从 0 开始数则是第 3 个)的两位十六进制数,MTIME 写从第 5 个字节开始的 4 个字节,按小端无符号十进制数书写。
  3. 创建 /root/repro/build.sh <출력파일> [소스디렉터리](占位符依次为输出文件与源目录)。源目录的默认值是 /root/repro/src。像 naive-build.sh 那样复制到暂存目录,但要使用 SOURCE_DATE_EPOCH(如果外部没有给出,默认值是 1735689600),tar 按名称排序后打包,把所有成员的修改时间固定为该纪元,把所有者和组固定为数字 0,压缩时让 gzip 头里不留下原始名称和时间。赋予执行权限,间隔 1 秒以上运行两次,生成 /root/repro/out/det-1.tar.gz 和 /root/repro/out/det-2.tar.gz,然后在 /root/repro 下把 sha256sum out/det-1.tar.gz out/det-2.tar.gz 的输出保存到 /root/repro/det.sha256。两个值必须相同。
  4. 创建 /root/repro/verify-repro.sh <빌드스크립트>(占位符为构建脚本)。把收到的脚本在临时目录中运行两次(中间间隔 1 秒以上),比较产物的 sha256。相同时输出一行 SAME <다이제스트>(占位符为摘要)并以 0 退出;不同时在第一行输出 DIFF <다이제스트1> <다이제스트2>(占位符为两个摘要),接着展示两个产物的 tar -tvf 差异,然后以 1 退出;构建本身失败时,输出第一行以 ERROR 开头的行并以 2 退出。不要在工作目录中留下临时产物。赋予执行权限后,把对 /root/repro/build.sh 运行的结果保存到 /root/repro/verify-good.txt,把对 /root/repro/naive-build.sh 运行的结果保存到 /root/repro/verify-naive.txt。
  5. 创建 /root/repro/bad-build.sh <출력파일> [소스디렉터리](占位符依次为输出文件与源目录)。像 build.sh 一样确定性地打包,但在打包之前,往暂存目录中再放入一个 BUILDINFO 文件。内容是三行 built_at=<나노초까지 찍은 UTC 시각>(占位符为精确到纳秒的 UTC 时间)、built_on=<호스트 이름>(占位符为主机名)、nonce=<난수>(占位符为随机数)。赋予执行权限后,把 /root/repro/verify-repro.sh /root/repro/bad-build.sh 的输出保存到 /root/repro/verify-bad.txt。第一行必须以 DIFF 开头。这个脚本不要修改,保持原样——在下一步和最后一步中要把它当作反例使用。
  6. 修改 /root/repro/build.sh,在打包之前往暂存目录放入 BUILDINFO,但要用只来自输入的值来填写。共三行,顺序也照此——version=<소스디렉터리의 VERSION 내용>(占位符为源目录中 VERSION 的内容)、source_tree_sha256=<소스 트리 해시>(占位符为源码树哈希)、source_date_epoch=<쓰고 있는 에포크>(占位符为正在使用的纪元)。源码树哈希,是在源目录中运行 LC_ALL=C find . -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum 所得值的前 64 位。修改之后,确认 /root/repro/verify-repro.sh /root/repro/build.sh 仍然输出 SAME,并把从新构建的产物中取出的 BUILDINFO 原样保存到 /root/repro/buildinfo.txt(先以 /root/repro/out/prov.tar.gz 构建一次,再使用 tar -xOf /root/repro/out/prov.tar.gz ./BUILDINFO 即可)。
  7. 用 skopeo 读取 /opt/images/alpine_3.20.tar(oci-archive)。把清单原文原样保存到 /root/repro/manifest.json,并把该文件的 sha256 以一行 sha256:<64자리 16진수>(占位符为 64 位十六进制数)写入 /root/repro/manifest.sha256。然后把同一个归档两次复制到 OCI 布局 /root/repro/oci/alpine,创建标签 v1 和 v2(两个标签的摘要必须相同)。最后在 /root/repro/layer-recompress.txt 中按 <이름> <64자리 16진수>(占位符依次为名称与 64 位十六进制数)的形式写三行——第一行 original 是清单所指向的层 blob 文件的 sha256,第二行 uncompressed 是用 gzip -dc 解压该 blob 得到的字节的 sha256,第三行 recompressed 是把解压得到的字节用 gzip -n -9 重新压缩后的字节的 sha256。original 和 recompressed 必须不同。
  8. 创建 /root/repro/repro-gate.sh <빌드스크립트> <보고서파일>(占位符依次为构建脚本与报告文件)。把构建运行两次(中间间隔 1 秒以上),相同时在屏幕和报告的第一行输出 REPRODUCIBLE <다이제스트>(占位符为摘要)并以 0 退出;不同时在屏幕和报告的第一行输出 DIFFERENT <다이제스트1> <다이제스트2>(占位符为两个摘要)并以 3 退出;构建失败时输出以 ERROR 开头的行并以 1 退出。不同时,报告中在第一行之后,要同时写明两个产物的 tar -tvf 差异、解包后比较的内容差异(diff -ru)以及第一个出现差异的字节位置。如果报告文件的上级目录不存在,必须创建。赋予执行权限后,用 /root/repro/build.sh 运行并把报告保存到 /root/repro/report/good.txt,用 /root/repro/bad-build.sh 运行并把报告保存到 /root/repro/report/bad.txt。

参考

源码没变,产物的摘要却变了

在 /root/repro/src 创建源码树——src/app.py、src/lib/util.py、src/conf/app.conf、src/README.md,以及 src/VERSION(内容是一行 1.4.0)。接着创建 /root/repro/naive-build.sh <출력파일> [소스디렉터리](占位符依次为输出文件与源目录)。源目录的默认值是 /root/repro/src,先把源码复制到临时暂存目录,再用 tar -czf <출력파일> -C <스테이징> .(占位符依次为输出文件与暂存目录)打包。赋予执行权限,间隔 1 秒以上运行两次,生成 /root/repro/out/naive-1.tar.gz 和 /root/repro/out/naive-2.tar.gz,然后在 /root/repro 下把 sha256sum out/naive-1.tar.gz out/naive-2.tar.gz 的输出原样保存到 /root/repro/naive.sha256。两个值必须不同。

临时目录用 mktemp -d 创建,并用 trap ... EXIT 删除。复制时用 cp -r 的话,副本的修改时间就是“现在”——这个值被记录在哪里,正是本实验的出发点。间隔 1 秒,是因为修改时间的分辨率是 1 秒。

追查不同的字节,就会找到修改时间和 gzip 头

留下两个产物为什么不同的证据。第一,把 diff <(tar --full-time -tvf /root/repro/out/naive-1.tar.gz) <(tar --full-time -tvf /root/repro/out/naive-2.tar.gz) 的输出原样保存到 /root/repro/tar-diff.txt(不能为空)。第二,确认 gzip 头——把 gzip -dc /root/repro/out/naive-1.tar.gz 的结果保存到 /root/repro/out/inner-1.tar,然后对同一个文件用两种方式压缩,生成 /root/repro/out/hdr-keep.gz(保留原始名称和时间的默认压缩)和 /root/repro/out/hdr-none.gz(去掉名称和时间的压缩)。第三,在 /root/repro/gzip-header.txt 中按 <파일이름> <FLG> <MTIME>(占位符依次为文件名、FLG、MTIME)的形式写两行。文件名依次为 hdr-keep.gz、hdr-none.gz,FLG 写第 4 个字节(从 0 开始数则是第 3 个)的两位十六进制数,MTIME 写从第 5 个字节开始的 4 个字节,按小端无符号十进制数书写。

tar -tvf 的默认输出只显示到分钟,1 秒的差异会被掩盖——所以需要 --full-time。od -An -tx1 -j3 -N1 <파일>(占位符为文件)会把 FLG 的一个字节、od -An -tu4 -j4 -N4 <파일> 会把 MTIME 的 4 个字节转换成人能读懂的数。空格用 tr -d ' ' 删除。gzip 保留原始名称时,会在 FLG 中置起一个位。请回想 RFC 1952 的头部示意图。

钉死顺序、时间和所有者之后,连字节都相同了

创建 /root/repro/build.sh <출력파일> [소스디렉터리](占位符依次为输出文件与源目录)。源目录的默认值是 /root/repro/src。像 naive-build.sh 那样复制到暂存目录,但要使用 SOURCE_DATE_EPOCH(如果外部没有给出,默认值是 1735689600),tar 按名称排序后打包,把所有成员的修改时间固定为该纪元,把所有者和组固定为数字 0,压缩时让 gzip 头里不留下原始名称和时间。赋予执行权限,间隔 1 秒以上运行两次,生成 /root/repro/out/det-1.tar.gz 和 /root/repro/out/det-2.tar.gz,然后在 /root/repro 下把 sha256sum out/det-1.tar.gz out/det-2.tar.gz 的输出保存到 /root/repro/det.sha256。两个值必须相同。

请查阅 GNU tar 的 --sort、--mtime、--owner、--group、--numeric-owner。tar 的 -z 会通过 stdin 调用 gzip,但如果要直接传入去掉头部名称和时间的选项,就必须把 tar 的输出通过管道交给 gzip。排序可能随区域设置而变,所以加上 LC_ALL=C 更稳妥。

把“是否可复现”这件事交给脚本去问

创建 /root/repro/verify-repro.sh <빌드스크립트>(占位符为构建脚本)。把收到的脚本在临时目录中运行两次(中间间隔 1 秒以上),比较产物的 sha256。相同时输出一行 SAME <다이제스트>(占位符为摘要)并以 0 退出;不同时在第一行输出 DIFF <다이제스트1> <다이제스트2>(占位符为两个摘要),接着展示两个产物的 tar -tvf 差异,然后以 1 退出;构建本身失败时,输出第一行以 ERROR 开头的行并以 2 退出。不要在工作目录中留下临时产物。赋予执行权限后,把对 /root/repro/build.sh 运行的结果保存到 /root/repro/verify-good.txt,把对 /root/repro/naive-build.sh 运行的结果保存到 /root/repro/verify-naive.txt。

构建脚本的契约在前面的步骤里已经定了——第一个参数是输出文件路径。加上 set -e 的话,diff 一发现差异,脚本就先死掉了。也可能出现列表相同而字节不同的情形,所以也要事先确定那时要展示什么。

把构建时间写进产物之后,检查脚本把它抓了出来

创建 /root/repro/bad-build.sh <출력파일> [소스디렉터리](占位符依次为输出文件与源目录)。像 build.sh 一样确定性地打包,但在打包之前,往暂存目录中再放入一个 BUILDINFO 文件。内容是三行 built_at=<나노초까지 찍은 UTC 시각>(占位符为精确到纳秒的 UTC 时间)、built_on=<호스트 이름>(占位符为主机名)、nonce=<난수>(占位符为随机数)。赋予执行权限后,把 /root/repro/verify-repro.sh /root/repro/bad-build.sh 的输出保存到 /root/repro/verify-bad.txt。第一行必须以 DIFF 开头。这个脚本不要修改,保持原样——在下一步和最后一步中要把它当作反例使用。

date -u +%FT%T.%NZ 会精确到纳秒。如果只精确到秒,两次构建可能在同一秒内结束而碰巧通过。别忘了,产物必须仍然是能打开的正常 tar.gz——损坏的构建和不可复现的构建是两回事。

只用输入生成来源信息,重新找回可复现性

修改 /root/repro/build.sh,在打包之前往暂存目录放入 BUILDINFO,但要用只来自输入的值来填写。共三行,顺序也照此——version=<소스디렉터리의 VERSION 내용>(占位符为源目录中 VERSION 的内容)、source_tree_sha256=<소스 트리 해시>(占位符为源码树哈希)、source_date_epoch=<쓰고 있는 에포크>(占位符为正在使用的纪元)。源码树哈希,是在源目录中运行 LC_ALL=C find . -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum 所得值的前 64 位。修改之后,确认 /root/repro/verify-repro.sh /root/repro/build.sh 仍然输出 SAME,并把从新构建的产物中取出的 BUILDINFO 原样保存到 /root/repro/buildinfo.txt(先以 /root/repro/out/prov.tar.gz 构建一次,再使用 tar -xOf /root/repro/out/prov.tar.gz ./BUILDINFO 即可)。

哈希必须在源目录而不是暂存目录中计算——如果 BUILDINFO 自己也混进哈希,就无法知道这个值到底是针对什么的。像提交哈希这样附带在输入上的值可以放进去,而构建时间、主机名、随机数不行。评分器还会用把源码改动一个字符的副本也构建一遍,查看哈希是否随之改变。

摘要保证的是字节,而不保证构建

用 skopeo 读取 /opt/images/alpine_3.20.tar(oci-archive)。把清单原文原样保存到 /root/repro/manifest.json,并把该文件的 sha256 以一行 sha256:<64자리 16진수>(占位符为 64 位十六进制数)写入 /root/repro/manifest.sha256。然后把同一个归档两次复制到 OCI 布局 /root/repro/oci/alpine,创建标签 v1 和 v2(两个标签的摘要必须相同)。最后在 /root/repro/layer-recompress.txt 中按 <이름> <64자리 16진수>(占位符依次为名称与 64 位十六进制数)的形式写三行——第一行 original 是清单所指向的层 blob 文件的 sha256,第二行 uncompressed 是用 gzip -dc 解压该 blob 得到的字节的 sha256,第三行 recompressed 是把解压得到的字节用 gzip -n -9 重新压缩后的字节的 sha256。original 和 recompressed 必须不同。

skopeo inspect --raw oci-archive:<파일>(占位符为文件)会原样输出清单原文——摘要就是这些字节的 sha256。复制用 skopeo copy --insecure-policy oci-archive:<파일> oci:<디렉터리>:<태그>(占位符依次为文件、目录、标签),上级目录必须事先存在。在布局中,blob 文件的名称就是该 blob 自身的摘要。不同机器的架构不同,所以不要背下这些值,要当场计算。

在流水线里拦住不可复现的构建

创建 /root/repro/repro-gate.sh <빌드스크립트> <보고서파일>(占位符依次为构建脚本与报告文件)。把构建运行两次(中间间隔 1 秒以上),相同时在屏幕和报告的第一行输出 REPRODUCIBLE <다이제스트>(占位符为摘要)并以 0 退出;不同时在屏幕和报告的第一行输出 DIFFERENT <다이제스트1> <다이제스트2>(占位符为两个摘要)并以 3 退出;构建失败时输出以 ERROR 开头的行并以 1 退出。不同时,报告中在第一行之后,要同时写明两个产物的 tar -tvf 差异、解包后比较的内容差异(diff -ru)以及第一个出现差异的字节位置。如果报告文件的上级目录不存在,必须创建。赋予执行权限后,用 /root/repro/build.sh 运行并把报告保存到 /root/repro/report/good.txt,用 /root/repro/bad-build.sh 运行并把报告保存到 /root/repro/report/bad.txt。

不要直接使用前面步骤的 verify-repro.sh,而要让这个脚本自己处理三种结果和报告——门禁在失败时留下哪里不同,才有价值。要查看内容差异,必须把两个产物分别解包到临时目录。评分器会用它自己创建的正常、非确定性、损坏这三个构建脚本来测试这个门禁。