依存関係の管理

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

これまでのページを振り返ってみると、1 つのテーマが何度も繰り返されます。独自のコードの管理はかなり簡単ですが、依存関係の管理はずっと困難です。依存関係にはさまざまなものがあります。タスクに依存したり(リリースを完了としてマークする前にドキュメントを push するなど)、アーティファクト(例: 「コードをビルドするために最新バージョンのコンピュータ ビジョン ライブラリを用意する必要がある」)に依存している場合もあります。しかし、いずれにしても、「これを利用するにはまずこれが必要だ」という考え方は、ビルドシステムの設計で繰り返し繰り返されるものであり、依存関係の管理はビルドシステムにとって最も基本的な仕事といえます。

モジュールと依存関係の処理

Bazel などのアーティファクトベースのビルドシステムを使用するプロジェクトは、BUILD ファイルを介して互いに依存関係を表現するモジュールのセットに分割されます。これらのモジュールと依存関係を適切に編成すると、ビルドシステムのパフォーマンスとメンテナンスの労力に大きな影響を与える可能性があります。

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

アーティファクト ベースのビルドを構造化する際に最初に浮上してくる課題は、個々のモジュールに含める機能の量を決定することです。Bazel では、モジュールは、java_librarygo_binary などのビルド可能単位を指定するターゲットで表されます。極端に言えば、1 つの BUILD ファイルをルートに配置し、そのプロジェクトのソースファイルをすべて再帰的にグローブすることで、プロジェクト全体を 1 つのモジュールに含めることができます。他方では、ほぼすべてのソースファイルを独自のモジュールにできるため、事実上、各ファイルを依存する他のすべてのファイルに BUILD ファイルをリストアップする必要があります。

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

正確な粒度は言語によって異なりますが、多くの場合、タスクベースのビルドシステムで作成されるモジュールよりも、非常に小さなモジュールが優先されます。多くの場合、Google の本番環境バイナリは数万件のターゲットに依存しています。中規模のチームでも、コードベース内で数百個のターゲットを所有できます。Java のようにパッケージ化の概念が強い言語の場合、各ディレクトリには通常、単一のパッケージ、ターゲット、BUILD ファイル(Bazel をベースにした別のビルドシステムが 1:1:1 ルールと呼ばれます)が含まれています。パッケージ化の強度が低い言語では、多くの場合、BUILD ファイルごとに複数のターゲットを定義します。

小さなビルド ターゲットのメリットは大規模なビルドを実際に実証し始めています。なぜなら、ビルドの高速化によってターゲットの再構築の必要性が減るからです。より詳細にテストを行うことで、メリットがよりいっそう魅力的になります。ターゲットを細かく設定することで、変更によって影響を受ける可能性のあるテストの一部だけをビルドシステムで実行できるようになるからです。Google では、小さなターゲットを使用することによる体系的なメリットを Google が信じているので、BUILD を自動的に管理してデベロッパーの負担を軽減するツールに投資することで、この欠点を緩和する取り組みを進めています。

buildifierbuildozer など、一部のツールは Bazel の buildtools ディレクトリで使用できます。

モジュール表示の最小化

Bazel やその他のビルドシステムを使用すると、各ターゲットが可視性を指定できます。可視性は、依存する他のターゲットを決定するプロパティです。非公開ターゲットは、独自の BUILD ファイル内でのみ参照できます。ターゲットは、明示的に定義された BUILD ファイルのリストへの公開範囲を拡大できます。また、一般公開の場合はワークスペースのすべてのターゲットに可視性を公開できます。

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

依存関係の管理

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

内部依存関係

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

推移的依存関係

図 1. 推移的依存関係

基礎となるツールについては、これで問題ありません。ビルド時に B と C の両方がターゲット A にリンクされるため、C で定義されたシンボルはすべて A に認識されます。Bazel もそうして何年も前から存在していましたが、Google が成長するにつれ、問題に直面するようになりました。B がリファクタリングされ、C に依存する必要がなくなったとします。その後、C に対する B の依存関係が削除されると、A と、B への依存関係を介して C を使用する他のターゲットは破損します。実質的に、ターゲットの依存関係は公開契約の一部となり、安全に変更することはできません。つまり、時間の経過とともに依存関係が蓄積され、Google での構築が遅くなり始めたということです。

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

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

外部依存関係

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

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

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

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

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

ワンバージョン ルール

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

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

推移的外部依存関係

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

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

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

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

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

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

しかし、この方法は多くのオーバーヘッドと複雑性をもたらします。各アーティファクトをビルドしてアーティファクト リポジトリにアップロードする責任はユーザーにあり、クライアントはそれらを最新の状態に維持する必要があります。また、システムのさまざまな部分がリポジトリ内のさまざまなポイントからビルドされ、ソースツリーの一貫したビューが表示されなくなるため、デバッグがさらに困難になります。

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

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

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