TT Lab
はじめる
学ぶ 学習パス コース

CI/CDパイプライン

同じソースを二度ビルドしたらダイジェストが変わった

TT Labで続きを見る

目標

ビルドのアーティファクトの非決定性をバイトのレベルで見つけ出し、SOURCE_DATE_EPOCHとtarのオプションで、2回のビルドがバイトまで同じになるようにしたうえで、それを守る確認スクリプトとゲートを作ります。

なぜ重要なのか

ハッシュを名前として使うことと、そのハッシュが再現されることは、別の問題です。同じコミットを2回ビルドしたのにアーティファクトのダイジェストが違えば、デプロイされたバイト列がそのソースから出たことを誰も証明できません。障害が起きたときに戻る先がなくなり、キャッシュは毎回外れ、署名は「このバイト列」だけを保証して「このソース」は保証できません。非決定性は、ほとんどいつも、いくつかのよくある場所から入ります。ファイルの更新時刻、圧縮ヘッダーに刻まれる時刻と名前、ディレクトリの読み取り順序、ビルドユーザーのuidとgid、そしてアーティファクトに書き込んだビルド時刻です。その場所を1つずつ固定していけば、ビルドは入力の関数になり、そのときから、1つのダイジェストがソース全体を指す名前になります。

ステップ

  1. /root/repro/srcにソースツリーを作ってください。src/app.py、src/lib/util.py、src/conf/app.conf、src/README.md、そしてsrc/VERSION(内容は1.4.0の1行)です。続いて/root/repro/naive-build.sh <출력파일> [소스디렉터리](プレースホルダーは出力ファイルとソースディレクトリです)を作ってください。ソースディレクトリの既定値は/root/repro/srcで、ソースを一時的なステージングディレクトリにコピーしたあと、tar -czf <출력파일> -C <스테이징> .(プレースホルダーは出力ファイルとステージングです)でアーカイブします。実行権限を付け、1秒以上の間隔を置いて2回実行して/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つの値は異なっている必要があります。
  2. 2つのアーティファクトがなぜ違うのか、証拠を残してください。1つ目に、/root/repro/tar-diff.txtにdiff <(tar --full-time -tvf /root/repro/out/naive-1.tar.gz) <(tar --full-time -tvf /root/repro/out/naive-2.tar.gz)の出力をそのまま保存します(空であってはいけません)。2つ目に、gzipのヘッダーを確認します。/root/repro/out/inner-1.tarにgzip -dc /root/repro/out/naive-1.tar.gzの結果を保存したあと、同じファイルを2とおりの方法で圧縮して、/root/repro/out/hdr-keep.gz(元のファイル名と時刻を残す既定の圧縮)と/root/repro/out/hdr-none.gz(名前と時刻を除く圧縮)を作ってください。3つ目に、/root/repro/gzip-header.txtに2行を<파일이름> <FLG> <MTIME>(プレースホルダーはファイル名です)の形で書いてください。ファイル名はhdr-keep.gz・hdr-none.gzの順で、FLGは4バイト目(0から数えると3番目)を16進数2桁で、MTIMEは5バイト目からの4バイトをリトルエンディアンの符号なし10進数で書きます。
  3. /root/repro/build.sh <출력파일> [소스디렉터리](プレースホルダーは出力ファイルとソースディレクトリです)を作ってください。ソースディレクトリの既定値は/root/repro/srcです。naive-build.shと同じようにステージングへコピーしますが、SOURCE_DATE_EPOCHを使い(外から与えられなければ既定値は1735689600)、tarを名前順にソートしてアーカイブし、すべてのメンバーの更新時刻をそのエポックに、所有者とグループを数値の0に固定し、圧縮はgzipのヘッダーに元のファイル名と時刻が残らないようにします。実行権限を付け、1秒以上の間隔を置いて2回実行して/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に保存してください。2つの値は同じでなければなりません。
  4. /root/repro/verify-repro.sh <빌드스크립트>(プレースホルダーはビルドスクリプトです)を作ってください。受け取ったスクリプトを一時ディレクトリで2回(間に1秒以上)実行して、アーティファクトのsha256を比較します。同じならSAME <다이제스트>(プレースホルダーはダイジェストです)を1行出して0で、違えば1行目にDIFF <다이제스트1> <다이제스트2>(プレースホルダーは2つのダイジェストです)を出し、続けて2つのアーティファクトのtar -tvfの差を示したあと1で、ビルド自体が失敗したら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ファイルをもう1つ入れます。内容は3行で、built_at=<나노초까지 찍은 UTC 시각>(プレースホルダーはナノ秒まで出力したUTC時刻です)、built_on=<호스트 이름>(プレースホルダーはホスト名です)、nonce=<난수>(プレースホルダーは乱数です)です。実行権限を付け、/root/repro/verify-repro.sh /root/repro/bad-build.shの出力を/root/repro/verify-bad.txtに保存してください。1行目はDIFFで始まっている必要があります。このスクリプトは直さず、そのままにしておきます。次のステップと最後のステップで、反例として使います。
  6. /root/repro/build.shを直して、アーカイブする直前にステージングにBUILDINFOを入れてください。ただし、入力からだけ得られる値で埋めます。3行で、順序もこのとおりです。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として1回ビルドしたあと、tar -xOf /root/repro/out/prov.tar.gz ./BUILDINFOを使えば取り出せます)。
  7. skopeoで/opt/images/alpine_3.20.tar(oci-archive)を読みます。マニフェストの原文を/root/repro/manifest.jsonにそのまま保存し、そのファイルのsha256を/root/repro/manifest.sha256にsha256:<64자리 16진수>(プレースホルダーは16進数64桁です)の1行で書いてください。続いて、同じアーカイブをOCIレイアウト/root/repro/oci/alpineへ2回コピーして、タグv1とv2を作ってください(2つのタグのダイジェストは同じでなければなりません)。最後に、/root/repro/layer-recompress.txtに3行を<이름> <64자리 16진수>(プレースホルダーは名前と16進数64桁です)の形で書いてください。1行目のoriginalはマニフェストが指すレイヤーのブロブファイルのsha256、2行目のuncompressedはそのブロブをgzip -dcで展開したバイト列のsha256、3行目のrecompressedは展開したバイト列をgzip -n -9で再び圧縮したバイト列のsha256です。originalとrecompressedは異なっている必要があります。
  8. /root/repro/repro-gate.sh <빌드스크립트> <보고서파일>(プレースホルダーはビルドスクリプトとレポートファイルです)を作ってください。ビルドを2回(間に1秒以上)実行し、同じならREPRODUCIBLE <다이제스트>を画面とレポートの1行目に出して0で、違えば画面とレポートの1行目にDIFFERENT <다이제스트1> <다이제스트2>を出して3で、ビルドが失敗したらERRORで始まる行を出して1で終了します。異なるときは、レポートに、1行目のあとに、2つのアーティファクトの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の1行)です。続いて/root/repro/naive-build.sh <출력파일> [소스디렉터리](プレースホルダーは出力ファイルとソースディレクトリです)を作ってください。ソースディレクトリの既定値は/root/repro/srcで、ソースを一時的なステージングディレクトリにコピーしたあと、tar -czf <출력파일> -C <스테이징> .(プレースホルダーは出力ファイルとステージングです)でアーカイブします。実行権限を付け、1秒以上の間隔を置いて2回実行して/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つの値は異なっている必要があります。

一時ディレクトリはmktemp -dで作り、trap ... EXITで削除します。コピーにcp -rを使うと、コピーの更新時刻が「今」になります。その値がどこに記録されるのかが、このラボの出発点です。1秒の間隔を置くのは、更新時刻の分解能が1秒だからです。

違うバイトを探していくと、更新時刻とgzipのヘッダーが出てくる

2つのアーティファクトがなぜ違うのか、証拠を残してください。1つ目に、/root/repro/tar-diff.txtにdiff <(tar --full-time -tvf /root/repro/out/naive-1.tar.gz) <(tar --full-time -tvf /root/repro/out/naive-2.tar.gz)の出力をそのまま保存します(空であってはいけません)。2つ目に、gzipのヘッダーを確認します。/root/repro/out/inner-1.tarにgzip -dc /root/repro/out/naive-1.tar.gzの結果を保存したあと、同じファイルを2とおりの方法で圧縮して、/root/repro/out/hdr-keep.gz(元のファイル名と時刻を残す既定の圧縮)と/root/repro/out/hdr-none.gz(名前と時刻を除く圧縮)を作ってください。3つ目に、/root/repro/gzip-header.txtに2行を<파일이름> <FLG> <MTIME>(プレースホルダーはファイル名です)の形で書いてください。ファイル名はhdr-keep.gz・hdr-none.gzの順で、FLGは4バイト目(0から数えると3番目)を16進数2桁で、MTIMEは5バイト目からの4バイトをリトルエンディアンの符号なし10進数で書きます。

tar -tvfの既定の出力は分までしか表示しないので、1秒の差が埋もれてしまいます。そのため--full-timeが必要です。od -An -tx1 -j3 -N1 <파일>がFLGの1バイトを、od -An -tu4 -j4 -N4 <파일>がMTIMEの4バイトを、人が読める数に変換してくれます(プレースホルダーはファイルです)。空白はtr -d ' 'で取り除きます。gzipは元のファイル名を残すとき、FLGのビットを1つ立てます。RFC 1952のヘッダーの図を思い出してください。

順序と時刻と所有者を固定したら、バイトまで同じになった

/root/repro/build.sh <출력파일> [소스디렉터리](プレースホルダーは出力ファイルとソースディレクトリです)を作ってください。ソースディレクトリの既定値は/root/repro/srcです。naive-build.shと同じようにステージングへコピーしますが、SOURCE_DATE_EPOCHを使い(外から与えられなければ既定値は1735689600)、tarを名前順にソートしてアーカイブし、すべてのメンバーの更新時刻をそのエポックに、所有者とグループを数値の0に固定し、圧縮はgzipのヘッダーに元のファイル名と時刻が残らないようにします。実行権限を付け、1秒以上の間隔を置いて2回実行して/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に保存してください。2つの値は同じでなければなりません。

GNU tarの--sort、--mtime、--owner、--group、--numeric-ownerを調べてみてください。tarの-zはgzipをstdinで呼び出しますが、ヘッダーから名前と時刻を除くオプションを直接指定するには、tarの出力をパイプでgzipに渡す必要があります。ソートはロケールによって変わることがあるので、LC_ALL=Cを付けるほうが安全です。

再現できるかどうかの確認を、スクリプトに任せる

/root/repro/verify-repro.sh <빌드스크립트>(プレースホルダーはビルドスクリプトです)を作ってください。受け取ったスクリプトを一時ディレクトリで2回(間に1秒以上)実行して、アーティファクトのsha256を比較します。同じならSAME <다이제스트>(プレースホルダーはダイジェストです)を1行出して0で、違えば1行目にDIFF <다이제스트1> <다이제스트2>(プレースホルダーは2つのダイジェストです)を出し、続けて2つのアーティファクトのtar -tvfの差を示したあと1で、ビルド自体が失敗したら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ファイルをもう1つ入れます。内容は3行で、built_at=<나노초까지 찍은 UTC 시각>(プレースホルダーはナノ秒まで出力したUTC時刻です)、built_on=<호스트 이름>(プレースホルダーはホスト名です)、nonce=<난수>(プレースホルダーは乱数です)です。実行権限を付け、/root/repro/verify-repro.sh /root/repro/bad-build.shの出力を/root/repro/verify-bad.txtに保存してください。1行目はDIFFで始まっている必要があります。このスクリプトは直さず、そのままにしておきます。次のステップと最後のステップで、反例として使います。

date -u +%FT%T.%NZはナノ秒まで出力します。秒単位までしか出力しないと、2つのビルドが同じ秒に終わって、偶然通ってしまうことがあります。アーティファクトが、今も開ける正常なtar.gzでなければならない点を忘れないでください。壊れたビルドと、再現できないビルドは、別の問題です。

来歴情報を入力からだけ作って、再現を取り戻す

/root/repro/build.shを直して、アーカイブする直前にステージングにBUILDINFOを入れてください。ただし、入力からだけ得られる値で埋めます。3行で、順序もこのとおりです。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として1回ビルドしたあと、tar -xOf /root/repro/out/prov.tar.gz ./BUILDINFOを使えば取り出せます)。

ハッシュは、ステージングではなくソースディレクトリで計算する必要があります。BUILDINFO自身がハッシュに混ざると、その値が何に対するものなのかわからなくなります。コミットハッシュのように入力に付いてくる値は入れてもかまいませんが、ビルド時刻・ホスト名・乱数は入れてはいけません。採点は、ソースを1文字変えたコピーでもビルドして、ハッシュがそれに合わせて変わるかどうかを確認します。

ダイジェストはバイト列を保証するが、ビルドは保証しない

skopeoで/opt/images/alpine_3.20.tar(oci-archive)を読みます。マニフェストの原文を/root/repro/manifest.jsonにそのまま保存し、そのファイルのsha256を/root/repro/manifest.sha256にsha256:<64자리 16진수>(プレースホルダーは16進数64桁です)の1行で書いてください。続いて、同じアーカイブをOCIレイアウト/root/repro/oci/alpineへ2回コピーして、タグv1とv2を作ってください(2つのタグのダイジェストは同じでなければなりません)。最後に、/root/repro/layer-recompress.txtに3行を<이름> <64자리 16진수>(プレースホルダーは名前と16進数64桁です)の形で書いてください。1行目のoriginalはマニフェストが指すレイヤーのブロブファイルのsha256、2行目のuncompressedはそのブロブをgzip -dcで展開したバイト列のsha256、3行目のrecompressedは展開したバイト列をgzip -n -9で再び圧縮したバイト列のsha256です。originalとrecompressedは異なっている必要があります。

skopeo inspect --raw oci-archive:<파일>がマニフェストの原文をそのまま出力します(プレースホルダーはファイルです)。ダイジェストは、まさにそのバイト列のsha256です。コピーはskopeo copy --insecure-policy oci-archive:<파일> oci:<디렉터리>:<태그>(プレースホルダーは、ファイル、ディレクトリ、タグです)で、親ディレクトリが先に存在している必要があります。レイアウトの中で、ブロブのファイル名は、そのブロブ自身のダイジェストです。アーキテクチャがマシンごとに違うので、値を暗記せず、その場で計算してください。

再現できないビルドを、パイプラインで止める

/root/repro/repro-gate.sh <빌드스크립트> <보고서파일>(プレースホルダーはビルドスクリプトとレポートファイルです)を作ってください。ビルドを2回(間に1秒以上)実行し、同じならREPRODUCIBLE <다이제스트>(プレースホルダーはダイジェストです)を画面とレポートの1行目に出して0で、違えば画面とレポートの1行目にDIFFERENT <다이제스트1> <다이제스트2>(プレースホルダーは2つのダイジェストです)を出して3で、ビルドが失敗したらERRORで始まる行を出して1で終了します。異なるときは、レポートに、1行目のあとに、2つのアーティファクトの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をそのまま使うのではなく、3通りの結末とレポートを、このスクリプトが直接扱うようにしてください。ゲートは、失敗したときに何が違うのかを残してこそ役に立ちます。内容の差を見るには、2つのアーティファクトをそれぞれ一時ディレクトリに展開する必要があります。採点は、自分で作った正常・非決定的・壊れたビルドスクリプトの3つで、このゲートを試します。