Dockerのマルチステージビルドで、ビルド環境と実行環境のセットアップを１つのDockerfileで完結させよう

[Code Chrysalis Advent Calendar 2019](https://qiita.com/advent-calendar/2019/codechrysalis)、24日目の投稿です。

こんにちは。現在、CodeChrysalisのイマーシブブートキャンプ（Cohort 10）に参加中のなおとです。

ブートキャンプも残すところ１週間を切りました。今は12/26(木)に行われるDemo Dayという卒業発表会に向けてチーム開発を日々頑張っています。お時間ある方は是非Demo Dayにご参加ください！
Demo Day の[詳細](https://www.meetup.com/ja-JP/CodeChrysalis/events/266069585/)はこちらになります。

Code Chrysalisのブートキャンプには、**多言語週間（Polyglottal Week）**と呼ばれる、今まで使ったことのないプログラミング言語を１つ選択し、１週間で言語の習得からアプリケーション開発までを行うという機会があります。

私はこの多言語週間で、今まで扱ったことのなかった**Golang**を選択し、Golangを使ったフルスタックアプリケーションを作成しました。また、Dockerに興味があったので、Docker上でアプリをビルド・実行する方法も合わせて学びました。今回は、私が学んだ内容の一部についてご紹介したいと思います。

## はじめに

本稿では、Dockerの基本的な知識があることを前提として、Dockerの重要な機能の１つである**マルチステージビルド**について解説していきます。

また、以下のレポジトリにサンプルコードを用意しました。今回説明する内容は、すべて以下のレポジトリをもとに検証を行っているので、ご自身の環境で試したい場合は合わせてご覧ください。
https://github.com/Imamachi-n/docker-multi-stage-build-101

## Docker上でアプリケーションをビルド・実行してみよう

まず始めに、作成したアプリケーションをDockerコンテナ上でビルド・実行したい場合、どのような方法を取ればいいのでしょうか？まずは簡単な例として、Golangで作成したアプリケーション（REST APIサーバ）を以下のDockerfileを用いて、ビルド・実行する場合を考えてみましょう。

以下に、サンプルのDockerfileを示しました。Dockerのベースイメージとして、公式の`golang`イメージを使っています。
ファイルの内容を簡単に説明すると、
1. `ENV`でGolangのビルド条件（OS、CPUアーキテクチャ等）を環境変数として設定。
2. `WORKDIR`で作業ディレクトリを指定。
3. `COPY`で、ローカル環境にあるGolangプロジェクトをDockerコンテナ内にコピー。
4. `RUN`でGolangのアプリケーションをビルドするコマンドを実行。
5. `EXPOSE`で9000番のポートを開け、このポートを通してDockerコンテナと通信ができるように設定。
6. 最後に、`ENTRYPOINT`でDockerコンテナ起動時に、Golangのアプリケーションが起動するように指定。
という内容が記述されています。

```dockerfile
FROM golang:1.13.4

ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
WORKDIR /docker-multi-stage-build-101
COPY . .
RUN go build -o "bin/goServer"
EXPOSE 9000
ENTRYPOINT ["/docker-multi-stage-build-101/bin/goServer"]
```

Dockerfileから、Dockerイメージをビルドし実行すると、Golangで作成したREST APIサーバが立ち上がります。

```bash
$ docker build . -f docker/01_raw/Dockerfile -t go-server-raw:dev
$ docker run --rm -p 9000:9000 go-server-raw:dev
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /api/user/:name/*action   --> docker-multi-stage-build-101/route.GetAction (4 handlers)
[GIN-debug] GET    /api/welcome              --> docker-multi-stage-build-101/route.GetWelcome (4 handlers)
[GIN-debug] Listening and serving HTTP on :9000
2019/12/22 02:31:38 Defaulting to port :9000
```

続いて、以下の通りに、`curl`コマンドを使ってDockerコンテナで立ち上げたREST APIサーバにアクセスしてみましょう。`Hello Code Chrysalis`と表示されれば成功です。これで、Dockerコンテナ上でアプリケーションが起動していることが確認できました。

```bash
$ curl -X GET 'http://localhost:9000/api/welcome?firstname=Code&lastname=Chrysalis' 
Hello Code Chrysalis
```

次に、`docker images`コマンドを使って、Dockerイメージのサイズを見てみましょう。なんと、**915MB**というかなり大きなサイズになっていることがわかります。

```bash
$ docker images
REPOSITORY        TAG    IMAGE ID            CREATED             SIZE
go-server-raw     dev    eb8f35015c94        11 seconds ago      915MB
```


これは、アプリケーションの実行に不要なもの（ビルド時に使用したライブラリ等）が、そのまま同じDockerイメージ内にゴミとして残ってしまっているためです。AWSやGCPなどの環境にデプロイすることを考えると、できればDockerイメージのサイズを小さくしたいですね。

この問題を解決するために、マルチステージビルドが登場する以前は、**Builderパターン**と呼ばれるコンテナ管理方法が利用されていました。

## マルチステージビルド以前： Builderパターン
Builderパターンでは、ビルド用とデプロイ用の２つのDockerfileを用意します。ビルド用のDockerコンテナでアプリケーションをビルドし、アプリケーションの実行に必要なものだけを実行用のコンテナにコピーします。

結果として、Builderパターンでアプリケーションが動作するDockerコンテナを用意した場合、以下のものが必要となります。
- ２つのDockerfile（ビルド用と実行用）
- （ビルド環境から実行環境への）ビルド成果物の受け渡し用のシェルスクリプト
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200291/5890a852-aabe-b9d4-c8c9-8c94f3175a51.png" width=50%>
これにより、上述したDockerイメージの肥大化を防ぐことができます。ただ、ちょっと考えてみてください。これってめんどくさくないですか？あと、複数のファイルに設定情報が散らばってしまっています。作成するDockerイメージが増えていった場合、ソースコードの管理が複雑化していく気がします…。

## Dockerマルチステージビルド
そこで、満を持してマルチステージビルドの登場です。この機能は、Docker 17.05から追加された機能になります。端的に言うと、Builderパターンで実践していた内容を１つのDockerfileにまとめることができます。

### ビルドステージ（中間コンテナイメージ）
普段ベースイメージを指定するために、Dockerfile内に`FROM`命令を記述していると思います。マルチステージビルドでは、以下のように、１つのDockerfile内に`FROM`命令を複数記述します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200291/02684c6b-a22a-9d0d-8edd-09b2c11789d3.png" width=60%>
前半の`FROM`以下のブロックのことをビルドステージ（中間Dockerイメージ）と呼んでいます。この中間Dockerイメージは、実行用Dockerイメージにビルド成果物を渡した後、削除されます。そのため、最終的に生成される実行用のDockerイメージに含まれることはありません。

### ビルドステージの命名
続いて、`AS`を使うことで、ビルドステージに対して名前を付けることができます。下図の例は、ビルドステージの中間Dockerイメージに対して`builder`という名前を指定しています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200291/c6533997-0c60-2cf3-ca9e-9fd72b4b6865.png" width=60%>

ちなみに名前を指定しなかった場合、`FROM`命令の順番に合わせて`0, 1, 2,...`という連番名が自動で振られます。例えば、上図の場合だと、ビルドステージは`0`、実行用のDockerイメージは`1`という連番名が振られます。

### ビルド成果物をビルド環境から実行環境のDockerイメージへコピー

ビルドステージで作成したビルド成果物を、後半の`FROM`以下のブロック（アプリケーション実行用のDockerイメージ）にコピーすることができます。方法としては、`COPY`の`--from`オプションでビルドステージ名を指定し、ビルド成果物をビルドステージから実行用のDockerイメージにコピーします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200291/f0b61a7b-5cfb-03de-a99f-4c840a839c60.png" width=60%>
このように、マルチステージビルドを使うことで、ビルド用と実行用のDockerfileを分けることなく、１つのDockerfileで記述することができるようになります。実はそれ以外にもメリットがあります。

ビルド用と実行用にDockerベースイメージをそれぞれ指定することができるので、例えば、`alpine`などの軽量コンテナイメージを実行用のDockerベースイメージとして選択することができます。こうすることで、作成されたDockerイメージ内に、アプリケーションの実行に必要なものだけを配置することができ、コンテナのサイズをよりスリム化させることができます。

### マルチステージビルドの具体例
それでは、具体的な例として、最初にお見せしたGolangのアプリケーション（REST APIサーバ）をマルチステージビルドを使ったDockerfileに書き換えてみたいと思います。

以下がDockerfileの内容になります。先程とほとんど変わりませんが、`COPY --from=builder`でビルドステージ内のビルド成果物（`/docker-multi-stage-build-101/bin/goServer`）を、実行用のDockerイメージに`/goServer`としてコピーしています。

```Dockerfile
# Builder image (intermediate container)
FROM golang:1.13.4 as builder

ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
WORKDIR /docker-multi-stage-build-101
COPY . .
RUN go build -o "bin/goServer"

# Runtime image
FROM alpine
COPY --from=builder /docker-multi-stage-build-101/bin/goServer /goServer
EXPOSE 9000
ENTRYPOINT ["/goServer"]
```

それでは、Dockerfileをビルドして、Dockerイメージのサイズがどれだけスリム化された確認してみましょう。

```bash
$ docker build . -f docker/02_multi-stage-build/Dockerfile -t go-server-multi:dev
$ docker images
REPOSITORY        TAG    IMAGE ID            CREATED             SIZE
go-server-multi   dev    2d5f5819aa81        4 hours ago         21.4MB
```

**21.4MB**とかなり小さなDockerイメージとなっていることがわかると思います。最初の例では**915MB**だったので、Dockerイメージのサイズをおよそ**1/40程度**までスリム化できています。

## 最後に
Dockerのマルチステージビルドを利用することで、アプリケーションのビルド環境と実行環境の設定を１つのDockerfileに記述することができます。これは、今まで使われていたBuilderパターンなどと比較しても、より簡素で管理のしやすい方法だと感じました。Dockerfileを記述する際は、積極的に使っていくといいのではないかと思います。

## 参考
**Use multi-stage builds**
Dockerの公式ドキュメントの説明です。
https://docs.docker.com/develop/develop-images/multistage-build/

**docker-multi-stage-build-101**
今回の記事で使用したサンプルコードになります。
https://github.com/Imamachi-n/docker-multi-stage-build-101

**BioRxivGo**
Code Chrysalisの多言語週間中に作成したフルスタックアプリケーション（Vue, Golang, PostgreSQLなど）になります。こちらのアプリケーションではマルチステージビルドに加えて、Docker composeでアプリケーションを起動しています。
https://github.com/Imamachi-n/BioRxivGo