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

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

アーティファクト ベースのビルドシステムには、システムによって定義される少数のタスクが含まれており、エンジニアは限られた方法で構成できます。エンジニアが何を構築するかはシステムに指示しますが、どのように構築するかはビルドシステムが決定します。タスクベースのビルドシステムと同様に、Bazel などのアーティファクト ベースのビルドシステムにもビルドファイルがありますが、ビルドファイルの内容は大きく異なります。Bazel のビルドファイルは、出力の生成方法を記述した Turing-complete スクリプト言語の命令セットではなく、ビルドするアーティファクトのセット、その依存関係、ビルド方法に影響する限られたオプション セットを記述する宣言型マニフェストです。エンジニアは、コマンドラインで bazel を実行するときに、ビルドするターゲットのセット(内容)を指定します。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: このターゲットの前にビルドしてリンクする必要がある他のターゲット

依存関係は、同じパッケージ内(MyBinary:mylib への依存関係など)にあることも、同じソース階層内の別のパッケージ内(//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 には、こうした問題の効果的な解決方法があります。次に進む前に、この点について確認しましょう。

依存関係としてのツール

以前に遭遇した問題の一つは、ビルドがマシンにインストールされているツールに依存し、ツールのバージョンや場所が異なるために、システム間でビルドを再現するのが難しい場合があることでした。ビルドまたはコンパイルするプラットフォームによってツールが異なる言語をプロジェクトで使用し(Windows と Linux など)、それぞれのプラットフォームで同じ処理を行うために若干異なるツールセットが必要な場合、問題はさらに難しくなります。

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

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

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

ビルドシステムの拡張

Bazel には、いくつかの一般的なプログラミング言語のターゲットが最初から用意されていますが、エンジニアは常に多くのことを必要としています。タスクベース システムの利点の一つは、あらゆる種類のビルドプロセスを柔軟にサポートできることです。アーティファクト ベースのビルドシステムでは、これをあきらめない方がよいでしょう。幸い、Bazel では、カスタムルールを追加することで、サポートされているターゲット タイプを拡張できます。

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

アクションのデベロッパーが、アクションの一部として非決定的なプロセスを導入するなどの操作を止める方法がないため、システムは確実とは言えません。しかし、このような状況は実際には頻繁には発生せず、不正行為の可能性をアクション レベルまで下がることで、エラーの発生は大幅に減ります。一般的な言語やツールをサポートするルールはオンラインで幅広く利用でき、ほとんどのプロジェクトで独自のルールを定義する必要はありません。このようなルールを使用する場合でも、ルールの定義はリポジトリの一元的な場所で定義するだけで済みます。つまり、ほとんどのエンジニアは実装について心配することなくルールを使用できます。

環境の分離

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

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

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

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

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

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

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

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