Help us understand the problem. What is going on with this article?

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

はじめに

Go 1.12 がリリースされましたね!
TLS 1.3 の対応や Go Modules の改良がされたようです。
Go 1.12 is released - The Go Blog

今回は 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 が存在するようなアプリケーションを想定しています。

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

開発環境

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

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 の詳細を追ってみます。

FROM golang:1.12.0-alpine3.9

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

WORKDIR /go/src/app

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

ENV GO111MODULE=on

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

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

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

EXPOSE 8080

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

CMD ["fresh"]

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

docker-compose

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

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

docker-compose の詳細

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

    build: .

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

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

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

    ports:
      - "8080:8080"

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

開発環境の実行

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

$ docker-compose up -d

本番環境

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

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 の詳細を追ってみます。

FROM golang:1.12.0 as builder

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

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

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

COPY go.mod go.sum ./

RUN go mod download

COPY . .

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

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

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

FROM scratch

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

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

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

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

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

USER myapp

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

ENTRYPOINT ["/go/bin/app"]

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

本番環境の実行

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

Dockerfile をビルドします。

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

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

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

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

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

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

番外編

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

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

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

ビルドステージに Alpine Linux のイメージを使用した 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 のイメージに変更しています。

FROM golang:1.12.0-alpine3.9 as builder

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

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

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

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言語生活
Create the smallest and secured golang docker image based on scratch
Non-privileged containers based on the scratch image
Use Multi-Stage Builds to Inject CA Certs

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away