Skip to content

필사 모드: gitが遅くなったとき — 大規模リポジトリを速くする構造的な処方

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — status 1回に12秒

クローンに40分かかり、ブランチを変えればコーヒーを入れに行けて、状態確認1回に12秒が過ぎていきます。

$ time git status
On branch main
nothing to commit, working tree clean

real	0m12.417s
user	0m1.902s
sys	0m9.884s

この状態で人がまず試すのは、検索上位の処方です。浅いクローンで取得し、整理の命令を強く回し、大きなファイルはLFSへ移す。3つとも状況によっては正しい処方ですが、原因を切り分けずに適用すると、たいてい何も速くならず、元に戻すのが難しくなるだけです

リポジトリが遅くなる理由は大きく3つの筋に分かれます。コミット履歴が長いこと、追跡しているファイル数が多いこと、大きなバイナリが入っていること。3つは別々のボトルネックを作り、処方も重なりません。

まず測定する — 3つの筋を切り分ける方法

最も速い診断はオブジェクトの統計です。

$ git count-objects -vH
count: 1042
size: 8.31 MiB
in-pack: 2841903
packs: 14
size-pack: 6.42 GiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes

ここでパックの大きさがコミット数に比べて過度に大きいなら、バイナリを疑います。パックの個数が2桁なら整理が滞っています。次に残り2つの軸を数えます。

$ git rev-list --count HEAD
84213

$ git ls-files | wc -l
  212847

コミット8万個は最近のハードウェアではさほど問題になりません。一方でワーキングツリーのファイル21万個は、状態確認に12秒かかる理由として十分です。状態確認は基本的に、追跡中のすべてのパスについてファイル情報を確認するからです。

大きなオブジェクトがどこにあるかも直接数えられます。

$ git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1 == "blob"' | sort -k3 -n -r | head -5
blob 4d7a2146 104857600 design/hero.psd
blob 91c0ffee  87031808 assets/renders/intro-01.mov
blob 2b7d13aa  73400320 assets/renders/intro-02.mov
blob a04e1f39  52428800 vendor/sdk/ios-framework.zip
blob 77bc09d1  41943040 design/board-v3.psd

どの命令がどこで時間を使っているかまで見たいなら、内蔵のトレースを有効にします。

$ GIT_TRACE2_PERF=1 git status 2>&1 | grep -E 'region|data' | tail -20

測定せずに選ぶ処方は、たいてい最も副作用の大きいものを選んでしまいます。順序は常に数えることが先です。

取得する量を減らす — 浅いクローンと部分クローンは代替品ではない

最もよくある助言が浅いクローンです。

$ git clone --depth=1 https://github.com/example/monorepo.git

転送量は確実に減りますが、このクローンには履歴がありません。その結果、履歴に依存する命令がすべて無力になります。ログはコミット1つで終わり、特定の行を誰がいつなぜ変えたのか追跡できず、2つのブランチの共通祖先を見つけられないのでマージとリベースが失敗します。タグを基にしたバージョン文字列の生成も動きません。あとから履歴が必要になって深さを増やす作業は、最初から取り直すよりサーバーに重い場合も多いのです。

部分クローンは別物です。コミットとツリーは全部取得し、ファイルの内容だけを必要なときに取ってきます。

$ git clone --filter=blob:none https://github.com/example/monorepo.git
Cloning into 'monorepo'...
remote: Enumerating objects: 2841903, done.
remote: Counting objects: 100% (2841903/2841903), done.
Receiving objects: 100% (1204418/1204418), 412.55 MiB | 22.31 MiB/s, done.
Resolving deltas: 100% (812004/812004), done.
Updating files: 100% (48211/48211), done.

こちらはログもbisectも共通祖先の計算もすべて正常です。代償は、以前のファイルの内容が必要になった瞬間にネットワークを経由する遅延です。古い行の履歴を追う作業は目に見えて遅くなります。

容量を基準にふるい落とす中間形もあります。大きなファイルだけをあとから取得させる方式で、別のサーバーなしにバイナリ問題のかなりの部分を和らげます。

$ git clone --filter=blob:limit=1m https://github.com/example/monorepo.git
方式減らすものそのまま動くもの壊れる・遅くなるもの向いている場面
浅いクローンコミット履歴最新スナップショットのビルドログ、行単位の履歴、共通祖先、タグ基準のバージョン捨てるCIチェックアウト
部分クローン blob:noneファイルの内容ログ、bisect、共通祖先古いファイルへアクセスするときのネットワーク遅延開発者クローンの既定値
部分クローン tree:0ツリーとファイルの内容コミットのメタデータ照会パス指定のログとほとんどのdiffコミット情報だけが必要なツール
スパースチェックアウトワーキングツリーのファイル数履歴の全体範囲外のパスの編集モノレポで1つの領域だけ見るとき

まとめるとこうです。浅いクローンは捨てるチェックアウトのための道具であり、開発者マシンの既定値として勧められるのは部分クローンです。自動化パイプラインでもマージ基準点の計算が必要なら、浅いクローンはすでに誤った選択です。

作業ツリーを減らす — スパースチェックアウトとファイル監視

ファイル数がボトルネックなら、クローンの方式では解決しません。ワーキングツリーに実際に展開するパスを減らす必要があります。

$ git clone --filter=blob:none --no-checkout https://github.com/example/monorepo.git
$ cd monorepo
$ git sparse-checkout init --cone --sparse-index
$ git sparse-checkout set services/payments libs/common
$ git checkout main

$ git ls-files | wc -l
    2143

ここではオプションが2つ重要です。コーンモードはディレクトリの接頭辞だけで判定し、マッチングの費用をほとんどなくします。古い文書に出てくる無視ルール風の自由なパターンは、パスごとにパターン全体を回す必要があるので、ファイルが増えるほどかえって遅くなります。スパースインデックスのオプションは、インデックスファイル自体のなかで範囲外のディレクトリを項目1つに畳んでおきます。ワーキングツリーだけを減らしてインデックスをそのままにすると、状態確認は相変わらず21万件の項目をなめます

その次がファイル監視です。状態確認が遅い根本原因は、変更の有無を知るためにすべてのパスの情報を読むことにありますが、オペレーティングシステムが変更を通知してくれるならその必要はありません。Git 2.37からは別のツールなしに内蔵の監視機能が入っています。

$ git config core.fsmonitor true
$ git config core.untrackedCache true

$ git status    # 初回はキャッシュを埋めるため遅くなります
$ time git status
real	0m0.512s

追跡されていないファイルのキャッシュも一緒に有効にする理由は、監視が教えてくれるのは変更されたパスだけであり、新しいファイルの探索は別のディレクトリ走査だからです。2つを同時に有効にして初めて効果が出ます。

読み取りを速くする — commit-graphと整理作業

履歴が長くて遅い場合の処方は別にあります。ログと共通祖先の計算はコミットオブジェクトを1つずつ開いて親をたどる作業ですが、この情報をあらかじめ計算して別ファイルに収めておけば、展開そのものを飛ばせます。

$ git commit-graph write --reachable --changed-paths
Expanding reachable commits in commit graph: 84213, done.
Writing out commit graph in 4 passes: 100% (336852/336852), done.

$ time git log --oneline -- services/payments | wc -l
    1841
real	0m0.63s

パスを指定したログが特に大きく変わります。変更パスのオプションが、コミットごとにどのパスが変わったかについての確率的なフィルタを一緒に保存するため、ほとんどのコミットを開きもせずに飛ばせるからです。

こうした補助ファイルを手で管理する必要はありません。Git 2.31からメンテナンス作業を予約しておく機能が標準で入りました。以前のように整理命令をcronに仕掛けていた慣行を置き換えます。

$ git maintenance start
$ git config --get-all maintenance.repo
/Users/dev/work/monorepo

ここでよくある誤った助言を1つ指摘しておく必要があります。

# 広く出回っていますが、たいてい損です
$ git gc --aggressive

このオプションは既存のデルタチェーンをすべて捨て、はるかに大きな探索窓で圧縮を最初から計算し直します。数ギガバイトのリポジトリではCPUを何時間も使い、そうして得られる容量の利得はたいてい大きくありません。Gitがふだん作るデルタがすでに十分よいからです。パックの個数が多くて参照が遅いことが問題なら、必要なのは再計算ではなく再配置です。

$ git repack --geometric=2 -d --write-midx
$ git multi-pack-index write

幾何級数的な再パックは小さなパックだけを選んで統合し、大きなパックはそのまま残します。マルチパックインデックスは複数のパックを1つの参照索引に束ね、パックの個数が参照費用に及ぼす影響をなくします。整理の目標はパックを1つにすることではなく、参照を一度で終わらせることです

大きなバイナリ — LFSがすることとしないこと

デザインファイルや動画がリポジトリに入っていると、ほかの処方がすべて無力になります。バイナリはデルタ圧縮がほとんど効かないので、100メガバイトのファイルを10回修正するとリポジトリは1ギガバイトずつ育ちます。

Git LFSがすることは思ったより単純です。指定したパスについて、コミットの時点でファイルの内容を短いポインタのテキストに差し替え、チェックアウトの時点で実際の内容を別のサーバーからダウンロードします。Gitが保存するのは常にポインタだけです。

$ git lfs track "*.psd"
$ cat .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text

$ git cat-file -p HEAD:design/hero.psd
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eafe21c1b8e4a6d0f0a2d0b1e6dbf3a9c
size 104857600

ここに最も誤解されやすい点があります。LFSを今日導入しても、昨日コミットしたバイナリはリポジトリから消えません。すでに作られたコミットのツリーは相変わらず元のblobを指しており、そのblobはpackfileのなかにそのまま残っています。クローンの容量は1バイトも減りません。過去まで整理するには履歴を書き直す別の作業が必要で、それは次の節の話と同じ代償を払います。

$ git lfs migrate import --include="*.psd,*.mov" --everything
migrate: Sorting commits: ..., done.
migrate: Rewriting commits: 100% (84213/84213), done.
migrate: Updating refs: ..., done.

限界もはっきり知っておく価値があります。サーバーがLFSに対応している必要があり、たいてい容量と帯域に別料金がかかります。ローカルのキャッシュは別途管理しなければなりません。そしてLFSはバイナリを別の場所へ移すだけで、マージできるようにしてくれるわけではありません。2人が同じデザインファイルを直せば、コンフリクトは相変わらず人が手で選ぶことになります。その問題を本当に減らしたいなら、ロック機能を使うか、そもそも成果物をリポジトリの外へ出すほうがましです。

$ git lfs prune                       # 古いローカルキャッシュの整理
$ GIT_LFS_SKIP_SMUDGE=1 git clone ... # ポインタだけ取得し、あとから選択的にダウンロード
$ git lfs pull --include="design/hero.psd"

1つ付け加えると、先に見た容量基準の部分クローンは、別のサーバーなしに似た効果の一部を出します。新しく始めるリポジトリなら、LFS導入の前にこの選択肢を先に検討する価値があります。

すでに肥大したリポジトリ — filter-repoとすべてのハッシュが変わる代償

過去のバイナリや誤って入った大容量ファイルを実際になくすには、履歴を書き直す必要があります。古い文書に出てくるfilter-branchはGitの公式文書自体が使用を思いとどまらせており、今の推奨ツールはfilter-repoです。

$ pip install git-filter-repo

$ git filter-repo --path assets/renders --invert-paths
Parsed 84213 commits
New history written in 132.71 seconds; now repacking/cleaning...
Completely finished after 218.04 seconds.

$ git count-objects -vH
in-pack: 1932450
size-pack: 1.14 GiB

6.42ギガバイトが1.14ギガバイトになりました。ここまではよい知らせで、代償はここからです。

履歴を書き直すというのはすべてのコミットの親が変わるという意味であり、親が変わればコミットハッシュはすべて変わります。その結果はリポジトリ1つにとどまりません。

  • 開いているすべてのPRが存在しないコミットを指すようになります。
  • タグとリリースを押し直す必要があり、特定のコミットハッシュを固定していたデプロイ設定とサブモジュールのリファレンスがすべて壊れます。
  • チーム全員が新しくクローンし直す必要があります。既存のクローンで取得すると古い履歴と新しい履歴の両方が入り、リポジトリが2倍になります。filter-repoが作業後にリモート設定をわざと消すのはこれが理由です。
  • サーバー側でも即座には消えません。ホスティングサービスではPRのリファレンスとフォークが古いコミットをつかんでいるため、実際の削除までには別途の整理依頼が必要な場合が多いのです。

ですからこの作業は命令ではなく計画を必要とします。マージを凍結し、時点を告知し、書き直し、強制的に押し、全員が再びクローンし、古いブランチを整理する、という順序です。元に戻せない処方はこの記事の処方のなかでこれ1つだけであり、だから最後に置かれています

おわりに — 処方より分類が先です

この記事の命令をすべて覚える必要はありません。覚えるべきは順序です。コミット数とファイル数とパックの大きさをまず数え、その結果に合う処方を1つずつ適用します。

履歴が長くて遅いなら部分クローンとcommit-graphです。ファイルが多くて遅いならスパースチェックアウトとファイル監視であり、このときインデックスまで一緒に減らさないと効果が半分に落ちます。バイナリが問題ならLFSか容量基準の部分クローンであり、過去まで消すなら履歴の書き直しという元に戻せない決断を引き受けることになります。

ほとんどのチームで最も大きな利得をもたらす2行は、メンテナンスの予約とファイル監視を有効にすることです。副作用がなく、元に戻しやすく、体感が即座に来ます。履歴の書き直しは、その2行では解決しないと確認されたあとに持ち出しても遅くありません。

オブジェクトとpackfileが実際にどんな姿をしているかはGitの内部構造で理解する命令の回に続きます。

현재 단락 (1/131)

クローンに40分かかり、ブランチを変えればコーヒーを入れに行けて、状態確認1回に12秒が過ぎていきます。

작성 글자: 0원문 글자: 7,570작성 단락: 0/131