🤖

🤖

:gijutsu_burogu:

GitHub Actionsを例にCI環境でのマルチステージビルドのキャッシュの活用について🐳

はじめに

以前、CI 環境での Docker ビルドのキャッシュについて記事を書きました。

kotaroooo0-dev.hatenablog.com

前回の記事のようにマルチステージビルドでない場合は、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 形式のファイルにセーブしたりロードできます。

docs.docker.jp

4.外部レジストリのキャッシュdocker build --cache-from🐷

これは外部キャッシュです。 今回は Docker Hub ですが、AWS の ECR などへも同様にキャッシュできます。 これによりレイヤーキャッシュが効きます。 --build-arg BUILDKIT_INLINE_CACHE=1のオプションも必須です。

ビルダーはレジストリからメタデータのみを取得し、キャッシュヒットの可能性があるレイヤーのみを pull します。 そのため、事前にdocker pullをする必要はありません。

docs.docker.com

試してみた

以下の 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

まとめ

f:id:kotaroooo0:20200726165950p:plain

🐷 でキャッシュがフルヒットする場合が最速ですがこのようなことは稀であるので、キャッシュを無理に使わずに BuildKit でビルド 🐶 するのが安定して高速であり実用的です。 中間イメージのキャッシュを効かせる 🦊🐷 が時間がかかってしまったのは、中間イメージのサイズが大きくdocker save や docker load、docker pull や docker push に時間がかかってしまうためだと考えられます。 中間イメージは 200MB 程度、成果物である最終イメージは 10MB 程度です。 キャッシュを効かせる準備をするより、BuildKit でさっさとビルドする方が良いということです。 YAML もすっきりしていて良いです。

GCP の CloudBuild であれば、Google 製のコンテナ内で動くコンテナビルダーの kaniko が簡単に使えるそうで良さそうです。 kaniko なら、マルチステージビルドだろうとなんだろうとキャッシュのことを考えずにいい感じにビルドしてくれます。 GitHub Actions でも利用できるようようなので試してみたいです。

github.com

CI 上でのマルチステージビルドのキャッシュは難しいです。

過去に書いた Docker ビルド関係の記事

GitHub ActionsでのDockerビルドをキャッシュで高速化する - 🤖

Go製CLIツールを使うDockerイメージをダイエットしてみた - 🤖

DockerイメージのビルドをBuildKitで並列実行し高速化する - 🤖

Dockerイメージのビルドで使うキャッシュの種類(レイヤーキャッシュ, BuildKit, CI) - 🤖

Dockerfileを正しく書けるように指摘してくれる静的解析ツール「hadolint」 - 🤖