🐳

Dockerに関するキャッシュたち

2022/02/19に公開
4

はじめに

Dockerを用いた開発では、適切にキャッシュを用いることで高速にビルド・開発できます。そのための知見は様々な記事で共有されており、ありがたい限りです。
しかし、「Dockerのキャッシュ」と言っても開発時とCI・CDでは行うことが違います。
この記事ではDockerを用いた開発における、各段階のキャッシュ機能を確認したいと思います。

主に「Dockerのキャッシュ」というと以下の4つに分類できると思いますので、それぞれについて解説していきます。

  1. Dockerのレイヤーキャッシュを活かす
    a. COPY・ADDの順番
    b. dockerignoreの設定
    c. マルチステージビルド
  2. buildkitによるキャッシュ
    a. --mount=type=cache
  3. CI・CDにおいてのキャッシュ
    a. 前回のビルドキャッシュを持ち越して使う
  4. リモートキャッシュ
    a. 開発者が初めてビルドする場合もキャッシュを使う

また、説明のために用いるGoプロジェクトのリポジトリはこちらです。
このリポジトリには今回説明するキャッシュが実装済みですので、よければ参考にしてください。

ベース

今回は以下のDockerfileを用いて作成できるイメージを改善していきます。

FROM golang:1.17.6

ENV CGO_ENABLED=0

WORKDIR /workdir

COPY . .
RUN go build -o app .

CMD ["/workdir/app"]

Dockerのレイヤーキャッシュを活かす

DockerはDockerfileに書かれた内容を解釈してイメージを作ってくれるわけですが、1つ1つの命令をレイヤー(層)とし、積み重ねてイメージを作成します。
レイヤーに変更がなければDockerは以前の結果を再利用してくれます(レイヤーキャッシュ)。逆に変更があれば、それ以降のレイヤーは再度作成されます。
ドキュメントによると、「COPY」と「ADD」はファイルの内容やメタデータ(最終アクセス時刻や更新時刻は含まない)から作られたハッシュを元に、変更がないかチェックされます。
「RUN」は単に実行するコマンド文字列(実行により変更されたファイルは見ない)が変化していないかチェックされます。

結局のところ、レイヤーを再利用することが重要なのでそのための工夫を見ていきましょう。

COPY・ADDの順番

COPY・ADDするファイルに変更があると以降のレイヤーキャッシュが効かなくなるので、順番が大事になってきます。変更の少ないファイルを先にCOPYしましょう。主にgo.modやyarn.lockなどの依存ライブラリを管理するファイルが該当するはずです。

FROM golang:1.17.6

ENV CGO_ENABLED=0

WORKDIR /workdir

+COPY go.mod go.sum .
+RUN go mod download

COPY . .
RUN go build -o app .

CMD ["/workdir/app"]

このように書くと、ソースコードを書き換えても新たにライブラリを追加しなければレイヤーキャッシュが使われます。

.dockerignoreの指定

Dockerがビルド時に必要とするファイル(DockerfileやCOPY/ADDするファイルなど)の情報をビルドコンテキスト呼びます。
docker build -t test-image .と実行した場合は.で指定されている、カレントディレクトリ以下の内容がDockerへ送られます。
不必要なファイルが含まれていると、ビルド時間の増加や無駄な再ビルドを引き起こします。
そのため、「プログラムのビルドに関係のないファイル(README.mdなど)」は含まないようにしましょう。
また、Dockerイメージに秘匿情報を含むと流出の恐れがあるので「秘匿情報(アクセスキーやパスワードなど)」も含まないようにしましょう。ビルド時に使用したい場合は後述するbuildkitを有効にした上で--mount=type=secretsを用いる方が良いでしょう。参考
.dockerignoreというファイルをビルド時のディレクトリに配置することでビルドコンテキストとして送らないファイルを指定できます(.gitignoreのDocker版みたいなやつですね)

# ignore git files
.git/
.gitignore
.github/

# ignore docker-compose files
docker-compose.*.yml

# ignore environment files
.env*

# ignore document files
.docs/
*.md

# ignore editor settings
.idea/
.vscode/

# ignore log files
*.log

詳しい記法はドキュメントを参照してください。
正しく記載できていればCOPY . .とDockerfileに記載しても、プログラムのビルドに関するファイルが変更されていないとキャッシュが使われるはずです。

マルチステージビルド

具体例を見た方がわかりやすいと思うため、先に示します。

+FROM golang:1.17.6 AS build

ENV CGO_ENABLED=0

WORKDIR /workdir

COPY go.mod go.sum .
RUN go mod download

COPY . .
RUN go build -o app .

+FROM gcr.io/distroless/static-debian11:latest

+WORKDIR /workdir

+COPY --from=build /workdir/app /workdir/
CMD ["/workdir/app"]

1つのDockerfileに2つのFROMがありますね。
1つ目のgolang:1.17.6を使用している方はビルド用になり、ビルド結果のみをgcr.io/distroless/static-debian11:latestを使用している方にコピーしています。こうすることで、最終的なイメージにはビルドされたプログラム以外含まれなくなり。軽量なイメージを作ることができます。

+go-docker-template          multi-stage     773107382a63   5 seconds ago       12MB
-go-docker-template          single-stage    7ef2476f0502   33 seconds ago      984MB

また、不必要なファイルを含まないためセキュリティ的にも良いです。

マルチステージビルドはキャッシュの面から見ても有効です。
以前は最終イメージを軽量化するため、以下のように無理やりRUNをつなげていました。

RUN apt update && apt install hoge fuga ... && echo hogefuga ... && wget something..

こうすると1つのレイヤーになるため、最終イメージは軽くなりますが少し変更するだけで全て実行し直す必要があります。
マルチステージビルドを用いると、最終イメージのサイズさえ気にすれば良いので、ビルド用の中間イメージでは以下のようにRUNを分けても問題ありません。

RUN apt update && apt install hoge fuga
RUN echo hogefuga
RUN wget something...
...

変更があってもレイヤーキャッシュを効果的に使用できますね。

buildkitによるキャッシュ

buildkitというのは、Docker buildを更に強くするツールキットです。高機能なキャッシュや並列ビルドなどを含んでいます。Docker 18.09から取り込まれているため、基本的な機能は使用できますが、実験的な機能は使用するか・できるかよく検討してください。

buildkitを有効にする

Docker version:18.09以降が必要になります。

  1. デーモンで有効にする。

macやWindows

Docker for Mac/Windowsの設定画面で以下のようにbuildkitが有効になっていればOKです。

なければバージョンアップするか、自分で記載すると良いはずです(設定を吹き飛ばさないように注意)

Linuxなど

/etc/docker/daemon.jsonでbuildkitを有効にして再起動しましょう。

{ "features": { "buildkit": true } }
  1. 実行時にDOCKER_BUILDKIT=1を環境変数として渡す。
DOCKER_BUILDKIT=1 docker build .

buildx

buildxというのは、buildkitに対応したDocker CLIの拡張機能です(ややこしい...)。
buildxは普通のDocker buildの機能を有しているのでdocker buildx installを実行し、デフォルトでbuildxが使われるようにしておくのが良いと思います。
以降はbuildxがデフォルトになっている前提で説明します。

キャッシュ機能

ここでは、buildkitを有効にすることで使えるようになる、Dockerfileの拡張記法によるキャッシュを説明します。

RUN --mount=type=cache という記法を用いることで、コンパイラやパッケージマネージャのキャッシュを用いることができます。それぞれのコンパイラやパッケージマネージャがどこにキャッシュを作成するかはドキュメントやコマンドで確認できると思います。
Goであれば go env | grep CACHEでキャッシュに関係する環境変数を確認できます。
キャッシュを用いることでgo buildgo mod download以前のレイヤーに変更があり、再度実行される際も以前のキャッシュを使った実行になるはずです。

+# syntax = docker/dockerfile:1.3
FROM golang:1.17.6 AS build

ENV CGO_ENABLED=0

WORKDIR /workdir

COPY go.mod go.sum .
-RUN go mod download
+RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .
-RUN go build -o app .
+RUN --mount=type=cache,target=/go/pkg/mod \
+    --mount=type=cache,target=/root/.cache/go-build \
+    go build .

FROM gcr.io/distroless/static-debian11:latest

WORKDIR /workdir

COPY --from=build /workdir/blog-server /workdir/
CMD ["/workdir/app"]

実際にGoのソースコードを少し書き変えると、ビルドは実行されても速くなっているのがわかります。

-[build 6/6] RUN go build -o app . 3.7s
+[build 6/6] RUN --mount=type=cache,target=/root/.cache/go-build go build -o app .  1.7s

その他のオプションや記法については ドキュメントで確認できます。

CI・CDにおいてのキャッシュとリモートキャッシュ

GitHub ActionsなどのCI・CD環境では実行するたびにホストが変わる(はず)です。そのため、何も設定しなければ今まで説明してきたキャッシュが次回の実行に持ち越されません。キャッシュをどこかへexportし、次に実行する際にimportする必要があります。

CI・CDでキャッシュを効かせる場合も、新たな場所でのビルド(新たにcloneした場合など)の場合も使う機能は同じで、buildkitのcacheを用います。

buildkitが提供するキャッシュにはいくつかの種類があります。 参考

名前 説明
inline イメージとキャッシュを1つにしてレジストリへpushします
registry イメージとキャッシュを別々にしてレジストリへpushします
local キャッシュをローカルに保存します
gha GitHub Actionsのキャッシュへ保存します

また、キャッシュには2つのモードがあります。inlineではminモードのキャッシュしか使えません。
マルチステージビルドをしている場合はmaxを使いたいですね。

名前 説明
min 最終イメージのキャッシュだけexportする
max 中間イメージも含んで全てのキャッシュをexportする

GitHub Axctionsしか使わないのであればghaを用いるのが簡単で良いと思います。
しかし、2022年2月現在

docker driver currently only supports importing build cache from the registry.

と書いてあるので、新たにcloneしたときやブランチを切り替えたときにもキャッシュを効かせたければregistryを使うのが良いと思われます。

mainブランチにマージされたらイメージとキャッシュをdockerhubにpushするjobを作ります。
GitHub Actionsにはbuild-push-actionというbuildkitを使った便利なものがあるので使っていきます。
https://github.com/docker/build-push-action

name: build
on:
  push:
    branches:
      - main

jobs:
  build:
    name: build app with cache
    runs-on: ubuntu-latest
    steps:
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1
      - name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build
        uses: docker/build-push-action@v2
        with:
          push: true
          tags: user/app:latest
          cache-from: type=registry,ref=user/app:buildcache
          cache-to: type=registry,ref=user/app:buildcache,mode=max

これでCI・CDを回すときにもキャッシュを使いつつ、キャッシュのないローカルでもキャッシュを使ってビルドできるはずです。
ローカルでリモートのキャッシュを使うには--cache-fromを使います。

docker build --cache-from=user/app:buildcache -t user/app .

以下のコマンドでDockerのビルドキャッシュを削除しても、リモートから取得したキャッシュが使われていると思います。

docker builder prune

さいごに

自分自身Dockerのキャッシュ周りを整理できていなかったので、良い勉強になりました。更に良い方法などがあればぜひ教えてください。優しいまさかり待ってます

参考文献

Discussion

kitaminkitamin
COPY go.mod go.sum .
RUN --mount=type=cache,target=/go/pkg/mod go mod download

とdownload側でキャッシュを使った場合、

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o app .

とbuild側でもモジュールキャッシュのパスを指定しないと、build側では/go/pkg/modが空なので再度モジュールのダウンロードが走るように思われます。

他のサイトでも同様に/root/.cache/go-buildのキャッシュだけを指示する説明を見かけましたが、もしbuild側のRUNに/go/pkg/modのキャッシュ指示を書かなくても再ダウンロードを起こさないようにできる方法があるのであればご教示いただけますか。
ちょうどこの辺りを業務でこの辺りを扱おうとして悩んでいたところでした。

※記事の本質から若干ずれる質問ですみません

masibwmasibw

コメントありがとうございます。手元の環境で確認しましたところ、おっしゃる通りbuild時にもgo/pkg/modのキャッシュを指定しないと再ダウンロードが走っておりました。

知識不足で申し訳ありません、記事も編集させていただきます。

kitaminkitamin

ご回答ありがとうございます。
何か裏技とかがあるわけではなかったのですね😢