🐳

リファクタリングしながら学ぶGoのDockerfileベストプラクティス

2022/09/24に公開

初めに

過去にGo及びDockerを学び始めの時、Go+Dockerにて環境構築したことがありました。
最近になって改めてそのDockerfileを見返してみて、改善の余地がある部分をDockerfileのベストプラクティスに沿って修正していこうと思います。

ちなみにDockerfileのベストプラクティスはそのままのタイトルでDocker社がドキュメントを公開しています。
https://docs.docker.jp/develop/develop-images/dockerfile_best-practices.html

対象のDockerfile

今回の説明用に改変してはいますが、対象となる環境について紹介します。
ディレクトリ全体の構成と、肝心のDockerfileの中身は下記のようになっています。
※この記事ではあくまでもDockerfileの書き方に焦点を当てるため、他のファイルの中身の説明等は省略させていただきます。

app/
├─┬ main.go
│ ├─go.mod
│ ├─go.sum
│ ├─config
│ │ ├─ local.env
│ │ └─ prod.env
│ └── Dockerfile
Dockerfile
FROM golang:1.18-alpine3.15 AS go
WORKDIR /app
ADD go.mod go.sum main.go ./
ADD config/local.env ./
RUN go mod download
RUN go build -o main /app/main.go
CMD /app/main

ベースイメージとしてgolangのランタイムの入ったalpineイメージを使用しており、

  • ソースコード等をADDで追加
  • RUNコマンドでgoのライブラリ等のダウンロード、Goのビルド
  • そして最後に実行

といった流れです。
main.goの中身は簡単なwebサーバーを8080番ポートで立ち上げるようになっています。

configディレクトリの中には環境ごとに異なる値をenvファイルにまとめており、Dockerfile内

ADD config/local.env ./

の行でコンテナ内にコピーしています。
ここではlocal.env(ローカル用設定)を渡しているため、これはローカル環境用のDockerfileということになります。
早速ビルドしてみます。

$ docker build -t godemo .
$ docker images
godemo    latest    69b1580c08fe    412MB

ビルド自体は正常に完了し、今回は412MBのイメージが作成できました。
イメージからコンテナを立ち上げるには、下記のようにコマンドを叩く想定です。

$ docker run -p 8080:8080 -d -t godemo

Dockerfileリファクタリング

さてここから、このDockerfileを改修してより良いイメージを作成していきたいと思います。
今回は大きく分けて3点ほど対応してみました。

  1. 静的解析ツールを使ってみる
  2. 環境ごとの.envを用意せず、環境変数として注入する
  3. 実行権限を最小限にする

1.静的解析ツールを使ってみる

プログラミングをする際でも静的解析ツールを導入してソースを書かれている方は多いかと思いますが、Dockerfileの為のツールも存在します。
個人的にはいきなりベストプラクティスを全て勉強して理解することは難しく感じているので、こういったツールに怒られ、教えてもらいながら学んでいく、というのは効率が良いと感じています。
今回は、hadolintというツールを使用してみました。

https://github.com/hadolint/hadolint

バイナリのみで実行可能で、Macであればhomebrewでインストールが可能です。

$ brew install hadolint

その後、対象となるDockerfileを指定して実行します。

$ hadolint ./Dockerfile

すると、4点ほど指摘が出ました。

DL3020 error: Use COPY instead of ADD for files and folders
DL3020 error: Use COPY instead of ADD for files and folders
DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

DL3020 error: Use COPY instead of ADD for files and folders

1点目と2点目の指摘は内容としては同じで、ADDではなくCOPYを使用してね、という指摘となります。
ADDはtarを展開したりファイルをリモートから取得してきたり等の機能があるようですが、今回のようにファイルを単純にコピーするだけであればCOPYコマンドを使用します。

ADDからCOPYへ
ADD go.mod go.sum main.go ./COPY go.mod go.sum main.go ./

DL3059 info: Multiple consecutive RUN instructions. Consider consolidation.

3点目の指摘は、RUNを複数行書いているのを1行にまとめて書くのを検討しよう的な意味です。
dockerイメージはレイヤを重ね合わせてできるのですが、RUN が実行される度にレイヤが増えるため、コマンドを&&等で連結することが推奨されています。

RUNを結合
RUN go mod download
RUN go build -o main /app/main.goRUN go mod download \
&& go build -o main /app/main.go

DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

4点目は単に書き方の指摘で、CMDは表記として下記のようにするのが正しいようです。

表記の修正
CMD /app/mainCMD [ "/app/main" ]

2.環境ごとの.envを用意せず、環境変数として注入する

今回config/local.envという、環境ごとのenvファイルをコンテナ内に渡すようにしており、環境に応じたDockerfileを作成する運用となっています。
Dockerコンテナのメリットの1つとして、環境の差異無く同一のコンテナを開発環境や本番環境で使用ができる、というのがよく挙げられます。
そのため今回も、作成したイメージを各環境で使い回すことができるように改善しようと思います。

その為には、コンテナ自身は 「ステートレス(状態を持たない)」 設計にすることがポイントです。
データはデータベースなどに持つ、ログは標準出力や外部ログストレージに出す等で、コンテナ自身に何かしらの状態を持たないようにします。
外部サービスへの接続情報などの環境によって違う値を持たせる場合にはどうするのかというと、今回のようにイメージの中に含めるのではなく、コンテナの起動時に外から環境変数として注入してやる方法が一般的です。

ローカルでのdocker runでは、-eオプションによって環境変数を渡してやることができます。

$ docker run -e HOGE_HOGE=test -p 8080:8080 -d -t godemo

またdocker-composeでコンテナ起動する場合は、docker-compose.ymlファイルに注入する環境変数の設定を書くことができます。
ローカル以外の環境においては、例えばECS等の場合でもコンテナ実行時の環境変数は簡単に設定することが可能です。
この辺りは、コンテナ基盤の実行環境であれば何かしらの形でサポートされているはずなのであまり心配はいらないかなと思います。
さて、環境変数を注入するよう対応した場合、
そもそものenvファイルのコピー(ADD config/local.env ./部分)自体が必要無くなります。
その為Dockerfileからは削除しておきましょう。

3.実行権限を最小限にする

デフォルトでは、コマンドがrootユーザで実行され、セキュリティ的にはよろしくありません。
rootで実行する必要がない場合であれば、非ルートユーザを指定するようにしてこの辺りの実行権限は最小限にとどめた方がより良くなります。

その場合、Dockerfileでユーザを作成&指定するか、下記のようにユーザIDを指定してやれば、実行時にはこのUIDで実行されます。

USER 1001

ちなみに別に1001でなくても大丈夫です。
OpenShiftとかではデプロイの際はランダムな数字のUIDを使用されるそうで、その辺のセキュリティ対策に近いやり方な気がします。
つまりここで適当なUIDを指定してイメージ作成ができる=OpenShiftなどのプラットフォームでも問題なくデプロイができ、互換性があると言えそうです。(OpenShift利用したことないので何とも言えませんが…)

リファクタリング後のDockerfile

上記のリファクタリングを一通り行ったDockerfileは下記のようになりました。

Dockerfile
FROM golang:1.18-alpine3.15 AS go
WORKDIR /app
COPY go.mod go.sum main.go ./
RUN go mod download \
&& go build -o main /app/main.go
USER 1001
CMD [ "/app/main" ]

もう一度hadolintをかけてみると、エラー等が何も表示されない状態となります。

マルチステージビルドしてみる

一応ここまででDockerfileのベストプラクティスに沿った形にはなりました。ここからは更にGoの利点を生かした形で改善を試みてみます。
Goは本来、コンパイルされて生成されたシングルバイナリだけで実行が可能です。
しかしこのDockerfileで作成されるイメージは、Goのランタイムやソースコードといった 「ビルドの際には必要になるのだけれども、実行の際には不要」 なものが大量に入っている状態です。
理想としては、実行段階ではコンテナの中にバイナリだけが存在する状態にできると、イメージサイズは今よりずっと小さくなります。
こういった要件の場合、マルチステージビルドを行うことで解決ができます。

マルチステージビルドについては説明が長くなってしまうため、ドキュメントのリンクを貼っておきます。
https://docs.docker.jp/develop/develop-images/multistage-build.html

このマルチステージビルドを利用して、1つ目のステージでGoのビルド、2つ目のステージでは、ビルドされたバイナリだけをコピーしてイメージを作成することができます。

図にすると大体↓のような感じです。

Dockerfileを2つ用意といったことをする必要はなく、ファイル内にFROMを追加するだけで実現可能です。
先に全体を記載しておくと、下記のようになります。

Dockerfile
# ステージ1
FROM golang:1.18-alpine3.15 AS go
WORKDIR /app
COPY go.mod go.sum main.go ./
RUN go mod download \
&& go build -o main /app/main.go

# ステージ2
FROM alpine:3.15
WORKDIR /app
COPY --from=go /app/main .
USER 1001
CMD [ "/app/main" ]

ステージ2の方が最終的なイメージとなります。
ベースイメージとして、最小限のalpineを指定しています。

# ステージ2
FROM alpine:3.15

ステージ1のFROMでは、AS goと書くことで、goという名前をつけた上で

COPY --from=go /app/main .

の行にてビルドしたバイナリをステージ1からステージ2にコピーしています。

その他の流れとしては、マルチステージビルド適用前と同じような感じですね。
それではこのDockerfileで「multidemo」という名前でイメージを作成して、イメージサイズを確認してみます。

$ docker build -t multidemo .
$ docker images
godemo       latest    69b1580c08fe    412MB   ←マルチステージじゃない
multidemo    latest    7d81af9ccfc5    12.1MB  ←マルチステージの方

一瞬12GBくらいになったように見えて戦慄しましたが、よくみると12.1MBでした。
マルチステージビルド適用前の物と比べると412MB→12MBとなり、かなりサイズが抑えられているのが分かります。


今回対象としたのは非常に単純なDockerfileでしたが、それでも多くの改善点が見つかりました。
実務ではもっと複雑かつあらゆる状況を想定してイメージを作成していく必要があると思いますが、良いDockerイメージを作成するためにサポートしてくれるツールやドキュメント等は豊富に揃っている為、活用していきたいところです。

Discussion