DockerでNode.jsを動かすときのベストプラクティス

こんにちは!バックエンドとインフラを勉強中のこのぴーです
前回はDockerイメージを軽量化する方法について解説しましたが、今回はDockerでNode.jsアプリケーションを動かす際に色々と考慮しなければいけない点があるのでそのあたりを解説していこうと思います

また、今回作成するDockerfileや.dockerignoreはGitHub上から確認できます

やってしまいがちな例

まず、悪い例として何も考えずにexpressのサーバをDockerを使って立ててみます

index.js (expressのドキュメントより引用)

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

package.json

{
  "name": "nodejs-docker",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "author": "conop",
  "license": "MIT",
  "dependencies": {
    "express": "^4.18.1"
  }
}

Dockerfile

FROM node
WORKDIR /app
COPY . .
RUN yarn install
CMD "yarn" "start"

ビルドと実行

docker build -t nodejs-docker .
docker run -p 3000:3000 nodejs-docker
>> yarn run v1.22.18
>> $ node index.js
>> sample app listening on port 3000
curl localhost:3000
>> Hello World!

とりあえずちゃんと動いていることが確認できたのでコンテナを停止します

docker kill `docker container ls -lq`
>> 9f0edd2ca969

サイズも一応確認しておきます

docker image ls nodejs-docker
>> REPOSITORY      TAG       IMAGE ID       CREATED             SIZE
>> nodejs-docker   latest    e460c9f1faa7   About an hour ago   1GB

およそ1GBでした

このDockerfileの何がダメなのか

アプリケーションがちゃんと動作したのでこれで良いと思うかもしれませんが、いくつかのバッドプラクティスが含まれてしまっています

ベースイメージが大きい・バージョンの指定がされていない

nodeイメージにはたくさんのツールやパッケージが含まれているためサイズが大きくなってしまい、ダウンロードやビルドに時間がかかる上、脆弱性が生まれる原因ともなります
また、バージョンが指定されていないとビルドするたびにnodeのバージョンが上がりアプリケーションが正常に動作してくれない可能性があります

package.jsonのdevDependenciesまでダウンロードされてしまう

本番環境でアプリケーションを動作させる場合はyarn add -Dで追加するテストツールやeslintなどのリンタは不要です
その分イメージサイズが大きくなってしまうのでdependenciesのみダウンロードされるようにします

ソースコードを変更した際にレイヤーのキャッシュが利用されない

Dockerfileの3行目でソースコードとpackage.jsonが同時にコピーされています
こうするとパッケージの追加や削除が無くてもコードを変更するだけで再ビルドした際にyarn installが走ってしまいます

5行目のCMDがshell形式になっている

CMD "yarn" "start"のように文字列のみで記述するのをshell形式、CMD ["yarn" "start"]のようにJSON配列の形式で記述するのをexec形式といいます
shell形式で記述するとPID 1のプロセスがシェルとなり、kubernetesなどのオーケストレータから送られたシグナルがアプリケーションまで伝搬しない可能性があります
実際に、コンテナを動かしているプロンプト上でCtrl+Cを押してもコンテナが停止しないのが分かります

NODE_ENV環境変数にproductionが指定されていない

Expressを含むいくつかのライブラリではNODE_ENVという環境変数にproductionが設定してあると、本番環境用の最適化が行われるため設定しておくと良いです

アプリケーションがrootで実行されている

もしアプリケーションにOSコマンドインジェクションやディレクトリトラバーサルなどの脆弱性があり、それが悪用された場合アプリケーションがroot権限で実行されていると悲惨なことになります

.dockerignoreが設定されていない

.dockerignoreは.gitignoreと同じようにnode_modules.git.envなどの本番環境で不要だったり、機密情報が含まれているファイルがイメージに含まれないように弾くための設定を記述するファイルです
不要なファイルが入っているとイメージサイズが大きくなったりビルドに時間がかかるようになります

マルチステージビルドを使用していない

前回の記事でも紹介しましたが、マルチステージビルドを使用して実行用の環境には最低限の物しか残さないようにします

実際に改善していく

それでは、前節で出てきたダメな点を解消していきます

ベースイメージを小さくし、バージョンも固定する

今回はパッケージをインストールしてExpressを動作させるだけなのでalpineイメージを使用します
また、nodeのバージョンもLTSの16系へ固定します

FROM node:16-alpine3.15
WORKDIR /app
COPY . .
RUN yarn install
CMD "yarn" "start"

ビルドしてサイズを確認します

docker build -t nodejs-docker .
docker image ls nodejs-docker
>> REPOSITORY      TAG       IMAGE ID       CREATED         SIZE
>> nodejs-docker   latest    d804656bebb0   5 seconds ago   119MB

イメージを変更する前の9分の1になりました

package.jsonのdependenciesのみダウンロードするようにする

yarn installをする際に--prodフラグを付けるとdevDependenciesにあるパッケージがダウンロードされなくなります
また、--frozen-lockfileフラグを付けるとyarn.lockが更新されず、パッケージの更新が必要な場合は失敗するようになるのでこちらも付けておきます

FROM node:16-alpine3.15
WORKDIR /app
COPY . .
RUN yarn install --prod --frozen-lockfile
CMD "yarn" "start"

コードを変更してもパッケージの再インストールが行われないようにする

ADDCOPY命令は、イメージに含まれるファイルのチェックサムが計算され、ファイルに変更があればキャッシュが無効化されます
そのため、頻繁に行われないpackage.jsonとyarn.lockを先にコピーしておきyarn installを実行することでコードを変更してもいちいち再インストールが行われないようにします

FROM node:16-alpine3.15
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --prod --frozen-lockfile

COPY . .
CMD "yarn" "start"

CMDの内容を変更する

先ほど説明したようにCMDをshell形式で記述すると/bin/sh -c "yarn start"のようになり、アプリケーションまでイベントのシグナルが伝搬しません
そのため、exec形式で記述することでイベントが届くようにします
CMD ["yarn", "start"]
こうすることで、直接yarnが実行されイベントをnodeのruntimeに転送してくれますが、すべてのイベントを転送してくれるわけではありません
すべてのシグナルを受信するためにyarnを使わずnodeを直接叩くようにします
CMD ["node", "index.js"]

これで良いと思うかもしれませんが、このままだと意図しない動作を起こしてしまいます
実際にコンテナを動かし、docker stopでSIGTERMを送って停止させてみます

docker stop `docker container ls -lq`
docker container ls -la
>> CONTAINER ID   IMAGE           COMMAND                  CREATED          STATUS                       PORTS     NAMES
>> 7b9e05565509   nodejs-docker   "docker-entrypoint.s…"   10 minutes ago   Exited (137) 9 minutes ago             focused_mestorf

10秒ほど経ってからコンテナが停止したと思います
また、コンテナの終了コードは137です

ドキュメントにあるように、Node.jsのシグナルを受信した場合の終了コードは128にシグナルコードの値を加えたものです
そのため、今回は137 - 128 = 9ということでSIGKILLで強制終了させられていることが分かります
DockerはSIGTERMを送ってから10秒経っても終了しない場合にSIGKILLを送信して強制終了さるので、なぜかSIGTERMが無視されているということになります

原因はnodeがPID 1で動作していることにあります
Linux環境において、PIDが1のプロセスはinitプロセスです
そして、Node.jsはPID 1で動くように設計されておらずSIGTERMなど一部のシグナルが効かない、ゾンビプロセスが生きたままになるといった現象が起きてしまうことがあります
それを回避するためにlightweight init systemというツールがあります
このツールをPID 1で動作させ、nodeをその子プロセスにすることでイベントがちゃんと伝達し、ゾンビプロセスも修了するようにできます

実際に組み込んでみます
今回はTiniを使います
README.mdの通りにDockerfileを編集していきます

FROM node:16-alpine3.15
WORKDIR /app

RUN apk add --no-cache tini

COPY package.json yarn.lock ./
RUN yarn install --prod --frozen-lockfile

COPY . .

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "index.js"]

これでちゃんとSIGTERMでコンテナが停止するか確認します

docker stop `docker container ls -lq`
docker container ls -la
>> CONTAINER ID   IMAGE           COMMAND                  CREATED         STATUS                       PORTS     NAMES
>> d82f65ffb00c   nodejs-docker   "/sbin/tini -- node …"   2 minutes ago   Exited (143) 2 minutes ago             quirky_lewin

今度は10秒待たずにコンテナが停止し、ステータスも143(SIGTERMは15番なので128 + 15 = 143)となっていることが確認できました

NODE_ENV環境変数にproductionを設定する

Expressのドキュメントにもあるように、NODE_ENVproductionが設定されているといくつかの恩恵がありますそのためDockerfileにENV NODE_ENV productionを追加します

アプリケーションをrootで実行されないようにする

alpineを含むnodeイメージにはnodeという名前のユーザとグループがデフォルトで存在しているのでこのユーザを使用します
また、ファイルをコピーしてくる際に--chown=user:groupを指定することでファイルの所有者もnodeユーザ・グループに変えるようにします

FROM node:16-alpine3.15
ENV NODE_ENV production

WORKDIR /app

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]

COPY --chown=node:node package.json yarn.lock ./
RUN yarn install --prod --frozen-lockfile

COPY --chown=node:node . .

USER node
CMD ["node", "index.js"]

.dockerignoreを設定する

.dockerignoreは.gitignoreと同じような記述方法で作成します
今回は.gitnode_modulesREADME.md.gitignoreが不要なのでそれらを弾くように設定します

.dockerignore

.git
node_modules
README.md
.gitignore

これで不要なファイルがイメージ内に取り込まれないようになりました

マルチステージビルドを使うようにする

実行環境に必要な物だけを含めるためマルチステージビルドを使っていきます
パッケージのインストール、tiniの取得をnode:16-alpine3.15イメージ上で、実行をgcr.io/distroless/nodejs:16イメージ上で行います
tiniはdistrolessイメージ上で動かすためにapkで取得するのではなくGitHub上からバイナリを直接取得するように変更しました

FROM node:16-alpine3.15 as builder

WORKDIR /app

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

COPY package.json yarn.lock ./
RUN yarn install --prod --frozen-lockfile

FROM gcr.io/distroless/nodejs:16
ENV NODE_ENV production

WORKDIR /app

COPY --from=builder --chown=nonroot:nonroot /tini /tini
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --chown=nonroot:nonroot . .

USER nonroot
EXPOSE 3000

ENTRYPOINT [ "/tini", "--", "/nodejs/bin/node" ]
CMD ["/app/index.js"]

さいごに

これでいくつかのベストプラクティスを採用したDockerfileが出来上がりました
ビルドしてサイズを確認してみます

docker build -t nodejs-docker .
docker image ls nodejs-docker
>> REPOSITORY      TAG       IMAGE ID       CREATED          SIZE
>> nodejs-docker   latest    4ef22942ad14   33 minutes ago   111MB

ベースイメージにalpineを使用したときとあまり変わっていませんが、最初よりも軽量化することができました
今日紹介した方法以外にもDockleやdocker scan(snyc)を使用した脆弱性のスキャンや、sha256のダイジェスト値を利用したベースイメージの検証、hadolintを使用したlintingなどを行うことでよりセキュアなイメージを作成するこが可能です