Dockerfileベストプラクティス - Node.jsアプリケーション編

この記事は [ZOZO Advent Calendar 2021 24日目](https://qiita.com/advent-calendar/2021/zozo) の記事です。

# 概要

最近業務でNode.jsを扱うことがありました。そこでNode.jsアプリケーションをDocker化する上でのチェックポイントとDockerfileベストプラクティスのおさらいをまとめました。
実は [Next.jsのドキュメント](https://nextjs.org/docs/deployment#docker-image) に答えらしきものがあることに後で気づきました。

## 環境

```shell
$ node -v
v16.13.1
$ npm -v
8.1.2
$ docker -v
Docker version 20.10.8, build 3967b7d
```

# サンプルアプリケーションの作成

サンプルアプリケーションは [Next.js](https://nextjs.org/) を使用しました。
Dockerfileとはあまり関係ないですが、ついでなのでNext.jsの起動方法も確認してみます。
まずは [Next.js公式チュートリアル](https://nextjs.org/learn/basics/create-nextjs-app) を参考に下記のコマンドを実行します。

```shell
$ npx create-next-app nextjs-blog-on-docker --use-npm --example "https://github.com/vercel/next-learn/tree/master/basics/learn-starter"
```

動作確認をします。

```shell
$ 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](http://localhost:3000/) にアクセスしてアプリケーションが起動しているのを確認したら `ctrl+c`で停止しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45634/fa2e6857-f4c6-7825-4414-f3c5186a42f9.png)

# Dockerfileの作成

アプリケーションのルートディレクトリにDockerfileを作成し、必要最低限の内容を書いて保存します。

```shell
$ touch Dockerfile
```

```docker
FROM node:16
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 8080
CMD [ "npm", "start" ]
```

imageをbuildしサイズを確認したところ1.19GBあることがわかりました。

```shell
$ 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を起動し動作確認をします。

```shell
$ 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](http://localhost:8080/) にアクセスし前述と同じWelcomeページが確認できたのでコンテナは停止しておきます。

```shell
$ docker stop $(docker container ls -q)
```

# Dockerfileのベストプラクティス

ここから本題になります。

## 1. .dockerignore で不要なファイルを除外

プロダクション環境にデプロイする上で不要なファイルを.dockerignoreに記述して除外します。
加えて.env、.aws、.npmrcなど秘匿情報が書かれている可能性があるファイルもあれば除外しておきます。

```docker
**/node_modules/
**/.git
**/npm-debug.log
```
ちなみに .gitignore を自動生成するツールとして [gibo](https://github.com/simonwhitaker/gibo) というものがありますが、 .dockerignore を自動生成する [dobo](https://github.com/KeisukeYamashita/dobo) というツールもあるようです。


## 2. マルチステージ・ビルドを使用する

アプリケーションのbuild環境と実行環境を分離して、実行環境には最終的な成果物だけを配置することによりimageサイズの縮小を図ります。

```docker
# 依存パッケージのインストール
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` でも不十分という話し](https://zenn.dev/izumin/scraps/15c9ad1248154f) もあります。別の機会に調べようと思います。）

参考: 
* [nodebestpractices/bootstrap-using-node.japanese.md at master · goldbergyoni/nodebestpractices](https://github.com/goldbergyoni/nodebestpractices/blob/master/sections/docker/bootstrap-using-node.japanese.md)
* [2021年1月時点Next.jsのGraceful Shutdown実装状況調査 - tom-256.log](https://mzqvis6akmakplpmcjx3.hatenablog.com/entry/2021/01/14/103134)

```docker
- CMD [ "npm", "start" ]
+ CMD [ "node_modules/.bin/next", "start" ]
```

## 4. 実行環境では軽量なベースimageを使用する

ベースimageに含まれているライブラリの数が多いほど脆弱性を生む可能性が高いため、プロダクション環境ではなるべく必要最低限で軽量なimageを使用します。
いくつか候補がありましたが、Googleによってメンテンスされている [Distroless](https://github.com/GoogleContainerTools/distroless) を使用することにしました。

```docker
- FROM node:16
+ FROM gcr.io/distroless/nodejs:16
```
Distrolessはそのままだとshellにログインもできないので少し工夫が必要です。その方法は後述します。

## 5. 特権ユーザを使用しない

セキュリティ面やDocker内で生成されたドキュメントの権限などに不都合があるので、rootユーザーを使う必要がなければ `USER` を用いてユーザを変更します。
また、Distrolessはadduserといったコマンドが使えないので、はじめから作成されているnonrootというユーザーを利用します。

参考: [Distrolessイメージをroot以外のユーザーで実行する](https://zenn.dev/necocoa/articles/distroless-non-root)

```docker
- COPY --from=builder /app/.next ./.next
+ COPY --from=builder --chown=nonroot:nonroot /app/.next ./.next

+ USER nonroot
```

コンテナにログインしてユーザーを確認してみます。
シェルにログインするには [gcr.io/distroless/nodejs:debug](http://gcr.io/distroless/nodejs:debug) imageを使用します。

```docker
- FROM gcr.io/distroless/nodejs:16
+ FROM gcr.io/distroless/nodejs:debug
```

```shell
# 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サイズを抑えることができます。

```docker
# 依存パッケージのインストール
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ステップを実行することで全体の実行時間を短くさせます。

```docker
# Build環境
FROM node:16 as builder
WORKDIR /app
- COPY . .
+ COPY package*.json ./
RUN npm ci
+ COPY . .
RUN npm run build
```

## 8. Lintを使用する

Lintツールは [hadolint](https://github.com/hadolint/hadolint) を利用しました。試しにFROMを削除して実行すると下記のようなエラーが出ました。このように事前にミスを防ぎます。

```docker
$ brew install hadolint
$ hadolint Dockerfile
Dockerfile:21 DL3022 warning: COPY --from should reference a previously defined FROM alias
```

## 9. 脆弱性スキャンを使用する

スキャンツールは [Dockle](https://github.com/goodwithtech/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
```

Dockleは見つかった脆弱性を5段階で評価してくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45634/7d7331a4-f2d0-b7a6-f7bf-82cbac26cf15.png)

# 結果

最終的なdockerfileは下記です。

```docker
# 依存パッケージのインストール
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
$ 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 さんの記事です。