Docker multi stage buildで変わるDockerfileの常識

More than 1 year has passed since last update.

Dockerイメージのサイズを1バイトでも削りたい皆さんに朗報です。

もうすぐリリースされるDocker 17.05でmulti stage buildという機能が導入される予定です。

こいつはこれまでのDockerfileの常識を覆す革新的な機能なのです。

Docker 17.05は本稿執筆時点では2017/05/03リリース予定となっており、現在はRC版が出てるので、気になる新機能を一足早くで試してみた。(2017/05/07追記:最終的に2017/05/04に正式リリースされました)

とりあえずこの新しいシンタックスのDockerfileを見てほしい。

FROM golang:alpine AS build-env

ADD . /work
WORKDIR /work
RUN go build -o hello main.go

FROM busybox
COPY --from=build-env /work/hello /usr/local/bin/hello
ENTRYPOINT ["/usr/local/bin/hello"]

何か違和感に気づいたかな?

そう、FROMが2回書いてある!!!!!

一瞬多重継承なのかな?と思ったけど、どうやらそういうことではないらしい。

multi stage buildの名前の通り、docker buildを複数のビルドに分割して実行できる。

こうすると何がうれしいのかというと、アプリケーションの開発用ビルドの依存とランタイムの依存を分離できる。


2017/04/19追記: コメントで指摘がありましたが、FROMを2回書くこと自体は既にできたようです(知らんかった)。厳密に言うとシンタックスとして新しいのは、複数のFROMイメージ間のファイルを COPY --from で直接を参照できるようになったのと AS で中間イメージに名前が付けられるようになったのが、今回の17.05リリースで追加された新しい機能だったようです。そしてこの合わせ技がうれしい意味を持ちます。

docker/docker#31257 build: add multi-stage build support

docker/docker#32063 Add support for named build stages


どういうことだってばよ?ってかんじなので、ちょっと具体例を挙げて試してみよう。

とりあえず適当なUbuntu16.04にDocker 17.05 RC1を入れてみた。

$ curl -fsSL https://test.docker.com/ | sh

$ docker version

Client:
Version: 17.05.0-ce-rc1
API version: 1.29
Go version: go1.7.5
Git commit: 2878a85
Built: Tue Apr 11 19:57:43 2017
OS/Arch: linux/amd64

Server:
Version: 17.05.0-ce-rc1
API version: 1.29 (minimum version 1.12)
Go version: go1.7.5
Git commit: 2878a85
Built: Tue Apr 11 19:57:43 2017
OS/Arch: linux/amd64
Experimental: false

サンプルとして適当なgolangのHello Worldを用意して、 main.go という名前で保存する。


main.go

package main

import "fmt"

func main() {
fmt.Println("Hello World!")
}


本題のDockefileを作成する。

FROM golang:alpine AS build-env

ADD . /work
WORKDIR /work
RUN go build -o hello main.go

FROM busybox
COPY --from=build-env /work/hello /usr/local/bin/hello
ENTRYPOINT ["/usr/local/bin/hello"]

これは冒頭で挙げた例と同じものだけど、改めて中身を説明しておくと、

最初のステージはgolangのビルド用に golang:alpine を使用する。

FROM のうしろに付けた AS キーワードで ビルドステージに build-env という名前をつけておき、

go build でgolangのプログラムをコンパイルして hello という名前で実行バイナリを保存している。

2つめのステージは実行用に busybox のイメージを使用する。

2つめのステージでは COPY --from=build-env で1つめのビルドステージのイメージを参照し、実行に必要な hello のバイナリだけをピンポイントでコピーしている。

ビルドしてみる。

$ docker build -t hello ./

Sending build context to Docker daemon 3.072kB
Step 1/7 : FROM golang:alpine AS build-env
---> c82f63bb2928
Step 2/7 : ADD . /work
---> 99dcd57710e1
Removing intermediate container c40285f497cf
Step 3/7 : WORKDIR /work
---> c467f9695c0c
Removing intermediate container e1cea9f53c34
Step 4/7 : RUN go build -o hello main.go
---> Running in ff0bd72ff9b7
---> db4ea63c9357
Removing intermediate container ff0bd72ff9b7
Step 5/7 : FROM busybox
---> 00f017a8c2a6
Step 6/7 : COPY --from=build-env /work/hello /usr/local/bin/hello
---> a058caaca167
Removing intermediate container 8781ac92ecdf
Step 7/7 : ENTRYPOINT /usr/local/bin/hello
---> Running in 8ceb1d1bd7c7
---> a2dd16706afc
Removing intermediate container 8ceb1d1bd7c7
Successfully built a2dd16706afc
Successfully tagged hello:latest

実行してみるとちゃんと実行できている。よさげ。

$ docker run -it --rm hello

Hello World!

イメージサイズを比べてみる。

$ docker images

REPOSITORY TAG IMAGE ID CREATED SIZE
<none> <none> db4ea63c9357 15 seconds ago 258MB
hello latest a2dd16706afc 15 seconds ago 2.66MB
<none> <none> f1639e328e73 5 minutes ago 258MB
golang alpine c82f63bb2928 10 days ago 257MB
busybox latest 00f017a8c2a6 5 weeks ago 1.11MB
alpine latest 4a415e366388 6 weeks ago 3.99MB

ビルドに使ってるベースイメージのgolang:alpineは257MBあるが、実行のベースイメージのbusyboxは1.11MBしかなく、コンパイル済みのバイナリをコピーしたhelloイメージも2.66MBしかない。圧倒的に小さい。

アプリケーションの開発用ビルドの依存とランタイムの依存を分離できるというのはこういう意味なのだ。

厳密に言えば、同じようなことは手動で複数のDockerfileを組み合わせれば今までもできたといえばできたんだけど、1ファイルで書けるようになったので気軽に中間イメージを色々使えるようになったのがうれしい。

中間イメージの部分は、イメージサイズをあんまり気にする必要がなくなるので、 apt-get したキャッシュのゴミ掃除をしたりとか、RUNコマンドを1レイヤに閉じ込めるために && でつなげまくるみたいな涙ぐましい努力は毎回する必要はなくなって、最終的な実行イメージのところだけ注意すればよい。

ナニコレ最高じゃないか。

Happy docker building!!!