DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

大きなGitリポジトリをクローンするときの工夫を図解します

こんにちは、SWETでCI/CDチームの前田( @mad_p )です。 SWETではCI/CDチームの一員として、Jenkins運用のサポートや、CI/CD回りのノウハウ蓄積・研究をしています。

はじめに

Gitリポジトリをクローンすると、ローカルフォルダにはそのリポジトリの全体がダウンロードされ .git というフォルダに格納されます。ブランチをチェックアウトすると、ブランチ内のファイルがワーキングツリーとして展開されます。この様子を図にするとこのようになります。

クローン+チェックアウト

この .git とワーキングツリーの使うディスク容量を節約しようというのが今回のお話です。特にJenkinsにおいて、大きめのGitリポジトリをクローンしてくる場合に課題があり、いろいろ工夫してみたので、その結果を紹介します。同じCI/CDチームの加瀬による記事「大規模リポジトリで高速にgit cloneするテクニック」と内容的に重なる部分もあるので、そちらの記事も参考にしてください。

今回、Jenkinsでの設定方法を多めに解説していますが、紹介する工夫はJenkins以外でも使えるものです。図を見て仕組みを理解しておくだけでも、後で役に立つと思います。

Gitリポジトリは大きくなっていく

アプリコードやアセットのリポジトリは、開発が進むにしたがって大きくなっていきます。これはゲーム開発では特に顕著です。とあるタイトルのアセットリポジトリは、そのままクローンすると .git が17GiB、チェックアウトした部分も含めると45GiB程度になっています。これは日々大きくなっていきます。

大きくなる原因

リポジトリ内のオブジェクトを簡略化して図にすると以下のようになります。丸がコミット、四角がファイルに対応します。

リポジトリ内のオブジェクト

リポジトリが時間とともに大きくなっていくのは、以下のような要因によるものです。それぞれに対して、何か対策があるか見てみましょう。

  • コミットされるファイル数が多くなる
    • これは必要だからコミットされているので、仕方がありません
  • コミットされるファイルが大きい
    • これの対策としては、大きいファイル(画像、音声データなど)をGit LFSに置くことで、リポジトリ内のオブジェクトとしては保存しない、という方法があります。LFSについては後でもう少し詳しく見てみます
  • 歴史が長くなる
    • これも長く開発が続いていくと増えるものなので、仕方がありません

コミットされるファイル数が多くなる方向、歴史が長くなる方向の2つの軸を意識しておくと、この後紹介する工夫の理解がしやすくなると思います。

大きくなると困ること

Gitリポジトリが大きくなると様々な問題が発生します。

  • クローンに時間がかかり、待ち時間やJenkinsジョブ実行時間が長くなる
  • クローンしたフォルダのディスク使用量が多くなり、ディスク枯渇が発生しやすくなる

これに加え、Jenkinsエージェント特有の事情として以下があり、巨大なリポジトリのクローンによる困り事は増幅されることになります。

  • Jenkinsではジョブごとにワークスペースが分離される。同じリポジトリを使うジョブが複数個あると、ひとつのリポジトリでも複数回別のフォルダにクローンされる。その回数分ディスク容量を必要とする
  • ワークスペースは、特に明示しない限り、次回の実行に備えて保存される。次回実行時にリポジトリのフェッチの負荷が低くなるというメリットとなる(クローン済の .git を使って差分だけを取ってくればよいため)。一方で、取っておく分のディスクを消費する

Gitリポジトリからローカルフォルダへのダウンロード量や通信量を減らすことで、これらの困り事を軽減できます。クローン時、チェックアウト時、LFSの順で、どのような工夫ができるかの方法を紹介します。また、その結果どのように節約できるのかを図にして見ていきましょう。

GitHub公式ブログ

ここで取り挙げる節約方法はGitHubブログの以下の記事でも解説されています。図が多くわかりやすいので、参照してみてください。本記事中の図も、この公式ブログの図を真似しています(わかりやすさのため、本記事ではツリーオブジェクトなど一部省略しています)。

クローン時のディスク・ダウンロード節約方法

大きめのリポジトリをクローンする場合のコツについて説明します。

git clone では通常、前述したオブジェクトの全体をダウンロードしてきて、 .git/objects 配下に置きます。以下では、この全部を取得するのではなく、一部を取得するためのテクニックを紹介します。これによって、クローン後のディスク使用量だけでなく、クローン時のネットワーク転送量(すなわち時間)も節約できます。

シャロークローン(shallow clone)

最新版だけを取ってきて、過去の歴史を取ってこない方法です。取得したい歴史の長さ(深さ)をdepthというパラメータで指定できます。通常は1でよいでしょう。シャロークローンの動作を図にすると以下のようになります。

シャロークローン

歴史の一番新しいほうからdepth分だけをサーバーから取得し、 .git 内に置きます。取得されない部分を図中では点線で表現しています。最新情報だけ取ってくるので、取得するオブジェクト数を節約できます。これは大きくなるリポジトリを歴史の長さ方向に限定して取得することに相当します。

シャロークローンのやりかた

  • CLIによる方法
    • git clone 時に --depth オプションで取得したい歴史の深さを指定する
      • git clone --depth=1 git@github.com:org/repo.git .
  • Jenkinsfileによる方法
    • checkout ステップに CloneOption を渡して shallowtruedepth を指定
checkout([$class: 'GitSCM',
    extensions: [[$class: 'CloneOption',
        shallow: true,  // ← shallow cloneを指定
        depth: 1,  // ← depthを指定
        timeout: 60]],
    branches: [[name: "feature/mybranch"]],
    gitTool: 'Default',
    userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • JenkinsのFreestyleジョブでの設定方法
    • 「ソースコード管理」の「追加処理」で「Advanced clone behaviors」を追加し、shallow cloneをチェック、shallow clone depthを入力

シャロークローンの欠点

シャロークローンには以下のような欠点があります。歴史に対する操作を使うことがわかっている場合は利用しないほうがいいかもしれません。

  • シャロークローンで取ってきていると、歴史をあやつる作業はできなくなる
    • git log で過去のコミットを見る
    • 過去のコミットをチェックアウトする
    • git diff で差分を見る
    • マージする
  • 公式ブログの説明によると、シャロークローン後のフェッチで、場合によっては結局過去の歴史を全部取ってしまうような場合もあるとのこと
    • 一度クローンした場所で、追加でフェッチをするような用途にはシャロークローンは向かない。つまりJenkins向きではない。一方でCircleCIなどクローンした結果を一度しか使わない環境では有効

なお、いったんはシャロークローンしたものの明示的に全部を取り直したいという場合は、 git fetch--unshallow オプションをつけます。

  • git fetch --unshallow

パーシャルクローン(partial clone)

コミット内の各ファイルは、そのメタ情報(ツリーオブジェクト)とファイル実体(ブロブオブジェクト)から成っています(上述のGitHubブログを参照)。パーシャルクローンは、ブロブやツリーオブジェクトを必要な部分のみ取得します。ローカルには、必要に応じてオブジェクトを取得するという情報(下の図では中空の四角で表現)が記録され、checkoutやdiffなど、実際に必要になった時点でオンデマンドにブロブオブジェクトがダウンロードされます。

パーシャルクローン

パーシャルクローンは操作に必要な要素のみを取得するので、歴史の長さ×ファイル数を2次元的な広がりと見て、必要な部分だけを取得することに相当します。この図はクローン後、いくつか操作して必要なものが取得された状況を表現しています。

パーシャルクローンのやりかた

ここではブロブを必要な部分のみ取得し、ツリーはすべて取る方法(ブロブレスクローン)を紹介します。

  • CLIによる方法
    • git clone 時に、 --filter オプションでブロブを取得しないことを指定する
      • git clone --filter=blob:none git@github.com:org/repo .
  • Jenkinsfileによる方法
    • checkout ステップには機能がない。 sh ステップでCLIのgitを利用する
  • Freestyleジョブでの設定方法
    • 同様にシェルスクリプトからgitを利用

パーシャルクローンの欠点

パーシャルクローンには以下のような欠点があります。

  • git diff など、ファイルの内容を必要とする操作をすると、オンデマンドでダウンロードされる
    • その時点でネットワーク接続が必要であることと、ダウンロード時間がかかることに注意

リファレンスリポジトリ(reference repository)の活用

Jenkinsでは複数のジョブから同一のリポジトリ(アプリソースやアセットなど)を参照することが多くあります。その場合、ジョブごとに別のフォルダに同じリポジトリをクローンしてくることになります。2回クローンした状態を図にするとこうなります。

複数回のフォルダにクローン

このとき、 .git はフォルダAとBの下にそれぞれ作成され、歴史を記述したオブジェクトはそれぞれの下に保存されます。ブランチを切ったりコミットしたり、特定のブランチだけフェッチすると、内容は完全に同一とはならないのですが、サーバーからコピーした分は同一のものがディスク上で別の場所に複数回保管されていることになります。特定のリポジトリに関するジョブが十数個にもなると、その数だけ複製を持つことになります。

Gitにはすでにクローン済の .git/objects を参照する機能があります。これを使うと、次の図のようになります。

リファレンスクローン

B/.git/objects の下に、「 A/.git/objects を見ろ」というファイルが記録され、そちらも参照してくれるようになります。AになくてBに必要なオブジェクトはサーバーからダウンロードされ、Bの .git/objects の下に保存されます(このため、Aの情報が多少古くても問題ありません)。これによって、Bの初回クローンは圧倒的に速くなり、ディスクも大きく節約できます。このとき、フォルダBから見てフォルダAを「リファレンス」と呼びます。リファレンスを利用したクローンを、この記事では「リファレンスクローン」と呼ぶことにしましょう。

リファレンスクローンのやりかた

フォルダBにクローンする時点で、すでにフォルダAにクローン済である場合、Aのパスをリファレンスとして指定できます。

  • CLIによる方法
    • git clone 時に、 --reference または --reference-if-able オプションでリファレンスとして使うクローン済みのフォルダを指定する
    • git clone --reference-if-able=/path/to/A git@github.com:org/repo ./B
      • --reference で指定するとリファレンスが存在しなければエラーとなる
      • --reference-if-able では存在しなければ無視される(警告のみ)
    • サブモジュールの取得にリファレンスを使いたい場合は、サブモジュールごとに指定する
      • git submodule update --init --reference=/path/to/A -- submodule/path
  • Jenkinsfileによる方法
    • CloneOptionreference を指定できる。リファレンスが存在しないと無視される(エラーメッセージが出るが処理は続行される)
checkout([$class: 'GitSCM',
    extensions: [[$class: 'CloneOption',
        reference: "/path/to/A", // ← リファレンスのパスを指定
        timeout: 60]],
    branches: [[name: "feature/mybranch"]],
    gitTool: 'Default',
    userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • Freestyleジョブでの設定方法
    • 「Advanced clone behaviors」→「リファレンスリポジトリのパス」に設定する。存在しないと無視される

通常どおりにクローンしたフォルダに対して、後からリファレンスを加えることもできます。

  • .git/objects/info/alternates というファイルを作成し、リファレンスの .git/objects のフルパスを書き込む
    • サブモジュールの場合は .git/modules/<submodule_path>/objects/info/alternates
  • alternates 作成後、以下のコマンドを実行すると、リファレンスに存在するオブジェクト(重複分)を削除できる
    • git repack -a -d -l

リファレンスクローンの注意点

AをリファレンスとしてBをクローンした後に、A側でオブジェクトが削除されると、Bから参照しようとしたときにオブジェクトが見つからずにエラーとなります。 git pull などの操作をきっかけとして、A側で git gc が自動実行されることがあり、これが発生します。フォルダAをまるごと削除した場合も同様です。 その場合、以下のようなエラーにぶつかるでしょう。

  • fatal: bad object HEAD
  • fatal: bad object <commit_hash>
  • fatal: unable to read <blob_hash>

一度こうなってしまうと修復はなかなか大変なため、リファレンスクローンは事情をよく知って使う必要があります。 git help clone--reference の項目を見ると、 --shared と同様の注意が必要と書いてあり、その注意書きはこうです。要約すると「A側でブランチが削除されるとB側で必要なものが消され、Bが壊れる場合がある。何をやっているかよく理解して使いなさい」ということです。

NOTE: this is a possibly dangerous operation; do not use it unless you understand what it does. If you clone your repository using this option and then delete branches (or use any other Git command that makes any existing commit unreferenced) in the source repository, some objects may become unreferenced (or dangling). These objects may be removed by normal Git operations (such as git commit) which automatically call git maintenance run --auto. (See git-maintenance(1).) If these objects are removed and were referenced by the cloned repository, then the cloned repository will become corrupt.

ローカルミラー活用法

これらの問題を回避するために、リファレンスで指す先はリファレンスで使われることだけに特化したミラーフォルダとして用意するのがよいと思います。組織的に運用しないと安定した稼動は難しいかもしれません。以下に、自分達のプラクティスを紹介しますので、参考にしてみてください。

  • ローカルマシン(Jenkinsエージェントなどごと)にリファレンス用途のクローン(以下ミラー)を作成する
export MIRROR_DIR=/path/to/mirror/dir
mkdir -p $MIRROR_DIR
git clone --no-checkout git@github.com:org/repo $MIRROR_DIR/repo
(cd $MIRROR_DIR/repo; git config gc.pruneExpire never)
  • ミラーでは git config gc.pruneExpire never しておくことで、自動gcによるオブジェクトの削除が起こらないように設定する
  • 環境変数 MIRROR_DIR でミラー群の位置を指すよう、 .bash_profile や、Jenkinsのノード管理で定義しておく
  • ミラーはベア(bare)リポジトリとしてクローンするか、 --no-checkout で作るとワーキングツリー分のディスクを節約できる
  • 定期的にフェッチするジョブをしかけておく
    • .git/gc.log に「too many unreachable loose objects」が出力されるが、これは無視してよい。リファレンスする側のフォルダで使っているオブジェクトがloose objectsとして検出されることが多い
    • ミラーにtmpオブジェクトが残ってしまったような場合など、どうしてもgcやpruneコマンドを使いたい場合は、以下のオプションをつける
      • git prune --expire=never; git gc --no-prune
  • クローン時は --reference-if-able=$MIRROR_DIR/repo オプションをつける

リファレンスクローンを利用する場合、サーバーから取得する歴史はリファレンスにないオブジェクトのみになります。このため、シャロークローンやパーシャルクローンを利用しなくても十分な節約になります。プロジェクトで利用する全部のリポジトリについてミラーを作る必要はなく、複数の場所に何度もクローンされやすいリポジトリを選んで設定するだけで、大きな効果を得ることができるでしょう。

チェックアウト時のディスク節約方法

ここまで、クローンのときの工夫を見てきました。続いてチェックアウトのときの工夫を紹介します。

ファイル数が多くなったリポジトリは、チェックアウトした結果のワーキングツリーもかなりディスクを使うことがあります。通常のチェックアウトの様子を図に示します。チェックアウト時にはHEADから参照されるすべてのファイルのコピーがワーキングツリーとしてコピーされます。

チェックアウト

スパースチェックアウト(sparse checkout)

必要とするファイルがリポジトリ内の一部だけである場合、例えば特定のシェルスクリプト数個だけの場合、リポジトリ全体ではなく、一部のフォルダやファイルだけをチェックアウトすることで、ワーキングツリーを小さくできます。これをスパースチェックアウトと呼びます。

スパースチェックアウト

スパースチェックアウトはファイル数方向を限定してワーキングツリーを小さくすることに相当します。歴史オブジェクトは全部ローカルに持ってきているので、歴史をあやつる作業もできます。逆に言えば、歴史の取得や .git の容量は節約していません。

スパースチェックアウトについては、こちらのブログ記事が参考になります。

スパースチェックアウトのやりかた

  • CLIによる方法 (上記ブログ記事も参照してください)
    • git clone 時に --sparse オプションでスパースチェックアウトであることを、 --no-checkout オプションで初回のチェックアウトしないことを指定する
    • クローン後 git sparse-checkout add でチェックアウトしたいフォルダを指定する
    • 必要なフォルダやファイルを指定するにはパターンも使える。パターンの書き方の詳細は git help sparse-checkout を参照
git clone --sparse --no-checkout git@github.com:org/repo .
cd repo
git sparse-checkout add want_folder1 want_folder2
git checkout
  • Jenkinsfileによる方法
checkout([$class: 'GitSCM',
     extensions: [[$class: 'SparseCheckoutPaths',
         sparseCheckoutPaths: // checkoutしたいフォルダ
             [[path: "want_folder1"],
              [path: "want_folder2"]]
     ]],
     branches: [[name: "feature/mybranch"]],
     gitTool: 'Default',
     userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • Freestyleジョブでの設定方法
    • ソースコード管理の「追加の処理」から「Sparse checkout paths」に設定

前述のパーシャルクローンと組み合わせて使うと、さらにディスクと通信時間を節約できます。

パーシャルクローン+スパースチェックアウト

このように組合せて使うと、ファイル数方向、歴史の長さ方向の両軸で限定したことに相当します。

LFS (Large File Storage) の活用

Gitリポジトリに大きいバイナリファイルをコミットしていくと、その歴史で .git は大きくなってしまいます。また、GitHubにはひとつのファイルの大きさは100MiBまでという制限があるため、これを超える大きさのファイルはそもそもコミットできません。ゲームのアセットなど、画像や音声ファイルを多く扱うリポジトリでは、ここで説明するLFSを利用するのがよいでしょう。

LFSはファイルの実体を別のストレージに置き、その参照(SHA256ハッシュ)のみをコミットします。図にすると以下のようになります。 .git 内には参照しか入らないため、 git clone の段階ではファイルの実体はダウンロードされません。 git lfs pull により、必要なオブジェクトの実体がダウンロードされる仕組みです(通常はクローン直後のチェックアウトで自動的に行われます)。

Git LFS

Git LFSの設定は、リポジトリ作成直後から行うのがおすすめです。先に大きなオブジェクトをコミットして、後からLFS化しても、コミットしてしまった分は大きなオブジェクトが歴史に残ってしまうからです。以下の手順でLFSの設定ができます。

  • マシンにgit-lfsをインストールする
    • brew install git-lfs , apt install git-lfs など
  • アカウントごとにLFSを使うことを設定する
    • git lfs install
      • チェックアウト時に自動的にLFSからダウンロードされるようになります
  • LFSを導入したいリポジトリで、特定の拡張子のファイルをLFSに格納するよう設定する
    • git lfs track "*.psd" "*.ogg"
  • 通常どおりコミットしてプッシュすると、設定した拡張子のファイルは、実体がLFSに送られる
  • クローンした側ではチェックアウト時に自動的にLFSファイルが取得される。明示的に取得する場合は以下コマンドを実行する
    • git lfs pull (現在チェックアウトしているブランチのオブジェクトを取得し、ワーキングツリーに置く)

LFSの設定・動作ついては、以下の公式ドキュメントや記事が参考になります。

すでに運用中のリポジトリを後からLFS化したい場合、 git lfs migrate というコマンドが使えます。LFSのマイグレーションは手順が多いため、ここでは説明しません。以下のチュートリアルが参考になるでしょう。

LFSのディスク節約方法

git lfs pull の動作は以下の2つに分けられます。

  • git lfs fetch
    • 現在のブランチで利用されているLFSオブジェクトをダウンロードし、ローカルストレージに置く
    • ローカルストレージは通常、クローンしたフォルダの .git/lfs (サブモジュールでは .git/modules/<submodule_path>/lfs
  • git lfs checkout
    • ローカルストレージからワーキングツリーにオブジェクトをコピーする

git lfs pullgit lfs fetch の後 git lfs checkout を行うことに相当します。

これを図にするとこうなります。 git lfs checkout でファイルがコピーされるため、LFSで管理されているファイルは、ローカルストレージ内とワーキングツリー内、通常2個ずつディスクに置かれることになります。

クローン後のLFSチェックアウト

ワーキングツリーのディスクを節約する

ワーキングツリー内のファイルは書き換えてコミットする使い方もあるので、 git lfs checkout では(ハードリンクなどではなく)ファイルをコピーすることが必要です。CIなど、read-onlyの作業しかしないことがわかっている場合には、コピー分のディスクを節約したくなります。

macOSではAPFSというファイルシステムの機能で、ファイルを複製するときに同じディスク領域を使い、ディスクを節約できます。書き込みが発生した時点でcopy-on-writeする動作になります(cpコマンドの -c オプション)。 git lfs dedup というコマンドを実行すると、LFSのローカルストレージとワーキングツリーを比較し、ファイル内容が同一のとき cp -c を使ってひとつにまとめるということをしてくれます。

ローカルストレージを節約する

ローカルストレージには過去のチェックアウトで使ったデータがたまっていくので、時々プルーン(おそうじ)したほうがよいかもしれません。とはいえ、よほど大きくなっていない限り、明示的にプルーンする必要はありません。

  • git lfs prune

ローカルストレージを共有する

同一のリポジトリをJenkinsの複数ジョブなどで複数回クローンすると、ローカルストレージはそれぞれの .git 下に作られます。つまり、クローンした回数分のオブジェクトがディスク上に保存されます。

複数フォルダとLFSローカルストレージ

ローカルストレージを変更し、フォルダAとBの両方で共有するよう設定すると、この重複分を減らすことができます。

  • git config --local lfs.storage /path/to/shared/storage
    • LFSのローカルストレージを特定のパスに設定します

同じリポジトリに対するクローンの各フォルダで、同一のパスを指定してこのコマンドを実行するとよいでしょう。

共有ローカルストレージ

この設定を使うと、大きくなりがちなLFSのローカルストレージを共有して、全体の容量を減らすことができます。なお、ストレージの共有を使っている場合は git lfs prune しないほうがよいです。図中のフォルダAで git lfs prune した場合、フォルダBからしか参照されていないオブジェクトは消されてしまいます(次回チェックアウト時にダウンロードし直されるので、エラーにはなりません)。

この設定をグローバルに行うこともできます。

  • git config --global lfs.storage /path/to/shared/storage

--global に設定すると $HOME/.gitconfig に設定が書き込まれ、そのアカウントでクローンするリポジトリすべてのLFSローカルストレージが同一の場所になります。これには以下のように利点・欠点がありますので、それに注意して利用するとよいでしょう。

  • 利点
    • クローンするたびにいちいちローカルストレージの設定をしなくてよい
    • サブモジュールでもLFSを使っている場合、すべてのサブモジュールで設定する必要がない
  • 欠点
    • 異なる元リポジトリに由来するオブジェクトが、ローカルストレージ内で混在する
      • 機密レベルの異なるリポジトリを扱う場合には、混在するとよくないかもしれない
      • なお、SHA256ハッシュを使っているので、別のオブジェクトが同名になってしまう(ハッシュの衝突)心配はない

まとめ

.git は気がつくと大きくなってしまうものです。うまく工夫することで、ディスク容量や転送時間を減らすことができます。歴史の長さとファイル数の増加どちらがより大きな要因であるか、また、クローンしたフォルダをどう使うかなどを考慮して、シャロークローンやパーシャルクローンなど適切な方法を選択しましょう。

Jenkinsエージェントのような環境では、同一のリポジトリを複数のフォルダにクローンすることが多く、 .git はその数ぶんだけ作られてしまいます。リファレンスクローンで共有したり、LFSのローカルストレージを共有すると、この「複数回」が原因となる重複を減らすことができるでしょう。

これらの工夫を使って、冒頭で例に出したタイトルでは、Jenkinsエージェントあたり300GiB程度のディスク容量を節約できました。いくつかのリポジトリが何度も何度もクローンされていたので、リファレンスクローンの効果が大きく250GiB程度の節約、LFSローカルストレージの共有が50GiB程度の節約になりました。スパースチェックアウトはまだ十分活用できていませんが、改善の余地があるのだと前向きに考えています。