JavaScriptモノレポのGitサイズを94%削減した方法
(jonathancreamer.com)- Microsoftの大規模JavaScriptモノレポ 1JS は、月間アクティブユーザー1,000人、約2,500個のパッケージ、約2,000万行の規模まで成長し、最近ではクローンサイズが 178GB に達して、一部の開発者はクローン自体が困難だった
- 初期に明らかになったボトルネックは、Beachballの変更ファイルが1つのフォルダーに数万個ずつ積み上がる構造で、ファイルが 40,000個 まで増えると新しいファイルを追加するたびに大きなtree objectが生成されていたことだった
- より大きな原因は、
CHANGELOG.mdやCHANGELOG.jsonのように長いファイル名が繰り返し更新される状況で、Gitの既存のパッキングコードがファイル名の末尾 16文字 だけを見てデルタ対象を選んでいたことだった git repack -adf --window=250も効果はあったが、パスベースのパッキングであるgit repack -adf --path-walkをMicrosoft Git fork2.47.0.vfs.0.2で実行すると、ローカルリポジトリは 178GBから5GB に減少した- 大規模モノレポで長い名前のファイルが頻繁に変わるなら、
pack.usePathWalk trueと新しいgit surveyコマンドを確認する必要があり、関連変更はGit upstreamにも反映される予定である
1JSモノレポの規模とボトルネック
- Microsoft内部の大規模JavaScriptモノレポ 1JS は、リポジトリ容量だけでなくコード量と貢献規模も大きい
- 月間アクティブユーザー1,000人以上
- 約2,500個のパッケージ
- 約2,000万行のコード
- 最近クローンしたリポジトリのサイズは 178GB で、一部のヨーロッパの開発者はサイズのせいでリポジトリをクローンできなかった
- 最初にクローンしたときは1〜2GB程度だったが、数か月後には約4GBまで増え、その後も増加し続けた
Lesson #1: 1つのフォルダーに数千個のファイルを溜め込まない
git-sizerが大きなblobとBeachball変更ファイルの問題を明らかにした- 大きなblobはバイナリを誤ってコミットしたときに発生し、Azure DevOpsのチェックインサイズ制限のような機能で防げる
- すでに履歴に入ったファイルは、後から削除するのが容易ではない
- 1JSは Beachball change files を使って、パッケージのsemver範囲の更新を自動化していた
- 用途は Changesets と似ている
- 目的は semantic-release のようにパッケージのバージョン更新を自動化することだった
- 変更ファイルを削除していなかったため、1つのフォルダーに 40,000個 まで溜まり、この状態で新しいファイルを追加すると大きなtree objectが生成された
- 対応は2つだった
- Beachball PR で、パッケージごとにファイル1つずつではなく複数の変更を単一の変更ファイルに入れるよう修正した
- 変更ファイル用フォルダーを定期的に自動整理する パイプライン を作成した
Lesson #2: versionedブランチとchangelogパッキング問題
- 1JSのバージョン管理フローでは、
mainのミラーであるversionedブランチを維持している- 実際のパッケージバージョンを保存して
mainのGit競合を避ける - NPMパッケージとしてリリースしたsemverバージョンとGitコミットの対応関係を正確に見られるようにする
- 実際のパッケージバージョンを保存して
versionedブランチには主にCHANGELOG.mdとCHANGELOG.jsonファイルへ内容を追加するコミットだけが含まれていたが、クローンが次第に困難になるほど肥大化した- リポジトリは2021年以降、毎年数十万行のコードと数百人の開発者が追加されて自然に大きくなっていたが、MicrosoftのOfficeモノレポより成長率が大きかったため、別の原因が疑われた
- Gitの
shallow checkout、sparse indexなど大規模モノレポ向け機能を作った開発者が調査した結果、versionedブランチを取得すると 125GBの追加Gitデータ をfetchしていた
Gitパッキングコードが誤ったデルタを作っていた仕組み
- 原因は、古いGitパッキングコードがファイルを圧縮前に比較するとき、ファイル名の末尾 16文字 しか確認していなかった動作にあった
- 通常Gitは変更されたファイルの diff だけをpushするが、このパッキング問題のため、異なるパッケージの
CHANGELOG.mdファイル同士で比較が行われていた - たとえば
repo/packages/foo/CHANGELOG.jsonを変更すると、Gitがpush準備の過程でrepo/packages/bar/CHANGELOG.jsonとdiffを生成することがありえた - その結果、さまざまなケースでファイル全体が繰り返し再pushされ、一部のファイルは数十MBになることもあった
- 問題の詳細はStoleeの Git mailing listの投稿 にまとめられている
repackとpath-walkの結果
- まず
git repack -adf --window=250でより大きなウィンドウを使ってpackファイル圧縮を改善し、リポジトリサイズは大きく減少した - その後 git-for-windows PR #5171 で、コミットをたどる従来方式の代わりに Gitパスをたどるパッキング方式 が追加された
- MicrosoftのGit forkである Microsoft git
git version 2.47.0.vfs.0.2で、新方式によるクローン後にrepackを実行した- 実行コマンド:
git repack -adf --path-walk - 結果: リポジトリサイズは 178GBから5GB に減少した
- 実行コマンド:
- 新しい設定オプションは、push時点でも正しいデルタが生成されるようにする
git config --global pack.usePathWalk true
git version 2.47.0.vfs.0.2を使う開発者は、ローカルでクローン後にrepackを実行し、新しいpath-walk pushアルゴリズムで成長率を抑えられる
GitHub、Azure DevOps、upstream Gitへの影響
- GitHubはrepackとGit garbage collectionを定期的に実行するが、現在のGitHubのパッキング方式では
CHANGELOG.md、CHANGELOG.jsonのようなファイルのデルタを正しく計算できない - 末尾16文字以上が同じ長いファイル名が時間とともに頻繁に変更される場合も影響を受けうる
- 例としてi18n向けの大きな文字列ファイルが挙げられている
- 1JSが使っているAzure DevOpsは、まだこのようなrepackを実行しておらず、サーバー側リポジトリのサイズも減らすために関連作業を進めている
- 関連変更はGit upstreamにも反映される予定である
大規模モノレポで確認すべき点
- 大規模なモノレポで
CHANGELOG.mdまたは比較的長い名前のファイルが繰り返し更新されるなら、path-walk 関連の変更を注視する必要がある - 新しい
git surveyコマンドは、リポジトリ状態を判断できるヒューリスティックを提供する- Top Files By Disk Size
- Top Directories By Inflated Size
- Top Files By Inflated Size
- このヒューリスティックは、path-walkの作業がそのリポジトリサイズにも影響するかを見積もるのに役立つ
1件のコメント
Hacker News のコメント
新しい
git-surveyコマンドがどこにあるのかというと、まだ git.git には入っていない筆者は Microsoft の Git フォークを使っており、このコマンドは今年の夏に追加されたもの: https://github.com/microsoft/git/pull/667
full-name-hashとpath-walkもフォークにしかないか、Git HEAD にしかないように見えるマニュアルページや 2.47 の変更ログでは見つからない
「ヨーロッパにいる人たちがリポジトリサイズのせいでクローンすらできない」とはどういう意味なのか気になる
大きなリポジトリをヨーロッパでは取得できず、ほかの場所では取得できるというのは奇妙だし、サーバー側の変更が適用されるまでは依然としてクローンできないようにも聞こえる
「ファイル全体を何度も再プッシュしていた」という文は途中で切れているようで、記事を読んでいる間 GIF がかなり気が散るし、リーダーモードでもそのまま表示される
もっと完全に書くなら、「ヨーロッパを旅行中に使うため、ノートPCにリポジトリ全体をクローンしようとした人がいたが、その時点ではディスクがいっぱいでできなかった。その後ディスクを整理してクローンできるようになった」くらいだと思う
ヨーロッパが 180GB のファイルを扱えないという大きな問題を意味しているわけではないと思う
特に当人が VPN、それもひどい「SSL VPN」の小細工を使っているならなおさらで、レイテンシ処理が悪いソフトウェアや壊れたウィンドウスケーリングを持つサーバーは、いまだに意外とよくある
開発者の大半がサーバーに近く、帯域の太い接続を使っていると、こうした問題はなかなか表面化しない
Azure DevOps のクローンは有線ギガビット接続をまったく飽和させられず、時間がかかりすぎるため、こちら側か Azure DevOps 側で結局止まって諦めることになる
幸い、ほぼすべての作業を Codespaces 内のシャロークローンで行っているので大きな問題ではないが、この記事の 1JS リポジトリ問題と同じようなサイズの急増が自分たちのリポジトリにもあるなら、修正されることを願っている
更新:ばかげたミスで、ファイル名にはフルパスが含まれていなかった
説明がGIF群に埋もれてしまっているようだが、ここでは末尾16文字が互いに異なる:
repo/packages/foo/CHANGELOG.mdを変更したのに、Gitがプッシュ準備中にrepo/packages/bar/CHANGELOG.mdと比較して差分を作った、という例path-walkAPIのカバーレターも参考になる: https://lore.kernel.org/all/pull.1786.git.1725935335.gitgitg...ブログの例はあまり明確ではないが、Gitは実質的にリポジトリ内の全ファイルの全バージョンを取り出し、パスの末尾16バイトをハッシュテーブルに入れて、同じファイルの異なるバージョンだろうと期待したものをデルタ圧縮用にまとめていた
記事の
foo/CHANGELOG.mdとbar/CHANGELOG.mdは13文字しかないので、例のままでは正しくなく、より長い共通サフィックスを持つパスを想像する必要があるこの部分は
--full-name-hashオプションで修正され、今では16バイトではなくパス全体を比較するその次に述べられているウィンドウサイズの増加は、まずいファイルのグループ化を迂回するハックに近く、本当の修正ではない
圧縮器に相変わらずひどい入力を与え、膨大なメモリを使って迂回しているだけなので、解決策のように提示されていたのは紛らわしかった
本当に興味深い部分はpath-walk APIと
--full-name-hashだここでファイル名がパスを意味しているのか、それともGitが同じ名前を持つ任意のファイルを選んで差分を作るのかが気になる
別のファイルを選ぶ方法に何らかのパターンがあるのかも知りたい
--full-name-hashPRの最初のコミットメッセージを見るとよい: https://github.com/git-for-windows/git/pull/5157/commits/d5c...抜粋すると、
"/CHANGELOG.json"は15文字で、beachballツールが生成するものであり、親ディレクトリの最後の1文字だけがこのファイルの異なるバージョンを区別できるが、それすら上位2ビットしか反映されないその文字が英字なら常に衝突し、
"/CHANGELOG.md"パスにも似た問題があるが、親ディレクトリで差が出る余地はもう少しあるグループ化アルゴリズムは、名前の右側から遠ざかるほど各文字に低い重みを与える:
hash = (hash >> 2) + (c << 24)ハッシュは32ビットで、フルパスの各8ビット文字を順にハッシュの最上位8ビットに加える一方、既存のハッシュビットを右に2ビットずつずらすため、最終ハッシュには末尾16文字だけが影響する
実際に何が起きるかはここで見られる: https://go.dev/play/p/JQpdUGXdQs7
Goに移植して
"aaa/CHANGELOG.md"と"zzz/CHANGELOG.md"の最終値を比較したので、aaaとzzzにいろいろな値を入れてみると、最終値にどれほどわずかしか影響しないかが分かるCHANGELOG.mdの末尾16文字はファイル名全体であり、そう解釈すればfilepathwalkの解法が問題を解決する理由も説明できるだが本当にファイル名の末尾16文字だけが基準で、フルパスではないのだとしたら、特にモノレポではこの問題ははるかに頻繁に起きていたはずだと思う
おそらく
fooとbarというメタ変数のせいで混乱したのだと思うし、実際のパッケージ名はfirstPkgやsecondPkgのように共通サフィックスがあった可能性が高い"/CHANGELOG.md"だけですでに13文字なので、共通サフィックスが3文字あるだけで16文字衝突が起きるnixpkgsでさっそく試してみたところ、GitHubからそのままクローンすると約5GBだった記事で触れられていた最初のオプションである
--window 250はサイズを1.7GBまで減らし、Microsoft Gitフォークの新しい--path-walkオプションは効果がやや小さく、合計1.9GBになったどちらも初期サイズの半分未満なので、GitHubがこうした処理を実行してくれる方法があるとよいし、人々がこういうことを直接制御できる形でホスティングし始めるならさらに良い
記事には、必要な変更を掘り下げてリリースしたDerrick Stoleeが登場する
Gitの内部構造、ローカルやCIでのGitクローンサイズ削減などに興味があるなら、DerrickはGitHubブログに優れた記事を多く書いている: https://github.blog/author/dstolee/
個人サイトも参考になる: https://stolee.dev/
誤って大きなバイナリをコミットしてしまったなら、
filter-branchを実行して問題のバイナリを削除し、全員にバイナリにはgit-lfsを使うよう教えて設定したうえで、強制プッシュし、各自の作業環境を正常な状態に戻すのが修正方法と言える理想的ではないが、使ってもいない大きなファイルをGitに置き続けるよりはまし
filter-branchが怖い人にはBFGもある: https://rtyley.github.io/bfg-repo-cleaner/他の人が指摘したように、この問題は小さいが頻繁に変わるファイルに関するものなので、容量を節約するには履歴から古いバージョンを削除し、今後はLFSを使えばよい
Gitをハックするのは楽しいだろうが、そもそもモノレポに2,500個のパッケージを入れない方法はないのかと思う
コードベースが大きくなるほど、既存の車輪を再発明しないことを期待しにくくなる。コードの存在を知らなかったり、自分のユースケースに対応するよう変更して既存の利用箇所を壊すのを恐れたりするためだ
体感では、モジュール50個から100個に増えるより、100個から200個に増えるほうが時間がかからない
完全に自分たちで作り出した問題に見える
最近GitButlerが公開したGitMergeの発表を見て、モノレポとスケーラビリティ関連の進展を追っているが、Microsoft、GitHub、GitLabから良いものがたくさん出てきている
最後の16文字検査とフルパス検査の問題が、デルタ圧縮、パックインデックス、マルチパックインデックスとどう絡むのか、もっと理解したい
MicrosoftがAzure DevOpsを自分たちで使っているのを見ると良い気分になる
ますます多くのAzureサービスがGitHubにしかネイティブコネクタを提供しなくなっているようで、Azure DevOpsは放置される方向なのかと思っていた
Gitの内部構造をそこまでよく知っている人が手の届くところにいて助けてもらえるというのは、この規模の会社でこうしたプロジェクトをやる際の本当に良い特権だと思う