メインコンテンツまでスキップ

ビルドパイプラインの概要

各 Takumi イメージは、Nix による宣言的(declarative)なビルドで作られています。 実行するコマンドの列を書くのではなく、イメージのあるべき中身を式として宣言し、その式と固定した入力だけからイメージを組み立てる方式です。

この方式を採用した結果、Takumi Images の他の機能が頼っている 3 つの性質が、運用の努力ではなくビルドシステムの性質として得られています。

1 つめに、ビルドに使ったソースを、ビルドツールのソースまで含めて全量トラックできます。 外部からソースを取得できる場所が、取得内容のハッシュを事前に宣言した固定出力導出に限られているからです(固定出力導出)。

2 つめに、同じ入力から同じ成果物ができることを、リリース側の CI で検証できます。 各ビルドステップが隔離されたサンドボックスで実行され、宣言していない入力には触れられないからです(密閉ビルド再現性の検証)。

3 つめに、イメージには実行に必要なものしか入りません。 entrypoint のプログラムから実行時にたどり着けるファイルだけを、機械的に集めてイメージにするからです(closure による最小構成)。

次の図は、ソースの取得から公開までの流れです。

以降では、この 3 つの性質がどう成り立つのかを、Nix を使ったことがない読者を想定して順に説明します。

命令的なビルドの不確定さ

対比のために、まず従来の方式から確かめます。 Dockerfile によるビルドは命令的(imperative)です。 イメージのあるべき姿ではなく、ベースイメージに対して実行する手順を書き、手順を上から実行した結果がイメージになります。

命令的なビルドは、実行するたびに結果が変わり得ます。 たとえば RUN apt-get install curl という行が実際に何をインストールするかは、その行を書いた時点ではなく、ビルドを実行した時点のパッケージレポジトリの状態で決まります。 同じ Dockerfile から今日作ったイメージと半年後に作ったイメージは、一般には別物です。 できあがったイメージがどのソースから来たのかを後から確かめる手段も、中身を直接調べる以外にほとんど残されていません。

Takumi Images がビルドに Nix を使うのは、この不確定さを、運用上の注意ではなく仕組みとして取り除くためです。 Nix では、ビルドのすべての入力(ソースコード、ビルド手順、依存パッケージ、ビルドに使うツール)をハッシュで固定し、その入力だけから出力が決まる形でビルドを実行します。

イメージを定義する式

各イメージの定義は、次のような短い Nix 式です。 これは curl イメージの定義の実例で、含めるパッケージと entrypoint を宣言しています。

{ pkgs, mkImage }:

mkImage {
name = "takumi/curl";
contents = with pkgs; [ curl ];
entrypoint = [ "${pkgs.curl}/bin/curl" ];
}

この式には、curl をどうビルドするかの手順が書かれていません。 手順は、式が参照するパッケージ集合(nixpkgs)の側に、これもまた式として定義されています。 イメージ側の式とパッケージ側の式を合わせて評価した結果が、そのイメージの完全な設計図になります。

導出: ビルドの単位

Nix は、評価した式を導出(derivation)という単位に落とし込みます。 導出は、ビルドを実行するコマンド、環境変数、依存する他の導出といった、ビルドに必要なすべての情報を機械的に記述したものです。 ビルドを「入力を与えると出力が一意に決まる純粋な関数」として扱う仕組み、と言い換えてもかまいません。

成果物は /nix/store 配下に、入力から計算されたハッシュを含むパスで格納されます。 次は実際のストアパスの例です。

/nix/store/vr4da99c31f7f9arw9ncws7pppsswlv7-apko-1.2.19

入力が少しでも違えば、このハッシュが変わり、成果物は別のパスに配置されます。 そのため、複数のバージョンや構成が衝突せずに共存でき、パスそのものが「どの入力からビルドされたか」の要約として機能します。

密閉ビルド

各導出のビルドは、専用のサンドボックスの中で実行されます。 これが密閉ビルド(hermetic build)です。 サンドボックスの中からは、その導出が宣言していない入力には手が届きません。 ネットワークへのアクセスも、サンドボックスの外にあるファイルへのアクセスも遮断され、ビルド時刻のようなタイムスタンプも固定値に正規化されます。 ビルドしたマシンにたまたま入っていたライブラリや、ビルドを実行した時刻が結果に混ざり込む余地が、構造的にありません。

固定出力導出: 外部との唯一の窓口

サンドボックスがネットワークを遮断しているなら、ソースコードはどこから入ってくるのか、という疑問が残ります。 その答えが固定出力導出(fixed-output derivation、FOD)です。 次は、ソース tarball を取得する FOD の典型的な書き方です。

src = fetchurl {
url = "mirror://gnu/hello/hello-2.12.2.tar.gz";
hash = "sha256-WpqZbcKSzCTc9BHO6H6S9qrluNE72caBm6x6nc4IGKs=";
};

FOD は例外的にネットワークからの取得を許されますが、そのかわり、取得する内容のハッシュを hash のように事前に宣言しなければなりません。 実際に取得した内容が宣言と一致しなければ、ビルドはその場で失敗します。 上流の tarball が静かに差し替えられていても、経路上で改ざんされていても、宣言と違うバイト列がビルドに入り込むことはありません。

ビルドグラフの中で外部に触れる場所が FOD に限られているので、あるイメージに入った全ソース(ビルドツールのソースまで含む)の URL、リビジョン、ハッシュは、グラフをたどるだけで機械的に列挙できます。 この列挙の仕組みと使いみちは Nix のビルド入力の導出 で説明します。

依存グラフの固定

パッケージ集合(nixpkgs)自体も、特定のコミットの tarball として、ハッシュつきで固定して参照します。

nixpkgs = fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/e8273b29fe1390ec8d4603f2477357555291432e.tar.gz";
sha256 = "1k8idy9ka87c7gjb9aiqrcx4l9kwblv8fs4pwf0byjivsmbjfdhi";
};

この固定を pin と呼びます。 pin はカタログ全体で共有しており、全イメージの全パッケージの版が、この 1 つのリビジョンから一斉に決まります。 pin を新しいリビジョンへ進めることが、上流のセキュリティ修正を取り込む主経路です(既知脆弱性への対応)。

closure による最小構成

イメージに載るのは、entrypoint のプログラムから実行時にたどり着けるストアパスの集合、すなわちランタイムの依存関係の閉包(closure)だけです。 Nix がバイナリの参照を機械的にたどって closure を求めるので、たとえば curl イメージに入るのは次のようなパスに限られます(ハッシュと細かな版は省略しています)。

/nix/store/<hash>-curl-8.x
/nix/store/<hash>-glibc-2.x
/nix/store/<hash>-openssl-3.x
/nix/store/<hash>-nss-cacert-…

実際の一覧は、イメージごとの SBOM として公開しています。 ビルドにだけ使うコンパイラやヘッダ、テストツールは closure に現れないため、イメージには入りません。 イメージは apkdpkg のようなパッケージデータベースもシェルも持たず、これが distroless な構成 の実体です。

マルチアーキテクチャでのビルド

各イメージは、amd64 と arm64 のそれぞれについて、独立にビルドします。 一方のアーキテクチャ向けのビルドが、もう一方のバイナリを流用することはありません。

SBOM は、ビルドしたイメージのランタイム closure から作るので、アーキテクチャごとに実体が異なります。 そのため、それぞれのアーキテクチャの manifest に、そのアーキテクチャ自身の SBOM を添付したうえで、両方の manifest を束ねる multi-arch OCI index を組み立てます。 docker pulldocker run の側では、この index から実行環境に合ったアーキテクチャの manifest が自動で選ばれます。

テスト

公開する前に、2つの層でテストします。

パッケージ単位のテストは、各パッケージが自前に持つテストです。 あるイメージをテストすると、そのイメージが含むすべてのパッケージのテストが、シェルを使えるテスト用ビルドの上で実行されます。 ひとつのパッケージは多くのイメージに現れるので、あるパッケージへの修正は、それを積むすべてのイメージについて一度に確認できます。

イメージ単位のテストは、本番の entrypoint をそのまま実行して確かめます。 entrypoint を上書きせずにイメージを実行し、終了ステータスと出力を確認したうえで、同じ呼び出しを、置き換え先として想定する公式イメージにも実行して比較します。 互換性とは、同じ docker run の呼び出しが同じように動くことなので、比較の際にイメージごとの引数調整はしません。

再現性の検証

ビルドが密閉されていることは、再ビルドが同じ結果になることでも確かめています。 同じ入力から二重にビルドし、できあがったイメージの tar のハッシュが一致することを検証します。 一致しなければ、それはビルドのどこかに宣言されていない入力が紛れ込んでいるサインなので、公開の前に見つけて取り除きます。

この検証により、記録された入力と成果物の対応は、説明上の主張ではなく CI 上の実測として確認されます。

署名と証跡、公開前の検査

公開前には、既知のパッケージマルウェアが紛れ込んでいないかを確認する検査を実施します。 この検査を通ったイメージだけが、次の証跡とともに公開されます。

署名は keyless です。 GitHub Actions の OIDC を使い、リリースを実行したワークフローの identity に、公開の Sigstore インスタンス経由で結び付けます。 長期のシークレットを持ち回る必要はありません。

SBOM(CycloneDX 形式)、provenance(SLSA 形式)、VEX(OpenVEX 形式)は、OCI referrers を使ってイメージの digest に結び付けます。 SBOM と provenance はアーキテクチャごとの manifest に、VEX は multi-arch index に添付します。

署名と各 attestation の検証方法、そしてそれぞれが証明することと証明しないことは、Attestation にまとめています。

公開後も続く運用

公開はゴールではなく、運用の一区切りです。 pin が前進するたびに、そのパッケージを積むすべてのイメージが、再ビルドと再テスト、再スキャンの対象になります。 この経路の詳細は 既知脆弱性への対応 で扱います。

同じ FOD マニフェストは、上流ソースの継続的な監視にも使います。 悪意ある変更の監視は上流のマルウェア検知で、公表前の脆弱性修正を追う仕組みは上流のゼロデイ修正検知で説明します。