この記事は ZOZO Advent Calendar 2021 24日目 の記事です。
概要
最近業務でNode.jsを扱うことがありました。そこでNode.jsアプリケーションをDocker化する上でのチェックポイントとDockerfileベストプラクティスのおさらいをまとめました。
実は Next.jsのドキュメント に答えらしきものがあることに後で気づきました。
環境
$ node -v
v16.13.1
$ npm -v
8.1.2
$ docker -v
Docker version 20.10.8, build 3967b7d
サンプルアプリケーションの作成
サンプルアプリケーションは Next.js を使用しました。
Dockerfileとはあまり関係ないですが、ついでなのでNext.jsの起動方法も確認してみます。
まずは Next.js公式チュートリアル を参考に下記のコマンドを実行します。
$ npx create-next-app nextjs-blog-on-docker --use-npm --example "https://github.com/vercel/next-learn/tree/master/basics/learn-starter"
動作確認をします。
$ cd nextjs-blog-on-docker
$ npm run dev
> dev
> next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled client and server successfully in 2.5s (158 modules)
Attention: Next.js now collects completely anonymous telemetry regarding usage.
This information is used to shape Next.js' roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://nextjs.org/telemetry
wait - compiling / (client and server)...
event - compiled client and server successfully in 895 ms (174 modules)
http://localhost:3000 にアクセスしてアプリケーションが起動しているのを確認したら ctrl+c
で停止しておきます。
Dockerfileの作成
アプリケーションのルートディレクトリにDockerfileを作成し、必要最低限の内容を書いて保存します。
$ touch Dockerfile
FROM node:16
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 8080
CMD [ "npm", "start" ]
imageをbuildしサイズを確認したところ1.19GBあることがわかりました。
$ docker build . -t nextjs-web-app-on-docker
...略
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
nextjs-web-app-on-docker latest 9812cd37e688 4 minutes ago 1.19GB
dockerを起動し動作確認をします。
$ docker run -p 8080:3000 -d nextjs-web-app-on-docker
fe330307542efd19930e26e0c43ad9121b7d99d79614849a78da3143d15ecb8b
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fe330307542e nextjs-web-app-on-docker "docker-entrypoint.s…" 21 seconds ago Up 20 seconds 8080/tcp, 0.0.0.0:8080->3000/tcp, :::8080->3000/tcp romantic_hugle
$ docker logs fe330307542e
> start
> next start
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
http://localhost:8080 にアクセスし前述と同じWelcomeページが確認できたのでコンテナは停止しておきます。
$ docker stop $(docker container ls -q)
Dockerfileのベストプラクティス
ここから本題になります。
1. .dockerignore で不要なファイルを除外
プロダクション環境にデプロイする上で不要なファイルを.dockerignoreに記述して除外します。
加えて.env、.aws、.npmrcなど秘匿情報が書かれている可能性があるファイルもあれば除外しておきます。
**/node_modules/
**/.git
**/npm-debug.log
ちなみに .gitignore を自動生成するツールとして gibo というものがありますが、 .dockerignore を自動生成する dobo というツールもあるようです。
2. マルチステージ・ビルドを使用する
アプリケーションのbuild環境と実行環境を分離して、実行環境には最終的な成果物だけを配置することによりimageサイズの縮小を図ります。
# 依存パッケージのインストール
FROM node:16 as deps
WORKDIR /app
# packeg.jsonとpackage-lock.jsonのみコピーする
COPY package*.json ./
RUN npm install
# Build環境
FROM node:16 as builder
WORKDIR /app
COPY . .
# depsステージでインストールしたパッケージをコピーする
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build
# 実行環境
FROM node:16
WORKDIR /app
ENV NODE_ENV production
# ファビコンが格納されたディレクトリをコピーする
COPY --from=builder /app/public ./public
# buildによって.next配下に生成されたhtml、JSON、JSファイルをコピーする
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/package.json ./package.json
EXPOSE 8080
CMD [ "npm", "start" ]
3. CMDにはnpmコマンドを使用しない
npmコマンドはシグナルをNode.jsアプリケーションに転送することができないので、KubernetesからGraceful Shutdownが実行できません。よって npm start
の代わりに node_modules/.bin/next start
を実行します。
(実は node_modules/.bin/next start
でも不十分という話し もあります。別の機会に調べようと思います。)
参考:
- nodebestpractices/bootstrap-using-node.japanese.md at master · goldbergyoni/nodebestpractices
- 2021年1月時点Next.jsのGraceful Shutdown実装状況調査 - tom-256.log
- CMD [ "npm", "start" ]
+ CMD [ "node_modules/.bin/next", "start" ]
4. 実行環境では軽量なベースimageを使用する
ベースimageに含まれているライブラリの数が多いほど脆弱性を生む可能性が高いため、プロダクション環境ではなるべく必要最低限で軽量なimageを使用します。
いくつか候補がありましたが、Googleによってメンテンスされている Distroless を使用することにしました。
- FROM node:16
+ FROM gcr.io/distroless/nodejs:16
Distrolessはそのままだとshellにログインもできないので少し工夫が必要です。その方法は後述します。
5. 特権ユーザを使用しない
セキュリティ面やDocker内で生成されたドキュメントの権限などに不都合があるので、rootユーザーを使う必要がなければ USER
を用いてユーザを変更します。
また、Distrolessはadduserといったコマンドが使えないので、はじめから作成されているnonrootというユーザーを利用します。
参考: Distrolessイメージをroot以外のユーザーで実行する
- COPY --from=builder /app/.next ./.next
+ COPY --from=builder --chown=nonroot:nonroot /app/.next ./.next
+ USER nonroot
コンテナにログインしてユーザーを確認してみます。
シェルにログインするには gcr.io/distroless/nodejs:debug imageを使用します。
- FROM gcr.io/distroless/nodejs:16
+ FROM gcr.io/distroless/nodejs:debug
# image build
$ docker build . -t nextjs-web-app-on-docker --no-cache
# シェルにログイン
$ docker run -p 8080:3000 --entrypoint=sh -ti nextjs-web-app-on-docker
# ユーザーを確認
/app $ whoami
nonroot
nonrootになっているのが確認できました。
6. npm installの代わりにnpm ciを使用する
npm ciはnpm installと同様に依存パッケージをダウンロードします。npm installとの違いはpackage-lock.jsonの更新をしないことで、これによって開発時とプロダクション時のコードの差がなくなります。また、node_modulesディレクトリを削除し常にクリーンインストールを行います。さらに --only=production
オプションでプロダクション環境では不要なdevDependenciesなパッケージを削除しimageサイズを抑えることができます。
# 依存パッケージのインストール
FROM node:16 AS deps
WORKDIR /app
COPY package*.json ./
# devDependenciesなパッケージを削除
RUN npm ci --only=production
# Build環境
FROM node:16 as builder
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
# 実行環境
FROM gcr.io/distroless/nodejs:16
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nonroot:nonroot /app/.next ./.next
# 不要なものが取り除かれたnode_modulesをプロダクションにコピーする
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nonroot
EXPOSE 8080
CMD [ "node_modules/.bin/next", "start" ]
7. ステップの順番を最適化する
package.json、package-lock.jsonの更新頻度は少ないため、先にコピーをしておきnpm ci(パッケージのダウンロード)の処理をキャッシュさせます。その後からbuildステップを実行することで全体の実行時間を短くさせます。
# Build環境
FROM node:16 as builder
WORKDIR /app
- COPY . .
+ COPY package*.json ./
RUN npm ci
+ COPY . .
RUN npm run build
8. Lintを使用する
Lintツールは hadolint を利用しました。試しにFROMを削除して実行すると下記のようなエラーが出ました。このように事前にミスを防ぎます。
$ brew install hadolint
$ hadolint Dockerfile
Dockerfile:21 DL3022 warning: COPY --from should reference a previously defined FROM alias
9. 脆弱性スキャンを使用する
スキャンツールは Dockle を利用しました。
$ brew install goodwithtech/r/dockle
$ dockle nextjs-web-app-on-docker:0.0.1
SKIP - DKL-LI-0001: Avoid empty password
* failed to detect etc/shadow,etc/master.passwd
INFO - CIS-DI-0005: Enable Content trust for Docker
* export DOCKER_CONTENT_TRUST=1 before docker pull/build
INFO - CIS-DI-0006: Add HEALTHCHECK instruction to the container image
* not found HEALTHCHECK statement
結果
最終的なdockerfileは下記です。
# 依存パッケージのインストール
FROM node:16 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Build環境
FROM node:16 as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 実行環境
FROM gcr.io/distroless/nodejs:debug
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nonroot:nonroot /app/.next ./.next
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nonroot
EXPOSE 8080
CMD [ "node_modules/.bin/next", "start" ]
どれくらいimageが軽くなったか確認します。
$ docker build . -t nextjs-web-app-on-docker:0.0.1 --no-cache
...略
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
nextjs-web-app-on-docker 0.0.1 8e81f00afeeb 3 minutes ago 321MB
1.19GB → 321MBまで軽量化することができました。
お疲れさまでした。
明日25日の大トリは @t_shimokawa さんの記事です。