Dockerビルドのキャッシュを使ってCI環境でのスローテストに暫定的に勝利した話

この記事は[NTTテクノクロスアドベントカレンダー 2022](https://qiita.com/advent-calendar/2022/ntt-tx) 13日目の記事です。

## [](https://qiita.com/nobeans/items/ed14bf710c4c05845a0a#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB)はじめに

こんにちは。NTTテクノクロスの中野です。

[昨年度](https://qiita.com/nobeans/items/ed14bf710c4c05845a0a) に引き続き、今年度も13日目の担当です。どちらも金曜日ではなくてよかったです。

さて、今回は、CIにおけるしぶといスローテストとの戦いでついに勝利を収めた、という最近うれしかった話をします。実際には、色々バタバタと試行錯誤をしてますが、話を簡単にするため多少構成にフィクションが含まれます。あしからずご了承ください。

## 状況設定

- Go言語によるプロダクトである
- オンプレのGitLabを使っている
- CIタスクは別のクラウドサービス上のGitlab Runner@Dockerコンテナで実行する
- ほぼすべてのテストは並行で実行できる実装になっている[^1]
- 全部のテストをCIで実行するとトータル10分強かかる

[^1]: これはこれで色々頑張った結果ではありますが、それはまた別のお話。

## 前半戦: GitLab Runner側のDockerにキャッシュさせる

GitLab Runnerで実行するDockerイメージとして[golang](https://hub.docker.com/_/golang)あたりを使えば、Go環境の必須コマンド等はサクッと用意できます。使ったDockerイメージはGitLab Runner側のDockerにキャッシュされるので、2回目以降はDockerイメージのダウンロードの時間はかかりません。

しかし、GoのプロダクトをCIとしてテストやlintを実行するためには、以下のような前準備が毎回の実行ごとに必要になります。

- ビルドやlintやテストで使っているツール一式を`go install`する必要がある
- 必要な依存モジュールを`go mod download`する必要がある

で、それぞれ本家から何度もダウンロードするよりも、CI側でキャッシュした方がいいよね、ということで、これらをGitLabの仕組みを使ってキャッシュするようにしていました。が、原因はよくわからないのですが、タスク開始時にロードしたりタスク終了時に保存したりするのに毎回だいたい3分くらいかかっていて、「これ、ひょっとして毎回本家から普通にダウンロードするよりもむしろだいぶ遅いのでは？」という状態でした。

真っ当な解決策としては、ビルド用のDockerイメージをあらかじめ生成してどこかのDockerレジストリにいれておいて、そのイメージを使ってCIタスクを実行する、という案が考えられます。が、諸事情によりオレオレDockerレジストリを用意するのが難しかったのと、資材に変更があったときにDockerイメージを生成してpushするなどのオペレーションが面倒そうなので、しかたなくこの構成で妥協していました。

ここで時間がしばらく空きます。

唐突ですが、ウチのチーム主催で隔週開催している社内の雑談会というのがありまして、ある回でたまたま「Dockerビルド時に色々とキャッシュが効くようにできるよ」という話題で盛り上がりました。そのとき、「あれ、これでもしかしてイケるんじゃないの？」とひらめきました。

どういうことかというと、

- CIタスクの中で、テスト実行用のDockerイメージを動的にビルドして、それを使ってテストを実行させればいいのでは？
    - もともとGitLab Runner側のDockerコンテナ上で、ホストのDockerデーモンを共有できるようになっていた(socket binding)
- Dockerビルド時は、必要な資材に変更がなければイメージキャッシュをそのまま利用できるでしょ？
    - キャッシュをわざわざロード/退避するのではなく、単にそこにあるイメージキャッシュを使うだけ
    - 必要なツールや依存性に変更がなければ、Docker的なキャッシュが効いてDockerビルドのコストもほぼゼロになるはず

というアイデアです。

で、色々試した結果として、ざっくり抜粋＆簡易化していますが、.gitlab-ci.ymlとDockerfileはこんな感じになりました。

* gitlab-ci.yml
```yaml
...
app-test:
  stage: test
  image: docker:cli
  script:
    # 並行でタスクを実行したときにDockerイメージが混線しないようにランダムなバージョンを付与する。
    - IMAGE_NAME="myapp-gitlab-ci:$(echo $RANDOM $RANDOM $RANDOM $RANDOM $RANDOM | md5sum | awk '{print $1}')"
    - DOCKER_BUILDKIT=1 docker build -f Dockerfile.gitlab-ci -t $IMAGE_NAME . --progress plain
    - docker run --rm -v $PWD:/app $IMAGE_NAME
    - docker rmi $IMAGE_NAME
...
```

- Dockerfile.gitlab-ci
```Dockerfile
# syntax=docker/dockerfile:1.4

FROM golang:1.19

# 全てのファイルをコピーすると、Dockerイメージのキャッシュが利用されず、
# 毎回ビルドするたびに依存関係をインターネットから取得してしまうため、
# 最低限のファイルだけコピーして依存関係を取得しておくことで、
# Dockerイメージのキャッシュを利用できるようにする。
COPY --link Makefile go.mod /app/
WORKDIR /app
RUN --mount=type=cache,target=/go,sharing=private \
    make tools-required deps
    # ↑実際のgo installやgo mod downloadはこのmakeタスク中で実行している。

# キャッシュを使うことで、ビルドを実行するごとに同じ依存関係をダウンロードするのを避けつつ、
# コンテナを実行する時にキャッシュ上のファイルを利用できるように、キャッシュから取り出しておく。
RUN --mount=type=cache,target=/go,sharing=private \
    cp -r /go /go.out-of-cache
RUN rm -r /go && mv /go.out-of-cache /go

# ファイル一式はイメージに焼き付けずに、実行時にボリュームマウントするようにしたかったが、
# マウントしようとしてもコンテナ実行時に空になってしまうため、仕方なく焼き付けることにした。
# GitLab RunnerをDockerコンテナで起動しつつ、コンテナ内にホストのDockerを共有する環境のせいかも？
# 並行でタスクを実行したときにDockerイメージが混線しないように、
# .gitlab-ci.yml側でDockerイメージのバージョン部にランダム値等によるユニークな文字列を指定する必要がある。
COPY --link . .

# コンテナ実行時にテスト等を実行できるようにする。
COPY <<EOS /entrypoint.sh
#!/bin/bash -ex
cd /app
make clean lint test
EOS
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
```

この結果、CIでのテストタスクの実行時間は...

```
改善前: 00:10:36
改善後: 00:07:49
```

と約3分も短縮できました！やりましたね！

## 後半戦: CPUコア数を増やしてみる

ここまでくると、あとはCPUパワー勝負です。GitLab Runner環境として元々4コアだったのを、16コアまで増やしてみると....

```
コア数増加後: 00:02:27
```

なんと、2分半を切ってくるという驚きの結果を達成しました！

何度か実行していると、他のタスクがあったりなかったりでそれなりに変動はありますが、長くても5分台という感じで、10分オーバーがあたりまえだった時代を考えると、全然問題ないレベルです。

## おわりに

「それって、CPUだけ増やしておけば、キャッシュで頑張らなくても、最速5分台なのでは」と言われるとまったくその通りです。が、その場合、実際の性能の変動を考えると5〜7分くらいでまだまだ「遅いなー」と感じてしまうレベルに留まるので、なんというか費用対効果的にちょっと弱い感じがするというかなんというか。今回、仕組み的に削るのが難しいと思っていたキャッシュのところをがっつり削れた結果、トータルで2〜5分の性能が達成できて、毎回「速い！」と感動できるくらい速くなって作業も捗るようになり、CPU増加のコストも十分ペイしている気持ちになれるようになった、と話だと理解していただければ幸いです。

というわけで、明日は @TACK_TX による記事を引き続きお楽しみください。