依存関係の管理

問題を報告 ソースを表示

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

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

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

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

アーティファクト ベースのビルドを構築する際に最初に考慮すべきことは、個々のモジュールにどの程度の機能を組み込むかです。Bazel では、モジュールは、java_librarygo_binary などのビルド可能単位を指定するターゲットで表されます。極端な例では、ルートに 1 つの BUILD ファイルを配置し、そのプロジェクトのソースファイルをすべて再帰的にグロビングすることで、プロジェクト全体を 1 つのモジュールにすることができます。他方の極端な例では、ほぼすべてのソースファイルを個別のモジュールにすることができ、実質的に各ファイルが依存するファイルごとに BUILD ファイルにリストする必要があります。

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

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

ビルド ターゲットを小さくすることで、分散ビルドを高速化し、ターゲットを再構築する頻度を下げることができるため、そのメリットが大規模に現れ始めます。ターゲットを細分化することで、ビルドシステムで、特定の変更によって影響を受ける可能性のあるテストのサブセットのみを実行することをスマートに行えるため、テストが登場すると利点はさらに顕著になります。Google は、小さいターゲットを使用することによる体系的なメリットを信じているため、このデメリットを軽減するために、BUILD ファイルを自動的に管理するツールに投資し、デベロッパーの負担を軽減しています。

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

モジュールの可視性の最小化

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 に依存する必要がなくなるようにリファクタリングされたとします。その後、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 つの異なるバージョンに暗黙的に依存することになるため、機能しなくなります。複数のバージョンを持つサードパーティ ライブラリに対して、ターゲットから新しい依存関係を追加することは実質的に安全ではありません。そのターゲットのユーザーがすでに別のバージョンに依存している可能性があるためです。1 つのバージョン ルールに従うと、この競合は不可能になります。ターゲットがサードパーティ ライブラリへの依存関係を追加した場合、既存の依存関係はすでに同じバージョンに存在するため、問題なく共存できます。

一時的な外部依存関係

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

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

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

この場合も、利便性と拡張性のどちらかを選択できます。小規模なプロジェクトでは、推移的依存関係の管理を気にする必要がないことが望ましい場合があります。また、自動の推移的依存関係を使用することで回避できる場合があります。組織とコードベースが拡大し、競合や予期しない結果が増えるにつれて、この戦略は魅力的ではなくなります。大規模な環境では、依存関係を手動で管理するコストは、依存関係の自動管理によって生じる問題に対処するコストよりもはるかに少なくなります。

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

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

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

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

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

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