Node.jsプロダクション環境のdockerfileを作る
TL; DR
# create base image and add node user
FROM node:16.16.0-alpine3.16 AS base
WORKDIR /app
RUN chown -R node:node /app && chmod -R 770 /app
# target stage development for local
FROM base AS development
COPY ./package*.json /app/
RUN npm install
COPY . .
EXPOSE 3001
ENTRYPOINT ["npm", "run", "start"]
# preparing stage for prod/staging, build and transpile ts to js
FROM base AS builder
COPY . .
USER node
RUN npm install --loglevel warn
RUN npm run build
# target stage staging
FROM base AS staging
LABEL org.label-schema.name="backend-staging"
ENV NODE_ENV=production
COPY /app/build ./build
COPY ./package*.json ./
RUN npm install --only=production
USER node
ENTRYPOINT ["node", "./build/index.js"]
EXPOSE 3001
# target stage production
FROM base AS production
# ARG DATE
LABEL org.label-schema.name="backend-prod"
# LABEL org.label-schema.version="0.1.0"
# LABEL org.label-schema.build-date="${DATE}"
ENV NODE_ENV=production
COPY /app/build ./build
COPY ./package*.json ./
RUN npm install --only=production
USER node
ENTRYPOINT ["node", "./build/index.js"]
EXPOSE 3001
解説
意外とすぐに手に取って使える内容が見つかりませんでした。こういうのはテンプレート化できるのでありふれると思いましたが、プロダクション用の内容が若干少ないイメージ。
今の内容に辿り着くにはいろんなソースを参考にしていました。解説しながら見てみたいと思います。
開発用
開発用は本当に、動けば良い程度のものです。
FROM node:16.16.0-alpine3.16
WORKDIR /app
COPY ./package*.json /app/
RUN npm install
COPY . .
EXPOSE 3001
CMD ["npm", "run", "start"]
当然、package.json
ファイルに、npm run start/dev
とかのスクリプトの定義が必要です。なくても、CMD ["node", ".src/index.js"]
なり、CMD ["nodemon", ".src/index.ts"]
なりでも動くはずです。
CMD
とENTRYPOINT
の違いについて こちら に参照。
プロダクション用に何が違うのか
それでこのままプロダクション用に用いるにはいきません。少なくとも次のいくつかの問題があります。
イメージサイズ問題
開発用に必要なパッケージ(npm i -D xxx
でインストールされたdevDependencies
というやつ)は基本的にプロダクション環境には不要。npm install
はdevDependencies
を含めてインストールするので、eslint/prettierとか、各種typesとか、要らない内容がnode_modules
フォルダーに入って、ビルド後のイメージサイズが無駄に増えます。ビルドしたコードも、最適化されて、サイズがソースより小さくなります。
ルートユーザーの懸念
セキュリティーの観点からルートユーザーのままでdockerを走らせるのはbad practiceなので、これをプロダクション環境で避けたい。そうするとユーザーとグループの作成、権限付与、ユーザーの切り替えが必要となります。
DRY原則
プロダクション用のファイルだと、ビルド、ユーザー切り替え、必要なファイルだけをコピー、少し違う操作が必要です。開発用のものをそのまま使えないですが、一部共通する部分があるので、コード再利用の観点から別途dockerfile.prod.yml
を作るより一つのdockerfile
にまとめたい。
ここでマルチステージビルドの出番となります。考え方としては、ビルドをいくつかのステップ(ステージ)に分けて、各ステップの間に参照可能にすることで、柔軟性を増やすことができます。また、イメージサイズ問題の解決にも役立ちます。
脆弱性
ルートユーザーの懸念とは別ですが、ビルド後docker scan
を使ってイメージの脆弱性をチェックする作業も必要となります(こちら)。確認した結果によって修復が必要な場合も多々あります。
他にも色々と違いがあるかもしれせんが今のところ思いついたものだけ書いておきました。
ステージ: ベースイメージ
ここはベースイメージを作ります。このベースは、dev, staging, prodなどの環境に問わず、どちらにも使える意味でのベースとなります。AS
分を使って名前を振り付けますが、ここは理解しやすくするためにただのAS base
です。
WORKDIR
はコンテナのworking directoryを指定でき、該当パスが存在しない場合は自動で作ってくれます。なのでRUN makedir -p xxx
とかは基本的に不要です。このコマンドはmakedir -p /app && cd /app
と同じ意味で良いでしょう。また、このworking directoryの指定は他のステージで継承されるため、ベースで指定すると、ほこのステージで一々WORKDIR
を打つ必要がありません。
# create base image and add node user
FROM node:16.16.0-alpine3.16 AS base
WORKDIR /app
RUN chown -R node:node /app && chmod -R 770 /app
RUN
、COPY
、CMD
系のコマンドは一つ毎にイメージにレイヤーを増やすので使いすぎないようにした方が良いです(こちら)。なのでユーザー追加と権限付与のコマンドも&&
で同じ行にまとめています。開発環境では正直このユーザー追加のレイヤーを利用していません。その意味でベースには不要かもしれません。他のステージに移しても良いところですが、個人的にstagingとprodで2回入れるのが嫌なので敢えてここにおきました。
ステージ: 開発用
こちらは特に変哲のない開発用のビルドの仕方ですが、最初のFROM base AS development
だけが違います。docker compose
またはdocker build
するときに、--target development
でビルドステージを指定することができます。以下のステージも同じです。
# target stage development for local
FROM base AS development
COPY ./package*.json /app/
RUN npm install
COPY . .
EXPOSE 3001
ENTRYPOINT ["npm", "run", "start"]
ステージ: ビルダー
ここではnpm run build
コマンドを通して、コードのプロダクションビルドに出力します。tsで書いたものはここでjsコードにトランスパイルされます。アウトプットのフォルダーはtsconfig
とかで指定できますが、通常build
またはdist
といった名前が慣習です。この段階でプロダクション環境用のコードができたとの認識で大丈夫です。
# preparing stage for prod/staging, build and transpile ts to js
FROM base AS builder
COPY . .
USER node
RUN npm install --loglevel warn
RUN npm run build
ステージ: プロダクション・ステージング用
この二つの環境では基本的に同じくビルドされたコードを使うので、大差がありません。productionの部分だけ例にします。
# target stage production
FROM base AS production
# ARG DATE
LABEL org.label-schema.name="backend-prod"
# LABEL org.label-schema.version="0.1.0"
# LABEL org.label-schema.build-date="${DATE}"
ENV NODE_ENV=production
COPY /app/build ./build
COPY ./package*.json ./
RUN npm install --only=production
USER node
ENTRYPOINT ["node", "./build/index.js"]
EXPOSE 3001
ここでLABEL
コマンドを使って、イメージにメタデータをつけています。このlabel-schema
について、こちらに詳しく定義があります。
build-dateも追加することが可能ですが、動的に入れることが若干面倒です。一例として、次のようにビルド時にbuild-arg
としてインジェクトすることが可能です。
docker build --target production --build-arg DATE=$(date -u +'%Y-%m-%d') -t my_app_prod:v1 .
1つ目のCOPY
コマンドでは、ビルダーステージでビルドされたコードのみをコピーします。WORDDIR
はすでに/app
で継承されているので、コピー先は./build
で十分です(もちろん絶対パスの/app/build
でも問題ないが)。二つ目のコピーはパッケージファイルを。
パッケージインストール時は開発用のdevDependenciesが不要なため、--only=production
をつけます。これらの操作が完了したら、アプリ起動する前にユーザーをnode
に切り替えます。
.dockerignore
ファイル
不要なファイル、特に.env
といった機密情報が入る可能性のあるファイルをミスってプロダクション環境のイメージにコピーしてしまうことを防止する意味で、作っておいたが無難。一例として:
# packages
**/node_modules/
# git
**/.git
**/.gitignore
# readme
**/README.md
**/LICENSE
# logs
**/npm-debug.log
**/*.log
# test
**/coverage
**/test
# editor
**/.vscode
**/.editorconfig
# build
**/dist
**/build
# secrets
**/.aws
**/.env
pm2
は?
よくプロダクション環境でのプロセスマネジメントツールとして導入されます。ただこれはコンテナが実行される環境によって不要との観点もあり得ます。今クラウド上で動くことが多くて、ヘルスチェック、リスタートポリシー、自動スケールなど諸々を考えて個人的に必要性が薄く感じます。こちらにも参照。
ただ詳しく実装して数値で見比べているわけではないので、あくまでも私見です。今後比べてみることもあるかもしれません。もし導入を検討する場合はこちらとこちらに参考。
終わりに
Nodejsのプロダクション用のdockerfile
について駆け足で書いてきましたが、また随時何か補足するかもしれません(なんかあった問題について書き忘れた気もしますが。。)
一旦これで。
Discussion