🐳

【2024年版】Dockerfileのベストプラクティスを整理しながらNode.jsで実践する

2024/11/11に公開

はじめに

最初はなんとなくで書いていた Dockerfile なのですが、社内用にベストプラクティスを整理するタイミングがあったので、実際に Node.js + TypeScript でアプリケーションを作成しながらまとめることにしました。

この記事でフォーカスするのは、 Dockerfile のベストプラクティスそのものの詳細ではなく、それらを整理と「結局どう実装すんねん?」ってところです。

主に以下の内容を参考にしています。

想定読者

Node.js における Dockerfile のベストプラクティスの具体を知りたい方についてはピッタリだと思いますが、その他の言語でも参考になる部分はあると思います。

本記事では、各用語やベストプラクティスの詳細は記載しませんが、該当箇所へのリンクを載せているので必要に応じて参照して下さい。

また、この記事に書いていない内容で、上記3つの記事に書かれているベストプラクティスもいくつかあるので、ぜひそれらも参考にして下さい。

環境

筆者の Docker 環境は以下の通りです。

Docker のバージョン
$ docker version
Client:
 Version:           27.3.1
 API version:       1.47
 Go version:        go1.22.7
 Git commit:        ce12230
 Built:             Fri Sep 20 11:38:18 2024
 OS/Arch:           darwin/arm64
 Context:           orbstack

Server: Docker Engine - Community
 Engine:
  Version:          27.3.1
  API version:      1.47 (minimum version 1.24)
  Go version:       go1.22.7
  Git commit:       41ca978
  Built:            Fri Sep 20 11:39:57 2024
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          v1.7.22
  GitCommit:        7f7fdf5fed64eb6a7caf99b3e12efcf9d60e311c
 runc:
  Version:          1.1.14
  GitCommit:        2c9f5602f0ba3d9da1c2596322dfc4e156844890
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

OrbStack を使用しています。軽いので Mac ユーザーにはおすすめです。

本題

ベストプラクティスを整理しながら、具体例として Node.js + TypeScript で Fastify を用いたAPIサーバーアプリケーションの Dockerfile を作成してみます。(Fastify側のコードについては説明しません)

以下の5つのポイントで整理してみました。

  1. 最適化観点
  2. セキュリティ観点
  3. Node.js 特有の観点
  4. その他の観点
  5. 静的解析

成果物は以下のリポジトリにあります。

Dockerfile 全体

1. 最適化観点

ビルド速度や軽量化などの観点でのベストプラクティスです。

  1. Multi-stage build を利用する
  2. 実行用イメージは必要最小限のものにする
  3. 不要な依存関係はインストールしない
  4. 命令の順番に気をつける
  5. ビルドコンテキストと .dockerignore を理解する
  6. mint を利用する

1-1. Multi-stage build を利用する

次の項目とも被りますが、ビルド用のイメージと実行用のイメージを分けることで、ビルド時に必要なものはビルド用のイメージにのみ、実行時に必要なものは実行用のイメージにのみ含めることができます。

こうすることで、実行時のイメージに含まれるものを最小限にできます。

Dockerfile
# ビルド用のイメージ
FROM debian:bookworm-slim AS builder
# 実行用のイメージ
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner

1-2. 実行用イメージは必要最小限のものにする

実行用イメージには、実行時に必要な最小限のライブラリやツールのみを含めたものを利用しましょう。

最も良いのは scratch から自前で必要なものだけを含めたイメージを作成することですが、少しやりすぎ感もあるので distroless イメージを利用するのが簡単かと思います。

Dockerfile
# 実行用のイメージ
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner

1-3. 不要な依存関係はインストールしない

実行時だけでなくビルド時にも、不要なパッケージはインストールしないようにしましょう。

apt-get install の際は --no-install-recommends を指定して、不要な依存関係をインストールしないようにしましょう。

Dockerfile
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
  ca-certificates \
  curl
EOF

また、以下は TypeScript 特有の対策ですが、 TypeScript コードのビルド時には devDependencies が必要なため一時的にインストールします。ただし実行時には不要なので、ビルド後に削除して dependencies のみを再インストールするようにしています。

yarn の設定

.yarnrc.ymlnodeLinker: node-modules を指定しています

https://github.com/mutex-inc/zenn-chrg1001-node-ts-docker/blob/main/.yarnrc.yml

  1. ビルド時には devDependencies も含めてインストール
  2. node_modules を削除して、実行時に必要な dependencies のみを再インストール
Dockerfile
# 依存関係のインストール
...
RUN yarn install --immutable

# ビルド & 実行時に必要な依存関係のみを再インストール
COPY tsconfig*.json ./
COPY src ./src
RUN <<EOF
yarn build
yarn workspaces focus --all --production
EOF

1-4. 命令の順番に気をつける

RUN, COPY, ADD などの命令はコンテナレイヤーを作成するため、以下の2点に意識して記述しましょう。

  • できるだけまとめて書く
  • 変更頻度の低い命令を先に書く

そうすることで、ビルド時のキャッシュを有効に活用できます。(キャッシュ無しでビルドするには --no-cache=true を指定すれば良いです)

1-4a. できるだけまとめて書く

Dockerfile
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
    ca-certificates \
    curl

ではなく

Dockerfile
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
  ca-certificates \
  curl
EOF

1-4b. 変更頻度の少ない順に命令を書く

Dockerfile
COPY .yarnrc.yml package.json yarn.lock ./
COPY tsconfig*.json ./
COPY src ./src

# 依存関係のインストール
RUN yarn install --immutable
# ビルド
RUN yarn build

ではなく

Dockerfile
# 依存関係のインストール
COPY .yarnrc.yml package.json yarn.lock ./
RUN yarn install --immutable

# ビルド
COPY tsconfig*.json ./
COPY src ./src
RUN yarn build

1-5. ビルドコンテキストと .dockerignore を理解する

以下のビルドコマンドにおける、 . はビルドコンテキストを指定するものです。

docker build -t node-ts-docker:0.0.1 .

ビルドコンテキストで指定された以下のファイルはすべて Docker デーモンに送信されます。

軽量化だけでなく、セキュリティ観点でも、これらは最小限にすべきです。

最善の方法は、

  1. 新たなディレクトリを用意する
  2. そこにビルドに必要なファイルのみを毎回コピー
  3. docker build

とすることかと思います。今回は簡単のため、以下のように .dockerignore を設定することで、必要なファイル(COPY で指定されるもの)のみをビルドコンテキストに含めるようにしています。

https://github.com/mutex-inc/zenn-chrg1001-node-ts-docker/blob/main/.dockerignore

1-6. mint を利用する

distroless などの軽量なベースイメージを利用できない場合や、さらに軽量化したい場合には mint (旧 DockerSlim) を利用しましょう。

詳細は先人たちの記事に譲りますが、「静的・動的解析を行い、不要なファイルや依存関係を削除したイメージ」を生成してくれます。

以下のように利用できます。

brew install docker-slim # インストール
mint slim node-ts-docker:0.0.1

どれくらい軽量化できたか見てみると

$ docker images --no-trunc -f "reference=node-ts-docker*"
REPOSITORY            TAG       IMAGE ID                                                                  CREATED          SIZE
node-ts-docker.slim   latest    sha256:24e7f1a1cc274b3e3397b4508465d14757bee833455f548760f8174cc7eed357   34 minutes ago   123MB
node-ts-docker        0.0.1     sha256:d549e619c0739b828a14f4fef17512e16f00361fefb8492e9f49e10a8d92d2bb   36 minutes ago   156MB

156MB -> 123MB となりました。(今回は元が distroless なので、そこまで大きくは変わりませんでした)

2. セキュリティ観点

セキュリティ観点でのベストプラクティスです。

  1. 最小権限のユーザーで実行する
  2. 信頼できるベースイメージを利用する
  3. イメージへの署名と署名の検証
  4. ビルドしたイメージのセキュリティチェックを行う

以下の、最適化観点で上げたものと被るものは省略します。

2-1. 最小権限のユーザーで実行する

USER を指定しない場合、デフォルトでは、 root ユーザーを使用してアプリケーションが実行されます。

実行用イメージにおいてroot権限が実行に不要であれば、 非root なユーザーでアプリケーションを実行しましょう。

distroless の場合は、 nonroot タグを指定すれば特に設定は不要です。

Dockerfile
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner

2-2. 信頼できるベースイメージを利用する

これはそのままです。よくわからないベースイメージではなく、信頼できるものを利用しましょう。

2-3. イメージへの署名と署名の検証

Docker Content Trust (DCT) を利用することで、イメージの署名と検証を行うことができます。

デフォルトではoffになっているため、 DOCKER_CONTENT_TRUST=1 で有効にできます。以下を実行したうえで docker build などを行うと、署名の検証等が行われます。

export DOCKER_CONTENT_TRUST=1

ただし、今回は有効にしていません。 distroless イメージのホストである Google Container Registry が DCT に対応していないためです。

詳しくは以下などを参考にして下さい。

2-4. ビルドしたイメージのセキュリティチェックを行う

ここでは、脆弱性スキャンと CIS Docker Benchmarks への準拠の方法を紹介します。

2-4a. 脆弱性スキャンを行う

作成した Dockerイメージに含まれる依存関係に脆弱性がないかをチェックするために、 Docker のプラグインである Docker Scout CLI を利用します。

調べるとダッシュボードを利用する方法がたくさん出てきますが、ローカルイメージに対するスキャン[1]も可能です。

Docker Desktop (4.17以降) 以外の環境 (OrbStack など) で Docker を使用している場合は、別途 Docker Scout CLI をインストールしてください。

curl -sSfL https://raw.githubusercontent.com/docker/scout-cli/main/install.sh | sh -s --

その後、以下のコマンドで脆弱性をチェックできます。

docker scout qv node-ts-docker:0.0.1

その他 Docker Scout の細かい使い方については、公式ドキュメントを参照して下さい。

2-4b. CIS Docker Benchmarks に準拠する

CIS が定義している Docker の Benchmark である CIS Docker Benchmarks に準拠しているかをチェックします。

ツールは色々あるのですが、今回は dockle を利用します。理由としては、CIS Docker Benchmarks だけでなく、他のベストプラクティスへの準拠もチェックできる、また最も手軽に利用できるためです。(ただし対応バージョンは少し古く CIS Docker 1.13.0 Benchmark v1.0.0 です)

その他のツールの例

以下はいずれも、最新の CIS Docker Benchmark v1.6.0 に対応しています。

  • docker/docker-bench-security
    Docker 公式のツールです。ビルドイメージだけでなく、ホスト環境などを含めた総合的なチェックが可能です。

    上記リポジトリをクローンして、そのディレクトリ内で以下のように実行すれば、ビルドイメージに対するチェックのみ実施できます。

    sudo sh docker-bench-security.sh -t node-ts-docker:0.0.1 -c container_images
    
  • aquasecurity/docker-bench
    go でビルドして、configファイルを設置して実行するタイプのツールです。様々なバージョンの CIS Docker Benchmark に対応しています。

インストールは以下の通りです。

brew install goodwithtech/r/dockle

チェックの実施は以下の通りです。

dockle node-ts-docker:0.0.1

実行すると以下のような結果が出力されます。(.dockleignore なしで実行した場合)

KIP     - DKL-LI-0001: Avoid empty password
        * failed to detect etc/shadow,etc/master.passwd
INFO    - CIS-DI-0005: Enable Content trust for Docker
        * export DOCKER_CONTENT_TRUST=1 before docker pull/build
INFO    - CIS-DI-0006: Add HEALTHCHECK instruction to the container image
        * not found HEALTHCHECK statement

今回はいずれも対応しないので、 .dockleignore に記述して ignore します。

https://github.com/mutex-inc/zenn-chrg1001-node-ts-docker/blob/main/.dockleignore

3. Node.js 特有の観点

Node.js のアプリケーションを Docker 化する際のベストプラクティスです。

  1. カーネルシグナルに対する適切な処理を行う
  2. NODE_ENV=production を設定する

3-1. カーネルシグナルに対する適切な処理を行う

Node.js は PID 1 で動くように設計されていないため、コンテナ内でシグナルを適切に処理するように設定してあげる必要があります。

現在は(Docker 1.13 以降は)、--init フラグを使用するだけで対応できます。tini のインストールは不要です。

docker run --init ...

3-2. NODE_ENV=production を設定する

docker run -e "NODE_ENV=production" ...

NODE_ENV に関しては runner 側の ENV で設定しておいても良いかもしれません。

Dockerfile
...
ENV NODE_ENV=production
...

4. その他の観点

必須のベストプラクティスというよりは、こういう機能もありますよみたいなところです。

  1. pipe を使用する際は、-o pipefail を指定する
  2. ステージ間で共通の変数を使う
  3. ヒアドキュメントを利用する
  4. Parser directives を利用する
  5. Volta を利用する
  6. COPY ではなく RUN --mount オプションを利用する

4-1. pipe を使用する際は、-o pipefail を指定する

pipe を使用する際は、-o pipefail を指定することで、パイプライン内のコマンドがエラーを返した場合に、パイプライン全体がエラーとなるようにできます。

RUN ごとに毎回 set -o pipefail && をつけるか、 SHELL によって以下のように指定するかしましょう。

Dockerfile
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
...
RUN curl https://get.volta.sh | bash

4-2. ステージ間で共通の変数を使う

builder から runner に必要ファイルをコピーする際の、コピー元のディレクトリ名など、ステージ間で共通の値を使いたい場合があります。

これは、 ARG で定義した変数のスコープに気をつければ実現できます。イメージをまたいだ変数については以下のとおりです。

  • 親イメージ内で定義された変数については、子イメージで引き継がれる
  • グローバルスコープで定義された変数は、イメージ内で再宣言すれば利用できる

詳細は上記のドキュメントに譲りますが、2つ目の挙動を利用して以下のように記述することで、ビルド時に利用する変数を共通化できます。

Dockerfile
ARG BUILDER_APP_DIR=/app
ARG RUNNER_APP_DIR=/app

FROM debian:bookworm-slim AS builder

# 変数を利用できるように再宣言する
ARG BUILDER_APP_DIR

...

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner

# 変数を利用できるように再宣言する
ARG BUILDER_APP_DIR
ARG RUNNER_APP_DIR

4-3. ヒアドキュメントを利用する

可読性と保守性の観点から、複数行のコマンドを記述する際にヒアドキュメントを利用しましょう。

Dockerfile
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    ca-certificates \
    curl

ではなく

Dockerfile
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
  ca-certificates \
  curl
EOF

4-4. Parser directives を利用する

# directive=value の形式で、Dockerfile の解析方法を指定できます。

ここでは Docker Build checks を用いる際に役に立つ check ディレクティブについてのみ見ます。

デフォルトでは、ビルドチェックに失敗したビルドは、warning があってもステータスコード0で終了してしまいますが、これを失敗とするために #check=error=true を設定します。

他には、無視するベストプラクティスなども設定できます。

Dockerfile
# syntax=docker/dockerfile:1
# check=error=true

4-5. Volta を利用する

これはベストプラクティスではなく、 How to です。

弊社では Node.js のバージョン管理ツールとして Volta を採用しています。

Volta を利用するには、 ca-certificatescurl が必要なため、 apt-get install しています。

Dockerfile
# Volta のインストールのために bash が必要
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV BASH_ENV=~/.bashrc
ENV VOLTA_HOME=/root/.volta
ENV PATH=$VOLTA_HOME/bin:$PATH

# Volta をインストールするために curl と ca-certificates が必要
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
  ca-certificates \
  curl
EOF

# Volta のインストール
RUN curl https://get.volta.sh | bash

4-6. COPY ではなく RUN --mount オプションを利用する

コンパイル時に必要なファイルたちをビルドイメージにコピーする際には、 COPYADD ではなく、ビルドコンテキストから bind マウントする方法があります。ホスト側のファイルをマウントすることにより、 builder イメージ内に不要なファイルをコピーしないようにすることができます。

こうすることで、レイヤーを残さないようにできます。

例えば TypeScript では 「*.ts ファイルを builder イメージにコピーせずに bind マウントし、 ビルド結果の *js のみを builder イメージ内に吐き出す」という使い方ができます。

かなり冗長になるので今回は採用しませんでしたが、もし使う場合は以下のように記述できます。

Dockerfile
# 依存関係のインストール
RUN \
  --mount=type=bind,source=package.json,target=package.json \
  --mount=type=bind,source=yarn.lock,target=yarn.lock \
  --mount=type=bind,source=.yarnrc.yml,target=.yarnrc.yml \
  <<EOF
yarn install --immutable
EOF

# ビルド & 実行時に必要な依存関係のみを再インストール
RUN \
  --mount=type=bind,source=package.json,target=package.json \
  --mount=type=bind,source=yarn.lock,target=yarn.lock \
  --mount=type=bind,source=.yarnrc.yml,target=.yarnrc.yml \
  --mount=type=bind,source=tsconfig.json,target=tsconfig.json \
  --mount=type=bind,source=tsconfig.build.json,target=tsconfig.build.json \
  --mount=type=bind,source=src,target=src \
  <<EOF
yarn build
yarn workspaces focus --all --production
EOF

さらに、 --mount=type=cache を利用することで、おもにパッケージマネージャー周りの依存関係など一時的に必要となるものをキャッシュとして利用することもできます。

apt install 部分から全て利用した場合は以下のようにできます。

`--mount=type=cache` まで利用した例

かなり冗長になります。

yarn build のタイミングで必要な node_modules--mount=type=cache,target=node_modules,sharing=locked でキャッシュとして利用し、 yarn workspaces focus --all --production のタイミングではそれを指定せずに、実際に必要な依存関係のみを builder イメージ内に再インストールしているところもポイントです。

Dockerfile
# Volta をインストールするために curl と ca-certificates が必要
RUN \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=cache,target=/var/cache/apt,sharing=locked \
  <<EOF
apt-get update
apt-get install -y --no-install-recommends \
  ca-certificates \
  curl
EOF

# Volta のインストール
RUN \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=cache,target=/var/cache/apt,sharing=locked \
  <<EOF
  curl https://get.volta.sh | bash
EOF

# 依存関係のインストール
RUN \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=bind,source=package.json,target=package.json \
  --mount=type=bind,source=yarn.lock,target=yarn.lock \
  --mount=type=bind,source=.yarnrc.yml,target=.yarnrc.yml \
  --mount=type=cache,target=node_modules,sharing=locked \
  --mount=type=cache,target=.yarn,sharing=locked \
  <<EOF
yarn install --immutable
EOF

# ビルド
RUN \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=bind,source=package.json,target=package.json \
  --mount=type=bind,source=yarn.lock,target=yarn.lock \
  --mount=type=bind,source=.yarnrc.yml,target=.yarnrc.yml \
  --mount=type=bind,source=tsconfig.json,target=tsconfig.json \
  --mount=type=bind,source=tsconfig.build.json,target=tsconfig.build.json \
  --mount=type=bind,source=src,target=src \
  --mount=type=cache,target=node_modules,sharing=locked \
  --mount=type=cache,target=.yarn,sharing=locked \
  <<EOF
yarn build
EOF

# 実行時に必要な依存関係のみを再インストール
RUN \
  --mount=type=cache,target=/var/lib/apt,sharing=locked \
  --mount=type=cache,target=/var/cache/apt,sharing=locked \
  --mount=type=bind,source=package.json,target=package.json \
  --mount=type=bind,source=yarn.lock,target=yarn.lock \
  --mount=type=bind,source=.yarnrc.yml,target=.yarnrc.yml \
  --mount=type=bind,source=tsconfig.json,target=tsconfig.json \
  --mount=type=bind,source=tsconfig.build.json,target=tsconfig.build.json \
  --mount=type=cache,target=.yarn,sharing=locked \
  <<EOF
yarn workspaces focus --all --production
EOF

5. 静的解析

作成した Dockerfile が ここまであげたような Docker Best Practice に準拠しているかを確認するために、静的解析を行いましょう。

ツールとしては hadolint が有名ですが、(追加で)インストールが不要な Docker Build checks を用いても良いでしょう。

  1. hadolint
  2. Docker Build checks

5-1. hadolint

弊社では hadolint を採用しています。VS Code 拡張が存在しており、 Dockerfile を記述しているタイミングでリアルタイムに解析してくれるためです。

brew install hadolint

VS Code 拡張も入れておくと便利です。

以下のようなコマンドで、 Dockerfile の静的解析を行うことができます。

hadolint Dockerfile -c .hadolint.yml

無視するルールなどの諸々の設定は .hadolint.yml などに記載できます。

今回は、バージョン指定なしで apt-get install することを無視するように設定しています。

https://github.com/mutex-inc/zenn-chrg1001-node-ts-docker/blob/main/.hadolint.yml

5-2. Docker Build checks

Docker Build checks の場合は、以下のように利用できます。

docker build --check . -f Dockerfile

以下の記事も参考になるかと思います。

ビルド&実行してみる

これまでのところを踏まえて出来上がった Dockerfile をビルドして、コンテナを起動してみます。

Dockerfile 全体(再掲)

まず、 hadolint を用いて静的解析を行います。

hadolint Dockerfile -c .hadolint.yml

特に問題がなければ、以下のコマンドで Docker イメージをビルドします。

docker build . \
  -f Dockerfile \
  -t node-ts-docker:0.0.1 \
  --target runner \
  --build-arg "APP_PORT=3000"

ビルドしたイメージの脆弱性をチェックするために、 Docker Scout CLI を利用します。

docker scout qv node-ts-docker:0.0.1

次に、 CIS Docker Benchmarks に準拠しているかをチェックするために、 dockle を利用します。

dockle node-ts-docker:0.0.1

こちらでも問題は検出されなかったため、以下のコマンドで Docker コンテナを起動します。

docker run \
  --init \
  -p 127.0.0.1:3000:3000 \
  -e "NODE_ENV=production" \
  --name node-ts-docker \
  node-ts-docker:0.0.1

おわりに

社内で用いている Dockerfile のベストプラクティスを整理しつつ、 Node.js + TypeScript で Fastify を用いた API サーバーアプリケーションの Dockerfile を紹介しました。

ベストプラクティスはすべて実践しようとするのではなく、プロジェクトやチームの状況に応じて適切なものを選択することが重要です。そのためにも、そもそもどういうベストプラクティスがあるのかを知ることが大切だと思います。

次回以降、CIS Docker Benchmarks への準拠でその他のツールとして紹介した docker/docker-bench-security などを用いた、 Docker Container 周りのセキュリティについてまとめようかと思っています。

参考記事

脚注
  1. Analyze images locally ↩︎

mutex Official Tech Blog

Discussion