はじめに
これはDockerのbest practicesをGo言語のイメージを例にやってみた、という内容の記事です。
Dockerの使い方は覚えたけれど、どのようなDockerfileにすれば良いのかわからない。という方に1つの例を提供することを目的としています。
環境
- Docker: Docker version 20.10.23, build v20.10.23
Dockerfileサンプル
Dockerのbest practicesを参考に作成したDockerfileがこちらになります。
FROM golang:1.21.0-bullseye AS base
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod/ \
go mod download
FROM base AS dev
RUN go install github.com/go-delve/delve/cmd/dlv@latest && \
go install github.com/cosmtrek/air@latest
COPY . .
CMD ["air", "-c", ".air.toml"]
FROM base AS builder
COPY . .
RUN useradd -u 10001 scratch
RUN --mount=type=cache,target=/go/pkg/mod/ \
go build \
-ldflags="-s -w" \
-o golang-app \
-trimpath
FROM scratch AS runner
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app/golang-app golang-app
USER scratch
CMD ["/golang-app"]
適切なベースイメージを選択する
Security best practicesには、以下のような記述があります。
The first step towards achieving a secure image is to choose the right base image. When choosing an image, ensure it’s built from a trusted source and keep it small.
今回のようにDockerfileから独自のイメージを構築する際には、要件にあった最小限のベースイメージを選択するべきとされています。
主なメリットはプル時間の短縮とセキュリティの向上です。イメージサイズが小さいということはそこに含まれているパッケージ数も少ないということであり、攻撃者が狙う脆弱性の数も少なくなります。
軽量なベースイメージを選択するという意味では、今回採用した1.21.0-bullseye
よりも軽量な1.21.0-alpine3.18
があります。
※ alpineイメージはuseradd
が上手くいかずに諦めました。後から知ったのですが、alpineの場合useradd
ではなくadduser
やaddgroup
というコマンドを使うそうです。
レイヤーのキャッシュを活用する
Dockerのビルドキャッシュについて知ることで、ビルド時間を短縮できます。
Dockerfileの各命令はそれぞれが1つのレイヤーに対応しています。
レイヤーを構築する命令について
Best practices for writing Dockerfilesに以下のような記述があることから、厳密には1命令1レイヤーではないようです。
Only the instructions RUN, COPY, and ADD create layers. Other instructions create temporary intermediate images, and don’t increase the size of the build.
たとえばrunnerステージのイメージをdocker history
コマンドで確認してみると、4つのレイヤーがあることがわかります。
$ docker history --no-trunc golang:runner
IMAGE CREATED CREATED BY SIZE COMMENT
sha256:f5269c93a601261f48922e3bbe3ea20c9dd27fe8d908127b53afdb0950a81f36 44 seconds ago /bin/sh -c #(nop) CMD ["/golang-app"] 0B
sha256:6de04a2d12be7dcf72177582715cfc2037faaf23ba2d8c061c0d0b4c7bfdb298 45 seconds ago /bin/sh -c #(nop) USER scratch 0B
sha256:bfbf3472db0a1f864eb242aa4d261412617844d6b523388a2c1f431e72acd4de 45 seconds ago /bin/sh -c #(nop) COPY file:ae42242b7bba1bd208972c75cbaab0a93f64777c4969c6985e9426583c62c525 in golang-app 1.33MB
sha256:3e65b3f3dac37b22019fe4a98dc849e6b03caee141b95372d1f5ee854cb14551 46 seconds ago /bin/sh -c #(nop) COPY file:b0213a4138a2af371e3368129198de30da15688fcbc16fb3becede1c4a2f7f93 in /etc/passwd 967B
レイヤーは変更される度に再構築を行ないます。レイヤーが変更されると、その後ろにあるすべてのレイヤーも再構築されてしまい、ビルド時間が増加してしまいます。
依存関係のインストールなど、比較的に変更頻度の低い命令は上の方に。ソースファイルのCOPY
など変更頻度の高い命令は下に配置することが大切です。
また.dockerignore
ファイルを活用して不要なファイルをビルドコンテキストから除外することでもキャッシュの最適化をはかることができます。
node_modules
BuildKitを使う
BuildKitはより新しいビルダーです。BuildKitはバージョン23.0からDocker DesktopとDocker Engineのデフォルトビルダーになっています。
BuildKitを使用することで、不要なビルドステージのスキップ、ビルドステージ構築の並列化など、ビルドパフォーマンスが向上します。
またキャッシュマウントが使用できるようになり、パッケージをインストールする命令を高速化するのに役立ちます。キャッシュマウントでビルド中に使用するパッケージキャッシュを指定することで、レイヤーを再構築する場合でも、新しいパッケージや変更されたパッケージのみをダウンロードするようになります。
RUN --mount=type=cache,target=/go/pkg/mod/ \
go mod download
--mount=type=cache,target=<path>
という形式でキャッシュのディレクトリを指定します。
Go言語の場合は以下のコマンドでディレクトリの場所を調べることができます。
$ go env | grep CACHE
複数の命令を組み合わせる
複数の命令は組み合わせることでレイヤーの数を最小限に抑えることができます。
たとえば上述のDockerfileではRUN
命令をアンパサンド2つ(&&
)とバックスラッシュ(\
)で1つにまとめています。
# RUN go install github.com/cosmtrek/air@latest
# RUN go install github.com/go-delve/delve/cmd/dlv@latest
RUN go install github.com/cosmtrek/air@latest && \
go install github.com/go-delve/delve/cmd/dlv@latest
複数行にまたがる引数は英数順に並べ替えることでメンテナンス性が向上し、重複を防ぐことができます。
RUN --mount=type=cache,target=/go/pkg/mod/ \
go build \
-ldflags="-s -w" \
-o golang-app \
-trimpath
マルチステージビルドを使用する
マルチステージビルドを使うことで、本当に必要なものだけを最終イメージに含めることができます。
マルチステージビルドは複数のFROM
命令を記述することで使用できます。上述のDockerfileの場合は、base, dev, builder, runnerの4ステージで構成されています。
base, dev, runnerステージのイメージサイズを比べてみたいと思います。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
golang runner 522b09cddc5b 27 seconds ago 1.39MB
golang dev 4d8bd97eff7b 2 hours ago 1.1GB
golang base 186cddd98d38 2 hours ago 770MB
devステージのイメージサイズは、Go言語の依存関係のインストールに加え、開発用ツールのインストールなどを行なっているため1GBを超えています。
それに対してrunnerステージのイメージサイズは約1.4MBとかなり小さいことがわかります。
非ルートユーザーを使用する
コンテナが攻撃されたときのリスクを最小限に抑えるため、可能な限り非ルートユーザーでコンテナを実行するようにします。
USER scratch
scratch
イメージをベースにしたコンテナを非ルートユーザーで実行する方法は、Non-privileged containers based on the scratch imageを参考にしました。
ホット リロード・デバッガーツールを活用する
Go言語にもnodemon
のようにホット リロードを提供してくれるAirというツールがあります。devステージのCMD
命令で実行しているのがAirです。.air.toml
ファイルはサンプルをそのまま使用しました。
より詳細な情報はAirのGithubリポジトリを確認してください。
DelveはGo言語のデバッガーツールです。ステップ実行などさまざまなデバッグ機能を提供してくれます。
より詳細な情報はDelveのGithubリポジトリを確認してください。
イメージの脆弱性を確認する
脆弱性検出ツールを使うことでDockerイメージの脆弱性をスキャンできます。
脆弱性検出ツールの1つにGrypeがあります。今回はこのツールを使用してDockerイメージの脆弱性を検査してみたいと思います。
Tutorial: Get started with Goのrsc.io/quote
パッケージを使用したコードを使います。
ビルドしたrunnerステージのイメージをGrypeで脆弱性スキャンしてみます。
$ grype golang:runner --scope all-layers
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c 0.3.7 go-module GHSA-ppp9-7jff-5vj2 High
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c 0.3.8 go-module GHSA-69ch-w2m2-3vjp High
golang.org/x/text
パッケージに脆弱性があることがわかりました。0.3.8以上で修正されたとのことなので、パッケージのアップデートを行ない、再度脆弱性をスキャンします。
$ grype golang:runner --scope all-layers
No vulnerabilities found
golang.org/x/text
パッケージをアップデートすることで脆弱性は見つからなくなりました。
Grypeのより詳細な情報はGithubリポジトリを確認してください。
まとめ
今回の記事では、Dockerのbest practicesを参考にGo言語用のDockerfileを作成してみました。
これがGo言語用Dockerfileの1つの例になれば幸いです。