巨大レポジトリに向けた 数十倍速くするCI/CDのための最適化


今年もはじまりました[Retty Inc. Advent Calendar 2018](https://qiita.com/advent-calendar/2018/retty) です。
よろしくお願いします。

とゆことで、 1日目の記事としまして
「モノレポに向けたCI/CDの最適化」というテーマでお話をしたいと思います！

みなさんの職場では、CI/CDを活用されていますでしょうか？
古き良き Jenkins にはじまり、最近では travis CI、CircleCI といった  CIのPaaS や 
Gitlab や Github のプラットフォーム自体にも組み込まれるようになりました。

CI/CD は 開発プロセスの効率化を支える大事な要素ともいえます。


そんなCI/CDですが、その性質上「Fail fast, Fail often」が求められます。

**タスクはなんでもいいから早く終わって欲しい**

ということになります。

## 大きいレポジトリ での CI/CDは大変

年季の入ったモノレポ構成の場合、gitのヒストリも巨大になります。
通常通りに`git clone`を行うだけでで 5分以上のかかることはザラで、
ビルド時間のボトルネックになりえます。

また Docker等のイメージの構築をCI/CDでする場合も、
コードのコピーをしただけでも結構な容量になるため 一筋縄ではいきません。

本記事では、そのような巨大レポジトリでのビルド環境を最適化する方法を紹介します。

レポジトリの複雑さを測る
----------------------------------------

まず最初に
ある日突然、そんなレポジトリをさわることになって

「うわっ… 私のレポジトリでかすぎ…？」

と発狂中のあなた、安心してください。

よっぽどのことがない限り、そのレポジトリは少しサイズが大きい程度です。

処方箋として、ツールを使って実際にレポジトリのサイズを計測してみましょう。
githubはgitレポジトリの複雑さを測るツールを公開しています。
https://github.com/github/git-sizer/

Linuxレポジトリに比べれば、だいたいのレポジトリは大したことはありません。
せいぜい ファイルサイズが大きいオブジェクトが
履歴のどっかでgitに突っ込まれて、肥大化している程度の複雑さです。

ここで レポジトリの複雑さが大したことないな、という安心感を
最初に得ましょう

なんとかできそう、という安心感はものごとを進めるでとても大事です。


gitで高速にクローンする
----------------------------------------

巨大なレポジトリは出だしのレポジトリのダウンロードのための
クローンですら高速に行うことが難しくなります。

gitのリポジトリのサイズを大きくする要因は以下の２つです。

- git管理下にあるアプリケーションを構成するファイルサイズ
- 履歴を管理するgit objectsのファイルサイズ

このとき２つのテクニックを覚えておくと、より高速にクローンできます。

### Shallow clone

Shallow Cloneは レポジトリのうち、全ての履歴をcloneするのではなく
直近の履歴のみをクローンする方法です。
これは*git objectsのファイルサイズ* を減らすテクニックになります。

`git clone`/`git fetch` に `--depth`/`-d`オプションを利用することで
shallow cloneの設定が有効になります。

最新のcommitだけクローンしたい場合は以下の様にコマンドを打ってみましょう。

```
git clone -d 1 <REPOSITORY URL>
```

参考:

- [--depeth <depth> - Git](https://git-scm.com/docs/git-clone#git-clone---depthltdepthgt)

### Sparse checkout

レポジトリのうち利用するファイルだけをクローンするテクニックで
*git管理下にあるファイルサイズ* を減らすテクニックです。

こちらは shallow cloneと比べると、少々設定がややこしくなりますが
下記の手順で有効にすることができます。

```sh
git init
git remote add origin <GIT REPOSITORY URL>
cat > .git/info/sparse-checkout <<EOF
... # 必要なファイル群
EOF
git config core.sparsecheckout true
git fetch origin <GIT BRANCH>
```

gitのsubcommandであるgit cloneを利用しない点がミソになるでしょう。
またsparse checkoutはデフォルトでは有効になっていないため
設定をオンにしてから 利用する必要があります。

参考: 

- [Sparse Checkout - Git](https://git-scm.com/docs/git-read-tree#_sparse_checkout)

### CircleCIのジョブで適応する

CircleCIで汎用的に利用するためには、簡単に以下の様なスクリプトを組んでしまいましょう。

```sh
CIRCLE_WORKING_DIRECTORY="${CIRCLE_WORKING_DIRECTORY/#\~/$HOME}"
[[ ! -d $CIRCLE_WORKING_DIRECTORY ]] && mkdir -p $CIRCLE_WORKING_DIRECTORY
cd $CIRCLE_WORKING_DIRECTORY

git init
git remote add origin $CIRCLE_REPOSITORY_URL
git config core.sparsecheckout true

[[ -n $FILES ]] && echo $FILES | sed 's/,/\n/g' | tee .git/info/sparse-checkout

git fetch --depth=2 origin $CIRCLE_BRANCH
git fetch --depth=1 origin HEAD:refs/remotes/origin/HEAD
git checkout ${CIRCLE_BRANCH:-${CIRCLE_TAG}}
```

直近のコミットだけでは、git diffなどの差分を出力することができません。
差分等の利用を考えて、

- 対象ブランチを履歴を2コミット分
- デフォルトブランチのコミットを1コミット分

同期しています。

これを適応したところ、弊社では
クローンだけで5分以上かかっていた`git clone`が10秒強で可能となり
単純比較で30倍ほどの短縮が行えました。

Dockerで高速にpush / pullする
----------------------------------------

dockerをはじめとするコンテナ技術は実行環境のポータビリティを
飛躍的に高めモダンな開発において必須技術のひとつともいえるでしょう。

ファイルサイズもファイル数も巨大なモノレポでは
サイズの小さいDockerのイメージを作るのにも工夫が必要になります。

特にgitの履歴でなく、アプリケーションを構成するファイルが
肥大化している場合にはDockerのイメージサイズの削減は至難を究めます。
なぜなら、Dockerfile上での小手先だけの最適化では実現することができず
アプリケーション自体の最適化をほどこす必要がでるためです。

では、どうすれば良いのでしょうか？

イメージサイズを減らすことは諦めて、イメージのレイヤーに対して最適化を施すことで
レジストリとのやりとりを高速化することができます。

### Dockerイメージの構成について

Dockerイメージは複数のレイヤーで構成されており
これらがそれぞれDockerfileの一命令に対応します。
`docker push`や`docker pull`でやりとりされる単位は、このレイヤー単位です。

Dockerfile中で`COPY . ./` 等を通じて イメージを作成した場合
レイヤー単位で管理されるため、部分的な変更であってもレイヤーの全体のハッシュ値が変わることになります。
そのため ADDやCOPYで作成されるDockerのイメージレイヤーのサイズはレポジトリのサイズそのままとなり
docker push / pull する際のデータ量が顕著に増大することになります。

逆に言えば、このレイヤーを分割し差分のレイヤーを構築することができれば
やりとりするデータ量を劇的に減らすことが可能です。

参考:

- [About storage drivers - Docker Documentation](https://docs.docker.com/storage/storagedriver/#images-and-layers)


### Dockerの標準の差分計算機能について

Dockerには、Dockerfileを分けることで
レイヤーのサイズが差分分のみになるような機能が入っているようなのですが
バグのためか、パフォーマンスの問題で機能が削除されたのか
ほとんどの環境でこれを利用することはできません。

修正を期待するにしても、優先度が高くないのか実現難易度が高いのかわかりませんが
issueは立てられてから2年ほど経過しており、目処はたっていないため
利用をあてにするのも難しそうです。

参考: 

- [Is there a way to add only changed files ... - Stack Overflow](https://stackoverflow.com/questions/36553502/is-there-a-way-to-add-only-changed-files-to-a-docker-image-as-a-new-layer-with)
- [Comments on moby/moby - Github](https://github.com/moby/moby/issues/21950#issuecomment-208881571)

### 回避策1: ADDやCOPYを複数命令に分ける

こうやるところを

```Dockerfile
FROM <base image>

ADD . ./ 

# other process ...
```

```Dockerfile
FROM <base image>

ADD ./images ./
ADD ./assets ./
ADD ./src ./
# 適度に続ける...

# other process ...
```
と 分ける方法です。


問題点は

- Dockerfileは冗長になる
- ADD命令の順番に依存する (imagesが編集されると、全て作り直しになる = 通信するデータ量が増える)
- ディレクトリ構成が複雑であればあるほど難しくなる

一番簡単で、楽な方法でこれだけでも十分な最適化が望めると思います。
80:20の法則からも、レポジトリのサイズを大きくしているのは
大抵特定のディレクトリなので、思ったより冗長にはならないかも知れません

これで十分な最適化が望めない場合もあるかもしれません。
その場合には次の方法を使えば、大丈夫でしょう。

### 回避策2: rsyncを利用した差分適応イメージの構築

[neam/docker-diff-based-layers - Github](https://github.com/neam/docker-diff-based-layers)

rsyncを使うことでさらなる最適化ができます。
rsync には送信先と入力元のファイル群を比較し、差分のファイルリストを計算する機能が存在します。
この機能を利用することで、２つのイメージの差分を計算し
ベースイメージに適応することで差分適応イメージを構築することができます。

1. 比較元となるイメージを用意(OLD)
2. 比較先となるイメージの構築(NEW)
3. 比較先と比較元でrsyncを利用し、差分ファイルをまとめた圧縮ファイルの作成
4. 3.で生成したファイルを追加し、OLDイメージで展開するDockerfileを作成
5. 4.で生成したDockerfileをbuildし、NEWとする

ワークフローとしては少々複雑ですが、上記手順を利用することで
差分適応されたイメージを任意のイメージに対して構築することができます。
ただし 対象イメージにrsyncがインストールされていることが必要になります

以下は、参照先リンクからの結果の引用なりますが
差分適応することによって、pull/pushする際のイメージサイズを
**1/10000** と大きく圧縮することに成功しています

ベースのイメージ (参照元: [neam/docker-diff-based-layers](https://github.com/neam/docker-diff-based-layers#create-and-build-the-image-for-a-sample-project))

```
IMAGE               CREATED              CREATED BY                                      SIZE                COMMENT
d4b30af167f4        3 seconds ago        /bin/sh -c #(nop) COPY dir:68b8f374d8731b8ad8   16.78 MB
c898fe1daa44        About a minute ago   /bin/sh -c apt-get update && apt-get install    10.77 MB
39a8a358844a        4 months ago         /bin/sh -c #(nop) CMD ["/bin/bash"]             0 B
b1dacad9c5c9        4 months ago         /bin/sh -c #(nop) ADD file:5afd8eec1dc1e7666d   125.1 MB
```

追加でCOPYだけを行った場合は以下のようになります (参照元: [neam/docker-diff-based-layers](https://github.com/neam/docker-diff-based-layers#verify-that-basing-the-project-images-on-the-revision-1-image-tag-contents-does-not-lead-to-desired-outcome))

```
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
4a3115eaf267        3 seconds ago       /bin/sh -c #(nop) COPY dir:61d102421e6692b677   16.78 MB
d4b30af167f4        25 seconds ago      /bin/sh -c #(nop) COPY dir:68b8f374d8731b8ad8   16.78 MB
c898fe1daa44        2 minutes ago       /bin/sh -c apt-get update && apt-get install    10.77 MB
39a8a358844a        4 months ago        /bin/sh -c #(nop) CMD ["/bin/bash"]             0 B
b1dacad9c5c9        4 months ago        /bin/sh -c #(nop) ADD file:5afd8eec1dc1e7666d   125.1 MB
```

差分適応しイメージファイルを構築した場合の結果(参照元: [neam/docker-diff-based-layers](https://github.com/neam/docker-diff-based-layers#create-an-image-with-an-optimized-layer))

```
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
1920e750d362        24 seconds ago      /bin/sh -c if [ -s /.files-to-remove.list ];    0 B
1267bf926729        2 minutes ago       /bin/sh -c #(nop) ADD file:5021c627243e841a45   19 B
d04a2181b62a        2 minutes ago       /bin/sh -c #(nop) ADD file:14780990c926e673f2   264 B
d4b30af167f4        7 minutes ago       /bin/sh -c #(nop) COPY dir:68b8f374d8731b8ad8   16.78 MB
c898fe1daa44        9 minutes ago       /bin/sh -c apt-get update && apt-get install    10.77 MB
39a8a358844a        4 months ago        /bin/sh -c #(nop) CMD ["/bin/bash"]             0 B
b1dacad9c5c9        4 months ago        /bin/sh -c #(nop) ADD file:5afd8eec1dc1e7666d   125.1 MB
```

docker pull/push の際の対象となるレイヤーは1920e750d362, 1267bf926729, d04a2181b62a の3つの
レイヤーのやりとりのみで済みます。

単純な比較として 16.78MB -> 283B(=264B + 19B)となるため
1万倍のイメージサイズの圧縮に成功しています。

#### For CI

CIで組み込むとすれば、以下の様な感じになります

```sh
git clone https://github.com/neam/docker-diff-based-layers
cd docker-diff-based-layers

export RESTRICT_DIFF_TO_PATH=<target directory>
export OLD_IMAGE=$IMAGE:$VERSION_BASE
export NEW_IMAGE=$IMAGE:$VERSION

docker-compose \
    -f rsync-image-diff.docker-compose.yml up \
    --abort-on-container-exit new_large_layer \
    2>&1 1>/dev/null \
  && docker-compose \
    -f shell.docker-compose.yml \
    -f process-image-diff.docker-compose.yml \
    run --rm shell ./generate-dockerfile.sh \
  && {
    cd output;
    sudo sed -i -e '/\/$/d' ./files-to-remove.list;
    docker build -t $IMAGE:$VERSION .;
    cd ..
  }
```

上記の最適化を弊社で試したところ、レジストリとのやりとりがボトルネックであった
ビルド時間は自体は3分強から1分弱程度に短縮でました。
dockerイメージを利用する各種デプロイ環境ではビルド時間とレジストリへの反映に3
分強かかっていたイメージの更新時間は10秒弱で行えるようになり、20倍程度の短縮が実現できました。

## 終わりに

モノシリックなレポジトリでは 快適なCI/CD環境をつくるためには一工夫が必要になります。
本記事では、このためのテクニックとしてgitとDockerに対する最適化手法を紹介いたしました。

我々の環境では、手順を通じて数十倍程度のCI/CDの短縮することが実現できています。
これらのテクニックが効果を発揮するかどうかは対象のプロジェクト環境に強く依存するため
適応する際には、最初に正しくボトルネックを分析する必要がありますので注意してください。

ここまで記事におつきあいいただきありがとうございます