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"
コードを変更してもパッケージの再インストールが行われないようにする
ADD
とCOPY
命令は、イメージに含まれるファイルのチェックサムが計算され、ファイルに変更があればキャッシュが無効化されます
そのため、頻繁に行われない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_ENV
にproduction
が設定されているといくつかの恩恵がありますそのため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と同じような記述方法で作成します
今回は.git
、node_modules
、README.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などを行うことでよりセキュアなイメージを作成するこが可能です