Go言語でのDockerfileのベストプラクティスを考えてみた

本投稿は TECOTEC Advent Calendar 2023 の14日目の記事です。
こんにちは。次世代デジタル基盤開発事業部の椎葉です。
私は担当する案件でDockerを使用してGo言語のアプリケーションを作成しています。その中で、いくつかDockerfileの改良・修正を行いました。 今回はその経験を元にGo言語におけるDockerfileのベストプラクティスについて考えてみました。その内容について解説していきます。

実行環境

Docker version 24.0.7
Go 1.21.4

完成品

まずは結論から。今回作成したDockerfileはこちらになります。ポイントとなる点を解説していきます。

ARG GO_VERSION=1.21.4

# ビルドステージ
FROM golang:${GO_VERSION}-alpine3.18 AS builder

WORKDIR /app

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=bind,source=go.sum,target=go.sum \
    --mount=type=bind,source=go.mod,target=go.mod \
    go mod download -x

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=bind,target=. \
    go build -o /bin/app

# リリースステージ
FROM gcr.io/distroless/base-debian12:nonroot

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

ENTRYPOINT ["/bin/app"]

解説

ビルド引数でバージョンの変更を可能にする

ARG GO_VERSION=1.21.4
FROM golang:${GO_VERSION}-alpine3.18 AS builder

引数を指定するためにはARG命令を使用します。
ビルド時にビルド引数が指定されなかった場合は、デフォルト値が設定されます。(この場合1.21.4)
ビルド時に--build-argオプションを使うことで変数の値を変えることができます。

docker build --build-arg="GO_VERSION=1.21" .

Dockerfile内に直接バージョン情報を記載してもよいですが、別のバージョンを使いたいときに引数で渡せるようにしておくと便利です。

適切なベースイメージを使用する

FROM golang:${GO_VERSION}-alpine3.18 AS builder

ベースとなるイメージは要件に合った最小限のものを選択することが重要です。
イメージを小さく抑えられるとダウンロードが早くなるだけでなく、依存関係に起因する脆弱性の数も最小限に抑えることができます。
今回は軽量LinuxディストリビューションであるalpineがベースとなっているGo言語の公式イメージgolang:1.21.4-alpine3.18を選択しました。

キャッシュマウントを使用する

RUN --mount=type=cache,target=/go/pkg/mod \

キャッシュを利用することで差分が発生したモジュールだけダウンロードできます。targetにはモジュールがキャッシュされている場所を設定します。 自身の環境がどこにキャッシュされているかは以下のコマンドで確認ができます。

go env | grep CACHE

キャッシュを使用したときと使用しなかったときの比較をしてみます。
キャッシュを使用しなかった場合は何れかのパッケージに変更があった場合、すべてのパッケージのダウンロードが行われます。

#9 [builder 3/4] RUN --mount=type=bind,source=go.sum,target=go.sum     --mount=type=bind,source=go.mod,target=go.mod     go mod download -x
#9 0.320 # get https://proxy.golang.org/github.com/go-playground/validator/v10/@v/v10.14.0.mod
#9 0.320 # get https://proxy.golang.org/github.com/gabriel-vasile/mimetype/@v/v1.4.2.mod
#9 0.320 # get https://proxy.golang.org/github.com/chenzhuoyu/base64x/@v/v0.0.0-20221115062448-fe3a3abad311.mod
#9 0.320 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.mod
...(省略)
#9 DONE 3.7s

※今回の例ではginパッケージのバージョンをv1.9.1→v1.9.0に変更してビルドを実行しています。

一方、キャッシュを使用すると更新のあったパッケージのダウンロードだけが実行されます。
実行時間が3.7秒から0.6秒と約3秒ほど短縮できました。こういった待ち時間って以外と開発者体験の影響大きいですね。

#9 [builder 3/4] RUN --mount=type=cache,target=/go/pkg/mod     --mount=type=bind,source=go.sum,target=go.sum     --mount=type=bind,source=go.mod,target=go.mod     go mod download -x
#9 0.296 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.mod
#9 0.415 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.mod: 200 OK (0.119s)
#9 0.416 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.info
#9 0.431 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.info: 200 OK (0.015s)
#9 0.432 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.zip
#9 0.450 # get https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.zip: 200 OK (0.018s)
#9 DONE 0.6s

ビルド時も同様です。
キャッシュを使用しない場合ではアプリケーションコードに変更があったとき、フルビルドになり時間がかかってしまいます。キャッシュを使用すると差分だけをビルドするようになるのでビルド時間を大幅に短縮できます(12秒→0.8秒)。
※どちらもアプリケーションコードに数行変更を加えてビルドを実行しています。
ビルドキャッシュなし

#10 [builder 4/4] RUN --mount=type=cache,target=/go/pkg/mod     --mount=type=bind,target=.     go build -o /bin/app
#10 DONE 12.0s

ビルドキャッシュあり

#10 [builder 4/4] RUN --mount=type=cache,target=/go/pkg/mod     --mount=type=cache,target=/root/.cache/go-build     --mount=type=bind,target=.     go build -o /bin/app
#10 DONE 0.8s

Tips

キャッシュが効いていることを確認するときに便利なコマンドを紹介します。

docker build --progress=plain .

標準だとビルドの進捗状況が簡略表示されてしまうため、--progress=plainオプションを付けると詳細な進捗が見れます。

go clean -modcache # goのモジュールキャッシュを削除します。
go clean -cache # goのビルドキャッシュを削除します。
docker builder prune -a # Dockerのビルドキャッシュを削除します。

各種キャッシュを削除できます。

バインドマウントを使用する

    --mount=type=bind,source=go.sum,target=go.sum \
    --mount=type=bind,source=go.mod,target=go.mod \

モジュールのダウンロードに必要なgo.modgo.sumをコピーするのではなく、バインドマウントします。COPY命令を使用するとレイヤーが作られますが、バインドマウントではレイヤーは作られません。今回の例ではほとんど差はありませんが、レイヤー数を減らすことは軽量なイメージを作ることに繋がります。

マルチステージビルドを使用する

マルチステージビルドとは、ステージ毎にイメージのビルドを行う機能です。FROM命令をそれぞれのステージで記述することで使用するイメージを使い分けることができ、ステージの成果物はコピーして他のステージでも利用することができます。ビルド時に必要な機能をリリース用のイメージに入れずに済むため、イメージの大きさを最小限に抑えることができます。

REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
sample       v0.0.1    9422fd99c44d   8 seconds ago    233MB  # マルチステージビルドなし
sample       latest    1f863e90de57   26 minutes ago   31.6MB # マルチステージビルドあり

マルチステージビルドの使用有無によるイメージサイズの比較です。 マルチステージビルドを利用したほうがイメージサイズを大幅に減らせていることが分かります。

FROM gcr.io/distroless/base-debian12:nonroot

最終成果物のイメージにはgcr.io/distroless/base-debian12を選択しました。 distrolessイメージは必要最低限の機能しか入っていないため、イメージのサイズを最小限に抑えることができます。(aptやshellも含まれていません)

非ルートユーザーで実行する

コンテナが攻撃されたときのリスクを抑えるため、非ルートユーザーで実行することが推奨されています。

FROM gcr.io/distroless/base-debian12:nonroot

distrolessイメージのタグにnonrootを指定することで非ルートユーザーで実行できる機能があるため、それを利用しています。

まとめ

Dockerfileのベストプラクティスについての解説でした。 Dockerfileはアプリケーションコードに比べてなかなか触れない部分なので、学び直す良い機会となりました。他のプログラミング言語でも応用できると思うので、ぜひご参考いただければと思います。

最後まで閲覧いただき、ありがとうございました。

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp