アーティファクト ベースのビルドシステム

このページでは、アーティファクト ベースのビルドシステムと、その作成の背景にある理念について説明します。Bazel はアーティファクト ベースのビルドシステムです。タスクベースのビルドシステムはビルドスクリプトよりも優れていますが、個々のエンジニアが独自のタスクを定義できるため、エンジニアに過度の権限を与えることになります。

アーティファクト ベースのビルドシステムには、エンジニアが限定的に構成できる、システムによって定義された少数のタスクがあります。エンジニアはシステムに何を ビルドするかを指示しますが、ビルドシステムはどのように ビルドするかを決定します。タスクベースのビルドシステムと同様に、Bazel などのアーティファクト ベースのビルドシステムにもビルドファイルがありますが、ビルドファイルの内容は大きく異なります。Bazel のビルドファイルは、チューリング完全なスクリプト言語で出力の生成方法を記述する命令型コマンドのセットではなく、ビルドするアーティファクトのセット、その依存関係、ビルド方法に影響する限定的なオプションのセットを記述する宣言型マニフェストです。エンジニアがコマンドラインで bazel を実行すると、ビルドするターゲットのセット(内容 )を指定します。Bazel は、コンパイル ステップの構成、実行、スケジューリング(方法 )を行います。ビルドシステムが実行するツールを完全に制御できるようになったため、正確性を保証しながら、はるかに効率的に動作することを保証できます。

機能的な視点

アーティファクト ベースのビルドシステムと関数型プログラミングを簡単に比較できます。従来の命令型プログラミング言語(Java、C、Python など)では、タスクベースのビルドシステムでプログラマーが実行する一連のステップを定義するのと同じように、順次実行されるステートメントのリストを指定します。一方、関数型プログラミング言語(Haskell、ML など)は、一連の数式のように構成されています。関数型言語では、プログラマーは実行する計算を記述しますが、その計算をいつ、どのように実行するかはコンパイラに任せます。

これは、アーティファクト ベースのビルドシステムでマニフェストを宣言し、ビルドの実行方法をシステムに任せるという考え方に対応しています。関数型プログラミングでは簡単に表現できない問題も多くありますが、関数型プログラミングのメリットは大きいです。関数型プログラミング言語では、このようなプログラムを簡単に並列化し、命令型言語では不可能な正確性を保証できます。関数型プログラミングを使用して表現する最も簡単な問題は、一連のルールまたは関数を使用してあるデータを別のデータに変換するだけの問題です。ビルドシステムはまさにそれです。システム全体は、ソースファイル(コンパイラなどのツール)を入力として受け取り、バイナリを出力として生成する数学関数です。そのため、関数型プログラミングの原則に基づいてビルドシステムを構築することは理にかなっています。

アーティファクト ベースのビルドシステムについて

Google のビルドシステムである Blaze は、最初のアーティファクト ベースのビルドシステムでした。Bazel は Blaze のオープンソース バージョンです。

Bazel でのビルドファイル(通常は BUILD という名前)は次のようになります。

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

Bazel では、BUILD ファイルでターゲットを定義します。ここでは、java_binaryjava_library の 2 種類のターゲットがあります。各ターゲットは、システムによって作成できるアーティファクトに対応しています。バイナリ ターゲットは直接実行できるバイナリを生成し、ライブラリ ターゲットはバイナリや他のライブラリで使用できるライブラリを生成します。すべてのターゲットには次のものがあります。

  • name: コマンドラインや他のターゲットでターゲットを参照する方法
  • srcs: ターゲットのアーティファクトを作成するためにコンパイルするソースファイル
  • deps: このターゲットの前にビルドしてリンクする必要がある他のターゲット

依存関係は、同じパッケージ内(MyBinary's :mylibへの依存関係など)にすることも、同じソース階層内の別のパッケージ (mylib's //java/com/example/commonへの依存関係など)にすることもできます。

タスクベースのビルドシステムと同様に、Bazel のコマンドライン ツールを使用してビルドを実行します。MyBinary ターゲットをビルドするには、bazel build :MyBinary を実行します。クリーンなリポジトリでこのコマンドを初めて入力すると、Bazel は次の処理を行います。

  1. ワークスペース内のすべての BUILD ファイルを解析して、アーティファクト間の依存関係のグラフを作成します。
  2. グラフを使用して、MyBinary の推移的依存関係を特定します。つまり、MyBinary が依存するすべてのターゲットと、それらのターゲットが依存するすべてのターゲットを再帰的に特定します。
  3. これらの依存関係を順番にビルドします。Bazel は、他の依存関係がない各ターゲットのビルドから開始し、各ターゲットでビルドする必要がある依存関係を追跡します。ターゲットのすべての依存関係がビルドされると、Bazel はそのターゲットのビルドを開始します。このプロセスは、MyBinary の推移的依存関係がすべてビルドされるまで続きます。
  4. MyBinary をビルドして、ステップ 3 でビルドされたすべての依存関係をリンクする最終的な実行可能バイナリを生成します。

基本的には、タスクベースのビルドシステムを使用した場合とそれほど違いがないように思えるかもしれません。実際、最終的なバイナリは同じであり、バイナリを生成するプロセスでは、一連のステップを分析して依存関係を見つけ、それらのステップを順番に実行します。ただし、重要な違いがあります。1 つ目はステップ 3 にあります。Bazel は、各ターゲットが Java ライブラリのみを生成することを知っているため、任意のユーザー定義スクリプトではなく Java コンパイラを実行するだけでよいことを認識しています。そのため、これらのステップを並行して実行しても安全であることを認識しています。 これにより、マルチコア マシンでターゲットを 1 つずつビルドする場合と比較して、パフォーマンスが大幅に向上します。これは、アーティファクト ベースのアプローチでは、ビルドシステムが独自の実行戦略を管理するため、並列処理についてより強力な保証が可能になるためです。

メリットは並列処理だけではありません。このアプローチの次のメリットは、デベロッパーが変更を加えることなく bazel build :MyBinary を 2 回目に実行したときに明らかになります。Bazel は 1 秒以内に終了し、ターゲットが最新であることを示すメッセージが表示されます。これは、前述の関数型プログラミング パラダイムによるものです。Bazel は、各ターゲットが Java コンパイラの実行結果のみであることを認識しています。また、Java コンパイラの出力はその入力のみに依存するため、入力が変更されていない限り、出力を再利用できます。 この分析はすべてのレベルで機能します。MyBinary.java が変更された場合、Bazel は MyBinary を再ビルドし、mylib を再利用します。//java/com/example/common のソースファイルが変更された場合、Bazel はそのライブラリ、mylibMyBinary を再ビルドし、//java/com/example/myproduct/otherlib を再利用します。Bazel は、各ステップで実行するツールのプロパティを認識しているため、古いビルドを生成しないことを保証しながら、毎回最小限のアーティファクト セットのみを再ビルドできます。

ビルドプロセスをタスクではなくアーティファクトの観点から再構築することは、微妙ですが強力です。プログラマーに公開される柔軟性を減らすことで、ビルドシステムはビルドの各ステップで何が行われているかをより詳細に把握できます。この知識を使用して、ビルドプロセスを並列化し、その出力を再利用することで、ビルドを大幅に効率化できます。これは最初のステップにすぎず、並列処理と再利用のビルディング ブロックは、分散型でスケーラビリティの高いビルドシステムの基盤となります。

その他の便利な Bazel の機能

アーティファクト ベースのビルドシステムは、タスクベースのビルドシステムに固有の並列処理と再利用の問題を根本的に解決します。ただし、前述のいくつかの問題はまだ解決されていません。Bazel には、これらの問題を解決する巧妙な方法があります。次に説明します。

依存関係としてのツール

前述の問題の 1 つは、ビルドがマシンにインストールされているツールに依存しているため、ツール バージョンやロケーションが異なるため、システム間でビルドを再現することが難しいことでした。プロジェクトで、ビルドまたはコンパイルするプラットフォーム(Windows と Linux など)に応じて異なるツールが必要な言語を使用している場合、問題はさらに複雑になります。これらのプラットフォームでは、同じジョブを実行するためにわずかに異なるツールセットが必要になります。

Bazel は、ツールを各ターゲットの依存関係として扱うことで、この問題の最初の部分を解決します。ワークスペース内のすべての java_library は、デフォルトで既知のコンパイラである Java コンパイラに暗黙的に依存します。Bazel が java_library をビルドするたびに、指定されたコンパイラが既知のロケーションで使用可能であることを確認します。他の依存関係と同様に、Java コンパイラが変更されると、それに依存するすべてのアーティファクトが再ビルドされます。

Bazel は、ビルド構成を設定することで、プラットフォームの独立性という問題の 2 番目の部分を解決します。ターゲットはツールに直接依存するのではなく、構成のタイプに依存します。

  • ホスト構成: ビルド中に実行されるビルドツール
  • ターゲット構成: 最終的にリクエストしたバイナリのビルド

ビルドシステムの拡張

Bazel には、いくつかの一般的なプログラミング言語のターゲットが用意されていますが、エンジニアは常にそれ以上のことを行いたいと考えています。タスクベースのシステムのメリットの一つは、あらゆる種類のビルドプロセスをサポートできる柔軟性です。アーティファクト ベースのビルドシステムでそれを放棄するのは望ましくありません。 幸いなことに、Bazel では、 カスタムルールを追加することで、サポートされているターゲット タイプを拡張できます。

Bazel でルールを定義するには、ルール作成者がルールに必要な入力(BUILD ファイルで渡される属性の形式)と、ルールが生成する固定された出力セットを宣言します。作成者は、そのルールによって生成されるアクションも定義します。各アクションは、入力と出力を宣言し、特定の実行可能ファイルを実行するか、特定の文字列をファイルに書き込みます。また、入力と出力を介して他のアクションに接続できます。つまり、アクションはビルドシステムの最小単位のコンポーザブル ユニットです。アクションは、宣言された入力と出力のみを使用する限り、任意の処理を行うことができます。Bazel は、アクションのスケジューリングと結果のキャッシュを適切に処理します。

アクション デベロッパーがアクションの一部として非決定論的プロセスを導入するのを防ぐ方法がないため、システムは完璧ではありません。ただし、実際にはこのようなことはあまり起こりません。不正使用の可能性をアクション レベルまで押し下げることで、エラーの発生を大幅に減らすことができます。多くの一般的な言語とツールをサポートするルールはオンラインで広く公開されており、ほとんどのプロジェクトで独自のルールを定義する必要はありません。独自のルールを定義する場合でも、ルール定義はリポジトリ内の 1 か所でのみ定義する必要があります。つまり、ほとんどのエンジニアは、実装を気にすることなくこれらのルールを使用できます。

環境の分離

アクションは、他のシステムのタスクと同じ問題に直面する可能性があります。同じファイルに書き込むアクションを作成して、競合が発生する可能性はありませんか?実際、Bazel はこのような 競合をサンドボックス化を使用して不可能にします。 サポートされているシステムでは、ファイルシステム サンドボックスを介して、すべてのアクションが他のすべてのアクションから分離されます。実際には、各アクションは、宣言した入力と生成した出力を含む、ファイルシステムの制限されたビューのみを表示できます。これは、Docker の基盤となるテクノロジーである Linux の LXC などのシステムによって強制されます。つまり、アクションは宣言されていないファイルを読み取ることができないため、アクションが競合することはありません。また、書き込みは行っても宣言されていないファイルは、アクションが完了すると破棄されます。Bazel はサンドボックスを使用して、ネットワーク経由でのアクションの通信を制限します。

外部依存関係の決定論的化

まだ 1 つ問題が残っています。ビルドシステムでは、依存関係(ツールまたはライブラリ)を直接ビルドするのではなく、外部ソースからダウンロードする必要があることがよくあります。この例では、@com_google_common_guava_guava//jar 依存関係を使用して、Maven から JAR ファイルをダウンロードしています。

現在のワークスペース外のファイルに依存するのは危険です。これらのファイルはいつでも変更される可能性があるため、ビルドシステムで常に最新かどうかを確認する必要があります。ワークスペースのソースコードに対応する変更がないリモート ファイルが変更されると、ビルドを再現できなくなる可能性があります。つまり、依存関係の変更に気づかないため、ある日ビルドが成功し、次の日に失敗する可能性があります。最後に、外部依存関係がサードパーティによって所有されている場合、大きなセキュリティ リスクが生じる可能性があります。攻撃者がそのサードパーティ サーバーに侵入できると、依存関係ファイルを独自の設計のものに置き換えることができ、ビルド環境とその出力を完全に制御できる可能性があります。

根本的な問題は、ソース管理にチェックインしなくても、ビルドシステムでこれらのファイルを認識できるようにすることです。依存関係の更新は慎重に選択する必要がありますが、その選択は個々のエンジニアが管理するのではなく、システムによって自動的に行うのではなく、一元的に行う必要があります。これは、「Live at Head」モデルでも、ビルドを決定論的にしたいと考えているためです。つまり、先週のコミットをチェックアウトした場合、現在の依存関係ではなく、当時の依存関係が表示されるようにする必要があります。

Bazel やその他のビルドシステムでは、ワークスペース内のすべての外部依存関係の暗号学的ハッシュをリストするワークスペース全体のマニフェスト ファイルを必要とすることで、この問題に対処しています。 ハッシュは、ファイル全体をソース管理にチェックインせずに、ファイルを一意に表す簡潔な方法です。ワークスペースから新しい外部依存関係が参照されるたびに、その依存関係のハッシュがマニフェストに手動または自動で追加されます。Bazel がビルドを実行すると、キャッシュされた依存関係の実際のハッシュをマニフェストで定義されている想定されるハッシュと比較し、ハッシュが異なる場合にのみファイルを再ダウンロードします。

ダウンロードしたアーティファクトのハッシュがマニフェストで宣言されているハッシュと異なる場合、マニフェストのハッシュが更新されない限り、ビルドは失敗します。これは自動的に行うことができますが、ビルドで新しい依存関係を受け入れる前に、その変更を承認してソース管理にチェックインする必要があります。つまり、依存関係が更新された日時が常に記録され、ワークスペースのソースに対応する変更がない限り、外部依存関係を変更することはできません。 また、古いバージョンのソースコードをチェックアウトする場合、ビルドでは、そのバージョンがチェックインされた時点で使用されていた依存関係が必ず使用されます(それらの依存関係が使用できなくなった場合は失敗します)。

もちろん、リモート サーバーが使用できなくなったり、破損したデータが提供されたりすると、問題が発生する可能性があります。その依存関係の別のコピーがない場合、すべてのビルドが失敗する可能性があります。この問題を回避するため、重要なプロジェクトでは、信頼して管理できるサーバーまたはサービスにすべての依存関係をミラーリングすることをおすすめします。そうしないと、チェックインされたハッシュでセキュリティが保証されていても、ビルドシステムの可用性について常にサードパーティに依存することになります。