Node.jsアプリケーションのDockerイメージのサイズを削減する方法について調べた

Next.jsアプリケーションのDockerイメージのサイズが大きくて困っていたので調べていたところ、Next.jsの公式ドキュメントにDocker Imageというセクションがあり、おすすめ設定が記載されているのを見つけました。以前はここまで詳細な記述はなかったのですが、つい10日ほど前に追加されたようです。

ドキュメントには一番おすすめの方法だけ書かれているのですが、もともと「What is the best way to use NextJS with docker? · Discussion #16995 · vercel/next.js · GitHub」というdiscussionがあり、ドキュメントの記述はここでの議論が元になっているようです。Discussionではいくつか例が示されているのですが、それぞれさまざまな最適化テクニックが利用されており、どれくらいの容量になるか気になったので、Dockerfileを書いて調べてみました。

検証に使ったコードはこちらに置いてあります。

github.com

なお、各テクニックはNext.jsに限らずNode.jsのアプリケーション一般で通用するものだと思います。


まずはベースラインとして最適化なしのDockerfileです。

FROM node

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]


デフォルトのnodeイメージはDebianベースなので元からサイズが大きいです。イメージのサイズを小さくしたい場合は、可能であればまずはAlpine Linuxベースのnode:alpineに乗り換えるのがいいでしょう。

(追記)Alpineでは標準Cライブラリとしてglibcではなくmusl-libcが使われており、Node.jsでもネイティブモジュールを使っている場合に互換性の問題が起きることがあります。2021年時点では、alpineではなく、glibcが採用されておりかつサイズが小さいdistrolessやslimイメージを使うのがおすすめです(ブックマークコメントでの指摘ありがとうございます)。

blog.inductor.me

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock /usr/src/app
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

yarn installはデフォルトではdevDependenciesも含めて全ての依存パッケージをインストールします。アプリケーション実行用のイメージにLinterやテストフレームワークなどの開発用ツールを含める必要はないので、--productionオプションを利用して、devDependenciesをインストールしないようにすると、イメージのサイズを小さくできます。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

さらに、Yarnのダウンロードキャッシュを削除すると、その分イメージのサイズを小さくできます。先程のdiscussionでは二つ方法が紹介されていました。

一つはyarn cache cleanコマンドを使う方法です。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile \
  && yarn cache clean

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

もう一つは、キャッシュディレクトリを/dev/shm以下にする方法です。/dev/shmはLinuxにおけるRAMディスクのマウント先で、キャッシュディレクトリをここに指定すると、手で明示的にキャッシュを消さずとも、イメージのレイヤーにキャッシュが含まれなくなります。

ただし、この方法を利用する場合は、たいてい/dev/shmのサイズが足りなくなる*1ため、docker buildコマンドの--shm-sizeオプションで十分なサイズを指定してやる必要があります。一つめの方法を採用する方が面倒が少なくて楽でしょう。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
ENV YARN_CACHE_FOLDER=/dev/shm/yarn_cache
RUN yarn install --production --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]
$ docker build --shm-size 1gb -f Dockerfile.dev-shm-yarn-cache

さらにイメージサイズを小さくするには、アプリケーションの実行に不要なコードをイメージに含めないようにするとよいです。テストコードは実行に関係ないので削除できますし、Next.jsのようにソースコードをトランスパイル・ビルドしている場合は、ビルド元のコードは不要です。

必要なファイルのみイメージに含める方法はいくつかありますが、ファイルの削除し忘れなどのミスを避けるには、multi-stage buildを使うとよいです。これはNext.jsのドキュメントでおすすめされている方法と同様です。この方法だと、Yarnのパッケージキャッシュを手で削除する必要がないのも楽です。

FROM node:alpine AS deps
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile

FROM node:alpine AS builder
WORKDIR /usr/src/app
COPY . .
COPY --from=deps /usr/src/app/node_modules ./node_modules
ENV NODE_ENV=production
RUN yarn build

FROM node:alpine AS runner
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/public ./public
COPY --from=builder /usr/src/app/.next ./.next
COPY --from=builder /usr/src/app/node_modules ./node_modules
EXPOSE 3000
CMD ["yarn", "start"]

では、それぞれのイメージのサイズを比較してみます。

$ docker images nextjs-docker --format "table {{.Tag}}\t{{.Size}}" | sed -e '1d' | sort -k2 -h -r 
simple               1.17GB # Debianベース
alpine               399MB # Alpineベース
install-production   315MB # yarn install --production
dev-shm-yarn-cache   157MB # キャッシュディレクトリを /dev/shm 以下にする
clean-cache          157MB # yarn cache clean
staged               156MB # Multi-stage build

結果、Next.jsのドキュメントでおすすめされている方法でビルドしたイメージが一番小さくなりました。全く最適化していないものに比べて1/8以下のサイズです。ベースイメージを変更したのが一番影響がありますが、他の手法もMB単位でサイズが小さくなっています。Multi-stage buildはこの比較だとあまり意味がないように見えますが、実際のアプリケーションでは実行時に不要なファイルがこのサンプルより多いでしょうから、ここで示したよりも効果が出るでしょう。

*1:デフォルトは64MB