For

2023.4.4

Docker + Next.jsにおける通常のMulti-Stage Buildとstandaloneのイメージサイズを比較する

概要

Next.jsには standaloneというオプション があります。
こちらを利用すると、本番環境で必要のないパッケージ(devDependencies)を含めないようにするのはもちろん、 next export したときのようにサーバー機能を失うこともありません。
これらの恩恵を受けつつDockerのイメージサイズを削減できるとのことなので、どのくらいの効果があるのか検証してみます。
本来は一部のファイルをCDNで配信することで最適化できますが、今回の検証ではそのあたりのファイルもまとめてDockerイメージに含めた状態で比較してみます。

準備

今回はシンプルなNext.jsのアプリケーションを用意して、依存関係だけ調整したものをベースとして利用します。
まずはそちらの準備から行なっていきます。

bash_____terminal_____yarn create next-app
yarn remove @types/node @types/react @types/react-dom
yarn add -D @types/node @types/react @types/react-dom


package.json はこれだけです。

json_____package.json_____{
  "name": "multi-stage-build-test",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "eslint": "^8.37.0",
    "eslint-config-next": "^13.2.4",
    "next": "13.2.4",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "5.0.3"
  },
  "devDependencies": {
    "@types/node": "18.15.11",
    "@types/react": "18.0.33",
    "@types/react-dom": "^18.0.11"
  }
}


検証

それでは早速検証していきます。

node:16.13.2-alpine

今回は node:16.13.2-alpine というDockerイメージを使って検証していくので、まずはなにもしない状態でのイメージサイズを確認します。

Dockerfile_____Dockerfile.plain_____FROM node:16.13.2-alpine

WORKDIR /workspace

CMD ["tail", "-f", "/dev/null"]


bash_____terminal_____docker build -t sample_plain -f Dockerfile.plain .


上記を実行すると 109MB のDockerイメージができました。

脳死

続いて脳死状態でただただアプリケーションをビルドしてサーバーを起動するDockerイメージのサイズを確認します。

Dockerfile_____Dockerfile.normal_____FROM node:16.13.2-alpine

WORKDIR /workspace
COPY . .
RUN yarn --frozen-lockfile --production && yarn build

ENV NODE_ENV production
EXPOSE 3000

CMD ["yarn", "start"]


bash_____terminal_____docker build -t sample_normal -f Dockerfile.normal .


上記を実行すると 2.24GB ものDockerイメージができてしまいました。
ただ、こちらは約1.7GBのyarnのキャッシュを含んでいる状態なので、次のMulti-Stage Buildではこちらも削除した状態のイメージを作成します。

Multi-Stage Build

続いてMulti-Stage BuildしたときのDockerイメージのサイズを確認します。
builderステージでビルドしたもののコピーと yarn start するためにdependenciesだけインストールし、最後にyarnのキャッシュを削除します。

Dockerfile_____Dockerfile.production_____FROM node:16.13.2-alpine as builder

WORKDIR /workspace
COPY . .
RUN yarn --frozen-lockfile && yarn build

FROM node:16.13.2-alpine
WORKDIR /workspace
COPY --from=builder /workspace/.next ./.next
COPY --from=builder /workspace/public ./public
COPY package.json ./
RUN yarn --frozen-lockfile --production --ignore-scripts && yarn cache clean

ENV NODE_ENV production
EXPOSE 3000

CMD ["yarn", "start"]


bash_____terminal_____docker build -t sample_production -f Dockerfile.production .
# ...
[+] Building 132.9s (13/13) FINISHED


上記を実行すると 447MB のDockerイメージができました。
yarnのキャッシュが約1.7GBと大きかったのでこれを削除するだけでもかなり小さくなります。
また、サーバー起動に不要なファイルをbuilderステージに置いてきているのでこのような結果となりました。
ちなみにビルド時間は 132.9s でした。

standalone

最後に standalone で確認してみます。
まず有効化するために next.config.js に以下の1行を追加します。

javascript_____next.config.js_____/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  output: 'standalone' // <- 追加
}

module.exports = nextConfig


そして以下のようにDockerfileを用意してビルドします。

Dockerfile_____Dockerfile.standalone_____# Must be standalone mode

FROM node:16.13.2-alpine as builder

WORKDIR /workspace
COPY . .
RUN yarn --frozen-lockfile && yarn build &&\
  cp -r -u .next/server .next/standalone/.next &&\
  cp -r -u .next/static .next/standalone/.next &&\
  cp -r -u public .next/standalone

FROM node:16.13.2-alpine
WORKDIR /workspace
COPY --from=builder /workspace/.next/standalone ./

ENV NODE_ENV production
EXPOSE 3000

CMD ["node", "server.js"]


bash_____terminal_____docker build -t sample_standalone -f Dockerfile.standalone .
# ...
[+] Building 54.9s (10/10) FINISHED


上記を実行するとDockerイメージは 121MB となりました!
ただのNext.jsアプリケーションとはいえほとんど node:16.13.2-alpine の109MBと変わらないようなサイズとなりました。
もちろん上記のイメージからコンテナを作成してホストのポートとマッピングし localhost:3000 で確認すると問題なく閲覧できます。
ちなみにビルド時間は 54.9s となっており、ビルド時間への明確な影響度はわかりかねますが今回の検証においては大幅なビルド時間短縮効果も得られました。

まとめ

今回はNext.jsのstandaloneオプションを利用しDockerのイメージサイズを確認してみました。
実際に開発していくアプリケーションでどこまでの効果が得られるかはプロジェクトごとに見ていきながらstandaloneオプションを利用していきたいと思います。