Git の単一リポジトリ

単一リポジトリとは

単一リポジトリの定義はさまざまですが、私たちは次のように定義しています。

  • リポジトリに複数の論理プロジェクト (iOS クライアントと Web アプリケーションなど) が含まれている

  • これらのプロジェクトは、ほとんどは互いに無関係だが、ゆるく接続されていたり、他の手段 (依存関係管理ツールなど) で接続されていたりすることもある

  • リポジトリは多くの点で大きい:

    • コミット数

    • ブランチやタグの数

    • 追跡されるファイル数

    • 追跡されるコンテンツのサイズ (リポジトリの .git ディレクトリを見て測定)

Facebook では、そのような単一リポジトリの一例を使用しています:

数十万個のファイルにわたって 1 週間に数千件のコミットがある Facebook のメイン ソース リポジトリは巨大です。これは、2013 年に 1,700 万行のコードと 44,000 個のファイルがチェックインされた Linux カーネルの何倍もの大きさです。

そして、パフォーマンス テストを実施する際、Facebook が使用したテスト リポジトリは次のとおりです。

  • 400 万件のコミット

  • 直線的な履歴

  • 約 130 万のファイル

  • .git ディレクトリのサイズは約 15 GB

  • インデックス ファイルのサイズは 191 MB

概念上の課題

Git の単一リポジトリで無関係なプロジェクトを管理する場合、多くの概念上の課題があります。

まず、Git は、commit が実行されるたびにツリー全体の状態を追跡します。これは、単一のプロジェクトや関連するプロジェクトでは問題ありませんが、関連のないプロジェクトが多数含まれているリポジトリでは扱いにくくなります。簡単に言うと、ツリー内の無関係な部分でのコミットが、開発者に関連するサブツリーに影響します。この問題は、大量のコミットによってさらに顕著になり、ツリーの履歴が増加します。ブランチの先端がめまぐるしく変わるため、変更をプッシュするにはローカルで頻繁にマージまたはリベースする必要があります。

Git では、タグは特定のコミットの名前付きエイリアスで、ツリー全体を参照します。しかし、単一リポジトリのコンテキストでは、タグの有用性は低下します。考えてみてください。単一リポジトリで継続的にデプロイされる Web アプリケーションに取り組んでいる場合、バージョン管理された iOS クライアントのリリース タグにはどのような関連性がありますか?

パフォーマンスの問題

これらの概念的な課題に加えて、単一リポジトリのセットアップに影響するパフォーマンス上の問題が数多くあります。

コミット数

無関係なプロジェクトを大規模な単一のリポジトリで管理すると、コミット レベルで問題が発生する場合があります。時間が経つにつれて、コミット数が高い成長率で大幅に増えていきます (Facebook は「週に数千のコミット」と言っています)。これは特に厄介です。なぜなら、Git は有向非循環グラフ (DAG) を使ってプロジェクトの履歴を表すからです。コミット数が多いと、履歴が深まるにつれてグラフをたどるコマンドが遅くなる可能性があります。

この例としては、git log を介してリポジトリの履歴を調べたり、git blame を使用してファイルの変更に注釈を付けたりすることが挙げられます。git blame では、リポジトリに大量のコミットがある場合、Git は blame 情報を計算するために無関係なコミットを多数調べなければなりません。その他の例としては、あらゆる種類の到達可能性に関する質問(たとえば、コミット A がコミット B から到達可能かどうか)に答えることが挙げられます。単一リポジトリ内にある大量の無関係なモジュールとパフォーマンスの問題とが一緒になって、事態がさらに悪化します。

ref の数

単一リポジトリ内に多数の ref (ブランチやタグなど) があると、多くの点でパフォーマンスに影響します。

ref advertisement には、単一リポジトリ内のすべての ref が含まれています。ref advertisement はリモート git 操作の最初のフェーズなので、これは git clonegit fetch、または git push などの操作に影響します。ref の数が多いと、これらの操作を実行するときにパフォーマンスが低下します。ref advertisement を確認するには、リポジトリの URL で git ls-remote を使用します。たとえば、git ls-remote git://git.kernel.org/ pub/scm/linux/kernel/git/torvalds/linux.git を使用すると、Linux カーネル リポジトリ内のすべての ref がリストされます。

ref が緩く保存されている場合、ブランチのリスト表示は遅くなります。git gc の ref を 1 つのファイルにパックすれば、20,000 を超える ref でも高速に一覧表示できます(最大 0.06 秒)。

リポジトリのコミット履歴をトラバースし、各 ref を考慮する必要がある操作の場合 (例: git branch--contains SHA1)、単一リポジトリでは処理が遅くなります。21708 の ref が含まれるリポジトリでは、古いコミット (ほとんどすべての ref から到達可能) を含む ref を一覧表示するには、次のような時間がかかりました。

ユーザー時間 (秒): 146.44*

*これは、ページ キャッシュと基盤となるストレージ レイヤーによって異なります。

追跡されるファイル数

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

したがって、追跡されるファイルの数は、多くの操作のパフォーマンス * に影響します。

  • git status が遅くなることがある (すべてのファイルに対して統計が実行され、インデックス ファイルが肥大化する)

  • git commit も遅くなる可能性がある (やはり、すべてのファイルに対して統計が実行される)

* これは、ページ キャッシュと基盤となるストレージ レイヤーによって異なり、数万または数十万にのぼる大量のファイルがある場合にのみ注目に値します。

大容量ファイル

単一のサブツリー/プロジェクト内に大きなファイルがあると、リポジトリ全体のパフォーマンスに影響します。たとえば、単一リポジトリ内の iOS クライアント プロジェクトに大きなメディア アセットが追加されると、まったく無関係なプロジェクトに取り組んでいる開発者 (またはビルド エージェント) にもそのアセットがクローンされます。

複合効果

ファイルの数、変更頻度、サイズなどの問題が組み合わさると、パフォーマンスに与える影響が大きくなります。

  • サブツリーのコンテキスト (作業中のサブツリーなど) で便利なブランチ/タグの切り替えを行っても、ツリー全体が更新されます。このプロセスは、影響を受けるファイルの数が多いために時間がかかる場合があり、回避策が必要です。たとえば、git checkout ref-28642-31335 -- templates を使って ./templates ディレクトリを指定されたブランチに一致させると、たとえ HEAD を更新していなくても、更新されたファイルが変更済みとしてインデックスでマークされるという副作用があります。

  • すべての情報が転送前にパックファイルに圧縮されるため、クローン作成とフェッチは遅くなり、サーバーのリソースを大量に消費します。

  • ガベージ コレクションは低速で、既定ではプッシュ時にトリガーされます (ガベージ コレクションが必要な場合)。

  • リソース使用率は、git upload-pack, git gc など、パックファイルの (再) 作成を伴うすべての操作で高くなります。

緩和戦略

モノリシック リポジトリでありがちな特殊な使用例を Git がサポートできればすばらしいことですが、Git が大成功し、人気を博した原動力であった Git の設計目標は、その目標とは異なる方法で使用したいという欲求と両立しないことがあります。大半のチームにとっての朗報は、実際に本当に大規模なモノリシック リポジトリは例外的であるため、この記事と同様に興味深いことに (そう願います)、モノリシック リポジトリはみなさんが直面している状況には当てはまらない可能性が高いでしょう。

とはいえ、大規模なリポジトリで作業するときに役立つさまざまな緩和戦略があります。長い履歴や大きなバイナリ アセットを持つリポジトリについて、同僚の Nicola Paolucci がいくつかの回避策を述べています。

ref の削除

リポジトリに数万個の ref がある場合、不要になった ref を削除することを検討する必要があります。DAG では、変更がどのように進化したかを示す履歴が保持されますが、マージ コミットではその親が指し示されるので、ブランチが存在しなくなっても、ブランチで行われた作業を追跡できます。

ブランチ ベースのワークフローでは、保持したい長期間存続するブランチの数を少なくする必要があります。マージ後に、存続期間の短いフィーチャー ブランチを思い切って削除してください。

master や production などの main ブランチにマージされたすべてのブランチを削除することを検討してください。main ブランチからコミットに到達可能で、ブランチをマージ コミットでマージしている限り、変更がどのように進化したかを示す履歴を追跡することは引き続き可能です。既定のマージ コミット メッセージにはブランチ名が含まれていることが多く、必要に応じてこの情報を保持できます。

大量ファイルの処理

リポジトリに大量のファイル (数万から数十万) がある場合、バッファ キャッシュとして使用できる十分なメモリを備えた高速ローカル ストレージを使用すると役に立ちます。これは、たとえば、Facebook が Mercurial 用に実装した変更と同様の、大きな変更がクライアントにとって必要になる領域です。

同社のアプローチでは、すべてのファイルを反復処理して変更があったかどうかを確認する代わりに、ファイル システム通知を使用してファイルの変更を記録しました。同様のアプローチ (やはり watchman を使用) が git でも議論されていますが、まだ実現されていません。

Git LFS (大容量ファイル ストレージ) を使用する

このセクションは 2016 年 1 月 20 日に更新されました

動画やグラフィックなどの大容量ファイルを含むプロジェクトの場合、Git LFS は、リポジトリのサイズと全体的なパフォーマンスへの影響を制限するための 1 つの選択肢です。Git LFS は、大容量オブジェクトをリポジトリに直接格納する代わりに、このオブジェクトへの参照を含む同じ名前の小さなプレースホルダー ファイルを保存します。このオブジェクト本体は、専用の大容量オブジェクト ストアに格納されます。Git LFS は Git のネイティブ プッシュ、プル、チェックアウト、フェッチ操作にフックして、ワークツリー内のこれらのオブジェクトの転送と置換を透過的に処理します。つまり、リポジトリのサイズが肥大化するというペナルティなしに、通常どおりリポジトリ内の大容量ファイルを操作できます。

Bitbucket Server 4.3 (以降) には、完全に準拠した Git LFS v1.0 以降の実装が組み込まれており、LFS によって追跡される大容量画像アセットのプレビューと差分を Bitbucket UI 内で直接行うことができます。

LFS が追跡する画像

私の同僚であるアトラシアンの Steve Streeting は、LFS プロジェクトに積極的に貢献しており、最近このプロジェクトについて記事を書きました

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

最も根本的な回避策は、単一リポジトリをより小さく、より集中した git リポジトリに分割することです。1 つのリポジトリですべての変更を追跡するのではなく、場合によってはリリース サイクルが似ているモジュールやコンポーネントを特定してコンポーネントの境界を特定することを試みてください。明確なサブコンポーネントの試金石は、リポジトリ内でタグを使用してみて、ソース ツリーの他の部分でタグが意味をなすかどうかを調べる方法です。

Git が単一リポジトリをエレガントにサポートできればすばらしいことですが、単一リポジトリの概念は、そもそも Git が大成功を収め、人気を博している理由と少し矛盾しています。しかし、このことは、単一リポジトリを使用しているからといって、Git の機能をあきらめるべきだという意味ではありません。ほとんどの場合、どのような問題が発生しようとも、実行可能な解決策があります。

推奨

Bitbucket ブログ

DevOps ラーニング パス

Git の詳細

その他の Git ガイドとリソースについては、このハブをご確認ください。