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

問題を報告 ソースを表示

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

アーティファクト ベースのビルドシステムには、システムにより定義された少数のタスクがあり、エンジニアはこれらのタスクを制限付きで構成できます。エンジニアは何を構築するかをシステムに指示しますが、ビルドシステムがどのように構築するかを決定します。タスクベースのビルドシステムと同様に、Bazel などのアーティファクト ベースのビルドシステムにもビルドファイルがありますが、ビルドファイルの内容は大きく異なります。Bazel のビルドファイルは、出力の生成方法を記述する Turing で完全なスクリプト言語の命令セットではなく、ビルドするアーティファクト、その依存関係、ビルド方法に影響する限られたオプション セットを記述する宣言型マニフェストです。エンジニアがコマンドラインで 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:mylib への依存関係など)か、同じソース階層内の別のパッケージ内(mylib//java/com/example/common への依存関係など)に存在します。

タスクベースのビルドシステムと同様に、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 on Linux の LXC などのシステムによって強制されます。つまり、宣言していないファイルを読み取ることはできず、書き込みはあるが宣言していないファイルはアクション終了時に破棄されるため、アクション同士が競合することは不可能です。また、Bazel ではサンドボックスを使用して、アクションがネットワーク経由で通信しないように制限しています。

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

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

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

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

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

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

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