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

  • 13
    いいね
  • 0
    コメント

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

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

とりあえずこの新しいシンタックスの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を複数のビルドに分割して実行できる。
こうすると何がうれしいのかというと、アプリケーションの開発用ビルドの依存とランタイムの依存を分離できる。

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

とりあえず適当な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!!!