🤔

Node.jsプロダクション環境のdockerfileを作る

2022/09/03に公開

TL; DR

Github

# 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 --chown=node:node . .
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 --chown=node:node --from=builder /app/build ./build
COPY --chown=node:node ./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 --chown=node:node --from=builder /app/build ./build
COPY --chown=node:node ./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"]なりでも動くはずです。

CMDENTRYPOINTの違いについて こちら に参照。

プロダクション用に何が違うのか

それでこのままプロダクション用に用いるにはいきません。少なくとも次のいくつかの問題があります。

イメージサイズ問題

開発用に必要なパッケージ(npm i -D xxxでインストールされたdevDependenciesというやつ)は基本的にプロダクション環境には不要。npm installdevDependenciesを含めてインストールするので、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

RUNCOPYCMD系のコマンドは一つ毎にイメージにレイヤーを増やすので使いすぎないようにした方が良いです(こちら)。なのでユーザー追加と権限付与のコマンドも&&で同じ行にまとめています。開発環境では正直このユーザー追加のレイヤーを利用していません。その意味でベースには不要かもしれません。他のステージに移しても良いところですが、個人的に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 --chown=node:node . .
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 --chown=node:node --from=builder /app/build ./build
COPY --chown=node:node ./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について駆け足で書いてきましたが、また随時何か補足するかもしれません(なんかあった問題について書き忘れた気もしますが。。)

一旦これで。

GitHubで編集を提案

Discussion