見出し画像

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

こんにちは。プロダクト開発部のmitsuです。
今回はGoの実行環境をDockerで構築する際のDockerfileのベストプラクティスの紹介をさせていただきます。

その前に、初の記事投稿になるので簡単に自己紹介をさせていただきます。
私のエンジニア歴は約8年、Newbeesには去年の3月に入社しました。
現在はマッチングサービスのWeb開発や、業務管理システムのバックエンドの開発を行なっています。

最近プライベートでハマっていることは、愛する妻と船橋市にあるアンデルセン公園というテーマパークに散歩に行くことです。
広大な敷地内に沢山のお花や植物があり散歩にピッタリで、動物と触れ合うこともできます。
我が家では年パスを購入しているので、涼しい時期は週一で通っています。
デンマークの美しい田園風景を再現した場所もあり、その風景を幸せそうに眺める妻の横顔を見るのが私の最大の楽しみです。

はじめに

NewbeesではGoによるシステム開発を行なっていますが、一部のシステムは本番の実行環境としてAWSのFargate + ECSを選択し、コンテナ化したアプリケーションをデプロイしています。
私自身、これまでDockerの用途としてはローカルの開発環境などに留めることが多かったのですが、本番環境でもDockerコンテナによるデプロイを行うということで、セキュリティや運用を意識したDockerfileを書く必要がありました。

本記事ではその際に学んだ知見や、ベストプラクティスに沿ったDockerfileを書くための方法をご紹介いたします。
今回は「リファクタリングしながら学ぶ」という事で、まずは改善の余地のあるDockerfileを用意した上で、そのファイルをベストプラクティスに沿って修正していきます。
ちなみに「Dockerfileのベストプラクティス」に関しては、そのままのタイトルでDocker社がドキュメントを公開しています。

対象のDockerfile

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

app/
├─┬ main.go
│ ├─go.mod
│ ├─go.sum
│ ├─config
│ │ ├─ local.env
│ │ └─ prod.env
│ └── 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イメージを使用しており、

  1. ソースコードなどをADDで追加

  2. RUNでGoのライブラリなどのダウンロード、Goのビルド

  3. 最後に実行

といった流れです。
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というツールを使用してみました。

バイナリのみで実行可能で、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 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 go mod download
RUN go build -o main /app/main.go
↓
RUN 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/main
↓
CMD [ "/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は、下記のようになりました。

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のランタイムやソースコードといった 「ビルドの際には必要になるのだけれども、実行の際には不要」 なものが大量に入っている状態です。
理想としては、実行段階ではコンテナの中にバイナリだけが存在する状態にできると、イメージサイズは今よりずっと小さくなります。
こういった要件の場合、マルチステージビルドを行うことで解決ができます。

マルチステージビルドについては説明が長くなってしまうため、ドキュメントのリンクを貼っておきます。

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

図にすると、大体こんな感じです。

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

# ステージ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イメージを作成するためにサポートしてくれるツールやドキュメントなどは豊富に揃っているため、活用していきたいところです。

Newbeesでは一緒に働く仲間を募集しています

フルリモート勤務を導入し、場所にとらわれない自由な仕事のやり方が可能です。詳細は以下をご覧ください


みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!