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

問題を報告する ソースを表示 Nightly · 8.4 · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

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

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

機能的な視点

アーティファクト ベースのビルドシステムと関数型プログラミングの間には、簡単に類推できます。従来の命令型プログラミング言語(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: このターゲットの前にビルドしてリンクする必要がある他のターゲット

依存関係は、同じパッケージ内(: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 でビルドされたすべての依存関係をリンクする最終的な実行可能バイナリを生成します。

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

ただし、メリットは並列処理だけではありません。このアプローチの次の利点は、デベロッパーが変更を加えることなく 2 回目に 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 には、これらの問題を解決する巧妙な方法があります。次に進む前に、それらについて説明します。

依存関係としてのツール

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

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

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

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

ビルドシステムの拡張

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

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

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

環境の分離

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

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

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

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

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

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

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

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