GitLab CIでRegistryをキャッシュとして使いビルドにかかる時間を短縮する


tl;dr
------------------

このドキュメントの内容を見てやっただけ

https://docs.gitlab.com/ce/ci/docker/using_docker_build.html#using-docker-caching


背景
-------------------------

Mavenでビルドを行う際に、GitLab CIが動くたびに毎回パッケージのダウンロードを行ってしまう。
これはGitLabは記憶領域が永続化されてないためである。これをどうにかしたい。

上記のドキュメントはその1つの手としてRegistryを使うやり方を書いてあります。この考え方自体はGitLabに限らず使えそう。

キャッシュとして使うわけなので、それにまつわる注意もあります。


やり方
-------------------------

### ファイル例

わけあってFedora 29がビルド環境となっています。ビルド用イメージなんで数GB単位で膨れても実用上はそんなに問題になりません。

このビルド用イメージを作るための`Devel-Dockerfile`を別途用意します。
これぐらいならマルチステージビルドで1つのDockerfile内で出来る範囲でもありますが、Registryに乗せるためにファイルを分けます。


``` Dockerfile:Devel-Dockerfile
FROM fedora:29

RUN dnf update -y && \
  dnf install -y boost-devel make openssl-devel gcc-c++ java-1.8.0-openjdk maven postgresql-server python3-django python3-django-rest-framework python3-requests && \
  dnf install -y boost-static openssl-static libstdc++-static glibc-static zlib-static && \
  dnf clean all
```


GitLabのCIファイルはこんな感じ。
不要そうなのは削ったり移動したりしていますが、ドキュメントの受け売りです。
GitLab CIによる実行中は`CI_JOB_TOKEN`が与えられるおかげでGitLab Registryへアクセスができます。

``` .gitlab-ci.yml
stages:
  - devel-image-build

variables:
  DOCKER_HOST: tcp://docker:2375
  DEVEL_IMAGE_TAG: $CI_REGISTRY_IMAGE/devel

before_script:
  - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY

devel-image-build:
  stage: devel-image-build
  image: docker:stable
  services:
    - docker:dind
  script:
    - docker pull $DEVEL_IMAGE_TAG:latest || true
    - docker build -f Devel-Dockerfile --cache-from $DEVEL_IMAGE_TAG:latest -t $DEVEL_IMAGE_TAG:latest .
    - docker push $DEVEL_IMAGE_TAG:latest
```

最初のstage(`devel-image-build`)でImageを（あれば）pullしてbuildしてpushしてるのが要点です。

今はstageがこの1個だけですが、この後に続くstageはこのPushしたImageを使って実行することができます。これを「キャッシュ」に使います。

ゴールとしては、dnfによるパッケージ導入の時間が省略される事を期待しています。

ではCIを動かして効果を見ます。


### 1回目 (所要時間 3分38秒)

1回目は何もない状態なので時間がかかります。

```
$ docker pull $DEVEL_IMAGE_TAG:latest || true
Error response from daemon: manifest for registry.gitlab.com/fukasawah/sample1/devel:latest not found
$ docker build -f Devel-Dockerfile --cache-from $DEVEL_IMAGE_TAG:latest -t $DEVEL_IMAGE_TAG:latest .
Sending build context to Docker daemon  3.222MB

Step 1/2 : FROM fedora:29
29: Pulling from library/fedora
e1736333d405: Pulling fs layer
e1736333d405: Verifying Checksum
e1736333d405: Download complete
e1736333d405: Pull complete
Digest: sha256:40d5a9945876a4e475faf5277ac4fcfb81dc272d71a7586c38b5222a920d801a
Status: Downloaded newer image for fedora:29
 ---> 8bd04269a51b
Step 2/2 : RUN dnf update -y &&   dnf install -y boost-devel make openssl-devel gcc-c++ java-1.8.0-openjdk maven postgresql-server python3-django python3-django-rest-framework python3-requests &&   dnf install -y boost-static openssl-static libstdc++-static glibc-static zlib-static &&   dnf clean all
 ---> Running in d29fb713832f

...(中略)...

Removing intermediate container d29fb713832f
 ---> b34557bee35f
Successfully built b34557bee35f
Successfully tagged registry.gitlab.com/fukasawah/sample1/devel:latest
$ docker push $DEVEL_IMAGE_TAG:latest
The push refers to repository [registry.gitlab.com/fukasawah/sample1/devel]
802f34a98631: Preparing
4e667c437282: Preparing
4e667c437282: Layer already exists
802f34a98631: Pushed
latest: digest: sha256:6f86366fe23f4960f38c1e31a941a3e5897cfd4240f78053832534c449b2089d size: 742
Job succeeded
```

ここまでは普通です。


### 2回目（所要時間 1分0秒）

単純にpipelineをリトライして、キャッシュを使うかを試します。

今度はRegistryにあるはずなので、pullが機能して、キャッシュを使ってくれるはず・・・。

```
$ docker pull $DEVEL_IMAGE_TAG:latest || true
latest: Pulling from fukasawah/sample1/devel
e1736333d405: Pulling fs layer
963246ed8796: Pulling fs layer
e1736333d405: Verifying Checksum
e1736333d405: Download complete
963246ed8796: Verifying Checksum
963246ed8796: Download complete
e1736333d405: Pull complete
963246ed8796: Pull complete
Digest: sha256:6f86366fe23f4960f38c1e31a941a3e5897cfd4240f78053832534c449b2089d
Status: Downloaded newer image for registry.gitlab.com/fukasawah/sample1/devel:latest
$ docker build -f Devel-Dockerfile --cache-from $DEVEL_IMAGE_TAG:latest -t $DEVEL_IMAGE_TAG:latest .
Sending build context to Docker daemon  3.222MB

Step 1/2 : FROM fedora:29
29: Pulling from library/fedora
e1736333d405: Already exists
Digest: sha256:40d5a9945876a4e475faf5277ac4fcfb81dc272d71a7586c38b5222a920d801a
Status: Downloaded newer image for fedora:29
 ---> 8bd04269a51b
Step 2/2 : RUN dnf update -y &&   dnf install -y boost-devel make openssl-devel gcc-c++ java-1.8.0-openjdk maven postgresql-server python3-django python3-django-rest-framework python3-requests &&   dnf install -y boost-static openssl-static libstdc++-static glibc-static zlib-static &&   dnf clean all
 ---> Using cache
 ---> b34557bee35f
Successfully built b34557bee35f
Successfully tagged registry.gitlab.com/fukasawah/sample1/devel:latest
$ docker push $DEVEL_IMAGE_TAG:latest
The push refers to repository [registry.gitlab.com/fukasawah/sample1/devel]
802f34a98631: Preparing
4e667c437282: Preparing
4e667c437282: Layer already exists
802f34a98631: Layer already exists
latest: digest: sha256:6f86366fe23f4960f38c1e31a941a3e5897cfd4240f78053832534c449b2089d size: 742
Job succeeded
```

ちゃんとpullが行われ、Step1,2もキャッシュが効いていることがわかりますね。そして**2分ほど早くなった**！

タイムスタンプが出てないのが良くないですが、イメージサイズが膨れるのでPullの時間は増えてます。
それでも各ダウンロードを行うよりは断然早いです。


キャッシュに関する注意
---------

RegistryにPushしてから次にそのDockerfileに変化があるまでの間、アップデートに追従できなくなります。
なので「CI上だとビルドできるのに手元（のDockerfileから）だとビルドできない」とか言った事が起きます。

簡単な方法としては、1日1回は強制的にキャッシュを捨てて作り直す事でこのリスクを減らせるかと思います。


おわり
---------

今回はFedoraのdnfの例でしたが、セットアップに時間がかかるものであれば、この手法でイメージにして再利用する形にする事ができるはず。

実際にはJavaプロジェクトを複数抱えていたので、Mavenの実行に必要なもの（プラグイン、プロジェクトで依存しているライブラリ）をある程度入れたイメージを作っておく、といった事をやりました。ちなみにMavenの実行に必要なものをあらかじめ含めておくには、[前に記事を書いています](https://qiita.com/fukasawah/items/ac03af27894e0383c33d)。



おまけ
---------

### Dockerfile更新した場合

Dockerfileは更新されることはよくあるはずであり、その場合はちゃんと内容を追従してほしい。
また、変更検知だけではなく、途中までのキャッシュは使ってくれるかどうかを試す。

ビルド用イメージを作るDockerfileを以下のように書き換えたらどうなるか？

``` diff:Devel-Dockerfile
  FROM fedora:29

  RUN dnf update -y && \
    dnf install -y boost-devel make openssl-devel gcc-c++ java-1.8.0-openjdk maven postgresql-server python3-django python3-django-rest-framework python3-requests && \
    dnf install -y boost-static openssl-static libstdc++-static glibc-static zlib-static && \
    dnf clean all

+ RUN dnf install -y git

```

以下そのログ。

```
$ docker pull $DEVEL_IMAGE_TAG:latest || true
latest: Pulling from fukasawah/sample1/devel
e1736333d405: Pulling fs layer
963246ed8796: Pulling fs layer
e1736333d405: Verifying Checksum
e1736333d405: Download complete
963246ed8796: Verifying Checksum
963246ed8796: Download complete
e1736333d405: Pull complete
963246ed8796: Pull complete
Digest: sha256:6f86366fe23f4960f38c1e31a941a3e5897cfd4240f78053832534c449b2089d
Status: Downloaded newer image for registry.gitlab.com/fukasawah/sample1/devel:latest
$ docker build -f Devel-Dockerfile --cache-from $DEVEL_IMAGE_TAG:latest -t $DEVEL_IMAGE_TAG:latest .
Sending build context to Docker daemon  3.222MB

Step 1/3 : FROM fedora:29
29: Pulling from library/fedora
e1736333d405: Already exists
Digest: sha256:40d5a9945876a4e475faf5277ac4fcfb81dc272d71a7586c38b5222a920d801a
Status: Downloaded newer image for fedora:29
 ---> 8bd04269a51b
Step 2/3 : RUN dnf update -y &&   dnf install -y boost-devel make openssl-devel gcc-c++ java-1.8.0-openjdk maven postgresql-server python3-django python3-django-rest-framework python3-requests &&   dnf install -y boost-static openssl-static libstdc++-static glibc-static zlib-static &&   dnf clean all
 ---> Using cache
 ---> b34557bee35f
Step 3/3 : RUN dnf install -y git
 ---> Running in eceab0b3acf6

...（中略）...

 ---> 82255da07349
Successfully built 82255da07349
Successfully tagged registry.gitlab.com/fukasawah/sample1/devel:latest
$ docker push $DEVEL_IMAGE_TAG:latest
The push refers to repository [registry.gitlab.com/fukasawah/sample1/devel]
96ddf53e815b: Preparing
802f34a98631: Preparing
4e667c437282: Preparing
802f34a98631: Layer already exists
4e667c437282: Layer already exists
96ddf53e815b: Pushed
latest: digest: sha256:ca3178437a7d0a726ce6b72e9d2c3508df7ac1810c4c880488faa49bf1bde4ea size: 955
Job succeeded
```

Step2の内容が「Using cache」となっているので、Pullしたときのキャッシュを使ってくれている。
追記したStep3も行われて新しくImageがPushされているので、ちゃんと更新できている。


### `--cache-from`ってなんだ？

よくわからずドキュメントに書かれていたことに従っていたが、自分のDockerの理解では、同じDockerfileならこの内容が書き変わらない限り同じのはずだと思っていた。（今回はADDやCOPYは使っていないので、他のファイルの変化も関係ないはず）

なので、`--cache-from`がなくても、pullしたときに各コマンドのレイヤが取得できるはずで、それでキャッシュが機能するのでは？

``` diff
      - docker:dind
    script:
      - docker pull $DEVEL_IMAGE_TAG:latest || true
-     - docker build -f Devel-Dockerfile --cache-from $DEVEL_IMAGE_TAG:latest -t $DEVEL_IMAGE_TAG:latest .
+     - docker build -f Devel-Dockerfile -t $DEVEL_IMAGE_TAG:latest .
      - docker push $DEVEL_IMAGE_TAG:latest
```

しかし、ダメだった。

```
$ docker pull $DEVEL_IMAGE_TAG:latest || true
latest: Pulling from fukasawah/sample1/devel
e1736333d405: Pulling fs layer
963246ed8796: Pulling fs layer
45bcede4d205: Pulling fs layer
e1736333d405: Verifying Checksum
e1736333d405: Download complete
963246ed8796: Verifying Checksum
963246ed8796: Download complete
45bcede4d205: Verifying Checksum
45bcede4d205: Download complete
e1736333d405: Pull complete
963246ed8796: Pull complete
45bcede4d205: Pull complete
Digest: sha256:ca3178437a7d0a726ce6b72e9d2c3508df7ac1810c4c880488faa49bf1bde4ea
Status: Downloaded newer image for registry.gitlab.com/fukasawah/sample1/devel:latest


$ docker build -f Devel-Dockerfile -t $DEVEL_IMAGE_TAG:latest .
Sending build context to Docker daemon  3.222MB

Step 1/3 : FROM fedora:29
29: Pulling from library/fedora
e1736333d405: Already exists
Digest: sha256:40d5a9945876a4e475faf5277ac4fcfb81dc272d71a7586c38b5222a920d801a
Status: Downloaded newer image for fedora:29
 ---> 8bd04269a51b
Step 2/3 : RUN dnf update -y &&   dnf install -y boost-devel make openssl-devel gcc-c++ java-1.8.0-openjdk maven postgresql-server python3-django python3-django-rest-framework python3-requests &&   dnf install -y boost-static openssl-static libstdc++-static glibc-static zlib-static &&   dnf clean all
 ---> Running in 0ceeebc33982
 
...中略...

 ---> 8b2b180e6167
Step 3/3 : RUN dnf install -y git
 ---> Running in 11769da5e219

...中略...

 ---> 2f02c401f817
Successfully built 2f02c401f817
Successfully tagged registry.gitlab.com/fukasawah/sample1/devel:latest
$ docker push $DEVEL_IMAGE_TAG:latest
The push refers to repository [registry.gitlab.com/fukasawah/sample1/devel]
bcbc296c9a00: Preparing
309afb3161a4: Preparing
4e667c437282: Preparing
4e667c437282: Layer already exists
bcbc296c9a00: Pushed
309afb3161a4: Pushed
latest: digest: sha256:86ab0f09038121cb3ca83f120507de9f90b2b37dca7cca2334a3a3373996c307 size: 955
```

Step 1「e1736333d405: Already exists」とあるので、ベースのイメージはうまく再利用できているが、
Step 2が「Using cache」とはならなかった。Dockerfile内のRUNの内容は書き変わってないがダメらしい。

多分、`--cache-from`は指定された場合はそのイメージに含まれるレイヤーを使うようにするオプションなのかな。
しかしそうなると、buildで作られるレイヤーとpullで作られるレイヤーをわざわざ区別していることになるはず。レイヤーは同じもんだろうって感じはするのだけど。

この辺り、ドキュメントにも特に書いてなくて、よくわからなかったので実装の経緯から学ぶことにした。以下が多分それと思われる時系列。

- v1.10.0でimageとlayerを管理する仕組み(?)が変わった（[Engine v1.10.0 content addressability migration](https://github.com/moby/moby/wiki/Engine-v1.10.0-content-addressability-migration)）
  -  過去がどういう仕組かよくわかってないので何とも言えない...
- 新しい仕組みのせいでpullのキャッシュが効かなくなって辛いというIssueが建つ（[Issue#20316](https://github.com/moby/moby/issues/20316)）
- `--cache-from`を明示的に指定することで取れるようにした（[PR#24711](https://github.com/moby/moby/pull/24711) と [PR#26839](https://github.com/moby/moby/pull/26839/)）

内容が理解できてないので学べてない・・・

PRのコードを見た感じ、`--cache-from`で明示的に指定しないとpullしたimageのレイヤーは使わない、というセキュリティ的な意図もあるように見える。
この辺りで調べてたら、v1.10.0の新しい仕組みについて、以下のドキュメントでこういった記載がある。

https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface

> As of Docker 1.10.0, all images are stored and accessed by the cryptographic checksums of their contents, limiting the possibility of an attacker causing a collision with an existing image.

以前のpullの脆弱な部分に対して、仕組みを変えたことで攻撃できる可能性を減らす仕組みが入れている、と読める。
逆を言えば、それまでは攻撃を受ける可能性があったということなのかな。例えば悪意があるImageをPullしたりすることで簡単にレイヤを上書きできるとか。うーん、合ってるかもわからん。

ちょっとずれたけど、要点の「pullしたイメージ（のレイヤー）をキャッシュで使いたいなら`--cache-from`が必要なのか？」という点は経緯からなんとなく必要そうだ、というのが分かった。
後はどうやって管理しているのか、がわからないと先が見えそうにないので、一旦ここまで。