Next.js アプリビルド&Dockerビルドを爆速にする「スタンドアローン」を使ってみた

Next.js アプリケーションをDocker ビルドする際に時間がかかって上昇していくPCの熱を確かめるしかなかったり、そもそもよくわからないまま盲目的にDockerfileを構成していたりという状況でした。

ビルド高速化する方法を調べるなかで、Next.js アプリケーションの効率的・軽量なビルド方法やDocker ビルドで活用する方法について調べてみました。

ところで、なんか記事をGeminiがラジオ風に仕上げてくれました。

それからGeminiがインフォグラフィックも作成してくれました。見栄えよい。

Next.js Dockerビルド高速化ガイド
そのDockerビルド、遅くないですか?

Next.jsアプリのDockerイメージが、知らず知らずのうちに「重く」なっているかもしれません。しかし、簡単な設定一つで劇的に改善できます。

デフォルトビルドの現実

開発時には便利なツールも、本番イメージに含めるとパフォーマンスの足かせに。まるで冬山装備で近所の買い物に行くようなものです。

~650 MB
一般的なイメージサイズ
スタンドアローンビルドの威力

Next.jsの`output: “standalone”`は、実行に必要な最小限のファイルだけを抽出し、イメージを劇的に軽量化します。

~100 MB
最適化後のイメージサイズ
そもそも「ビルド」とは?
① 抽出:必要なものだけを選ぶ

巨大な`node_modules`という”万能工具セット”から、今回の作業に本当に必要な工具だけをポーチに詰める作業です。これにより、不要なファイルが最終成果物に含まれるのを防ぎます。

② 最適化:使いやすい形に整える

コードの圧縮、バンドル、静的HTMLの生成などを行い、本番環境で最高のパフォーマンスを発揮できるよう準備します。試作段階のキッチンから、お客様に提供する洗練された一皿へ。

解決策は`output: “standalone”`

`next.config.js`にたった一行追加するだけで、Next.jsが自動的に実行に必要な最小限のファイル群を`.next/standalone`ディレクトリにまとめてくれます。


// next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone", // ★ この行を追加!
};

module.exports = nextConfig;
                
驚きのインパクト

スタンドアローンがもたらす効果は、イメージサイズの削減だけではありません。

Dockerイメージサイズ比較

同じアプリケーションでも、ビルド方法でこれだけの差が生まれます。

Dockerでの実装プロセス
ステップ1: `deps`ステージ

依存関係をインストールします。`package.json`に変更がなければ、このステップはキャッシュされ、ビルドが高速化します。

ステップ2: `builder`ステージ

ソースコードをコピーし、`npm run build`を実行。ここで`.next/standalone`が生成されます。

ステップ3: `runner`ステージ

`builder`から`.next/standalone`と`public`ディレクトリのみをコピー。軽量な最終イメージが完成します。

忘れてはいけない注意点
`public`ディレクトリのコピー

画像などの静的ファイルが入った`public`ディレクトリは、スタンドアローン出力に含まれません。Dockerfileの`runner`ステージで手動でコピーする必要があります。

COPY –from=builder /app/public ./public
起動コマンドの変更

`npm start`ではなく、生成された`server.js`を直接`node`で実行します。Dockerfileの`CMD`命令を忘れずに変更しましょう。

CMD [“node”, “server.js”]

Next.js アプリケーションのビルドとは?

Next.js にとってのビルドが何を意味するのかを考えてみます。アプリケーションのビルドとは、大まかに以下の2つのプロセスです。

  • 必要なファイルの抽出
  • ファイルの最適化・成果物出力

必要なファイルの抽出

Next.js や Node.js のアプリケーションを動かすには node_modules が必要不可欠です。しかし、node_modules は開発時のさまざまな用途に対応できるよう、デフォルトでは非常に多くのファイルを含んでいます。また、package.json でインストールされる各種依存関係も同様で、インストールはされているものの、アプリケーションとして実際に動かすにあたっては必ずしも必要でないモジュールも含まれています。「何にでも使える万能工具セット」のようなもので、ドライバーもペンチもハンマーも、果ては宇宙飛行士用の特殊工具まで全部入っています。


ところで、ビルド時にこれらのファイル全てを含めるとどうなるでしょうか?使用していないモジュールまで含まれてしまい、結果として成果物のファイルの肥大化や、含まれるコード量が増えることによる脆弱性の増大といった問題につながってしまいます。日曜大工をするのに、宇宙飛行士用の特殊工具は必要はありませんね。

そこでビルド時は、アプリケーションのコードを解析し、実際に実行時に必要な依存関係やファイルだけを抽出するのです。言い換えれば、不要なモジュールは無視するということです。これは「Tree Shaking」とも呼ばれます。

ファイルの最適化・成果物出力

この「最適化」という言葉は少し分かりにくいですが、つまりはモジュールやファイルなどを「いい感じ」に整理し、実行効率を高めることです。

例えば、以下のような最適化が行われます。

  • 依存関係のバンドル:Webpack や SWC などのツールで依存関係をバンドル(束ねる)し利用しやすい形にまとめる
  • ルーティングの最適化:App Router や Pages Router のディレクトリ構造を解析しルーティング情報を最適化
  • 静的サイト生成 (SSG):SSG を利用している場合、事前に HTML ファイルを生成
  • コードの圧縮・ミニファイ:JavaScript、CSS、HTML などのコードから不要な改行やコメントを削除し、ファイルサイズを削減する
  • 画像最適化:画像のサイズ変更やフォーマット変換など

ここも、一見すると「開発環境でアプリケーションが動作しているのだからわざわざこのような最適化処理をしなくてもよいのでは?」と思いますが、本番環境でアプリケーションを実行する際には、開発環境とは異なる要件が求められるため、このような最適化が不可欠です。

  • パフォーマンス:最適化されたコードは、ロード時間が短縮され、実行速度が向上する
  • リソース消費:不要なコードやデータが削減されることで、サーバーのリソース(メモリ、CPU、ディスクI/O)消費が抑えられる
  • セキュリティ:不要なコードやライブラリを除外することで、攻撃ベクトルとなる可能性のある潜在的な脆弱性を減らせる
  • バンドルサイズ:特にフロントエンドのアプリケーションでは、ブラウザにダウンロードされるJavaScriptのバンドルサイズが小さくなり初期表示が速くなる

例えるなら、開発環境は「料理の試作段階、調味料も計量カップも全部出しっぱなし」ですが、本番環境は「お客様に提供する料理、無駄なく素早く提供できるオペレーション」が必要、といったところでしょうか。

Next.js アプリケーションビルドの「スタンドアローン」活用

このように Next.js は、もともとが優秀なので、ビルドをする際にすでに軽量化や最適化をある程度自動で行ってくれます。ただそれであっても、さらに効率化することでビルドをスピーディーに行う方法があります。

どういうことか? Next.js のアプリケーションをビルドする際、本番環境での実行には不要な開発ツール、例えば Babel、Webpack、ESLint、TypeScript、Jestなどのライブラリが含まれてしまうこともあります。これらはアプリケーションの開発時には役立ちますが、ビルド後に「アプリケーションを実行する」だけであれば含める必要がありません。

そこで、こういった不要なライブラリやモジュールなどを無視し、またビルド時には必要でもビルド後には不要になるモジュールを省略することで、ビルドの高速化や Docker イメージの軽量化につながる機能が用意されています。それが「スタンドアローン」です。公式ドキュメントはこちら

スタンドアローンとは、Next.js アプリケーションが必要最小限の構成だけでも動作するように、実行時に必要なファイル(依存関係、node_modules の一部、ビルド済みコードなど)を自動的にまとめ、1つの独立したディレクトリに抽出してくれる機能です。これにより、Docker イメージのサイズを劇的に小さくできます。賢い。

特徴スタンドアローン (output: “standalone”) を使用する場合スタンドアローンを使用しない場合
イメージサイズ非常に小さい(必要な node_modules のサブセットのみ)大きい(全ての node_modules が含まれる可能性が高い)
デプロイの容易さ高い(.next/standalone と public のみコピーすればよい)低い(.next/、public/、node_modules/ を個別に管理する必要がある)
起動時間高速相対的に遅い
メモリ使用量削減される削減されない
依存関係管理実行に必要な依存関係のみを自動で集約するため、プラットフォーム依存のバイナリ問題も起こりにくいnode_modules 全体をコピーすると、環境間のバイナリ不一致リスクがある
起動コマンドnode server.js (シンプル)npm start (package.json に依存)

これはどのように適用すればよいか?next.config.js ファイルに以下の設定を記述するだけで OK です。

next.config.js
/** @type {import('next').NextConfig} */

const nextConfig = {

  output: "standalone", // ★ この行を追加!

};

module.exports = nextConfig;

スタンドアローンを適用せずに Next.js アプリケーションをビルドする場合、.next/ ディレクトリ内にビルド成果物が生成されますが、スタンドアローン適用すると、.next/standalone/ ディレクトリが生成され、このなかに実行可能な成果物が格納されることになります。

これらは当然ローカル環境でも動かすことができ、該当のディレクトリ (.next/standalone/) に移動し、そのなかにある server.js ファイルを node server.js のような形で起動すれば OK です。

Bash
# ビルド後
cd ./.next/standalone
node server.js

なお注意点としては、通常はアプリケーションを実行する際に npm start コマンドを使いますが(Dockerファイルにもそのように記述しますが)、スタンドアローン環境でアプリケーションを実行する場合は、上記の node server.js コマンドを使用する必要があり、Dockerファイルにもそのように記述する必要があります。

またもう1つの注意点として、output: “standalone” はアプリケーションの実行に必要なものだけを抽出するため、アプリケーションコードから直接参照されていないファイルは含まれません。例えば、public/ ディレクトリに画像イメージやその他の robots.txt 、favicon.ico といった静的ファイルを保存することが多いと思いますが、これらは standalone/ ディレクトリにはデフォルトでは含まれないため、Docker イメージに手動でコピーする必要があります。詳しくは後述。

いくつか手間はかかるものの、スタンドアローンは、通常のビルドとは異なり、ビルドの成果物を非常に軽量な形でアウトプットしてくれるため、アプリケーションのパフォーマンス最適化にもつながり、Docker ビルドの最適化にもつながります。

スタンドアローンで Docker ビルドする方法

Next.js アプリケーションをスタンドアローン形式でアウトプットし、Docker ビルドの際にも適用させたいなら、マルチステージビルドが非常に相性が良いです。これは、複数の FROM ステートメントを使って、ビルドプロセスを段階的に進める方法です。

大まかな流れは以下の3ステップ+起動コマンドです。

  • 1.depsステージ:依存関係の解決
  • 2.buildステージ:Next.js アプリケーションのビルド
  • 3.runnerステージ:Next.js アプリケーション本番環境の構築

1.depsステージ – 依存関係のインストールとキャッシュ

depsステージでは、アプリケーションの依存関係をインストールします。変更がない限り、この層は Docker キャッシュを利用してスキップされるため、ビルドが高速になります。

Dockerfile
# -----------------------------------------------------------------------------

# Stage 1: Dependencies - 依存関係のインストールとキャッシュ

# -----------------------------------------------------------------------------

FROM node:18-alpine AS deps

WORKDIR /app

# package.json とロックファイルを先にコピー

# これにより、これらのファイルに変更がない限り、次のRUNコマンドがキャッシュされる

COPY package.json package-lock.json* yarn.lock* ./

# 依存関係をインストール

# package-lock.json があれば npm ci を優先し、なければ npm install を実行

RUN if [ -f package-lock.json ]; then \

      npm ci; \

    elif [ -f yarn.lock ]; then \

      yarn install --frozen-lockfile; \

    else \

      npm install; \

    fi
  • ベースイメージのインストール:node:18-alpine という軽量な Node.js イメージをベースに
  • モジュールのインストール:package.json やロックファイル(package-lock.json または yarn.lock)を先にコピーし、その内容をもとに依存関係をインストールする

なお次回以降のDocker ビルドですが、Docker は、各コマンドの実行結果をレイヤーとしてキャッシュします。package.json やロックファイルに変更がない限り、イメージキャッシュが再利用されるため、依存関係のインストールはスキップされ、ビルドが爆速になります。

npm系モジュールのインストール方法について、「npm ci」「npm install」どちらを使うか? npm install は package.json をもとに再度依存関係を選択し直したうえでインストールするのでバージョンの不整合が起こるリスクが�ります。対して、 npm ci は 実際に開発環境でインストールされた package-lock.json にもとづいて厳密に依存関係をインストールします。開発環境と全く同じ依存関係を使う方が確実なので優先的に活用すると吉です。

2.builder ステージ – Next.js アプリケーションのビルド

builder ステージでは、ステップ1でインストールした依存関係をもとに、アプリケーションのソースコードをコピーし、本番ビルドを実行します。

Dockerfile
# -----------------------------------------------------------------------------

# Stage 2: Builder - Next.js アプリケーションのビルド

# -----------------------------------------------------------------------------

FROM node:18-alpine AS builder

WORKDIR /app

# Stage 1 でインストールした node_modules をコピー (キャッシュを利用)

COPY --from=deps /app/node_modules ./node_modules

# アプリケーションのソースコード全体をコピー

# ビルドコンテキストに不要なファイルを含めないよう、.dockerignore を適切に設定すること

COPY . .

# 本番環境変数 (ビルド時に埋め込まれる公開変数用)

ENV NODE_ENV production

# Next.js アプリケーションをビルド

# next.config.js に `output: 'standalone'` が設定されていることを前提とします

RUN npm run build

ここで行われているアプリケーションのビルドは、ローカル環境で npm run build などを行う時と同じです。Next.js はここでコードの最適化、バンドル、静的ファイルの生成などを行います。

ステップ1の deps ステージから node_modules をコピーすることで、「再インストールなし」で依存関係を利用できます。

注意点としては、スタンドアローンによるビルドの場合、成果物は .next/standalone/ に出力される点です。そのため、後で必要なファイルをコピーする際はこの standalone ディレクトリからコピーしていく必要があります。

そもそもステージを分ける理由ですが、このビルドステージでは「依存関係のなかから実行に必要なモジュールだけを抽出し、それを最適な形に変換する」という処理をしています。詳しくは「Next.js アプリケーションのビルドとは?」のセクションで説明した通りで、この「賢いビルド」を行うことで本番環境では本当に必要なファイルだけを適切な形で活用できるようになります。

3.runner ステージ – 本番用イメージの構築

ステップ2でビルドされた成果物のうち、実行に必要なファイルを最終的なランナー環境にコピーします。必要なファイルだけを取捨選択することで、 最終的にはスリムなDocker イメージが完成します。

Dockerfile
# -----------------------------------------------------------------------------

# Stage 3: Runner - 本番用イメージ (非常にスリムな実行環境)

# Next.jsのStandalone Outputを利用し、最小限のファイルのみをピックアップします

# -----------------------------------------------------------------------------

FROM node:18-alpine AS runner

WORKDIR /app

ENV NODE_ENV production

# Next.jsのStandalone Outputディレクトリをコピー

# これには、アプリケーションコード、node_modulesの必要部分、サーバーエントリーポイントが含まれます

COPY --from=builder /app/.next/standalone ./

# public ディレクトリは standalone には含まれないため、別途コピーが必要

COPY --from=builder /app/public ./public

# ポート公開

EXPOSE 3000

# Next.js アプリケーションの起動コマンド

# `output: 'standalone'` を使用している場合、サーバーのエントリポイントは `server.js` です

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

このステージが、実際にデプロイされる Docker イメージとなります。

繰り返しになりますが、スタンドアローンビルドを行う場合は、.next/ ディレクトリ丸ごとではなく、.next/standalone/ に軽量化された成果物が格納されているので、それをターゲットとしてランナー環境にコピーします。「お弁当箱に、完成したおかずだけを詰める」ように、調理器具や残った食材は含めません。

こちらも繰り返しになりますが、Next.js のスタンドアローンビルドでは、デフォルトでは public/ ディレクトリのファイルは含まれていません。そのため、これらをコピーするように、Dockerfile 側で明確に指示する必要があります。

実行コマンド

通常は npm start などと記述しますが、スタンドアローン環境でアプリケーションを実行する場合は、server.js ファイルを直接起動する必要があるため、node server.js と記述する必要があります。

bash
# Dockerコンテナの起動コマンド例

node server.js

このように、スタンドアローン形式のアプリケーションを Docker ビルドする場合は、マルチステージ環境を用意したり、必要なファイルのコピーを明示的に指定したり、起動コマンドを変更したりといったポイントは押さえておきたいところです。

スタンドアローンによるビルドサイズは通常の1/6に

ビルド時間は計測し忘れましたが、Dockerイメージのサイズ比較はこちらです。

太い赤枠で囲っているのがスタンドアロンでビルドしたDocker イメージです。

6分の1以下になっていますね。信じられないようですが、これは本当に同じアプリケーションをビルドした結果です。言い換えると通常のビルドではアプリケーションの実行に使用されていないモジュールが数多く存在しディスクやメモリーを不要に圧迫しているということでしょう。これを知ってしまったら、通常のビルド方法には戻れなくなります。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です