依存関係の管理

これまでのページを振り返ってみると、1 つのテーマが何度も繰り返されています。つまり、独自のコードの管理は非常に簡単ですが、その依存関係の管理ははるかに困難になります。依存関係にはさまざまなものがあります。タスクに依存することも(「リリースを完了としてマークする前にドキュメントを push する」など)、アーティファクトに依存することもあります(「コードをビルドするには、最新バージョンのコンピュータ ビジョン ライブラリが必要」など)。コードベースの別の部分に内部依存関係がある場合や、コードベースの別の部分に内部依存関係がある場合や、別のコードに外部の依存関係が存在する場合もあれば、別のデータベースを所有している場合もあります。しかし、いずれにせよ、「これを実現するには必要な作業」という考えはビルドシステムの設計では繰り返し繰り返されるものであり、依存関係の管理はおそらくビルドシステムの最も基本的な仕事です。

モジュールと依存関係の取り扱い

Bazel などのアーティファクト ベースのビルドシステムを使用するプロジェクトは、BUILD ファイルを介して相互の依存関係を表す一連のモジュールに分割されます。これらのモジュールと依存関係を適切に整理すると、ビルドシステムのパフォーマンスとメンテナンスにかかる時間の両方に大きな効果をもたらす可能性があります。

細分化されたモジュールと 1:1:1 ルールの使用

アーティファクト ベースのビルドを構築する際に最初に直面する問題は、個々のモジュールに含めるべき機能の量を決定することです。Bazel では、モジュールjava_librarygo_binary などのビルド可能単位を指定するターゲットで表されます。一方、1 つの BUILD ファイルをルートに配置し、そのプロジェクトのすべてのソースファイルを再帰的にグロビングすることで、プロジェクト全体を 1 つのモジュールに含めることもできます。もう 1 つの極端な例は、ほぼすべてのソースファイルを独自のモジュールにすることで、各ファイルが依存する他のすべてのファイルを BUILD ファイルにリストすることが必要な場合です。

ほとんどのプロジェクトは、この中間に位置するため、パフォーマンスと保守性のトレードオフが伴います。プロジェクト全体で 1 つのモジュールを使用すると、外部依存関係を追加する場合を除き、BUILD ファイルを編集する必要はありませんが、ビルドシステムは常にプロジェクト全体を一度にビルドする必要があります。つまり、ビルドの一部を並列化または分散することはできません。また、ビルド済みの部分をキャッシュに保存することもできません。ファイルごとに 1 つのモジュールはその逆です。ビルドシステムは、ビルドのステップをキャッシュに保存してスケジュールするうえで最大限の柔軟性を備えていますが、エンジニアが参照するファイルを変更するたびに、依存関係のリストを維持するにはより多くの労力を費やす必要があります。

正確な粒度は言語によって(多くの場合は言語内でも)異なりますが、Google は、通常タスクベースのビルドシステムで記述するモジュールよりも、はるかに小さいモジュールを優先する傾向があります。Google の一般的な本番環境バイナリは、多くの場合、数万のターゲットに依存しており、中規模のチームであってもコードベース内で数百のターゲットを所有できます。パッケージ化の概念が組み込まれた Java などの言語の場合、各ディレクトリには通常、1 つのパッケージ、ターゲット、BUILD ファイルが含まれます(Bazel ベースの別のビルドシステムである Pants では、これを 1:1:1 ルールと呼んでいます)。パッケージング規則が弱い言語では、BUILD ファイルごとに複数のターゲットを定義することがよくあります。

ビルド ターゲットが小さいほど、分散ビルドが高速化され、ターゲットの再構築の必要性が減るため、大規模なビルド ターゲットのメリットが現れ始めます。ターゲットを細かく設定することで、ビルドシステムは、特定の変更の影響を受ける可能性のあるテストのサブセットのみをよりスマートに実行できるため、この利点はさらに魅力的になります。Google は、ターゲットを小さくすることには体系的なメリットがあると考えているため、BUILD ファイルを自動的に管理してデベロッパーの負担を回避するツールに投資することで、このデメリットを軽減する取り組みを進めました。

これらのツールの一部(buildifierbuildozer など)は、Bazel で使用できる buildtools ディレクトリにあります。

モジュールの可視性を最小限に抑える

Bazel などのビルドシステムでは、各ターゲットで公開設定を指定できます。このプロパティにより、他のターゲットに依存している可能性があるかどうかが決まります。限定公開ターゲットは、それ自体の BUILD ファイル内でのみ参照できます。ターゲットは、明示的に定義された BUILD ファイルのリスト(一般公開の場合はワークスペース内のすべてのターゲット)のターゲットを幅広く可視化できます。

ほとんどのプログラミング言語と同様に、通常はできる限り可視性を最小限に抑えることをおすすめします。通常、Google のチームは、そのターゲットが Google のどのチームでも利用できる広く使用されているライブラリを表す場合にのみ、ターゲットを公開します。コードを使用する前に他者との調整が必要なチームは、ターゲットの公開設定として顧客ターゲットの許可リストを維持します。各チームの内部実装ターゲットは、チームが所有するディレクトリのみに制限され、ほとんどの BUILD ファイルには非公開でないターゲットが 1 つだけ含まれます。

依存関係の管理

モジュールは互いに参照できる必要があります。コードベースを詳細なモジュールに分割することの欠点は、モジュール間の依存関係を管理する必要があることです(ただし、ツールを使用することで自動化できます)。通常、これらの依存関係を表現すると、最終的には BUILD ファイル内のコンテンツの大部分になります。

内部依存関係

きめ細かいモジュールに分割された大規模なプロジェクトでは、ほとんどの依存関係が内部である可能性があります。つまり、同じソース リポジトリで定義され、ビルドされた別のターゲットに存在します。内部依存関係は、ビルドの実行中にビルド済みアーティファクトとしてダウンロードされるのではなく、ソースからビルドされるという点で外部依存関係とは異なります。また、内部依存関係には「バージョン」という概念はありません。ターゲットとそのすべての内部依存関係は、常にリポジトリ内の同じ commit/リビジョンでビルドされます。内部依存関係に関して慎重に対処すべき問題の 1 つは、推移的依存関係の扱い方です(図 1)。ターゲット A が、共通ライブラリ ターゲット C に依存するターゲット B に依存しているとします。ターゲット A はターゲット C で定義された クラスを使用できるでしょうか

推移的依存関係

図 1. 推移的依存関係

基盤となるツールに関しては、これで問題はありません。ビルド時に B と C の両方がターゲット A にリンクされるため、C で定義されたシンボルはすべて A に認識されます。Bazel は長年にわたってこの処理を許可していましたが、Google が成長するにつれ、問題が生じ始めました。B がリファクタリングされ、C に依存する必要がなくなったとします。B の C への依存関係が削除されると、A と、B への依存関係を介して C を使用していた他のターゲットが機能しなくなります。事実上、ターゲットの依存関係は公開コントラクトの一部となり、安全に変更することはできません。これは、時間の経過とともに依存関係が蓄積し、Google のビルドが遅くなり始めたことを意味します。

Google は最終的に、Bazel に「厳密な推移的依存関係モード」を導入することでこの問題を解決しました。このモードでは、ターゲットがシンボルに直接依存せずにシンボルを参照しようとしたかどうかを Bazel が検出します。その場合は、エラーと、依存関係を自動的に挿入できるシェルコマンドを返して失敗します。この変更を Google のコードベース全体にロールアウトし、数百万ものビルド ターゲットをリファクタリングして依存関係を明示的にリスト化する作業は、数年にわたる取り組みでしたが、それだけの価値がありました。ターゲットの不要な依存関係が少なくなるため、ビルドがはるかに高速になり、エンジニアは不要な依存関係を削除できます。依存関係に依存するターゲットの破損について心配する必要はありません。

通常どおり、厳密な推移的依存関係の適用にはトレードオフが伴いました。頻繁に使用されるライブラリを偶発的に pull するのではなく、多くの場所に明示的にリストする必要がなくなり、エンジニアが BUILD ファイルに依存関係を追加することにより多くの労力を費やす必要があったため、ビルドファイルがさらに複雑になりました。それ以来、Google は、不足している多くの依存関係を自動的に検出し、デベロッパーの介入なしにそれらの依存関係を BUILD ファイルに追加することで、この労力を軽減するツールを開発してきました。しかし、このようなツールがなくても、コードベースのスケーリングに伴い、それだけの価値があることがわかりました。BUILD ファイルに依存関係を明示的に追加することは 1 回限りのコストですが、暗黙的な推移的依存関係を処理すると、ビルド ターゲットが存在する限り、継続的な問題が発生する可能性があります。Bazel は、デフォルトで Java コードに厳格な推移的依存関係を適用します。

外部依存関係

内部的な依存関係でない場合は、外部の依存関係であることが必要です。外部依存関係とは、ビルドシステムの外部でビルドおよび保存されているアーティファクト上の依存関係です。依存関係はアーティファクト リポジトリから直接インポートされ(通常はインターネット経由でアクセスされます)、ソースからビルドされるのではなく、そのまま使用されます。外部依存関係と内部依存関係の最大の違いの一つは、外部依存関係にはバージョンがあり、それらのバージョンがプロジェクトのソースコードとは独立して存在することです。

自動と手動の依存関係管理

ビルドシステムを使用すると、外部依存関係のバージョンを手動または自動で管理できます。手動で管理する場合、ビルドファイルは、アーティファクト リポジトリからダウンロードするバージョンを明示的にリストします。多くの場合、1.1.4 などのセマンティック バージョン文字列が使用されます。ソースファイルが自動的に管理される場合、許容されるバージョンの範囲を指定し、ビルドシステムは常に最新のバージョンをダウンロードします。たとえば、Gradle では、依存関係のバージョンを「1.+」として宣言できます。これにより、メジャー バージョンが 1 である限り、依存関係のマイナー バージョンまたはパッチ バージョンが許容されることを指定できます。

自動管理の依存関係は小規模なプロジェクトには便利ですが、たいていの場合、規模の小さいプロジェクトや、複数のエンジニアが担当しているプロジェクトでは、障害の原因となります。依存関係が自動的に管理される問題は、バージョンを更新するタイミングを制御できないことです。外部関係者が(セマンティック バージョニングを使用していると主張している場合でも)互換性を破る更新を行わないことを保証する方法はありません。そのため、ある日に正常に機能していたビルドが次の日には破損し、変更内容を検出したり、動作状態にロールバックしたりする簡単な方法がない可能性があります。ビルドが壊れなくても、追跡できない動作やパフォーマンスの変化がある場合があります。

一方、手動で管理する依存関係はソース管理の変更が必要なため、簡単に検出してロールバックできます。また、古いバージョンのリポジトリをチェックアウトして、古い依存関係を使用してビルドすることもできます。Bazel では、すべての依存関係のバージョンを手動で指定する必要があります。中程度の規模でも、手動でのバージョン管理のオーバーヘッドは、その安定性の点で十分な価値があります。

1 つのバージョンのルール

ライブラリのバージョンが異なると、通常は異なるアーティファクトで表されます。したがって、理論上、同じ外部依存関係の異なるバージョンをビルドシステム内で異なる名前で宣言することはできません。これにより、各ターゲットで、使用する依存関係のバージョンを選択できます。実際には多くの問題が生じるため、Google ではコードベース内のすべてのサードパーティ依存関係に厳格な 1 つのバージョン ルールを適用しています。

複数のバージョンを許可する場合の最大の問題は、ダイヤモンドの依存関係の問題です。ターゲット A がターゲット B と外部ライブラリの v1 に依存しているとします。後でターゲット B をリファクタリングして同じ外部ライブラリの v2 への依存関係を追加した場合、ターゲット A は同じライブラリの 2 つの異なるバージョンに暗黙的に依存するため、ターゲット A は機能しなくなります。実質的に、複数のバージョンを持つサードパーティ ライブラリにターゲットから新しい依存関係を追加することは安全ではありません。そのターゲットのユーザーはすでに別のバージョンに依存している可能性があるためです。1 つのバージョン ルールに従うことで、この競合は不可能になります。ターゲットがサードパーティ ライブラリへの依存関係を追加した場合、既存の依存関係はすでに同じバージョンに存在するため、問題なく共存できます。

推移的な外部依存関係

外部依存関係の推移的依存関係に対処することは、特に難しい場合があります。Maven Central などの多くのアーティファクト リポジトリでは、リポジトリ内の他のアーティファクトの特定のバージョンへの依存関係を指定できます。Maven や Gradle などのビルドツールは、多くの場合、各推移的な依存関係をデフォルトで再帰的にダウンロードします。つまり、プロジェクトに 1 つの依存関係を追加すると、合計で数十のアーティファクトがダウンロードされる可能性があります。

これは非常に便利です。新しいライブラリに依存関係を追加するときに、そのライブラリの各推移的依存関係を追跡してすべてを手動で追加するのは大変です。ただし、大きな欠点もあります。異なるライブラリが同じサードパーティ ライブラリの異なるバージョンに依存する可能性があるため、この方法は必然的に 1 つのバージョン ルールに違反し、ダイヤモンドの依存関係の問題につながります。ターゲットが同じ依存関係の異なるバージョンを使用する 2 つの外部ライブラリに依存している場合、どちらを取得するかわかりません。つまり、外部依存関係を更新すると、その依存関係の一部で競合するバージョンを取り込んでくると、コードベース全体で一見無関係なエラーが発生する可能性があります。

このため、Bazel は推移的な依存関係を自動的にダウンロードしません。残念ながら、特効薬はありません。Bazel では、リポジトリの外部依存関係をすべて列挙したグローバル ファイルと、リポジトリ全体でその依存関係に使用される明示的なバージョンを指定する必要があります。幸いなことに、Bazel には、一連の Maven アーティファクトの推移的な依存関係を含むファイルを自動生成できるツールが用意されています。このツールを 1 回実行してプロジェクトの初期 WORKSPACE ファイルを生成できます。その後、このファイルを手動で更新して、各依存関係のバージョンを調整できます。

繰り返しになりますが、ここでの選択は、利便性とスケーラビリティのどちらかです。小規模なプロジェクトでは、推移的依存関係を自身で管理する必要がない場合があり、自動推移的依存関係を使用することで解決できる場合があります。組織とコードベースが拡大し、競合や予期しない結果がどんどん頻繁に発生するにつれ、この戦略の魅力は薄れていきます。規模が大きくなると、依存関係を手動で管理するコストは、依存関係の自動管理に起因する問題に対処するコストよりもはるかに少なくなります。

外部依存関係を使用してビルド結果をキャッシュに保存する

多くの場合、外部依存関係は、ソースコードを提供せずに安定版のライブラリをリリースするサードパーティによって提供されます。組織によっては、独自のコードの一部をアーティファクトとして利用できるようにし、他のコードが内部依存関係ではなくサードパーティとして依存できるようにする場合もあります。アーティファクトのビルドは遅くてもダウンロードは高速であれば、理論的にはビルドを高速化できます。

ただし、この方法では、多くのオーバーヘッドと複雑さも生まれます。誰かが各アーティファクトをビルドしてアーティファクト リポジトリにアップロードする責任を負い、クライアントは最新バージョンが最新の状態に保たれていることを確認する必要があります。また、システムのさまざまな部分がリポジトリ内のさまざまなポイントからビルドされ、ソースツリーの一貫したビューがなくなるため、デバッグも非常に困難になります。

ビルドに時間がかかるアーティファクトの問題を解決するには、前述のように、リモート キャッシュをサポートするビルドシステムを使用することをおすすめします。このようなビルドシステムは、各ビルドで生成されたアーティファクトを、エンジニア間で共有される場所に保存します。したがって、他の誰かが最近ビルドしたアーティファクトにデベロッパーが依存している場合、ビルドシステムはビルドするのではなく、自動的にダウンロードします。これにより、アーティファクトに直接依存することによるパフォーマンス上のメリットをすべて享受しながら、常に同じソースからビルドされた場合と同様にビルドの整合性が維持されます。これは Google が内部的に使用している戦略です。リモート キャッシュを使用するように Bazel を構成できます。

外部依存関係のセキュリティと信頼性

サードパーティのソースからのアーティファクトに依存することには、本質的にリスクがあります。サードパーティ ソース(アーティファクト リポジトリなど)がダウンした場合、外部依存関係をダウンロードできない場合、ビルド全体が停止する可能性があるため、可用性のリスクが発生します。セキュリティ上のリスクもあります。サードパーティ システムが攻撃者によって侵害された場合、参照されたアーティファクトを攻撃者が独自の設計に置き換えることで、ビルドに任意のコードを挿入できる可能性があります。どちらの問題も、依存するアーティファクトを、管理するサーバーにミラーリングし、ビルドシステムが Maven Central などのサードパーティのアーティファクト リポジトリにアクセスできないようにすることで軽減できます。その反面、ミラーの維持には労力とリソースが必要になるため、ミラーを使用するかどうかの選択はプロジェクトの規模によって異なることがよくあります。セキュリティの問題は、ソース リポジトリで各サードパーティ アーティファクトのハッシュを指定する必要があり、アーティファクトが改ざんされた場合にビルドが失敗するようにすることで、オーバーヘッドをほとんど発生させることなく完全に防止できます。この問題を完全に回避するもう 1 つの方法は、プロジェクトの依存関係をベンダー化することです。プロジェクトで依存関係をベンダー化する場合、依存関係は、プロジェクトのソースコードとともにソースまたはバイナリとしてソース管理にチェックインされます。これは実質的に、プロジェクトのすべての外部依存関係が内部依存関係に変換されることを意味します。Google は、このアプローチを内部的に使用し、Google 全体で参照されるすべてのサードパーティ ライブラリを、Google のソースツリーのルートにある third_party ディレクトリにチェックします。ただし、Google のソース管理システムが、非常に大規模な monorepo を処理するようにカスタムビルドされているため、これが Google でのみ機能し、すべての組織にとってベンダー化が選択肢となるとは限りません。