ニッチな話題ですが、業務における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つ記事が書けてしまうので今回は省略しますが、depth
もsingle-branch
のどちらもこのrefspecによって取得するブランチをmasterだけに限定していることが高速化3のポイントです。refspecについてもっと深く知りたい方はドキュメントを見てみましょう。
補足:depth=1
とsingle-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-submodules
はgit submodule update --init --recursive
と同じ処理をgit clone
と同時にするオプションです。git clone
だけでsubmoudleのセットアップも完了するので便利です。
--shallow-submodules
を付けるとsubmoduleのリポジトリがdepth=1
でgit 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の利用が提案されています。
他に探したところ、30GBを超える超巨大なリポジトリをdepth
とsparse checkout
で対応した事例もあるようです。
巨大なリポジトリのJenkinsからCircleCIへの移行においてshallow cloneとsparse checkoutで前処理を高速化する
Bitrise
Bitriseのsteps-git-cloneはデフォルトではdepth
のオプションが有効になりません。ですが、clone_depth
というオプションを設定するとdepth
オプション付きでcloneされるようになります。
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種のアドベントカレンダーを書いてます!それぞれ違った種類なのでぜひ見てみてください。
- DeNA Advent Calendar 2020:DeNAエンジニアによるアドベントカレンダー
- DeNA 20 新卒 Advent Calendar 2020:DeNA 20新卒エンジニアによるアドベントカレンダー
- DeNA 21 新卒 Advent Calendar 2021:DeNA 21内定者エンジニアによるアドベントカレンダー
この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい! Follow @DeNAxTech
-
コミットの歴史を修正してファイル自体がなかったように歴史を改変してしまう場合はその限りではないですが、相当に難しい作業です。↩
-
.git/objectsの中身について興味がある方は10.2 Gitの内側 - Gitオブジェクトが参考になるでしょう。↩
-
このrefspecについて理解できるとPinterestで1行追加しただけでgit cloneが99%高速化されたという理由が分かります。↩