はじめに
以前、CI 環境での Docker ビルドのキャッシュについて記事を書きました。
前回の記事のようにマルチステージビルドでない場合は、1 Dockerfile 1 Cache で問題ありませんでした。 しかし、今では Docker イメージの軽量化のためにマルチステージビルドは欠かせません。 ローカルでの実行では問題ありませんが、dockerd が毎度変わる CI 環境や Docker In Docker(dind)では問題が起こります。
最終イメージの中にビルドプロセスの全てが含まれていれば 1 Dockerfile 1 Cache で単純な--cache-from
を指定するだけで良いです。
マルチステージビルドは最終イメージの軽量化が目的であるため、最終イメージにキャッシュすべき情報はほとんど含まれておらずキャッシュしても無意味です。
キャッシュしたいステージを明示的に--target
で指定して、ビルドされたイメージを外部に保存する必要があります。
マルチステージビルドになるとキャッシュが難しくなります。
試す選択肢
1.普通のdocker build
🐭
キャッシュが効かない普通のdocker build .
です。
GitHub Actions は毎回 dockerd が変わりステートレスなのでこのままではレイヤーキャッシュは効きません。
2.普通のdocker build
+ BuildKit🐶
🐭 と同様ですが、BuildKit を効かせます。
環境変数にDOCKER_BUILDKIT=1
をセットしdocker build .
するだけです。
3.actions/cache + docker save & load
🦊
GitHub Actions では、actions/cache を使うことでキャッシュできます。
また、docker save
とdocker load
により、イメージを tar 形式のファイルにセーブしたりロードできます。
4.外部レジストリのキャッシュdocker build --cache-from
🐷
これは外部キャッシュです。
今回は Docker Hub ですが、AWS の ECR などへも同様にキャッシュできます。
これによりレイヤーキャッシュが効きます。
--build-arg BUILDKIT_INLINE_CACHE=1
のオプションも必須です。
ビルダーはレジストリからメタデータのみを取得し、キャッシュヒットの可能性があるレイヤーのみを pull します。
そのため、事前にdocker pull
をする必要はありません。
試してみた
以下の Dockerfile で今回は試してみました。 前半の builder ステージで Go のソースコードからバイナリを生成して、最終イメージにバイナリをコピーしています。
FROM golang:1.14-alpine as builder WORKDIR /go/src/github.com/kotaroooo0/snowforecast-twitter-bot/app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o main FROM alpine:latest COPY --from=builder /go/src/github.com/kotaroooo0/snowforecast-twitter-bot/app/main /main COPY .env / EXPOSE 3000 ENTRYPOINT ["/main"]
1. 普通のdocker build
🐭
.env
ファイルを作成しているのはアプリケーションに必要なためであり、本質的には関係ありません。
build_no_cache: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@master - name: Create .env file run: echo "${{ secrets.GITHUBACTION_ENV }}" > .env - name: Build run: docker build .
48 秒でした。 https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/886546983?check_suite_focus=true
2. 普通のdocker build
+ BuildKit 🐶
DOCKER_BUILDKIT: 1
とすると、BuildKit が有効になります。
build_no_cache_buildkit: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@master - name: Create .env file run: echo "${{ secrets.GITHUBACTION_ENV }}" > .env - name: Build env: DOCKER_BUILDKIT: 1 run: docker build .
31 秒でした。 https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/886567325?check_suite_focus=true
3.actions/cache + docker save & load
🦊
build_with_docker_save_load: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@master - name: Create .env file run: echo "${{ secrets.GITHUBACTION_ENV }}" > .env # actions/cacheを使うためにpathとkeyを指定 - id: docker-cache-step uses: actions/cache@v1 with: path: /tmp/saveload-cache key: docker-saveload-${{ hashFiles('Dockerfile') }} # キャッシュがあるならdocker loadでイメージを取得 - run: docker load -i /tmp/saveload-cache/go-builder.tar || true if: steps.docker-cache-step.outputs.cache-hit == 'true' # のちにdocker saveしたいため中間イメージのみをビルド # --cache-from=thing-cacheによりdocker loadで読み込んだものをキャッシュとして利用 - run: docker build . -t thing --target builder --cache-from=thing-cache # 最終イメージをビルド # 中間イメージをすでにビルドしているのでキャッシュは効く - run: docker build . --cache-from=thing-cache # 中間イメージをdocker saveで保存 - run: docker tag thing thing-cache && mkdir -p /tmp/saveload-cache && docker save thing-cache -o /tmp/saveload-cache/go-builder.tar && ls -lh /tmp/saveload-cache || true if: always() && steps.docker-cache-step.outputs.cache-hit != 'true'
キャッシュがヒットしない 1 回目は 1 分 8 秒でした。 加えて、キャッシュを保存するのにさらに 45 秒かかりました。 https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/909479280?check_suite_focus=true
キャッシュがフルでヒットする場合は 13 秒でした。 加えて、キャッシュの復元に 33 秒かかりました。 https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/909484937
キャッシュが一部ヒットするような変更(main.go
の修正など)をした場合にどれだけ時間が短縮できるか調べました。
ビルド自体は 39 秒でした。
加えて、キャッシュの復元に 35 秒かかりました。
https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/909488577?check_suite_focus=true
4.外部レジストリのキャッシュ docker build --cache-from
🐷
cache-from-with-build-arg: runs-on: ubuntu-18.04 steps: - uses: actions/checkout@master - name: Create .env file run: echo "${{ secrets.GITHUBACTION_ENV }}" > .env - name: login run: docker login -u $DOCKERHUB_USER -p $DOCKERHUB_PASS # 最終イメージはビルドプロセスのキャッシュを含んでいないため、中間イメージをビルドしてプッシュ - name: build builder image run: docker build --target builder --cache-from $IMAGE:builder --tag $IMAGE:builder --build-arg BUILDKIT_INLINE_CACHE=1 . - name: build image run: docker build --cache-from $IMAGE:builder --cache-from $IMAGE:latest --tag $IMAGE:latest --build-arg BUILDKIT_INLINE_CACHE=1 . # &とwaitで並列実行 - name: push image run: | docker push $IMAGE:builder & docker push $IMAGE:latest & wait
キャッシュがヒットしない 1 回目は 35 秒でした。 加えて、キャッシュを保存するのにさらに 6 秒かかりました。 https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/897778413
キャッシュがフルでヒットする場合は 24 秒でした。 加えて、キャッシュを保存するのにさらに 5 秒かかりました。 https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/897795622
キャッシュが一部ヒットするような変更(main.go
の修正など)をした場合にどれだけ時間が短縮できるか調べました。
ビルド自体は 36 秒でした。
加えて、キャッシュの保存に 9 秒かかりました。
https://github.com/kotaroooo0/snowforecast-twitter-bot/runs/897844111?check_suite_focus=true
まとめ
🐷 でキャッシュがフルヒットする場合が最速ですがこのようなことは稀であるので、キャッシュを無理に使わずに BuildKit でビルド 🐶 するのが安定して高速であり実用的です。
中間イメージのキャッシュを効かせる 🦊🐷 が時間がかかってしまったのは、中間イメージのサイズが大きくdocker save
や docker load
、docker pull
や docker push
に時間がかかってしまうためだと考えられます。
中間イメージは 200MB 程度、成果物である最終イメージは 10MB 程度です。
キャッシュを効かせる準備をするより、BuildKit でさっさとビルドする方が良いということです。
YAML もすっきりしていて良いです。
GCP の CloudBuild であれば、Google 製のコンテナ内で動くコンテナビルダーの kaniko が簡単に使えるそうで良さそうです。 kaniko なら、マルチステージビルドだろうとなんだろうとキャッシュのことを考えずにいい感じにビルドしてくれます。 GitHub Actions でも利用できるようようなので試してみたいです。
CI 上でのマルチステージビルドのキャッシュは難しいです。
過去に書いた Docker ビルド関係の記事
GitHub ActionsでのDockerビルドをキャッシュで高速化する - 🤖
Go製CLIツールを使うDockerイメージをダイエットしてみた - 🤖
DockerイメージのビルドをBuildKitで並列実行し高速化する - 🤖