ハッシュで名前を付けても再現できるとは限らない
一言でいうと
アーティファクトにハッシュで名前を付けることと、同じソースから同じバイト列が出るようにすることは、別の話です。前者はラベルを正確に付ける作業で、後者はビルドを「入力が同じなら出力が同じ関数」にする作業です。ラベルだけ付けておいて再現できると言っているパイプラインは、非常に多くあります。
なぜ必要なのか
「ソースのハッシュでタグを付けたから再現できる」という言葉はよく聞きますが、証明されたことはまれです。同じコミットを2回ビルドして、2つのアーティファクトのSHA-256を直接比べてみると、たいてい違います。それなのに名前は同じです。名前が同じで内容が違うアーティファクトは、3つのものを同時に壊します。キャッシュが嘘をつき、プロモーションのモデルが崩れ、障害の振り返りで「あのとき出ていったのは正確にこれなのか」という問いに誰も答えられなくなります。
再現性が実際に買ってくれるものは3つあります。1つ目は、キャッシュを信頼できるようになることです。同じキーなら同じ結果という前提が成り立ってはじめて、キャッシュのヒットを「ビルドを省略してよい」根拠として使えます。2つ目は、アーティファクトの来歴を、作り直して確認できることです。同じソースと同じ手順でもう一度ビルドしてバイト列が一致すれば、そのアーティファクトがそのソースから出たことを、署名がなくても突き合わせられます。3つ目は、障害のとき、そのときのアーティファクトを復元できることです。レジストリから削除されていても、ソースと手順が残っていれば、同じバイト列をもう一度得られます。
非決定性はどこから入るのか
出どころはほぼ決まっています。新しい言語や新しいツールに出会っても、リストは大きくは変わりません。
- ビルド時刻。アーティファクトの中にビルドした時刻を埋め込むと、毎回変わります。圧縮ファイルのヘッダー、アーカイブ内のファイルの更新時刻、生成されたソースのコメントまで含まれます。
- ファイルの順序。ディレクトリを走査する順序はファイルシステムが決めるもので、その順序は保証されません。アーカイブに入る順番が変わると、バイト列が変わります。
- uid/gidと権限。ビルドに使うアカウントが違うと、アーカイブ内の所有者情報が変わります。umaskが違うと、権限ビットが変わります。
- ロケールとソート。
LC_ALLによってソート結果が変わり、そのソート結果が一覧ファイルやアーカイブの順序に反映されます。 - 絶対パス。ビルドディレクトリのパスがアーティファクトの中に入ると、作業スペースの名前が違うだけで結果が変わります。
- gzipのヘッダー。gzipは、既定で元のファイル名と時刻をヘッダーに書きます。
- 並列実行の順序。複数のタスクが1つの結果ファイルに順序なく追記すると、毎回順序が変わります。
どう固定するのか
中心となる約束が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以上が必要です。そして判定は、目ではなくハッシュで行います。2回作って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
次のラボですること
シェルとtar、sha256sumだけで、同じソースから同じバイト列が出るかを自分で突き合わせます。まず、何の対策もなくアーカイブを2回作ってハッシュが分かれるのを確認し、時刻・順序・所有者・gzipヘッダーを1つずつ固定しながら、どの項目が何バイトを動かしたのかを目で追います。最後に、SOURCE_DATE_EPOCHをコミット時刻から取り出して使うビルドスクリプトにまとめ、2回実行した結果のハッシュが同じかどうかで判定します。このPodではコンテナを起動できないので、イメージ側は、skopeoですでにあるoci-archiveのダイジェストを読んで突き合わせる方法で扱います。