マルチステージビルドを使ってDockerfileで複数のステージを継承しながらイメージを定義する方法

おはようございます。こんにちは。こんばんは。

[OPTIMIND x Acompany Advent Calendar 2021](https://qiita.com/advent-calendar/2021/optimind_acompany)の9日目を担当することになりました、
Acompanyエンジニアの田中です。

みなさん12月いかがお過ごしでしょうか。令和3年も終わろうとしていますね。（え？もう令和４年目？）
僕は先日ストーブの前でみかんを食べていたら急に年末を感じました。みかん美味しい。

さて、今回はタイトルの通り、マルチステージビルドを使ってDockerfileで複数のステージを継承しながらイメージを定義する方法について紹介します。

# はじめに
マルチステージビルドはDockerのバージョン17.05以上で使用可能なビルド方法で、1つのDockerfileの中に複数の`FROM`命令を書くことでイメージをステージごとにビルドすることができる機能です。各ステージは独立にビルドされますが、ステージ間でファイルをコピーすることも可能です。
実際に適当なアプリケーションを例に使い方を見ていきましょう。

今回は例として`Hello World`と喋るmain.goを用意しました。

```go:main.go
package main

import (
	"fmt"
)

func main(){
	fmt.Println("Hello world")
}
```


## マルチステージビルドの使い方


まずマルチステージビルドを使わずに普通にDockerfileを書いてみます。

```Dockerfile:Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.17
WORKDIR /
COPY main.go /
RUN go build main.go
CMD ["./main"]
```
ビルドすると以下のようになります。

```shell
$ docker build -t myapp:latest .
...
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
myapp        latest    510459185c19   4 seconds ago   943MB
golang       1.17      2e7da682bb63   5 days ago      941MB
alpine       latest    c059bfaa849c   2 weeks ago     5.59MB
$ docker run myapp:latest
Hello World
```
実行できました。

ではこれを実行する最強のコンテナのイメージをマルチステージビルドで作ってみましょう。

```Dockerfile:Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.17
WORKDIR /
COPY main.go /
RUN go build main.go

FROM alpine:latest
WORKDIR /
COPY --from=0 /main ./
CMD ["./main"]
```

`FROM golang:1.17`から始まる1つ目のステージではアプリケーションをビルドしていますが、このステージでは実行を行なっていません。続く `FROM alpine:latest`から始まる2つ目のステージで1つ目のステージからビルド済みのバイナリのみを`COPY`でもってきて実行しています。
こんな感じでビルドと実行をステージで分けてやることで、実行時に使用するイメージには必要最小限のものしか入れないようにして軽量化することができます。非常に便利ですね。

このDockerfileからビルドする際は先ほどと同様`build`コマンドを実行してやるだけです。

```
$ docker build -t myapp2:latest .
...
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
myapp2       latest    5e37a5aeed6c   4 seconds ago   7.35MB  // <- 2つ目のステージのイメージ
<none>       <none>    cd5ea6a13fae   4 seconds ago   943MB   // <- 1つ目のステージのイメージ
golang       1.17      2e7da682bb63   5 days ago      941MB
alpine       latest    c059bfaa849c   2 weeks ago     5.59MB
$ docker run myapp:latest
Hello world
```
実行できました。

なんということでしょう！
先程のマルチステージビルドを使わない場合だと943MBだった実行用イメージが、
alpineをベースにした7.35MBの軽量なイメージに早変わり！

かなりお手軽に軽量化することができました。デプロイ時間もだいぶ短縮できそうですね。

マルチステージビルドを使うと開発環境用ステージ、テスト用ステージ、本番環境用ステージのように複数の環境を切り分けてイメージにすることが可能です。その際それぞれのステージで共通した処理を行いたい場合があります。その際に使える便利な方法を紹介します。

## マルチステージビルドで複数の環境を定義
`FROM`命令はイメージだけではなく前のステージに対しても使うことが可能です。
[公式ドキュメント](https://docs.docker.com/develop/develop-images/multistage-build/#use-a-previous-stage-as-a-new-stage)の一番下で説明されていて案外見落とされがちですがとても便利。
また、`AS`を使うことでステージに名前をつけてビルドステージを指定することもできます。

この記述方法を使うと以下のようにDockerfileが書けます。

```Dockerfile:Dockerfile
# syntax=docker/dockerfile:1
# 共通処理をまとめた基底ステージ
FROM ubuntu:20.04 as base
ENV PATH $PATH:/usr/local/go/bin
RUN apt-get update && \
    apt-get install -y \
    ca-certificates \
    wget \
    vim
RUN wget https://dl.google.com/go/go1.17.linux-amd64.tar.gz && \
    tar -C /usr/local -xzf go1.17.linux-amd64.tar.gz
RUN mkdir /root/myapp

# 開発環境用devステージ
FROM base as dev
WORKDIR /root/myapp
RUN go install golang.org/x/tools/cmd/goimports@latest
RUN go install golang.org/x/lint/golint@latest

# アプリケーションのbuilderステージ
FROM base as builder
WORKDIR /root/myapp
COPY main.go ./
RUN go build main.go

# 本番環境用prodステージ
FROM alpine:latest as prod
WORKDIR /root/myapp
# builderステージから成果物だけ持ってくる
COPY --from=builder /root/myapp/main ./
CMD ["./main"]
```

ビルドするステージを指定する場合は以下のように `--target`オプションを使います。

```shell
$ docker build -t myapp/prod:latest --target prod .    # base + dev + builder + prodステージをビルド
...
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
myapp/prod   latest    bb6fe8644492   5 seconds ago   7.35MB // <- prodステージのイメージ
<none>       <none>    d934f861ea4e   6 seconds ago   720MB  // <- builderステージのイメージ
<none>       <none>    37f815320424   7 seconds ago   771MB  // <- devステージのイメージ
alpine       latest    c059bfaa849c   2 weeks ago     5.59MB
ubuntu       20.04     ba6acccedd29   7 weeks ago     72.8MB
```

イメージのリストからもわかる通り、マルチステージビルドであるステージをターゲットにビルドを行うと
それ以前の全てのステージがビルドされてしまいます。prodステージのビルドはdevステージに依存していないので、ビルドをスキップしたいです。

そういう場合は、[BuildKit](https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/build_enhancements/)を使ってビルドをすることで必要なステージだけビルドすることが可能です。

```shell
$ DOCKER_BUILDKIT=1 docker build -t myapp/prod:latest --target prod . 
...
 => [internal] load metadata for docker.io/library/alpine:latest                                                                                                                         3.0s
 => [internal] load metadata for docker.io/library/ubuntu:20.04                                                                                                                          2.9s
...
 => [base 1/3] FROM docker.io/library/ubuntu:20.04@sha256:626ffe58f6e7566e00254b638eb7e0f3b11d4da9675088f4781a50ae288f3322                                                              11.8s
 => => resolve 
...
 => [prod 1/3] FROM docker.io/library/alpine:latest@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300                                                              1.3s
 => => resolve 
...
 => [base 2/3] RUN apt-get update &&     apt-get install -y     ca-certificates     wget     vim                                                                                        43.0s
 => [base 3/3] RUN wget https://dl.google.com/go/go1.17.linux-amd64.tar.gz &&     tar -C /usr/local -xzf go1.17.linux-amd64.tar.gz                                                      47.2s
 => [builder 1/3] COPY main.go ./                                                                                                                                                        0.0s
 => [builder 2/3] RUN go build main.go                                                                                                                                                   0.5s
 => [prod 2/3] COPY --from=builder /main ./ 
 => exporting to image 
...
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
myapp/prod   latest    f845490f2588   2 minutes ago   7.35MB
```

myapp/prodイメージだけ作成できました。

## docker-composeでステージを切り替える
マルチステージビルドで記述したDockerfileをdocker-compose.ymlで切り替える場合は以下のように書くことができます。

```yml:docker-compose.yml
version: "3.3"

services:
  myapp_dev:
    tty: true
    build:
      context: .
      dockerfile: Dockerfile
      target: dev
    volumes:
      - type: bind
        source: .
        target: /root/myapp

  myapp_prod:
    build:
      context: .
      dockerfile: Dockerfile
      target: prod
```

BuildKitを使ってビルドする場合は先頭に`COMPOSE_DOCKER_CLI_BUILD=1`をつけます。

```shell
$ COMPOSE_DOCKER_CLI_BUILD=1 docker-compose build myapp_dev
$ docker-compose run myapp_dev /bin/bash
Creating qiita_myapp_dev_run ... done
root@1dbaa3d9bcd7:~/myapp# go build main.go && ./main
Hello World
```
無事実行できました。

## 参考文献
https://docs.docker.com/develop/develop-images/multistage-build/

https://docs.docker.com/develop/develop-images/build_enhancements/