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

問題を報告する ソースを表示

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

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

機能的な視点

アーティファクト ベースのビルドシステムと機能プログラミングは簡単に比較できます。従来の命令型プログラミング言語(Java、C、Python など)では、実行されるステートメントのリストが 1 つずつ順番に指定します。これは、タスクベースのビルドシステムで、実行する一連のステップをプログラマーが定義する場合と同様です。一方、関数型プログラミング言語(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 ファイルによってターゲットが定義されます。ここでの 2 つのタイプのターゲットは、java_binaryjava_library です。すべてのターゲットは、システムが作成できるアーティファクトに対応しています。バイナリ ターゲットは直接実行できるバイナリを生成し、ライブラリ ターゲットはバイナリやその他のライブラリで使用できるライブラリを生成します。すべてのターゲットには以下があります。

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

依存関係は、同じパッケージ内(:mylib への MyBinary の依存関係など)か、同じソース階層内の別のパッケージ内(//java/com/example/common に対する mylib の依存関係など)のいずれかにあります。

タスクベースのビルドシステムと同様に、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 を入力したときに、Bazel が 1 秒未満で終了し、ターゲットが最新であることを知らせるメッセージが表示されることです。これは、前に説明した関数型プログラミングのパラダイムによって実現可能です。Bazel は、各ターゲットが Java コンパイラの実行のみの結果であり、Java コンパイラからの出力が入力にのみ依存していることを認識しているため、出力が変更されていなければ再利用できます。この分析はすべてのレベルで機能します。MyBinary.java が変更された場合、Bazel は MyBinary を再ビルドしますが、mylib を再利用します。//java/com/example/common のソースファイルが変更されると、Bazel はそのライブラリ、mylibMyBinary を再ビルドしますが、//java/com/example/myproduct/otherlib を再利用します。Bazel は各ステップで実行されるツールのプロパティを認識しているため、毎回最小限のアーティファクトのみを再ビルドしながら、古いビルドを生成しないことを保証できます。

ビルドプロセスをタスクではなくアーティファクトの観点から再構築することは、わずかでありながら強力です。プログラマーが参照する柔軟性が低下するため、ビルドシステムはビルドのあらゆるステップで何を行うかを詳しく把握できます。その知識を利用して、ビルドプロセスを並列化し、その出力を再利用することで、ビルドの効率を大幅に向上させることができます。ただし、これは最初のステップにすぎません。並列処理と再利用の構成要素は、分散型でスケーラビリティの高いビルドシステムの基礎となります。

その他の Bazel トリック

アーティファクト ベースのビルドシステムは、並列処理に関する問題を根本的に解決し、タスクベースのビルドシステムに固有の再利用を実現します。しかし、まだ対処していない問題が引き続きいくつか存在します。Bazel にはこれらの問題を解決するための巧妙な方法があり、次に進む前にそれらについて話し合う必要があります。

依存関係としてのツール

以前直面した問題の一つは、ビルドがマシンにインストールされているツールに依存しており、ツールのバージョンや場所が異なるため、システム間でビルドを再現できないことがありました。プロジェクトで構築またはコンパイルされるプラットフォームに応じて異なるツールを必要とする言語(Windows と Linux など)を使用する場合、これらのプラットフォームでは、同じジョブを行うために少し異なるツールセットが必要になります。

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

Bazel はビルド構成を設定することで、プラットフォームの独立した部分を解決します。ターゲットはツールに直接依存するのではなく、構成の種類によって異なります。

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

ビルドシステムの拡張

Bazel にはすぐに使える一般的なプログラミング言語のターゲットが用意されていますが、エンジニアはもっと多くのことを望んでいます。タスクベース システムの利点の一部は、あらゆる種類のビルドプロセスを柔軟にサポートできることにあります。アーティファクト ベースのビルドシステムではそれを無視した方がよいでしょう。幸い、Bazel では、カスタムルールを追加して、サポートされているターゲット タイプを拡張できます。

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

アクションのデベロッパーが非確定的なプロセスを導入しているような動作を止める手段はないため、このシステムは完全ではありません。しかし、これは実際にはそれほど頻繁には実行されず、不正使用の可能性をアクション レベルまで押し下げることで、エラーが生じる機会が大幅に減ります。多くの一般的な言語とツールをサポートするルールは、オンラインで幅広く利用できます。ほとんどのプロジェクトで独自のルールを定義する必要はありません。そのような場合でも、ルールの定義をリポジトリの 1 か所に定義するだけで済みます。つまり、ほとんどのエンジニアは、実装を気にすることなくこれらのルールを使用できます。

環境の分離

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

外部依存関係を確定的にする

依然として 1 つの問題があります。ビルドシステムは、直接ビルドするよりも、外部ソースから依存関係(ツールやライブラリなど)をダウンロードしなければならないことがよくあります。これは、Maven から JAR ファイルをダウンロードする @com_google_common_guava_guava//jar 依存関係を介して確認できます。

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

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

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

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

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