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を分析するツールです。自分のイメージに問題がある場合はこういったツールを利用して分析するのもいいかもしれないです。
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しない場合は関係無いです。