【Docker】Dockerのbest practicesをGo言語で

# はじめに

これは[Dockerのbest practices](https://docs.docker.com/develop/)をGo言語のイメージを例にやってみた、という内容の記事です。

Dockerの使い方は覚えたけれど、どのようなDockerfileにすれば良いのかわからない。という方に1つの例を提供することを目的としています。

## 環境

- Docker: Docker version 20.10.23, build v20.10.23

# Dockerfileサンプル

Dockerのbest practicesを参考に作成したDockerfileがこちらになります。

```docker: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](https://docs.docker.com/develop/security-best-practices/#choose-the-right-base-image)には、以下のような記述があります。

> 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`というコマンドを使うそうです。

https://stackoverflow.com/questions/49955097/how-do-i-add-a-user-when-im-using-alpine-as-a-base-image

# レイヤーのキャッシュを活用する

Dockerのビルドキャッシュについて知ることで、ビルド時間を短縮できます。

Dockerfileの各命令はそれぞれが1つのレイヤーに対応しています。

:::note info
レイヤーを構築する命令について

[Best practices for writing Dockerfiles](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#minimize-the-number-of-layers)に以下のような記述があることから、厳密には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つのレイヤーがあることがわかります。

```bash
$ 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`ファイルを活用して不要なファイルをビルドコンテキストから除外することでもキャッシュの最適化をはかることができます。

```docker:.dockerignore
node_modules
```

# BuildKitを使う

[BuildKit](https://docs.docker.com/build/buildkit/)はより新しいビルダーです。BuildKitはバージョン23.0からDocker DesktopとDocker Engineのデフォルトビルダーになっています。

BuildKitを使用することで、不要なビルドステージのスキップ、ビルドステージ構築の並列化など、ビルドパフォーマンスが向上します。

またキャッシュマウントが使用できるようになり、パッケージをインストールする命令を高速化するのに役立ちます。キャッシュマウントでビルド中に使用するパッケージキャッシュを指定することで、レイヤーを再構築する場合でも、新しいパッケージや変更されたパッケージのみをダウンロードするようになります。

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

`--mount=type=cache,target=<path>`という形式でキャッシュのディレクトリを指定します。

Go言語の場合は以下のコマンドでディレクトリの場所を調べることができます。

```bash
$ go env | grep CACHE
```

# 複数の命令を組み合わせる

複数の命令は組み合わせることでレイヤーの数を最小限に抑えることができます。

たとえば上述のDockerfileでは`RUN`命令をアンパサンド2つ（`&&`）とバックスラッシュ（`\`）で1つにまとめています。

```Docker
# 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
```

複数行にまたがる引数は英数順に並べ替えることでメンテナンス性が向上し、重複を防ぐことができます。

```Docker
RUN --mount=type=cache,target=/go/pkg/mod/ \
    go build \
    -ldflags="-s -w" \
    -o golang-app \
    -trimpath
```

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

[マルチステージビルド](https://docs.docker.com/build/building/multi-stage/)を使うことで、本当に必要なものだけを最終イメージに含めることができます。

マルチステージビルドは複数の`FROM`命令を記述することで使用できます。上述のDockerfileの場合は、base, dev, builder, runnerの4ステージで構成されています。

base, dev, runnerステージのイメージサイズを比べてみたいと思います。

```bash
$ 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とかなり小さいことがわかります。

# 非ルートユーザーを使用する

コンテナが攻撃されたときのリスクを最小限に抑えるため、可能な限り非ルートユーザーでコンテナを実行するようにします。

```Docker
USER scratch
```

`scratch`イメージをベースにしたコンテナを非ルートユーザーで実行する方法は、[Non-privileged containers based on the scratch image](https://medium.com/@lizrice/non-privileged-containers-based-on-the-scratch-image-a80105d6d341)を参考にしました。

# ホット リロード・デバッガーツールを活用する

Go言語にも`nodemon`のようにホット リロードを提供してくれるAirというツールがあります。devステージの`CMD`命令で実行しているのがAirです。`.air.toml`ファイルは[サンプル](https://github.com/cosmtrek/air/blob/master/air_example.toml)をそのまま使用しました。

より詳細な情報は[AirのGithubリポジトリ](https://github.com/cosmtrek/air)を確認してください。

DelveはGo言語のデバッガーツールです。ステップ実行などさまざまなデバッグ機能を提供してくれます。

より詳細な情報は[DelveのGithubリポジトリ](https://github.com/go-delve/delve)を確認してください。

# イメージの脆弱性を確認する

脆弱性検出ツールを使うことでDockerイメージの脆弱性をスキャンできます。

脆弱性検出ツールの1つにGrypeがあります。今回はこのツールを使用してDockerイメージの脆弱性を検査してみたいと思います。

[Tutorial: Get started with Go](https://go.dev/doc/tutorial/getting-started)の`rsc.io/quote`パッケージを使用したコードを使います。

ビルドしたrunnerステージのイメージをGrypeで脆弱性スキャンしてみます。

```bash
$ 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以上で修正されたとのことなので、パッケージのアップデートを行ない、再度脆弱性をスキャンします。

```bash
$ grype golang:runner --scope all-layers
No vulnerabilities found
```

`golang.org/x/text`パッケージをアップデートすることで脆弱性は見つからなくなりました。

Grypeのより詳細な情報は[Githubリポジトリ](https://github.com/anchore/grype)を確認してください。

# まとめ

今回の記事では、[Dockerのbest practices](https://docs.docker.com/develop/)を参考にGo言語用のDockerfileを作成してみました。

これがGo言語用Dockerfileの1つの例になれば幸いです。