【2024年版】Dockerfileのベストプラクティスを整理しながらNode.jsで実践する
はじめに
最初はなんとなくで書いていた 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つのポイントで整理してみました。
成果物は以下のリポジトリにあります。
Dockerfile 全体
1. 最適化観点
ビルド速度や軽量化などの観点でのベストプラクティスです。
- Multi-stage build を利用する
- 実行用イメージは必要最小限のものにする
- 不要な依存関係はインストールしない
- 命令の順番に気をつける
- ビルドコンテキストと
.dockerignore
を理解する - mint を利用する
1-1. Multi-stage build を利用する
次の項目とも被りますが、ビルド用のイメージと実行用のイメージを分けることで、ビルド時に必要なものはビルド用のイメージにのみ、実行時に必要なものは実行用のイメージにのみ含めることができます。
こうすることで、実行時のイメージに含まれるものを最小限にできます。
# ビルド用のイメージ
FROM debian:bookworm-slim AS builder
# 実行用のイメージ
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner
1-2. 実行用イメージは必要最小限のものにする
実行用イメージには、実行時に必要な最小限のライブラリやツールのみを含めたものを利用しましょう。
最も良いのは scratch
から自前で必要なものだけを含めたイメージを作成することですが、少しやりすぎ感もあるので distroless イメージを利用するのが簡単かと思います。
# 実行用のイメージ
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner
1-3. 不要な依存関係はインストールしない
実行時だけでなくビルド時にも、不要なパッケージはインストールしないようにしましょう。
apt-get install
の際は --no-install-recommends
を指定して、不要な依存関係をインストールしないようにしましょう。
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl
EOF
また、以下は TypeScript 特有の対策ですが、 TypeScript コードのビルド時には devDependencies
が必要なため一時的にインストールします。ただし実行時には不要なので、ビルド後に削除して dependencies
のみを再インストールするようにしています。
yarn の設定
.yarnrc.yml
に nodeLinker: node-modules
を指定しています
- ビルド時には
devDependencies
も含めてインストール -
node_modules
を削除して、実行時に必要なdependencies
のみを再インストール
# 依存関係のインストール
...
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. できるだけまとめて書く
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
ca-certificates \
curl
ではなく
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl
EOF
1-4b. 変更頻度の少ない順に命令を書く
COPY .yarnrc.yml package.json yarn.lock ./
COPY tsconfig*.json ./
COPY src ./src
# 依存関係のインストール
RUN yarn install --immutable
# ビルド
RUN yarn build
ではなく
# 依存関係のインストール
COPY .yarnrc.yml package.json yarn.lock ./
RUN yarn install --immutable
# ビルド
COPY tsconfig*.json ./
COPY src ./src
RUN yarn build
.dockerignore
を理解する
1-5. ビルドコンテキストと
以下のビルドコマンドにおける、 .
はビルドコンテキストを指定するものです。
docker build -t node-ts-docker:0.0.1 .
ビルドコンテキストで指定された以下のファイルはすべて Docker デーモンに送信されます。
軽量化だけでなく、セキュリティ観点でも、これらは最小限にすべきです。
最善の方法は、
- 新たなディレクトリを用意する
- そこにビルドに必要なファイルのみを毎回コピー
docker build
とすることかと思います。今回は簡単のため、以下のように .dockerignore
を設定することで、必要なファイル(COPY
で指定されるもの)のみをビルドコンテキストに含めるようにしています。
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. セキュリティ観点
セキュリティ観点でのベストプラクティスです。
以下の、最適化観点で上げたものと被るものは省略します。
-
実行用イメージは必要最小限のものにする, 不要な依存関係はインストールしない
今後出てきうる脆弱性にぶつかる可能性を少しでも減らすためにも、実行時のイメージには不要なものは含めないようにしましょう。 -
ビルドコンテキストと
.dockerignore
を理解する
機密情報はビルドコンテキストに含めないようにしましょう。RUN
コマンドでrm -rf
で削除しても、途中のレイヤには残ります。
2-1. 最小権限のユーザーで実行する
USER
を指定しない場合、デフォルトでは、 root ユーザーを使用してアプリケーションが実行されます。
実行用イメージにおいてroot権限が実行に不要であれば、 非root なユーザーでアプリケーションを実行しましょう。
distroless の場合は、 nonroot
タグを指定すれば特に設定は不要です。
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 に対応していないためです。
詳しくは以下などを参考にして下さい。
- Content trust in Docker, Docker のコンテントトラスト
- Docker Content Trust 101
- Dockerイメージのなりすましや改ざんを防ぐには?(Docker Content Trust)
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 の細かい使い方については、公式ドキュメントを参照して下さい。
- docker scoutでimageの脆弱性診断をしたりダッシュボードにアクセスしたり
- docker scoutでimageの脆弱性を減らしていく手順
- docker scout quickview で Docker イメージの脆弱性情報を取得
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 します。
3. Node.js 特有の観点
Node.js のアプリケーションを Docker 化する際のベストプラクティスです。
3-1. カーネルシグナルに対する適切な処理を行う
Node.js は PID 1 で動くように設計されていないため、コンテナ内でシグナルを適切に処理するように設定してあげる必要があります。
現在は(Docker 1.13 以降は)、--init
フラグを使用するだけで対応できます。tini
のインストールは不要です。
docker run --init ...
NODE_ENV=production
を設定する
3-2.
docker run -e "NODE_ENV=production" ...
NODE_ENV
に関しては runner
側の ENV
で設定しておいても良いかもしれません。
...
ENV NODE_ENV=production
...
4. その他の観点
必須のベストプラクティスというよりは、こういう機能もありますよみたいなところです。
- pipe を使用する際は、
-o pipefail
を指定する - ステージ間で共通の変数を使う
- ヒアドキュメントを利用する
- Parser directives を利用する
- Volta を利用する
- COPY ではなく RUN --mount オプションを利用する
-o pipefail
を指定する
4-1. pipe を使用する際は、
pipe を使用する際は、-o pipefail
を指定することで、パイプライン内のコマンドがエラーを返した場合に、パイプライン全体がエラーとなるようにできます。
RUN
ごとに毎回 set -o pipefail &&
をつけるか、 SHELL
によって以下のように指定するかしましょう。
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
...
RUN curl https://get.volta.sh | bash
4-2. ステージ間で共通の変数を使う
builder から runner に必要ファイルをコピーする際の、コピー元のディレクトリ名など、ステージ間で共通の値を使いたい場合があります。
これは、 ARG
で定義した変数のスコープに気をつければ実現できます。イメージをまたいだ変数については以下のとおりです。
- 親イメージ内で定義された変数については、子イメージで引き継がれる
- グローバルスコープで定義された変数は、イメージ内で再宣言すれば利用できる
詳細は上記のドキュメントに譲りますが、2つ目の挙動を利用して以下のように記述することで、ビルド時に利用する変数を共通化できます。
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. ヒアドキュメントを利用する
可読性と保守性の観点から、複数行のコマンドを記述する際にヒアドキュメントを利用しましょう。
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl
ではなく
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
を設定します。
他には、無視するベストプラクティスなども設定できます。
# syntax=docker/dockerfile:1
# check=error=true
4-5. Volta を利用する
これはベストプラクティスではなく、 How to です。
弊社では Node.js のバージョン管理ツールとして Volta を採用しています。
Volta を利用するには、 ca-certificates
と curl
が必要なため、 apt-get install
しています。
# 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 オプションを利用する
コンパイル時に必要なファイルたちをビルドイメージにコピーする際には、 COPY
や ADD
ではなく、ビルドコンテキストから bind マウントする方法があります。ホスト側のファイルをマウントすることにより、 builder
イメージ内に不要なファイルをコピーしないようにすることができます。
こうすることで、レイヤーを残さないようにできます。
例えば TypeScript では 「*.ts
ファイルを builder
イメージにコピーせずに bind マウントし、 ビルド結果の *js
のみを builder
イメージ内に吐き出す」という使い方ができます。
かなり冗長になるので今回は採用しませんでしたが、もし使う場合は以下のように記述できます。
# 依存関係のインストール
RUN \
\
<<EOF
yarn install --immutable
EOF
# ビルド & 実行時に必要な依存関係のみを再インストール
RUN \
\
<<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
イメージ内に再インストールしているところもポイントです。
# Volta をインストールするために curl と ca-certificates が必要
RUN \
\
<<EOF
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl
EOF
# Volta のインストール
RUN \
\
<<EOF
curl https://get.volta.sh | bash
EOF
# 依存関係のインストール
RUN \
\
<<EOF
yarn install --immutable
EOF
# ビルド
RUN \
\
<<EOF
yarn build
EOF
# 実行時に必要な依存関係のみを再インストール
RUN \
\
<<EOF
yarn workspaces focus --all --production
EOF
5. 静的解析
作成した Dockerfile が ここまであげたような Docker Best Practice に準拠しているかを確認するために、静的解析を行いましょう。
ツールとしては hadolint が有名ですが、(追加で)インストールが不要な 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
することを無視するように設定しています。
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 周りのセキュリティについてまとめようかと思っています。
参考記事
- Docker and Node.js Best Practices
- 社内のDockerfileのベストプラクティスを公開します
- DockerでNode.jsを動かすときのベストプラクティス
- Dockerfileのベストプラクティス
- distrolessのnonrootイメージを使おう
- Docker ノウハウ集
- 2024年版のDockerfileの考え方&書き方
Discussion