Node.jsのDockerfile Best Practice

Ko Yamaura

概要

Node.jsのDockerfileのベストプラクティスについて調べたのでまとめてみました。

目次

完成品

最終的な完成形を先に共有します。条件としては

  • Node.js v16
  • TypeScript

を利用しているプロジェクト向けになりますが、Nodeのバージョンは他のバージョンでも動くと思います。

以下、Dockerfileとdockerignoreのサンプルです。

Dockerfile

FROM node:16 AS build

ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini

WORKDIR /home/node
COPY package.json package-lock.json .
# if you dont need npmrc secret, RUN npm ci
RUN --mount=type=secret,id=npm,target=/root/.npmrc npm ci
COPY . .
RUN npm run build


FROM node:16-slim
ENV NODE_ENV=production

COPY --from=build --chown=node:node /tini /tini

WORKDIR /home/node
COPY --from=build --chown=node:node /home/node/node_modules node_modules
COPY --from=build --chown=node:node /home/node/package*.json .
COPY --from=build --chown=node:node /home/node/dist dist

USER node
RUN npm prune --production

EXPOSE 8080
ENTRYPOINT ["/tini", "--"]
CMD [ "node", "dist/index.js" ]

.dockerignore

**/node_modules/
**/.git
**/README.md
**/LICENSE
**/.vscode
**/npm-debug.log
**/coverage
**/.env
**/.editorconfig
**/.aws
**/dist
**/.npmrc

ポイント毎の解説

マルチステージビルド

Dockerにはマルチステージビルドといってステージを分けてビルドすることができます。 Node.jsでTypeScriptを使っている場合はTypeScriptを最初のビルドステージでトランスパイルしてJavaScriptを生成、最終ステージにはJavaScriptだけで含むDockerfile イメージを作ることができます。

メリットとしては以下がよく挙げられます。

  • Dockerイメージの最小化
  • ビルド、リリースの高速化
  • セキュリティが含まれるパッケージの最小化

Docker build cacheの活用

Dockerはレイヤー毎にキャッシュされ、上から順番にキャッシュがあればキャッシュが採用されます。キャッシュが使えないレイヤー以降はキャッシュが利用されずに順番に命令が実行されるので、変更の少ない処理を上に記述することでdocker buildの速度を高速化できます。 キャッシュ対象は

具体例

package.json, package-lock.jsonファイルの変更頻度はあまり多く無いはずなので、まず初めにnpm ciまでを終わらせ、その後ソースファイルをコピーすることをお勧めします。

COPY "package.json" "package-lock.json" "./"
RUN npm ci
COPY . .
RUN npm run build

ビルド時の引数にシークレットを含めない

npm installに必要だからとnpmrcをDockerfileに含めれると、外部からの攻撃者がプライベートなpackageにアクセスすることができるようになります。マルチステージビルドなどで次のステージでこれを削除することもできますが、その場合untagged image listからも削除しないといけないことを忘れないでください。別のやり方としては docker build-kitのシークレット機能を利用することをお勧めします。

RUN --mount=type=secret,id=npm,target=/root/.npmrc npm ci

Bootstrapにはnodeコマンドを利用し、npmコマンドは避けよう

よく CMD ["npm", "run", "start"]でコンテナを起動している例がありますが、npmコマンドを使ってコンテナを起動するとOSのシグナルをコードに渡してくれず、グレースフルシャットダウンやゾンビプロセスなどの問題を生む原因になります。処理中のリクエストやデータを欠損することにもなるので nodeコマンドを利用して立ち上げましょう。

中間ツールの利用も避けよう

Kubernetes(以下k8s)などのDockerラインタイムオーケストレータはコンテナの健全性や再配置の決定を行ってくれます。 PM2やClusterなどの中間ツールを使ってコンテナを立ち上げた場合(CMD ["pm2-runtime", "indes.js"])

はnodeプロセスの再起動をコンテナ内で行うのでk8sはエラーに気づかず、適切な再配置ができません。

dockerignoreを活用しよう

Dockerに一度機密ファイルをコピーすると、あとからDocker内でファイルを削除してもレイヤーごとの履歴などで機密ファイルを保持してしまいますのでセキュリティの観点からしてとても危険です。機密ファイル、不要なファイルはdockerignoreファイルで除外しましょう。 適切に除外することでキャッシュの有効性も上がり、ビルド速度の向上にもつながります。

依存関係のクリーンアップ

ビルドステージやテストステージにおいてdevDependenciesが必要になることもありますが、本番ステージには不要ですよね。 その場合はnode_modulesから本番環境で必要なdependenciesのみを残すために npm prune --production コマンドを実行しましょう。

Docker V8

nodejsは内部でV8が用いられており、このV8の性能を最大限引き出すこともパフォーマンスを向上させます。 よく使われる設定は --max-old-space-size です。V8の古いメモリ領域の上限を設定できます。 V8はメモリ消費量が上限に近づくにつれて使われていないメモリを解放するためにガベージコレクションに多くの時間を費やすようになります。

例として2GiBのメモリを持つマシーンの場合、1536(1.5GiB)に設定することで、残りのメモリを他の用途に利用でき、スワップを防ぐができます。

node --max-old-space-size=1536 index.js

Dockerのイメージはなるべく小さくしよう

小さいイメージベースを利用しよう

slim、alpine linuxなど小さいDockerイメージをベースにすることで、脆弱性を減らし、イメージサイズを最小化できます。

Diveで分析しよう

Dockerのイメージやlayerを分析するツールです。自分のイメージに問題がある場合はこういったツールを利用して分析するのもいいかもしれないです。

Dive

Dockerをlintしよう

ソースコードと同じようにDockerfileも事前に問題に対処できるlintされるべきです。 不明なリポジトリからのダウンロードやsudo権限での実行などを事前にチェックできます。

Hadolint

オープンソースのDockerfile linterです。 手動やCIプロセスで利用でき、Dockerベストプラクティスに従うことを目的としたlinterです。

Dockerのイメージにlatestタグの利用は避けよう

latestタグはDockerのデフォルトタグで、正確に最新のタグではないでの実際の運用では利用しないようにしましょう。

含まれるソースのgitの最新コミットIDなどを利用することをおすすめします。

イメージをScanして脆弱性を確認する

脆弱性はOSレベルにもありますし、build時に脅威となるツールがインストールされている可能性もあります。そのため、できあがった最終的なイメージをスキャンすることをおすすめします。

スキャナツール

NODE_MODULESキャッシュを消す

npm, yarnはnpm installを実行するとキャッシュを生成し、次回以降installする際に既にキャッシュにあればそれを利用するようにします。 開発で利用するlocalマシンで直接npm installするのであれば効果的ですが、コンテナの場合は意味がありません。

RUN npm ci --production && npm cache clean --force

マルチステージビルドにおいて次のビルドで新しいパッケージをinstallしない場合は関係無いです。

参考

Node.js Useful V8 options