Dockerに関するキャッシュたち
はじめに
Dockerを用いた開発では、適切にキャッシュを用いることで高速にビルド・開発できます。そのための知見は様々な記事で共有されており、ありがたい限りです。
しかし、「Dockerのキャッシュ」と言っても開発時とCI・CDでは行うことが違います。
この記事ではDockerを用いた開発における、各段階のキャッシュ機能を確認したいと思います。
主に「Dockerのキャッシュ」というと以下の4つに分類できると思いますので、それぞれについて解説していきます。
- Dockerのレイヤーキャッシュを活かす
a. COPY・ADDの順番
b. dockerignoreの設定
c. マルチステージビルド - buildkitによるキャッシュ
a. --mount=type=cache - CI・CDにおいてのキャッシュ
a. 前回のビルドキャッシュを持ち越して使う - リモートキャッシュ
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 /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以降が必要になります。
- デーモンで有効にする。
macやWindows
Docker for Mac/Windowsの設定画面で以下のようにbuildkitが有効になっていればOKです。
なければバージョンアップするか、自分で記載すると良いはずです(設定を吹き飛ばさないように注意)
Linuxなど
/etc/docker/daemon.json
でbuildkitを有効にして再起動しましょう。
{ "features": { "buildkit": true } }
- 実行時に
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 build
やgo 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 go mod download
COPY . .
-RUN go build -o app .
+RUN \
+ 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を使った便利なものがあるので使っていきます。
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
Goに限定した話でいうとこういうツールもあって楽しいです
とdownload側でキャッシュを使った場合、
とbuild側でもモジュールキャッシュのパスを指定しないと、build側では/go/pkg/modが空なので再度モジュールのダウンロードが走るように思われます。
他のサイトでも同様に/root/.cache/go-buildのキャッシュだけを指示する説明を見かけましたが、もしbuild側のRUNに/go/pkg/modのキャッシュ指示を書かなくても再ダウンロードを起こさないようにできる方法があるのであればご教示いただけますか。
ちょうどこの辺りを業務でこの辺りを扱おうとして悩んでいたところでした。
※記事の本質から若干ずれる質問ですみません
コメントありがとうございます。手元の環境で確認しましたところ、おっしゃる通りbuild時にも
go/pkg/mod
のキャッシュを指定しないと再ダウンロードが走っておりました。知識不足で申し訳ありません、記事も編集させていただきます。
ご回答ありがとうございます。
何か裏技とかがあるわけではなかったのですね😢