マルチステージビルドの利用

読む時間の目安: 3 分

マルチステージビルドは Dockerfile を読みやすく保守しやすくするように、最適化に取り組むユーザーにとって非常にありがたいものです。

感謝

Alex Ellis 氏に感謝します。 氏のブログ投稿 Builder pattern vs. Multi-stage builds in Docker に基づいて、以下の利用例を掲載する許可を頂きました。

マルチステージビルド以前

イメージをビルドする際に取り組むことといえば、ほとんどがそのイメージサイズを小さく抑えることです。 Dockerfile 内の各命令は、イメージに対してレイヤーを追加します。 そこで次のレイヤー処理に入る前には、不要となった生成物はクリーンアップしておくことが必要です。 現実に効果的な Dockerfile を書くためには、いつもながらトリッキーなシェルのテクニックや、レイヤーができる限り小さくなるようなロジックを考えたりすることが必要でした。 つまり各レイヤーは、それ以前のレイヤーから受け継ぐべき生成物のみを持ち、他のものは一切持たないようにすることが必要であったわけです。

これまでのごくあたりまえの方法として、開発環境向けの Dockerfile を 1 つ用意し、そこにアプリケーションの構築に必要なものをすべて含めます。 そこから本番環境向けとしてスリム化したものをもう 1 つ用意して、アプリケーションそのものとそれを動かすために必要なもののみを含めるようにします。 これは「開発パターン」(builder pattern)と呼ばれてきました。 ただこの 2 つの Dockerfile を保守していくことは、目指すものではありません。

以下に示すのはDockerfile.buildDockerfileを用いる例であり、上述の開発パターンにこだわったやり方です。

Dockerfile.build:

# syntax=docker/dockerfile:1
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
COPY app.go ./
RUN go get -d -v golang.org/x/net/html \
  && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

上の例を見てわかるように、本来 2 つあるRUNコマンドを Bash の&&オペレーターによって連結しています。 これを行うことで、イメージ内に不要なレイヤーが生成されることを防いでいます。 ただこれでは間違いを起こしやすく、保守もやりづらくなります。 別のコマンドを挿入するのは簡単なことなので、\文字を使って行を分割するようなことは止めにして、以下のようにします。

Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY app ./
CMD ["./app"]

build.sh:

#!/bin/sh
echo Building alexellis2/href-counter:build

docker build --build-arg https_proxy=$https_proxy --build-arg http_proxy=$http_proxy \
    -t alexellis2/href-counter:build . -f Dockerfile.build

docker container create --name extract alexellis2/href-counter:build
docker container cp extract:/go/src/github.com/alexellis/href-counter/app ./app
docker container rm -f extract

echo Building alexellis2/href-counter:latest

docker build --no-cache -t alexellis2/href-counter:latest .
rm ./app

build.shスクリプトを実行すると、1 つめのイメージがビルドされます。 そこからコンテナーを生成してイメージ内容をコピーし、2 つめのイメージがビルドされます。 2 つのイメージは、それなりの容量をとるものであり、ローカルディスク上にappの生成物も残ったままです。

マルチステージビルドは、広範囲にわたってこのような状況を簡略化します。

マルチステージビルドの利用

マルチステージビルドを行うには、Dockerfile 内にFROM行を複数記述します。 各FROM命令のベースイメージは、それぞれに異なるものとなり、各命令から新しいビルドステージが開始されます。 イメージ内に生成された内容を選び出して、一方から他方にコピーすることができます。 そして最終イメージに含めたくない内容は、放っておくことができます。 こういったことがどのようにして動作するのかを見るために、前節で示した Dockerfile をマルチステージビルドを使ったものに変更してみます。

Dockerfile:

# syntax=docker/dockerfile:1
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]

Dockerfile はただ 1 つ用意するだけです。 またビルドスクリプトも個別に用意するわけではありません。 単にdocker buildを実行するだけです。

$ docker build -t alexellis2/href-counter:latest .

最終結果として、以前と変わらずに本番環境向けの小さなイメージができあがりました。 しかも複雑さが一切なくなっています。 中間的なイメージを作る必要などありません。 さらに生成した内容をローカルシステムに抽出することも一切不要です。

どうやってこれが動いているのでしょう? 2 つめのFROM命令は、alpine:latestをベースイメージとして新たなビルドステージを開始しています。 そしてCOPY --from=0という行では、直前のステージで作り出された生成内容を、単純に新たなステージにコピーしています。 Go 言語の SDK やその他の中間生成物は取り残されていて、最終的なイメージには保存されていません。

ビルドステージの命名

デフォルトではステージに名前はつきません。 そこでステージを参照するには、ステージを表わす整数値を用います。 この整数値は、最初のFROM命令を 0 として順次割り振られるものです。 ただしFROM命令にAS <NAME>の構文を加えれば、ステージに名前をつけることができます。 以下の例はこれまでのものをさらに充実させて、ステージに名前をつけ、COPY命令においてその名前を利用します。 これはつまり、Dockerfile 内の命令の記述順が、後々変更になったとしても、COPYは確実に動作するということです。

# syntax=docker/dockerfile:1
FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go    ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]

ビルドステージの指定

イメージをビルドする際に、Dockerfile 内に含まれるビルドステージをすべてビルドしなければならない、というわけではありません。 ビルド対象とするステージは指定することができます。 以下のコマンドは、前述のDockerfileを利用しつつ、builderと名付けたステージのみビルドするものです。

$ docker build --target builder -t alexellis2/href-counter:latest .

この機能が役立つ例として以下があります。

  • 特定のビルドステージをデバッグすることができます。
  • debugステージでは、デバッグシンボルやデバッグツールを最大限利用し、productionステージはスリムなものにすることができます。
  • testingステージでは、アプリに用いるテストデータを投入し、本番環境向けの別のステージビルドでは、本物のデータを利用できます。

外部イメージの「ステージ」としての利用

マルチステージビルドの利用にあたって、ステージのコピーは Dockerfile 内での直前のステージだけに限定されるものではありません。 COPY --from命令では別のイメージからコピーすることができます。 その際にはローカルや Docker レジストリ上のイメージ名、タグ名、あるいはタグ ID を指定します。 Docker クライアントは必要なときにはイメージを取得します。 そしてそこから構築内容をコピーします。 コマンド構文は以下のようになります。

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

前のステージを新たなステージとして利用

前にビルドしたステージを参照して用いることができます。 それにはFROMディレクティブを用いて、たとえば以下のようにします。

# syntax=docker/dockerfile:1
FROM alpine:latest AS builder
RUN apk --no-cache add build-base

FROM builder AS build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp

FROM builder AS build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp

バージョン互換性

マルチステージビルドの文法は Docker Engine 17.05 から導入されました。

images, containers, best practices, multi-stage, multistage