巨大リポジトリのGitをCommit-Graphで高速化:Log、Rebase、ブランチ確認をサクサクに

Git tutorial - IT technology blog
Git tutorial - IT technology blog

1. 10万コミット規模のリポジトリがもたらす苦痛

約半年前、私たちのチームは約30個のマイクロサービスを1つのモノレポ(Monorepo)に統合することを決定しました。最初は極めて順調でした。しかし、45名の開発者がコミットを重ね続けた結果、わずか8ヶ月でコミット数は瞬く間に14万件を突破し、300本以上のアクティブなブランチが並行して運用される状態になったのです。

こうなると、日常的な基本操作すら悪夢へと変わり始めました。

  • git log --graph --oneline -n 20 を実行すると、数行のブランチツリーを描画するだけでPCのファンが唸りを上げ、丸々5秒も待たされる。
  • コードがステージング環境に反映されたか確認するために git branch --contains <commit_id> を叩くと、12〜15秒待たされるのは日常茶飯事。
  • 最もストレスだったのが rebase です。Gitは深く枝分かれしたブランチ間の祖先関係(ancestry)を1つずつ探索しなければならず、開発者のマシンやCI RunnerのCPU使用率が100%に張り付いてフリーズすることもしばしばありました。

ブラウザを起動するよりもCLIツールの応答待ちのほうが長いという状況は、開発のリズムを著しく阻害していました。

2. コミットグラフが肥大化すると、なぜGitは失速するのか?

Gitはデータを有向非巡回グラフ(DAG)モデルで管理しています。各コミットは、1つまたは複数の親コミットへのポインタを保持しています。

小規模なプロジェクトであればこの構造は極めて高速に動作します。しかし、リポジトリのノード数が数十万規模に達すると、深刻な2つのボトルネックが顕在化します。

  • 大量のディスクI/O(Disk I/O)の発生: git log や git merge-base を実行する際、Gitはディスク上のpackfileからコミットオブジェクトを1つずつ展開して読み込む必要があります。実際には親コミットのハッシュ値とタイムスタンプというわずか2つの情報が必要なだけなのに、数万個ものオブジェクトを解凍・展開するため、膨大なリソースの無駄遣いが発生します。
  • コストの高いグラフ探索: コミットAがブランチBに含まれているかを判定するには、Bから過去へ向かって何千階層も遡る必要があります。インデックスが存在しない場合、BFS(幅優先探索)やDFS(深さ優先探索)アルゴリズムはコミットツリー全体をしらみつぶしに探索せざるを得ず、I/OとCPUサイクルを大量に消費してしまいます。

3. 応急処置とその限界

抜本的な解決策に辿り着く前に、チームではよく知られたアプローチをいくつか試行錯誤しましたが、いずれも特有の限界に直面しました。

アプローチ1:git gc によるガベージコレクション

git gc --prune=now --aggressive

このコマンドはルーズオブジェクト(loose objects)を圧縮してまとまったpackfileを生成します。.git ディレクトリの容量は削減されるものの、家系図のようなコミットツリーを走査するには依然としてコミットヘッダーを1つずつ解析しなければなりません。そのため、深い履歴探索を伴うコマンドの処理速度はほとんど改善されませんでした。

アプローチ2:Shallow clone の活用

git clone --depth 50 https://github.com/org/repo.git

直近50件のコミットのみを取得するため、CI Runnerにとっては有効な対策です。しかし、開発者のローカル環境では多くの問題を引き起こします。過去コードの git blame ができなくなったり、過去に遡るrebaseが不可能になったり、push時に予期せぬエラーが発生しやすくなったりします。

アプローチ3:リポジトリの再分割

モノレポを再び小さなリポジトリに分割するのは正攻法に聞こえます。しかし、リファクタリングには数ヶ月の工数がかかり、CI/CDパイプライン全体やチーム間の開発フローを混乱させる高いリスクを伴います。

4. 真の切り札:Git Commit-Graph の導入

Git 2.18以降(特に2.24以降で成熟)、「Commit-Graph」機能が導入されました。これは .git/objects/info/commit-graph に配置される単一のバイナリファイルです。

このファイルには、ハッシュ一覧、親子関係、コミット日時、世代番号(generation number)といったグラフの骨格情報があらかじめ保存されています。これにより、Gitは個々のオブジェクトを展開する必要がなくなり、軽量なバイナリ目次テーブルを直接参照できるようになります。

ステップ1:設定フラグの有効化

まず、ローカル環境でcommit-graphの読み書きを有効化します。

git config --global core.commitGraph true
git config --global gc.writeCommitGraph true

ステップ2:commit-graph ファイルの構築

リポジトリのディレクトリでターミナルを開き、次のコマンドを実行します。

git commit-graph write --reachable --changed-paths

--reachable フラグは、ブランチやタグが参照しているすべてのコミットを網羅します。特に --changed-paths オプション(Git 2.28以降でサポート)は、変更されたファイル一覧にBloomフィルターを組み込みます。これにより、git log -- path/to/file.go のようなファイル履歴のフィルタリング処理が4〜8倍高速化されます。

ステップ3:効果の検証

これまで待たされていたブランチ確認コマンドを再度計測してみましょう。

time git branch --contains HEAD~5000

14万コミットを抱える私たちのリポジトリで計測したところ、実行時間は 14.2秒からわずか0.16秒 へと劇的に短縮されました。git log --graph のツリー表示も瞬時にレンダリングされ、もたつきは完全に解消されました。

ステップ4:git maintenance による自動更新

毎日手動で更新コマンドを実行するのは現実的ではありません。Git 2.30以降では、このタスクをバックグラウンドプロセスに完全に任せることができます。

# リポジトリを定期メンテナンス対象に登録
git maintenance register

# バックグラウンドサービスを開始
git maintenance start

Gitは自動的にcronジョブ(Linuxではsystemd timer、Windowsではタスクスケジューラ)を設定します。1時間ごとに増分コミットグラフ(split commit-graph)をバックグラウンドで自動生成するため、意識することなくリポジトリを常に最高速度に保つことができます。

Share: