Git で monorepo を扱う際の課題とヒント

多くのユーザーがその柔軟さ故に Git を分散型バージョン管理システムとして採用しています。特に Git のブランチとマージのモデルは、分散型の開発ワークフローを実現する強力な方法となっています。この柔軟性が大半のユースケースに機能する一方で、それほど美しく扱いきれないこともいくつかあります。そのようなユースケースの一つは、monorepo という大きな一枚岩のリポジトリで Git を使用することです。この記事では、Git を使用して monorepo を扱う際の課題について説明し、その問題を緩和するヒントを提供します。

monorepo とは?

さまざまな定義がありますが、我々は monorepo を以下のように定義します。

  • 論理プロジェクトを二つ以上含むリポジトリ (iOS クライアントやウェブアプリケーションなど)
  • 各プロジェクトはほとんど関連がなく、疎結合、または異なる方法で繋がっている (依存管理ツール経由など)
  • いろいろな意味でリポジトリが大きい
    • コミットの数
    • ブランチの数またはタグの数
    • 管理されるファイルの数
    • 管理されるコンテンツのサイズ (リポジトリの .git ディレクトリを見ることで測定される)

一例として Facebook には、以下のような monorepo があります。

数十万のファイルに渡って週に数千コミットがあり、Facebook のメインソースリポジトリは巨大になっています。Linux カーネルより何倍も大きく、2013 年には 1700 万行のコードと 4 万 4 千ファイルがチェックインされました。

性能試験を実施していた時に Facebook が使用していたテストリポジトリは以下の通りです。

  • 400 万コミット
  • 一直線の履歴
  • 約 130 万のファイル
  • .git ディレクトリのサイズ 約 15GB
  • インデックスファイルのサイズ 約 191MB

概念上の課題

関連のないプロジェクトを Git の monorepo で管理する際には、概念上の課題が多くあります。

まず、Git は全コミットそれぞれのツリー全体の状態を追跡します。これは単一プロジェクトや関連するプロジェクトでは問題ありませんが、多くの関連のないプロジェクトを含むリポジトリでは大変な作業となります。単純に言うと、ツリーの関連のない部分でのコミットは、開発者に関連するサブツリーに影響を及ぼします。この問題は、ツリーの履歴を拡大する多数のコミットでより大規模になものになります。ブランチの終端が常に変わるため、変更を push するのに頻繁なマージや部分的なリベースが必要になります。

Git には、特定のコミットやツリー全体を表す名前のついたエイリアスとしてタグがあります。しかし、monorepo の背景ではタグの実用性は失われます。次のように自問して下さい。monorepo に継続的にデプロイされる Web アプリケーションの仕事をしている場合、バージョン管理される iOS クライアント用のリリースタグにどのような妥当性があるでしょうか?

性能面の課題

これらの概念上の課題だけでなく、monorepo のセットアップに影響する性能面の課題が多くあります。

コミットの数

大規模な単一プログラムで関連のないプロジェクトを管理すると、コミットレベルで煩わしくなることが分かります。そのうちに、急激な伸び方で大量のコミットに繋がってきます (Facebook では「週に数千コミット」です)。Git ではプロジェクトの履歴を表すのに 有向非巡回グラフ (DAG: directed acyclic graph) を使うため、これは特に問題になります。大量のコミットにより、グラフをたどる各コマンドは、履歴が深くなるほど遅くなるのです。

これの例として、git log でリポジトリの履歴を調査する作業や、git blame でファイルの変更に注釈を付ける作業も含まれます。git blame では、リポジトリに大量のコミットがある場合、Git は、blame の情報を計算するために関連のない大量のコミットをたどる必要があります。他の例としては、あらゆる種類の到達可能性の質問 (コミット A はコミット B に到達可能か、など) に答える作業があります。monorepo で見つかる多くの関連のないモジュールをまとめると、性能面の課題は悪化します。

参照の数

monorepo 内の大量の参照 (ブランチやタグなど) は、さまざまな点で性能に影響を及ぼします。

ref advertisement には、monorepo のすべての参照が含まれます。ref advertisement はリモートでの各 git 操作の最初の段階なので、git clonegit fetchgit push などの操作に影響を及ぼします。大量の参照により、これらの性能を行う時は性能が損なわれます。リポジトリ URL と共に git ls-remote を使用することで ref advertisement が見られます。例えば、git ls-remote
git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
とすれば、Linux Kernel リポジトリのすべての参照がリスト表示されます。

参照が大まかに保存されている場合、ブランチのリスト化が遅くなり得ます。git gc が行われた後、参照は単一ファイルにまとめられ、2 万以上ある参照のリスト化でも高速になります (約 0.06 秒)。

リポジトリのコミット履歴を横断して、各参照を検証する必要のある各操作 (git branch --contains SHA1 など) は、monorepo 内では遅くなります。
21708 の参照を持つリポジトリでは、古いコミットを含む参照のリスト化には以下の時間がかかります (ほぼすべての参照から到達可能)。

ユーザータイム (秒): 146.44*

*これはページキャッシュやその下にあるストレージ層によっても変わります。

管理されるファイルの数

インデックスまたはディレクトリキャッシュ (.git/index) は、リポジトリ内の全てのファイルを追跡しています。Git はこのインデックスを使い、各ファイル上で stat(1) を実行することでファイルに変更があったかどうかを判断し、ファイルの変更情報をインデックス内に格納されている情報と比較します。

従って、追跡されるファイルの数が多くの操作の性能* に影響を及ぼします。

  • git status が遅くなる場合があります (各ファイルの状態、インデックスファイルが大きくなる)
  • git commit も遅くなる場合があります (各ファイルの状態も同様)

*これはページキャッシュやその下にあるストレージ層によっても変わります。また、数万や数十万といった大量のファイルがあるときにしか気付きません。

巨大なファイル

単一サブツリーやプロジェクト内の巨大なファイルがリポジトリ全体の性能に影響します。例えば、関連のないプロジェクト上で作業する開発者 (または開発エージェント) をよそに、monorepo 内の iOS クライアントプロジェクトに追加された巨大なメディアファイルがクローンされたりします。

複合的な影響

ファイルの数であろうと、ファイルがどれだけ変更されたかであろうと、ファイルがどれだけ巨大かであろうと、これらの問題が組み合わさって性能にさらなる影響を及ぼします。

  • ブランチ間やタグ間を切り替えることはサブツリーの背景 (たとえば私が作業しているサブツリーなど) では非常に便利ですが、これはツリー全体を更新します。ファイル数の影響でこのプロセスは遅くなる可能性があり、次善策が必要になります。git checkout ref-28642-31335 -- templates を使用して、
    例えば ./templates ディレクトリを更新してブランチに適応させますが、 HEAD は更新しません。これは更新済ファイルをインデックス内で変更してマークするという副作用があります。
  • 変換前にパックファイルに全ての情報がまとめられるため、クローンやフェッチが遅くなりサーバー上でリソースが集中します。
  • ガベージコレクションが遅くなり、デフォルトでは (ガベージコレクションが必要な場合) プッシュで実行されます。
  • すべての操作でリソースの使用率が高くなります。これは git upload-packgit gc などパックファイルの (再) 生成を含みます。

Bitbucket はどうなのか?

Git の設計目標により、一枚岩のようなリポジトリはあらゆる Git リポジトリ管理ツールにとっての課題であり、それは Bitbucket にとっても変わりありません。さらに重要なことに、一枚岩のリポジトリは、サーバー側とクライアント (ユーザー) 側の両方に解決方法が必要となる課題を提示しています。

以下の表にこれらの課題を記します。

Screen Shot 2015-10-27 at 12.25.59 PM

緩和の戦略

もし一枚岩のリポジトリにありがちな特殊なユースケースを Git がサポートしていれば素晴らしいことでしょう。しかし、大きな成功と人気を実現した Git は、時々ある、当初の設計とは異なる方法で使用したいという要望には応えられません。チームの大多数にとっての良いニュースは、本当にとても大きな一枚岩リポジトリが普通ではなくほとんど例外的な傾向にあるということです。したがって、この記事のように興味深いことに、直面している場面で記事の内容を適用する必要する状況はほとんどないでしょう。

とは言っても、大きなリポジトリで作業する際に助けとなる緩和の戦略が色々あります。長い履歴または大きなバイナリ資産のあるリポジトリに対して、私の同僚の Nicola
Paolucci
解決方法 をいくつか説明しています。

参照を削除する

リポジトリに数万の参照がある場合、もう必要のない参照を削除するよう検討するべきです。DAG は変更がどのように行われたかという履歴を持ち、マージコミットがその親を指す一方で、すでにブランチが存在しない場合でもブランチ上で行われた作業が追跡される可能性があります。

ブランチベースのワークフロー では、あなたが保持したい長寿命のブランチは数を少なくするべきです。恐れずに短寿命のフィーチャーブランチをマージ後に削除してください。

マスターや生産などのメインブランチにマージされた全ブランチの削除を検討して下さい。メインブランチからコミットに到達可能で、マージコミットでブランチをマージしている限り、どのように変更が行われたかの履歴を追跡することはまだ可能です。デフォルトのマージコミットメッセージはブランチ名を含んでいることが多く、必要に応じてこの情報を保持することが可能です。

多数のファイルに対処する

リポジトリに多数 (数千から数万) のファイルがある場合、バッファーキャッシュ用として使用できるメモリーを大量に積んだ高速なローカルストレージを使うことで改善される可能性があります。これは、 Mercurial を導入した Facebook の例のように、クライアントにより大きな変更が必要となる領域です。

そのアプローチでは、変更があったかどうかを調べるために全ファイルを巡回するのではなく、ファイル変更を記録するためにファイルシステム通知を使いました。同様のアプローチ (これも監視を用いる) が、git 向けに議論されていますが、まだ結論は出ていません。

Git LFS を使用する

動画や画像などの大きなファイルを含むプロジェクト用に、大きなバイナリファイルを統合し、全体の性能に与える影響を抑えるためのオプションの一つとして Git LFS があります。私の同僚の Steve Streeting は、LFS プロジェクトに積極的に貢献していて、最近そのプロジェクトについての記事を書きました。

境界を特定してリポジトリを分割する

最も根本的な解決方法は、monorepo を分割してより小さく集中的なものにすることです。すべての変更を単一リポジトリで追跡する作業から離れてみてください。そして、その代わりにコンポーネントの境界を特定してください。もしかしたら、似たようなリリースサイクルを持つモジュールやコンポーネントを見つける事でそれができるかもしれません。サブコンポーネントを一掃する優れたリトマス試験紙は、リポジトリでタグを使い、ソースツリーの他の部分に対して道理にかなっているかどうかを見ることです。

Git が monorepo を美しくサポートしていれば素晴らしいのですが、monorepo の概念は、最初に Git を大きな成功と人気に導いたものとは少し食い違っています。しかし、monorepo があるからといって Git の可能性を諦めるべきという意味ではありません。多くの場合、問題に対して実行可能な解決方法があるのですから。




*本ブログは Atlassian Developers の翻訳です。本文中の日時などは投稿当時のものですのでご了承ください。
*原文 : 2015 年 10 月 21 日投稿 "Monorepos in Git"