【Next.js14】マルチステージビルド・standaloneモードでDockerイメージサイズを削減する方法【コピペでOK】

はじめに

こんにちは、株式会社TOKOSのツキヤです!
Next.jsをVercel以外でデプロイする際、コンテナ系のサービス(ex. ECS)を用いることがぼちぼちあるかと思います。
その際に、どうやってイメージサイズを圧縮するかは少し考えたりしますよね?
今回「マルチステージビルド」「standalone(スタンドアロン)モード」の2つの方法を用いてNext.jsのイメージサイズを圧縮する方法を記述します💪
なるべく分かりやすく、最終的にはコピペで動くように説明しますので、ぜひ最後まで読んでください!

対象読者・前提

  • Next.jsの基礎的な知識がある人
  • Docker・docker-composeの基礎的な知識がある人

マルチステージビルドとは?

マルチステージビルドに関してはご存知の方も多いかとは思いますが、簡単に説明だけします!
結論から言うと「最終的なビルドイメージのサイズを減らすための仕組み」です。
、、、これだけだと分からないと思うので、もう少し解説します😇

通常、アプリケーションを作る過程では、コンパイルや依存パッケージ・ライブラリのインストールなど、ビルドに必要なファイルがたくさん作られます。しかし、実際に動かす際にはこれらのファイルは不要ですよね。
例えば、昔のDockerfileだとhogeというミドルウェアをインストールした後、そのhogeに対するtmpファイルやcacheファイルをrmで削除する記述が見られると思います。

Dockefileのあまり良くない例
# ...

RUN apt-get update -qq \
  && apt-get install -y --no-install-recommends \
    curl \
    git \
    libmariadb-dev \
    build-essential \
  && apt-get clean \
  && rm -rf /var/cache/apt/archives/* \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
  && truncate -s 0 /var/log/*log

# ...

ですが、マルチステージビルドを使うことでそういった記述を無くし、アプリケーションの実行に本当に必要なパッケージ・ファイルのみでビルドをすることができます!
具体例は下記します😎

standalone(スタンドアロン)モードとは?

こちらについてはNext.js固有の設定方法なので、もしかしたら知らない方も居るかもしれないです。
standaloneモードについてひとことで言うと「本番デプロイに必要なファイルのみをコピーしたstandaloneフォルダを自動的に作成する」機能です!
つまり、Dockerイメージ内にこのstandalone/ 配下のディレクトリのみを入れることでイメージサイズをかなり圧縮できそうな気がしますね✨
こちらも具体例は今から説明していきます!

詳細が気になる方は下記公式サイトをご確認ください。

マルチステージビルドを使ったイメージの改善

早速、まずはマルチステージビルドを使うとどの程度イメージサイズが変わるかを確認してみます!
そのために、Next.jsのプロジェクトを create-next-app で作成し、イメージを作ってみます!

ターミナル
$ npx create-next-app@latest docker-project --ts --eslint

上記コマンドでdocker-projectというプロジェクトを作成します!
tailwindは入れておきます!

できあがったプロジェクトに対して、Dockerfiledocker-compose.ymlを作成して起動してみます!

docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: docker-project
    ports:
      - 3000:3000

docker-compose.ymlについてはただの起動用です!

そして、比較用となるDockerfileを上記にしてみます!
(今回はNode.jsのバージョンは20系にしています)

Dockerfile
FROM node:20.13-slim

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["npm", "run", "start"]


この時点では、マルチステージビルドもstandaloneモードも使用していないです。
$ docker compose up で立ち上がればOKです。

この時、イメージサイズは1.35GBでした。結構大きめですね。。
ここから、マルチステージビルド化を行います!

早速、マルチステージビルド化したDockerfileを下記します。

Dockerfile
FROM node:20.13-slim AS base

# ビルドステージ
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build

# 実行ステージ
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm install

COPY --from=builder /app/.next ./.next
# public/配下に画像等を配置する場合はコメントアウトを外す
# COPY --from=builder /app/public ./public

CMD ["npm", "run", "start"]

結論から言うと、上記記述に変更することでイメージサイズが812MBになりました✨
比較用と比べると4割位削減できていますね!

それでは簡単に説明します。
まずは1行目部分です。

Dockerfile
FROM node:20.13-slim AS base

これで、node:20.13-slimというベースイメージにbaseというエイリアスをつけました。
これにより、もしバージョンを変更したい場合は1行目のみを変更するだけでよくなります。

次にビルドフェーズです。

Dockerfile
# ビルドステージ
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build

ここでは、単純にnpm installをしてからnpm run buildをしているだけです。
このbuilderと名付けたイメージは、最終成果物には含まれないのが特徴です!

最後に実行フェーズです。

Dockerfile
# 実行ステージ
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./
RUN npm install

COPY --from=builder /app/.next ./.next
# public/配下に画像等を配置する場合はコメントアウトを外す
# COPY --from=builder /app/public ./public

CMD ["npm", "run", "start"]

この部分での記載が実際のイメージに含まれるようになります。

特徴的なのは、--from=builderの部分ですね!
この記述がある部分でのCOPYは、前段のbuilder内のディレクトリを参照することになります。
つまり、COPY --from=builder /app/.next ./.nextは「builderイメージ内の/app/.nextディレクトリをrunnerイメージ内の./.nextにコピーして」という命令になります!

何が嬉しいかというと、runnerイメージではnpm run buildをせずとも必要なファイル群をコピーすることでnpm run startを実行することができるということです✨
これで、npm run build時に生成される、実行時には不要なファイル群を取り除くことができ、結果としてイメージサイズの削減になっているのです!

さて、この時点でもまあまあイメージサイズを削減できているのですが、ここから更に削減していきます🔥🔥

standalone(スタンドアロン)モードを使ったイメージの改善

ここからstandaloneモードに仕上げていくのですが、1点だけnext.config.(m)jsの修正が必要です!

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
};

export default nextConfig;

上記のようにoutput: "standalone"とするだけで準備OKです!

こちらも、早速ですがDockerfile全体を記載します!

Dockerfile
FROM node:20.13-slim AS base

# ビルドステージ
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build

# 実行ステージ
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
# public/配下に画像等を配置する場合はコメントアウトを外す
# COPY --from=builder /app/public ./public

ENV HOSTNAME "0.0.0.0"
ENV PORT 3000
EXPOSE 3000

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

変更点は実行フェーズ以下です。

まず、COPYの対象が変わりました!
必須となるのが.next/static.next/standaloneです。
staticの方はそのまま同じパスにコピーするのですが、standaloneの方はカレントディレクトリ直下にコピーします!

次に、ホスト名とポートを設定します。
環境変数名HOSTNAMEPORTにそれぞれ設定したい値を入れるだけでOKです💪

最後に、サーバー起動コマンドの実行です。
ここは$ node server.jsというコマンドを実行してあげましょう!
以上で localhost:3000 で問題無くアクセスできるはずです。

そして、作成されたイメージサイズはなんと239MBです!!
マルチステージビルドのみと比べても7割以上削減できています✨

おわりに

今回は、Next.jsに対してマルチステージビルドとstandaloneモードを使ってDockerのイメージサイズを削減する方法を説明しました!
イメージサイズを削減することで、CI/CDの時間も短縮されたりして良いことしか無いはずなので積極的に使っていきたいですね💪
逆に、こうしたらもっと削減できる等の方法を知っている方が居ましたらご教示いただきたいです🙏

尚、今回のDocerfileはバインドマウント等を行っていないので、開発時にはバインドマウントをdocker-dompose.yml内に記述して別のDockerfileを用意した方が良いかもしれないです。