k8sを勉強しようと本を開いたらDockerイメージ軽量化の内容に遭遇したので、備忘録として。
マルチステージビルドとは
ざっくりまとめると、1つのDockerfileで複数のベースイメージを段階的に使用できる。
例えば、golangのアプリケーション用イメージを作成する場合、僕だったらこう書いてしまう…
# 通常(シングルステージビルド?)の書き方
FROM golang:1.14.1-alpine3.11
COPY ./main.go ./
RUN go build -o ./app ./main.go
ENTRYPOINT ["./app"]
にわかエンジニアなので、alpineイメージで軽量だぜ!(ドヤァ)みたいな。笑
# イメージビルド
$ docker build -t goapp1 .
# イメージサイズの確認
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
goapp1 latest e44201621bca 6 seconds ago 377MB
alpine 3.11 e389ae589224 4 months ago 5.62MB
golang 1.14.1-alpine3.11 760fdda71c8f 17 months ago 370MB
でも、ここから更に軽量化できるらしい。
処理毎にベースイメージを分割し、必要なものだけを最終イメージに残して軽量化する。
一方で、マルチステージビルドを活用すると、ビルド用イメージと実行用イメージを分けられる。
# マルチステージビルド
# 1つ目のベースイメージにbuilderという名前を付ける
FROM golang:1.14.1-alpine3.11 as builder
# ローカルファイルをbuilderイメージ内にコピー
COPY ./main.go ./
# ビルド先を指定
RUN go build -o /app ./main.go
# 計量イメージを2つ目のベースイメージとして指定
FROM alpine:3.11
# builderイメージからファイルをコピー
COPY --from=builder /app .
# 2つ目のイメージだけコマンド実行
ENTRYPOINT ["./app"]
処理内容としては、以下のように2つのイメージ間でbuildとrunの各処理が連携・分担される。
1. builder(golang:1.14.1-alpine3.11)イメージでmain.goをビルド
2. builderイメージでビルドしたappバイナリファイルをalpine:3.11イメージ内にコピー
3. alpine:3.11イメージでappを実行
したがって、最終的には2つ目のalpine:3.11イメージだけを使用するため、イメージが軽量化される。
# マルチステージビルドを使用したDockerfileからイメージビルド
$ docker build -t goapp2 .
# イメージサイズの確認
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
goapp2 latest 8fb96a4a5138 3 seconds ago 13.1MB
<none> <none> a8884f1d05c5 4 seconds ago 377MB
goapp1 latest e44201621bca 13 minutes ago 377MB
alpine 3.11 e389ae589224 4 months ago 5.62MB
golang 1.14.1-alpine3.11 760fdda71c8f 17 months ago 370MB
goapp1 (377MB) から、goapp2 (13.1MB) のように、イメージが軽量化されていることがわかる。
※1つ目のbuilderイメージが<none>として残っているが、不要であれば削除しても問題ない。
# IMAGE IDを指定して, <none>イメージ(builderイメージ)を削除
$ docker image rmi a8884f1d05c5
# イメージ一覧を表示
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
goapp2 latest 8fb96a4a5138 3 minutes ago 13.1MB
goapp1 latest e44201621bca 16 minutes ago 377MB
alpine 3.11 e389ae589224 4 months ago 5.62MB
golang 1.14.1-alpine3.11 760fdda71c8f 17 months ago 370MB
ステージ間COPYの注意点
マルチステージビルドを試していてハマった部分として、ステージ間のCOPY時に相対パスだとエラーになることがあった。
# [NG例!!!]
FROM golang:1.14.1-alpine3.11 as builder
COPY ./main.go ./
# "相対パス"でアウトプット先を指定
RUN go build -o ./app ./main.go
FROM alpine:3.11
# builderイメージから"相対パス"でファイルをコピー
COPY --from=builder ./app .
ENTRYPOINT ["./app"]
イメージのビルド結果
$ docker build -t ng-goapp .
Sending build context to Docker daemon 6.4MB
Step 1/6 : FROM golang:1.14.1-alpine3.11 as builder
---> 760fdda71c8f
Step 2/6 : COPY ./main.go ./
---> Using cache
---> 422b5182bade
Step 3/6 : RUN go build -o ./app ./main.go
---> Using cache
---> 524167b98ef9
Step 4/6 : FROM alpine:3.11
---> e389ae589224
Step 5/6 : COPY --from=builder ./app .
COPY failed: stat /var/lib/docker/overlay2/68c7926017fabca29e5aa3a12024d52b2e6e2f000dd08f046a8f698fc2079bf1/merged/app: no such file or directory
# ファイルが見つからず, エラーになる!
上記のエラーを解消したい。
各ベースイメージにおけるデフォルトのカレントディレクトリが同一とは限らない。
ステージ間のCOPY時に相対パスだとエラーになってしまう原因は、各ベースイメージにおけるデフォルトのカレントディレクトリが異なっているためだった。
FROM golang:1.14.1-alpine3.11
# カレントディレクトリを表示
ENTRYPOINT ["pwd"]
1つ目の(builder)イメージにおけるデフォルトのカレントディレクトリは、/go らしい。
$ docker image build -t test1 .
$ docker run test1
/go
同様の手順で、2つ目のapp実行用イメージにおけるデフォルトのカレントディレクトリを調べる。
FROM alpine:3.11
# カレントディレクトリを表示
ENTRYPOINT ["pwd"]
こちらは、/ らしい。
$ docker image build -t test2 .
$ docker run test2
/
よって、main.goが1つ目のイメージで /go/app にビルドされたのに対し、2つ目のイメージでは /app からコピーしようとしているため、エラーになっていたのだ。
[結論] ステージ間のCOPYには、絶対パスを使った方が無難。
総括として、ベースイメージ毎に相対パスの解釈はズレる可能性があるため、マルチステージビルドでCOPYする時には絶対パスを使ったほうが余計なバグは減らせる。
また、そもそもmain.goのビルド先も絶対パスで指定した方が安全と言える。
# マルチステージビルド
FROM golang:1.14.1-alpine3.11 as builder
COPY ./main.go ./
# "絶対パス"でビルド先を指定
RUN go build -o /app ./main.go
FROM alpine:3.11
# builderイメージから"絶対パス"でファイルをコピー
COPY --from=builder /app .
ENTRYPOINT ["./app"]
Dockerイメージはビルド時のデバッグが見えにくい分、Dockerfileで処理を明確化させるのが肝らしい。勉強になった。
参考文献
書籍
⻘⼭ 真也『Kubernetes完全ガイド 第2版』 インプレス社, 2020年
webサイト
Dockerドキュメント「Dockerfile を書くベスト・プラクティス - WORKDIR
Webエンジニアの雑記録「DockerfileのCOPYコマンドについて」(2019年12月20日)