依存関係の管理

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

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

Bazel などのアーティファクト ベースのビルドシステムを使用するプロジェクトは一連のモジュールに分割され、モジュールは BUILD ファイルを介して相互の依存関係を表現します。こうしたモジュールと依存関係を適切に構成すると、ビルドシステムのパフォーマンスと維持に必要な作業量の両方に大きな影響を及ぼす可能性があります。

きめ細かいモジュールと 1:1:1 ルールの使用

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

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

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

ビルド ターゲットを小さくすると、そのメリットが大規模に発揮されます。それが分散ビルドの高速化につながり、ターゲットを再ビルドする頻度が減るためです。テストが登場すると、この利点はさらに魅力的になります。ターゲットを絞り込んだことで、ビルドシステムは、変更の影響を受ける可能性のある限られたテストのサブセットのみを実行でき、よりスマートに実行できるようになります。Google は、ターゲットを小さくすることによる体系的なメリットがあると信じており、デベロッパーへの負担を避けるために BUILD ファイルを自動的に管理するためのツールに投資することで、デメリットを軽減する取り組みを進めました。

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

モジュールの公開設定を最小限に抑える

Bazel などのビルドシステムでは、各ターゲットで公開設定を指定できます。これにより、他のターゲットに依存するかどうかが決まります。プライベート ターゲットは、その BUILD ファイル内でのみ参照できます。ターゲットを使用すると、明示的に定義された BUILD ファイルのリストのターゲット(一般公開される場合は、ワークスペース内のすべてのターゲット)に対して、より広範な可視性を付与できます。

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

依存関係の管理

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

内部依存関係

きめ細かいモジュールに分割された大規模なプロジェクトでは、ほとんどの依存関係が内部的(つまり、同じソース リポジトリで定義されてビルドされた別のターゲット)にある可能性が高くなります。内部依存関係と外部依存関係との違いは、ビルドの実行中にビルド済みのアーティファクトとしてダウンロードされるのではなく、ソースからビルドされる点です。また、内部依存関係に「バージョン」という概念はありません。ターゲットとそのすべての内部依存関係は、常にリポジトリ内の同じ commit/リビジョンでビルドされます。内部依存関係に関して慎重に対処する必要がある問題のひとつに、推移的な依存関係の扱い方があります(図 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 は動作しなくなります。実質的に、ターゲットから複数のバージョンを持つサードパーティ ライブラリに新しい依存関係を追加することは安全ではありません。そのターゲットのすべてのユーザーがすでに別のバージョンに依存している可能性があるためです。単一バージョン ルールに従うことで、この競合は不可能になります。ターゲットがサードパーティ ライブラリへの依存関係を追加した場合、既存の依存関係はすでにその同じバージョンに存在するため、問題なく共存できます。

推移的な外部依存関係

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

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

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

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

外部依存関係を使用したビルド結果のキャッシュ保存

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

ただし、オーバーヘッドと複雑さも大きくなります。各アーティファクトのビルドとアーティファクト リポジトリへのアップロードは担当者が責任を持って行い、クライアントは最新バージョンを使用していることを確認する必要があります。また、システムのさまざまな部分がリポジトリ内のさまざまなポイントからビルドされ、ソースツリーの一貫したビューがなくなるため、デバッグも非常に困難になります。

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

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

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