DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

大規模リポジトリで高速にgit cloneするテクニック

ニッチな話題ですが、業務におけるCI/CDの現場では避けることのできない大規模リポジトリと戦うためのgit cloneのテクニックを紹介します。

この記事はDeNA Advent Calendar 2020の10日目の記事です。
CI/CDマニアの@Kesin11です。SWETではCI/CDチームの一員として、CI/CDの啓蒙活動やJenkinsを必要とするチームのサポートなどの業務を行っています。

はじめに

おそらくどこの会社でも1つぐらいは巨大なリポジトリが存在しているかと思いますが、歴史あるリポジトリはgit cloneするだけで数分を要し、checkout後のリポジトリサイズがGB単位になることも珍しくないでしょう。業務で古くから存在するプロジェクトのリポジトリを触ったことがある方はきっと経験があるかと思います。

git cloneを実行するのは最初のセットアップ時だけなのであまり問題にならないと思われるかもしれませんが、実は1日に何度もgit cloneを行う環境もあります。そう、CI/CDの環境ですね。

CircleCIやGitHub Actionsといった一般的なCIサービスでは、毎回まっさらな環境にリポジトリをgit cloneするところからスタートします。巨大リポジトリではこの1ステップ目のgit cloneだけで数分かかってしまうため、単にLintを行うだけのジョブであってもすぐに結果が返ってこないという状況になります。

gitでは仮にファイルを削除しても昔のコミットから復元可能です。つまり、過去全ての情報が保存されているためリポジトリは歴史と共に太っていく一方であり、改善の見込みもありません1

ところで、git cloneは知らない人がいないほどポピュラーなコマンドですが、実はオプションが豊富に存在することはご存知でしょうか?手元でman git-cloneを実行してみください。きっと知らないオプションがたくさんあり驚くと思います。オプションの中には不要な過去のコミット、ブランチ、ファイルなどを取得しないことでgit cloneを効率的にできるものがありますので、今回はこれらを活用するテクニックをお伝えします。

git cloneの各種オプション

オプションなし

この後に各種オプションを駆使したgit cloneを紹介するのですが、まずは比較のために何もオプションを付けないときの実行時間と、checkout後のリポジトリのサイズを計測しておきます。実験に使用するリポジトリには、歴史がとても長いgit/gitを採用しました。

$ time git clone git@github.com:git/git.git
Cloning into 'git'...
remote: Enumerating objects: 279, done.
remote: Counting objects: 100% (279/279), done.
remote: Compressing objects: 100% (99/99), done.
remote: Total 298430 (delta 193), reused 248 (delta 180), pack-reused 298151
Receiving objects: 100% (298430/298430), 148.71 MiB | 10.06 MiB/s, done.
Resolving deltas: 100% (222617/222617), done.
Updating files: 100% (3866/3866), done.

real    0m27.057s
user    0m14.959s
sys 0m3.038s

$ du -sh git git/.git
214M    git
169M    git/.git

時間についてはインターネット回線、時間帯などに依存するためあくまで参考程度にしてください。時間に加えてcheckout後のリポジトリそのものと、昔のコミットやgitのオブジェクトなどの情報を全て格納している.gitだけのサイズを計測しています。

これを基準とし、どこまで改善できるのかを見ていきましょう。

depth

まずは知ってる人も多いであろうdepthです。depthを指定すると、その数のコミットの歴史だけに限定したデータを取得します。これをshallow cloneと呼ぶことがあり、gitやCIサービスのドキュメントなどにも頻出しますので、shallow = depthなどのオプションによって歴史を省略したcheckoutと覚えておくとよいです。

過去のコミット分の情報が不要ということは、昔のファイルの状態を記録しているGitオブジェクトが軽くて済むということです。2

ではどれほど効果があるのか試してみましょう。

$ time git clone --depth=1 git@github.com:git/git.git git_depth1
Cloning into 'git_depth1'...
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1917 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.25 MiB/s, done.
Resolving deltas: 100% (356/356), done.
Updating files: 100% (3866/3866), done.

real    0m11.401s
user    0m0.466s
sys 0m0.862s

$ du -sh git_depth1/ git_depth1/.git
 56M    git_depth1/
 11M    git_depth1/.git

(繰り返しになりますが、git cloneにかかる時間自体は様々な要因によって安定しない可能性が高いので、あくまで参考にしてください)

--depth=1を指定すると、指定したブランチ(指定しない場合はデフォルトブランチ)の最後のコミットだけを取得します。オプションを何も付けなかった場合と比較して、時間は1/2以下、リポジトリの容量は約1/4で.gitだけに限定すれば約1/15程度になりました。時間に関しては誤差があるとはいえかなり改善できました。

代わりに、depth付きでgit cloneしたリポジトリではgit logをしても過去のコミットを見ることはできませんし、昔のコードをcheckoutもできません。とりあえず最新のリポジトリの状態でビルドしたいというCIなどの用途においては過去のコミットの情報は不要なので、メリットだけを享受できるでしょう。

single-branch

git cloneについて調べるとdepthの次に目にすることが多いのはsingle-branchではないでしょうか。その名の通り、単一のブランチだけの情報を取得するので通常よりは取得するデータ量が減ります。しかし、depthとは異なり指定したブランチの歴史は全て取得するので、削減量としてはdepthには及ばないはずです。実際に試してみましょう。

$ time git clone -b master --single-branch git@github.com:git/git.git git_single_branch
Cloning into 'git_single_branch'...
remote: Enumerating objects: 832, done.
remote: Counting objects: 100% (826/826), done.
remote: Compressing objects: 100% (804/804), done.
remote: Total 289165 (delta 28), reused 814 (delta 22), pack-reused 288339
Receiving objects: 100% (289165/289165), 143.57 MiB | 9.93 MiB/s, done.
Resolving deltas: 100% (216511/216511), done.
Updating files: 100% (3866/3866), done.

real    0m25.670s
user    0m13.348s
sys 0m2.899s

$ du -sh git_single_branch/ git_single_branch/.git
201M    git_single_branch/
155M    git_single_branch/.git

期待通りの結果になりました。通常よりは多少軽くなりましたが、depth=1には及びません。single-branchを使う場合の注意点としては、今後fetchやpullをした場合でもsingle-branchで指定したブランチ以外のリモートブランチは取得されないことです。つまり、pull-requestが出ているブランチをcheckoutしてエディタ上でコードレビューする、みたいな使い方はできなくなります。

この挙動になる理由はfetchのrefspecを見てもらうと分かります。各オプションでgit cloneしてきたリポジトリで比較してみます。

$ git config --get remote.origin.fetch

# 通常
+refs/heads/*:refs/remotes/origin/*

# depth=1
+refs/heads/master:refs/remotes/origin/master

# -b master --single-branch
+refs/heads/master:refs/remotes/origin/master

refspecを解説するとそれだけで1つ記事が書けてしまうので今回は省略しますが、depthsingle-branchのどちらもこのrefspecによって取得するブランチをmasterだけに限定していることが高速化3のポイントです。refspecについてもっと深く知りたい方はドキュメントを見てみましょう。

補足:depth=1single-branchの併用について

git cloneの高速化でよく目にする例として、git clone --depth=1 -b master --single-branchのように2つのオプションを併用するものですが、これは高速化の観点では意味はないはずです。なぜならdepthを指定している時点で取得するコミットの歴史は制限されていますし、先ほど示したようにrefspecも特定のブランチに限定されているためです。

shallow-since

これは少しマイナーなオプションですがdepthの日付版だと思ってください。depthではコミットの数で限定しましたが、shallow-sinceはある時点以降のコミットに限定して取得できます。CIの用途ではdepthで十分だと思いますが、例えば歴史が長いリポジトリにおいてある時期に全体的に書き換えてv2にしたのでもう昔のv1時代のコミットはgit logで見ることもない、というケースなどで自分のマシンにcloneする場合は便利かもしれません。

$ time git clone --shallow-since="2020/01/01" git@github.com:git/git.git git_shallow_since1
Cloning into 'git_shallow_since1'...
remote: Enumerating objects: 27410, done.
remote: Counting objects: 100% (27410/27410), done.
remote: Compressing objects: 100% (12816/12816), done.
remote: Total 27410 (delta 19562), reused 20870 (delta 14326), pack-reused 0
Receiving objects: 100% (27410/27410), 26.83 MiB | 9.33 MiB/s, done.
Resolving deltas: 100% (19562/19562), done.
Updating files: 100% (3866/3866), done.

real    0m17.672s
user    0m2.304s
sys 0m1.225s

$ du -sh git_shallow_since1/ git_shallow_since1/.git
 74M    git_shallow_since1/
 28M    git_shallow_since1/.git

本当に2020/01/01以降のコミットだけを取得しているのか調べてみましょう。結果に1つだけ2019-12-31が含まれていますが、おそらくタイムゾーン周りをあまり厳密に考慮しなかったからだと思われます。

$ git log --format="%H %as" | tail
0d2116c6441079a5a1091e4cf152fd9d5fa9811b 2020-01-05
9d48668cd51220f1d8b83c7e1aa65416520aeee6 2020-01-04
3a05aacddd8e3d65ba7988dc6e4f8a88bc4e3320 2020-01-04
4c5081614c0fa5a9060ae294cc4364f990254f04 2020-01-03
5bb457409c15d221ee38240f83362b3c6fd96421 2020-01-03
63020f175fe26f3250ac8d19d02ef9ee271006e5 2020-01-02
224c7d70fa14ed44d8e7e3ce1e165e05b7b23725 2019-12-31
9c8a294a1ae1335511475db9c0eb8841c0ec9738 2020-01-02
763a59e71cee1542665e640f63141a0bf89e6381 2020-01-01
44143583b76decf93c55b73adaf2367c22c88998 2019-12-31

sparse-checkout

ここまでは過去のコミット、つまり時間方向へのデータ量削減でした。別の方向としてcheckoutするファイルを限定するということも可能です。

sparse-checkoutによって、巨大なリポジトリの中から必要なファイルとそれに関係するコミットだけを取得できます。例えば、複数のプロジェクトが1つのリポジトリに同居するmonorepo構成の場合に、自分の作業に関係するプロジェクトだけをcheckoutできれば十分というケースで重宝します。

これを実現するためのgit sparse-checkoutというコマンドがv2.25.0から追加されたようなのですが、v2.29.0時点ではまだEXPERIMENTALとのことなのでここでは昔ながらの方法を使います。

# 自前でディレクトリを作って空のリポジトリを作るところからスタート
$ mkdir git_sparse
$ cd git_sparse
$ git init
$ git remote add origin git@github.com:git/git.git

# git/gitのDocumentationディレクトリのみをcheckoutするように設定
$ git config core.sparsecheckout true
$ echo "Documentation" > .git/info/sparse-checkout

# ここで初めてデータを取得しにいく
$ time git fetch --depth=1 origin master
remote: Enumerating objects: 3960, done.
remote: Counting objects: 100% (3960/3960), done.
remote: Compressing objects: 100% (3506/3506), done.
remote: Total 3960 (delta 356), reused 1918 (delta 287), pack-reused 0
Receiving objects: 100% (3960/3960), 9.30 MiB | 7.35 MiB/s, done.
Resolving deltas: 100% (356/356), done.
From github.com:git/git
 * branch            master     -> FETCH_HEAD
 * [new branch]      master     -> origin/master

real    0m11.582s
user    0m0.259s
sys 0m0.126s

# `git init`で作られたmasterブランチとfetchしたorigin/masterは全く別物なので、masterを上書きして完成
$ git reset --hard origin/master

$ du -sh git_sparse/ git_sparse/.git
 16M    git_sparse/
 10M    git_sparse/.git

git cloneが裏で行っている手順を手動で行っているので手間がかかりますが、こうして出来上がったリポジトリはgit/gitのDocumentationディレクトリだけがcheckoutされた状態なのでとても軽くなりました。

git cloneのオプションまとめ

ここまでの結果をまとめるとこのようになります。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし 0m27s 214M 169M
--depth=1 0m11s 56M 11M
-b master --single-branch 0m25s 201M 155M
--shallow-since="2020/01/01" 0m17s 74M 28M
sparse-checkout + depth=1 0m11s 16M 10M

実行時間に関してはあくまで参考値ですが、depthを付けると相当に改善が見込めそうです。checkout後のリポジトリのサイズは、当然ですがsparse-checkoutによってファイルを限定できれば無駄な容量を削減できることが分かるかと思います。

submodule

ここまではgit cloneによる1つのリポジトリのケースを見てきましたが、実際の業務ではsubmoduleをたくさん抱えているリポジトリに出会うこともあるかと思います。git cloneにはcloneと同時にsubmoduleを取得し、さらにそのときの振る舞いを制御するオプションも存在します。

歴史あるOSSをいくつか調べてみたのですがサンプルになるようなsubmoduleをたくさん抱えているリポジトリが見つからなかったため、社内のリポジトリを使って実験しました。そのため、詳細なコマンドは省いて結果だけを示します。

オプション 時間(timeのreal) checkout後のサイズ .gitのサイズ
なし(--recurse-submodules 3m15s 4.2G 2.7G
--depth=1 --recurse-submodules --shallow-submodules 2m2s 2.5G 1.0G
--depth=1 --recurse-submodules --shallow-submodules -j4 1m57s 2.5G 1.0G

--recurse-submodulesgit submodule update --init --recursiveと同じ処理をgit cloneと同時にするオプションです。git cloneだけでsubmoudleのセットアップも完了するので便利です。

--shallow-submodulesを付けるとsubmoduleのリポジトリがdepth=1git cloneされます。submoduleのリポジトリについて過去のコミットが不要であれば速度の向上、少なくともリポジトリのサイズを減らすことができます。

-j4(--jobs=4)はドキュメントによるとsubmoduleのfetchを同時に行う数とのことです。個人的には4並列にすることでもう少し時間が短縮されることを期待していたのですが、ほぼ変化はありませんでした。

今回実験したリポジトリはsubmoduleの数自体は多いのですが、一部のsubmoduleだけが突出してサイズが大きいという構成上、並列化の恩恵を受けにくかったのかもしれません。あるいは社内ネットワークに由来する何かがボトルネックだった可能性もありますが、今回の本題から外れてしまうので深く調査は行いませんでした。

各CIサービスの対応状況

git cloneの各オプションの効果について理解できたと思いますので、実際のCIサービスでこれらが活用できるのかを見ていきましょう。

なお、調査したのは執筆時点の2020/12です。各サービスとも日進月歩で更新されていますし、フォーラム等で機能要望を受け付けているので将来的には改善されているかもしれません。

CircleCI

CircleCIでは何も意識せずに最初のステップでcheckoutしていると思いますが、実はこの裏ではdepthオプション無しでgit cloneが実行されているため過去の全コミットが取得されています。これはCircleCIのログでCheckout codeのステップのログから確認できます。

CircleCIのサポートからは以下のページにて、checkoutの代わりに自前で--depth--shallow-sinceオプション付きでgit cloneするか、そのような機能を提供しているOrbsの利用が提案されています。

Shallow Repository Cloning

他に探したところ、30GBを超える超巨大なリポジトリをdepthsparse checkoutで対応した事例もあるようです。

巨大なリポジトリのJenkinsからCircleCIへの移行においてshallow cloneとsparse checkoutで前処理を高速化する

Bitrise

Bitriseのsteps-git-cloneはデフォルトではdepthのオプションが有効になりません。ですが、clone_depthというオプションを設定するとdepthオプション付きでcloneされるようになります。

https://github.com/bitrise-steplib/steps-git-clone/blob/b1cbe45b5704863c4c930111edeb7aa67d38f5c1/step.yml#L101-L107

sparse checkoutや、submoduleへのdepthの対応は自分がコードを読んだ限りでは無いように見えました。それらの対応が必要な場合は自分でgit cloneのコマンドを書く必要があります。

GitHub Actions

GitHub Actionsのactions/checkoutは、v2からデフォルトでdepth=1のオプションが有効になっています。逆にdepth無しでcloneが必要な場合はdepth: 0を指定するようにとREADMEに書いてあります。

submoduleについてはREADMEに詳細は書かれていませんでしたが、fetch-depthを指定するとsubmoduleを取得するときにもdepthが有効になりそうです。(v2.3.4のコードより)

https://github.com/actions/checkout/blob/v2.3.4/src/git-command-manager.ts#L317

sparse checkoutはサポートされていないようでしたので、必要な場合は自分で一連の処理を書く必要があります。

まとめ

大規模リポジトリでgit cloneするときのニッチなテクニックを紹介しました。

業務のリポジトリは何年も生き続けて肥大化していく一方なので、CIにおける最初のgit cloneにいつの間にか数分かかってしまうということも珍しくありません。そこはCIサービス側がいい感じに対応してくれていると思いきや、意外とそんなことはなかったりします

git cloneのオプションについて理解しておくことで、CIサービス側が用意している機能にオプションを渡すだけで改善の余地があるのかどうかを判断できるようになります。最悪、用意されている機能には頼らずに自分でgit cloneのコマンドを書くことでCIの実行時間を短縮できる可能性があります。

git cloneのオプションは他にもたくさん存在しており、なかなか奥深いのでman git-cloneを実行するか、Webのドキュメントをぜひ一読してみてください。

宣伝

DeNAでは今年、以下の3種のアドベントカレンダーを書いてます!それぞれ違った種類なのでぜひ見てみてください。

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!


  1. コミットの歴史を修正してファイル自体がなかったように歴史を改変してしまう場合はその限りではないですが、相当に難しい作業です。

  2. .git/objectsの中身について興味がある方は10.2 Gitの内側 - Gitオブジェクトが参考になるでしょう。

  3. このrefspecについて理解できるとPinterestで1行追加しただけでgit cloneが99%高速化されたという理由が分かります。