7 ポイント 投稿者 GN⁺ 2024-10-28 | 1件のコメント | WhatsAppで共有
  • Microsoftの大規模JavaScriptモノレポ 1JS は、月間アクティブユーザー1,000人、約2,500個のパッケージ、約2,000万行の規模まで成長し、最近ではクローンサイズが 178GB に達して、一部の開発者はクローン自体が困難だった
  • 初期に明らかになったボトルネックは、Beachballの変更ファイルが1つのフォルダーに数万個ずつ積み上がる構造で、ファイルが 40,000個 まで増えると新しいファイルを追加するたびに大きなtree objectが生成されていたことだった
  • より大きな原因は、CHANGELOG.mdCHANGELOG.json のように長いファイル名が繰り返し更新される状況で、Gitの既存のパッキングコードがファイル名の末尾 16文字 だけを見てデルタ対象を選んでいたことだった
  • git repack -adf --window=250 も効果はあったが、パスベースのパッキングである git repack -adf --path-walk をMicrosoft Git fork 2.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.mdCHANGELOG.json ファイルへ内容を追加するコミットだけが含まれていたが、クローンが次第に困難になるほど肥大化した
  • リポジトリは2021年以降、毎年数十万行のコードと数百人の開発者が追加されて自然に大きくなっていたが、MicrosoftのOfficeモノレポより成長率が大きかったため、別の原因が疑われた
  • Gitの shallow checkoutsparse 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.mdCHANGELOG.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件のコメント

 
GN⁺ 2024-10-28
Hacker News のコメント
  • 新しい git-survey コマンドがどこにあるのかというと、まだ git.git には入っていない
    筆者は Microsoft の Git フォークを使っており、このコマンドは今年の夏に追加されたもの: https://github.com/microsoft/git/pull/667

    • full-name-hashpath-walk もフォークにしかないか、Git HEAD にしかないように見える
      マニュアルページや 2.47 の変更ログでは見つからない
  • 「ヨーロッパにいる人たちがリポジトリサイズのせいでクローンすらできない」とはどういう意味なのか気になる
    大きなリポジトリをヨーロッパでは取得できず、ほかの場所では取得できるというのは奇妙だし、サーバー側の変更が適用されるまでは依然としてクローンできないようにも聞こえる
    「ファイル全体を何度も再プッシュしていた」という文は途中で切れているようで、記事を読んでいる間 GIF がかなり気が散るし、リーダーモードでもそのまま表示される

    • その文は逸話として読んだ
      もっと完全に書くなら、「ヨーロッパを旅行中に使うため、ノートPCにリポジトリ全体をクローンしようとした人がいたが、その時点ではディスクがいっぱいでできなかった。その後ディスクを整理してクローンできるようになった」くらいだと思う
      ヨーロッパが 180GB のファイルを扱えないという大きな問題を意味しているわけではないと思う
    • ここでの「ヨーロッパ」は、高レイテンシ、低帯域幅の代替表現である可能性が高い
      特に当人が VPN、それもひどい「SSL VPN」の小細工を使っているならなおさらで、レイテンシ処理が悪いソフトウェアや壊れたウィンドウスケーリングを持つサーバーは、いまだに意外とよくある
      開発者の大半がサーバーに近く、帯域の太い接続を使っていると、こうした問題はなかなか表面化しない
    • オーストラリアで Microsoft 内の別チームとして働いており、Azure DevOps の大きな モノレポを使っているが、全体クローンはほとんどできない
      Azure DevOps のクローンは有線ギガビット接続をまったく飽和させられず、時間がかかりすぎるため、こちら側か Azure DevOps 側で結局止まって諦めることになる
      幸い、ほぼすべての作業を Codespaces 内のシャロークローンで行っているので大きな問題ではないが、この記事の 1JS リポジトリ問題と同じようなサイズの急増が自分たちのリポジトリにもあるなら、修正されることを願っている
    • リポジトリがおそらく米国西海岸にあるので、ヨーロッパからクローンするたびに 大西洋を渡る必要があるのではないかと思う
    • インターネットインフラがあまり発達していない国、たとえば ドイツにいる可能性もある
  • 更新:ばかげたミスで、ファイル名にはフルパスが含まれていなかった
    説明がGIF群に埋もれてしまっているようだが、ここでは末尾16文字が互いに異なる:repo/packages/foo/CHANGELOG.mdを変更したのに、Gitがプッシュ準備中にrepo/packages/bar/CHANGELOG.mdと比較して差分を作った、という例

    • Derrickがこのカバーレターでよりうまく説明している: https://lore.kernel.org/git/pull.1785.git.1725890210.gitgitg...
      path-walk APIのカバーレターも参考になる: https://lore.kernel.org/all/pull.1786.git.1725935335.gitgitg...
      ブログの例はあまり明確ではないが、Gitは実質的にリポジトリ内の全ファイルの全バージョンを取り出し、パスの末尾16バイトをハッシュテーブルに入れて、同じファイルの異なるバージョンだろうと期待したものをデルタ圧縮用にまとめていた
      記事のfoo/CHANGELOG.mdbar/CHANGELOG.mdは13文字しかないので、例のままでは正しくなく、より長い共通サフィックスを持つパスを想像する必要がある
      この部分は--full-name-hashオプションで修正され、今では16バイトではなくパス全体を比較する
      その次に述べられているウィンドウサイズの増加は、まずいファイルのグループ化を迂回するハックに近く、本当の修正ではない
      圧縮器に相変わらずひどい入力を与え、膨大なメモリを使って迂回しているだけなので、解決策のように提示されていたのは紛らわしかった
      本当に興味深い部分はpath-walk API--full-name-hash
    • 記事の「彩りのある」語り口よりも、正確に何が起きたのかを説明してほしかった
      ここでファイル名がパスを意味しているのか、それともGitが同じ名前を持つ任意のファイルを選んで差分を作るのかが気になる
      別のファイルを選ぶ方法に何らかのパターンがあるのかも知りたい
    • フルパスが考慮されるのは確か
      --full-name-hash PRの最初のコミットメッセージを見るとよい: 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"の最終値を比較したので、aaazzzにいろいろな値を入れてみると、最終値にどれほどわずかしか影響しないかが分かる
    • ファイル名が必ずフルパスを含むとは限らない
      CHANGELOG.mdの末尾16文字はファイル名全体であり、そう解釈すればfilepathwalkの解法が問題を解決する理由も説明できる
      だが本当にファイル名の末尾16文字だけが基準で、フルパスではないのだとしたら、特にモノレポではこの問題ははるかに頻繁に起きていたはずだと思う
    • 自分もその部分が引っかかった
      おそらくfoobarというメタ変数のせいで混乱したのだと思うし、実際のパッケージ名はfirstPkgsecondPkgのように共通サフィックスがあった可能性が高い
      "/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を使えばよい
    • 核心的な問題は、一度入って変わらないバイナリファイルではなく、頻繁に変わる小さなバイナリファイルである
    • 最近推奨される方法はfilter-repo: https://github.com/newren/git-filter-repo
    • Linusのせいにするほうが簡単
  • Gitをハックするのは楽しいだろうが、そもそもモノレポに2,500個のパッケージを入れない方法はないのかと思う

    • コード行数は概して指数関数的に増える傾向がある
      コードベースが大きくなるほど、既存の車輪を再発明しないことを期待しにくくなる。コードの存在を知らなかったり、自分のユースケースに対応するよう変更して既存の利用箇所を壊すのを恐れたりするためだ
      体感では、モジュール50個から100個に増えるより、100個から200個に増えるほうが時間がかからない
    • それなら、関連するオーバーヘッドをすべて背負って2,500個のGitリポジトリを別々に持てばよい
    • 私も最初にまさにそう思った
      完全に自分たちで作り出した問題に見える
  • 最近GitButlerが公開したGitMergeの発表を見て、モノレポとスケーラビリティ関連の進展を追っているが、Microsoft、GitHub、GitLabから良いものがたくさん出てきている
    最後の16文字検査とフルパス検査の問題が、デルタ圧縮、パックインデックス、マルチパックインデックスとどう絡むのか、もっと理解したい

    • それほどオープンソースにとって良いことなら、カスタムGitコマンドを正式なGitに戻すためにマージリクエストを開く予定があるのか気になる
  • MicrosoftがAzure DevOpsを自分たちで使っているのを見ると良い気分になる
    ますます多くのAzureサービスがGitHubにしかネイティブコネクタを提供しなくなっているようで、Azure DevOpsは放置される方向なのかと思っていた

  • Gitの内部構造をそこまでよく知っている人が手の届くところにいて助けてもらえるというのは、この規模の会社でこうしたプロジェクトをやる際の本当に良い特権だと思う

    • GitHubのような組織と近い関係にあるのは確かに助けになるが、開発者がそれほど多い組織なら、どこでもこうしたかなりニッチな問題を解くために高度に専門化したコンサルタントを雇うコストを正当化できる