Go 1.12 の開発環境と本番環境の Dockerfile を考える

# はじめに

Go 1.12 がリリースされましたね！
TLS 1.3 の対応や Go Modules の改良がされたようです。
[Go 1.12 is released - The Go Blog](https://blog.golang.org/go1.12)

今回は Go Modules が含まれている Go 1.12 を使用して、開発環境と本番環境の Dockerfile の作成を考えてみます。
開発環境は docker-compose、本番環境は Kubernetes などのオーケストレーションツールにデプロイすることを想定した Dockerfile の作成を考えます。

アプリケーションは、Go Modules を利用してライブラリ管理し、外部に HTTPS 通信するアプリケーションを想定して Dockerfile を作成しています。
Listen ポートは 8080 ポートです。

# 環境

* macOS High Sierra Version 10.13.6
* Docker for Mac 2.0.0.3 (Engine: 18.09.2)

# ディレクトリ構成

以下のようなルートディレクトリに `main.go`、`go.mod`、`go.sum` が存在するようなアプリケーションを想定しています。
事前に `go mod init` コマンドと `go build` または、 `go mod download` コマンドを実行したプロジェクトを用意してください。

```
├── Dockerfile          // 開発環境の Dockerfile
├── Dockerfile.prod     // 本番環境の Dockerfile
├── docker-compose.yml  // 開発環境の docker-compose
├── go.mod
├── go.sum
└── main.go
```

# 開発環境

開発環境はソースコードが随時変更されるため、`fresh` というライブラリを用います。
ソースコード自体は、docker-compose で指定するルートを docker volume に割り当て、ホットリロードを実現します。

## Dockerfile

開発環境の Dockerfile の全体は以下のとおりです。

```Dockerfile:Dockerfile
FROM golang:1.12.0-alpine3.9

WORKDIR /go/src/app

ENV GO111MODULE=on

RUN apk add --no-cache \
        alpine-sdk \
        git \
    && go get github.com/pilu/fresh

EXPOSE 8080

CMD ["fresh"]
```

### Dockerfile の詳細

開発環境の Dockerfile の詳細を追ってみます。

```Dockerfile
FROM golang:1.12.0-alpine3.9
```

イメージは、執筆時点(2019/02)の最新の Alpine Linux の Go イメージを指定しています。

```Dockerfile
WORKDIR /go/src/app
```

`WORKDIR` でアプリケーションを実行するディレクトリを指定しています。

```Dockerfile
ENV GO111MODULE=on
```

Go 1.12 では、Go Modules を使用するために必要な環境変数 `GO111MODULE` を `on` にしています。

```Dockerfile
RUN apk add --no-cache \
        alpine-sdk \
        git \
    && go get github.com/pilu/fresh
```

パッケージとして一通りビルドに必要なものが入っている `alpine-sdk` と Go Modules でライブラリ取得時に内部的に `git` を利用しているので `git` をインストールしています。
また、Go 開発のホットリロードで利用する `fresh` をインストールしています。

```Dockerfile
EXPOSE 8080
```

8080 ポートで Listen するアプリケーションなので、`EXPOSE` 命令で 8080 ポートを明示しています。

```Dockerfile
CMD ["fresh"]
```

最後に `fresh` で起動することで、ホットリロードで起動する Dockerfile の完成です。

## docker-compose

開発環境の docker-compose ファイルは以下のとおりです。

```yaml:docker-compose.yml
version: '3'
services:
  app:
    build: .
    volumes:
      - ./:/go/src/app
    ports:
      - "8080:8080"
```

### docker-compose の詳細

開発環境の docker-compose の詳細を追ってみます。

```yaml
    build: .
```

`build` でビルドする Dockerfile のディレクトリを指定しています。

```yaml
    volumes:
      - ./:/go/src/app
```

`volumes` で Dockerfile でも指定した `WORKDIR` のディレクトリに対してマウントしています。こうすることでコンテナ内から開発マシンのディレクトリが見えるようになり、`fresh` のビルド対象になります。

```yaml
    ports:
      - "8080:8080"
```

`ports` は、8080 ポートで Listen するアプリケーションなので、開発マシンとコンテナのポートを割り当てています。

## 開発環境の実行

以下の docker-compose コマンドで起動し、`localhost:8080` にアクセスすることで開発が始められます。

```bash
$ docker-compose up -d
```

# 本番環境

本番環境は可能な限り小さいイメージを作るために、マルチステージビルドを用います。

## Dockerfile

本番環境の Dockerfile の全体は以下のとおりです。

```Dockerfile:Dockerfile.prod
FROM golang:1.12.0 as builder

WORKDIR /go/src/app

ENV GO111MODULE=on

RUN groupadd -g 10001 myapp \
    && useradd -u 10001 -g myapp myapp

COPY go.mod go.sum ./

RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/app

FROM scratch

COPY --from=builder /go/bin/app /go/bin/app
COPY --from=builder /etc/group /etc/group
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

EXPOSE 8080

USER myapp

ENTRYPOINT ["/go/bin/app"]
```

### Dockerfile の詳細

本番環境の Dockerfile の詳細を追ってみます。

```Dockerfile
FROM golang:1.12.0 as builder
```

イメージは Alpine Linux ではなくデフォルトのイメージに変更し、マルチステージビルドを用いるために `as builder` というステージ名を指定しています。
デフォルトのイメージに変更したのは、Go Modules のインストールに必要な `git` や Go ライブラリのインストールや Go のビルドに必要なパッケージが含まれているためです。
マルチステージビルドについては、ステージ名に任意の名前が付けられます。
こうすることで、次のステージでビルドした成果物をコピーできます。

```Dockerfile
RUN groupadd -g 10001 myapp \
    && useradd -u 10001 -g myapp myapp
```

ここでは、ユーザーグループとユーザーを作成しています。
これは、次のステージで利用するアプリケーションの実行ユーザーを作成するために実行しています。
また、次のステージで利用する `scratch` イメージは shell が実行できないため、ビルドステージでユーザーを作成しています。

```Dockerfile
COPY go.mod go.sum ./

RUN go mod download

COPY . .
```

開発環境では volume にマウントしていましたが、本番環境ではアプリケーションをそのままコンテナ内にコピーしています。
そして、`COPY` 命令で全てのファイルをコピーせず、事前に `go.mod` と `go.sum` ファイルをコピーしています。
これは、コピー後に `go mod download` を実行してライブラリをインストールすることで、次回以降ライブラリの追加、 `go.mod` と `go.sum` に変更がなくソースコードの変更のみであればソースコード全体をコピーする `COPY . .` の行から実行され、それより前のステップはキャッシュが活用されスキップされます。
こうすることでキャッシュが有効活用されるので、インストールのステップをスキップし、ビルド速度が向上します。

```Dockerfile
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/app
```

クロスコンパイル用の環境変数を指定し、`-ldflags="-w -s"` でバイナリを削減しています。

```Dockerfile
FROM scratch
```

前述のとおり `scratch` イメージは、shell も入っていない最小のイメージです。
小さなイメージを作るには良いですが、attach も出来ないので取り回しが悪い点は否めません。
状況によって、Alpine Linux のイメージを使うと良いと思います。

```Dockerfile
COPY --from=builder /go/bin/app /go/bin/app
```

前のステージでビルドしたアプリケーションを次のステージにコピーしています。
`--from=[NAME]` をコピーするファイルの前に宣言することでコピー元のステージが指定できます。

```Dockerfile
COPY --from=builder /etc/group /etc/group
COPY --from=builder /etc/passwd /etc/passwd
```

ここでは、ビルドステージからユーザーグループとユーザーのファイルをコピーしています。
このファイルをコピーすることで、ユーザーグループとユーザーを追加したことになるので、実行ユーザーを変更することが可能になります。

```Dockerfile
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
```

ここでは、HTTPS 通信をするために証明書をコピーしています。
HTTPS 通信をしないのであれば不要です。

```Dockerfile
USER myapp
```

作成したユーザーを `myapp` ユーザーに変更しています。
デフォルトでは root ユーザーで実行されるため、非 root ユーザーで実行するためにユーザーを変更しています。

```Dockerfile
ENTRYPOINT ["/go/bin/app"]
```

最後に `ENTRYPOINT` でアプリケーションを実行しています。
`ENTRYPOINT` にコマンドを指定することで、引数を用いるアプリケーションであれば実行時に引数がそのまま指定できます。

## 本番環境の実行

本来であれば CI でビルドし、CD でコンテナオーケストレーション環境にデプロイしますが、確認のために開発マシンで起動します。

Dockerfile をビルドします。

```bash
$ docker build -t myapp -f Dockerfile.prod .
```

8080 ポートでビルドしたコンテナを起動します。

```bash
$ docker run --name myapp -p 8080:8080 -it -d myapp
```

docker top コマンドで実行ユーザーを確認してみます。

```bash
$ docker top myapp
PID                 USER                TIME                COMMAND
13606               10001               0:00                /go/bin/app
```

`USER` が `10001` で実行されていることが分かります。

## 番外編

本番環境の例では、ビルドステージにデフォルトのイメージを使用して作成しました。
ただし、デフォルトのイメージを採用することによるデメリットも存在します。
以下に示すのは、デフォルトの Go と Alpine Linux の Docker イメージを比較したものです。

```bash
golang     1.12.0-alpine3.9    d4953956cf1e        17 hours ago         347MB
golang     1.12.0              c4f8e4c91496        17 hours ago         772MB
```

見てわかるとおり 2 つのイメージには、400MB 程度のイメージサイズの差があります。
これはつまり、Docker イメージを pull するときのダウンロード速度に影響します。
同じベースイメージを使っていれば再利用されますが、Go のバージョンアップをするときにベースイメージを変更する必要があるため、その時点では全体のビルド速度が低下します。

ビルドステージに Alpine Linux のイメージを使用した Dockerfile の全体は以下のとおりです。

```Dockerfile:Dockerfile.prod
FROM golang:1.12.0-alpine3.9 as builder

WORKDIR /go/src/app

ENV GO111MODULE=on

RUN addgroup -g 10001 -S myapp \
    && adduser -u 10001 -G myapp -S myapp

RUN apk add --no-cache \
        alpine-sdk \
        git

COPY . .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/app

FROM scratch

COPY --from=builder /go/bin/app /go/bin/app
COPY --from=builder /etc/group /etc/group
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

EXPOSE 8080

USER myapp

ENTRYPOINT ["/go/bin/app"]
```

変更点としては、イメージをデフォルトから Alpine Linux のイメージに変更しています。

```Dockerfile
FROM golang:1.12.0-alpine3.9 as builder
```

また、Debian 系と Alpine Linux とでディストリビューションが異なるため、ユーザーグループとユーザー追加のコマンドが異なります。

```Dockerfile
RUN addgroup -g 10001 -S myapp \
    && adduser -u 10001 -G myapp -S myapp
```

そして開発環境同様、`alpine-sdk` と `git` をインストールし、ファイルを全てコピーするように変更しています。

```Dockerfile
RUN apk add --no-cache \
        alpine-sdk \
        git

COPY . .
```

Alpine Linux を採用するメリットは、イメージサイズの小ささによるダウンロードの速さです。
毎度イメージのダウンロードからパッケージのインストールまで行うのであれば、デフォルトのイメージに比べイメージが小さい分速く終わります。

しかしながら番外編の Dockerfile にもデメリットがあり、本番環境の例で採用しなかったのは以下の理由からです。
Go Modules のインストールには、`git` に依存しているため `git` のインストールが必要です。
また、Go ライブラリのインストールや Go のビルドには `gcc` などのパッケージを必要とする場合があるため、`alpine-sdk` をインストールしています。
Alpine Linux のイメージにはそれらがデフォルトでインストールされていないため、事前にインストールする必要があります。
しかしそれらをインストールしてしまうと Docker のキャッシュがリセットされ、ソースコードを変更する度にパッケージのインストールが再度実行されてしまいます。
本番環境の例で紹介したデフォルトのイメージであれば `git` や必要なパッケージがインストールされているので、Docker のキャッシュを有効活用してソースコードのコピーより前のステップをスキップできます。
したがって、毎度のソースコードの変更が主なビルドを考えると Docker のキャッシュが有効なデフォルトのイメージが良いと思います。

Go のライブラリを使用しないなど、パッケージをインストールしないのであれば、Alpine Linux を選択するメリットがあると思います。

# さいごに

ここまで、Go 1.12 の開発環境と本番環境の Dockerfile の最適化を考えました。
次には Go 1.13 のリリースが控えていますが、Go Modules 自体は Go 1.13 からデフォルトで有効になるとのことなので、`GO111MODULE` の環境変数が不要になる程度で概ね使えるのではないでしょうか。
また、Docker 18.09 で正式に追加された `BuildKit` を試していないので、`BuildKit` を使うことでより最適化できるかもしれません。

# 参考
[Go v1.11 + Docker + fresh でホットリロード開発環境を作って愉快なGo言語生活](https://qiita.com/po3rin/items/9acd41ef428436335c97)
[Create the smallest and secured golang docker image based on scratch](https://medium.com/@chemidy/create-the-smallest-and-secured-golang-docker-image-based-on-scratch-4752223b7324)
[Non-privileged containers based on the scratch image](https://medium.com/@lizrice/non-privileged-containers-based-on-the-scratch-image-a80105d6d341)
[Use Multi-Stage Builds to Inject CA Certs](https://medium.com/on-docker/use-multi-stage-builds-to-inject-ca-certs-ad1e8f01de1b)