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

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

もうすぐリリースされるDocker 17.05でmulti stage buildという機能が導入される予定です。
こいつはこれまでのDockerfileの常識を覆す革新的な機能なのです。
Docker 17.05は本稿執筆時点では2017/05/03リリース予定となっており、現在はRC版が出てるので、気になる新機能を一足早くで試してみた。(2017/05/07追記:最終的に2017/05/04に正式リリースされました)

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

```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が２回書いてある！！！！！

一瞬多重継承なのかな？と思ったけど、どうやらそういうことではないらしい。
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](https://github.com/docker/docker/pull/31257)
[docker/docker#32063 Add support for named build stages](https://github.com/docker/docker/pull/32063)

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

とりあえず適当な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を作成する。

```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"]
```

これは冒頭で挙げた例と同じものだけど、改めて中身を説明しておくと、
最初のステージはgolangのビルド用に `golang:alpine` を使用する。
`FROM` のうしろに付けた `AS` キーワードで ビルドステージに `build-env` という名前をつけておき、
`go build` でgolangのプログラムをコンパイルして `hello` という名前で実行バイナリを保存している。

２つめのステージは実行用に `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!!!