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

マルチステージビルドは、Docker 17.05 またはそれ以上の Docker デーモンおよび Docker クライアントにおける新機能です。 マルチステージビルドは、Dockerfile を読みやすく保守しやすくするように、最適化に取り組むユーザーにとって非常にありがたいものです。

感謝

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

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

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

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

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

Dockerfile.build:

FROM golang:1.7.3
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
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:

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 create --name extract alexellis2/href-counter:build
docker cp extract:/go/src/github.com/alexellis/href-counter/app ./app
docker 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:

FROM golang:1.7.3
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 は確実に動作するということです。

FROM golang:1.7.3 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"]

次のステップ