Tagbangers Blog

Next.js による Docker Image の作成周りの雑記

最近のプロジェクトではフロントエンド開発のフレームワークとして Next.js を採用しています

フロントエンドの成果物は静的ビルドを行いサーバに配置 or バックエンドの成果物に含めることが基本ですが、Kubernetes 上に展開する場合はフロントエンド用に個別に Pod を用意してアプリケーションを立ち上げています

Next.js プロジェクトの Docker Image の作成

Next.js は開発環境以外にも本番稼働向けの Node.js サーバをスクリプトを用意しています

# プロジェクトの依存パッケージのインストール 
yarn install
# プロジェクトのビルド
yarn run build
# サーバの起動
yarn run start

シンプルですね

Kubernetes にデプロイするためには Docker Image のビルドが必要となります

この際に不必要なプロジェクトファイルやバイナリファイルが含まれているとイメージサイズの増加やセキュリティ上のリスクとなるため注意が必要となります

通常、Next.js の Docker Image を作成する場合は下記ファイルが必要となります

# ビルド成果物
/.next/
# スタティックファイル
/public/
# 依存パッケージ
/node_modules/
# 設定ファイル
/next.config.js

Docker Image の作成方法はいくつかあるかと思います

  • Docker Build 時にでビルド + イメージ作成を行う
  • 事前にビルドを行った成果物を用いて Docker Build でイメージ作成を行う

弊社では CI/CD ツールを用いてビルドとデプロイを切り分けて開発しており下記のようなフローを組んでいるため後者の方法を採用します

  • 通常開発時はブランチごとにビルド・テストが走りプロジェクトを検証 (CI)
  • CI の成果物としてビルド成果物・テストカバレッジ等が保存
  • デプロイ時に特定のブランチの成果物を用いてデプロイを行う (CD)

さて、ビルド時に必要なファイルのなかに node_modules がありましたがこのファイルを CI の成果物として含めるのは下記の理由により不適切です

  • node_modules には開発・ビルド用しか用いない不要なパッケージが含まれる
  • package.json が変更されない限り成果物が全く同じなので CI/CD 環境のストレージを無駄に消費する

そのため Docker Build 時に本番環境用に必要な依存パッケージをダウンロードするようにします

ここで注意点として下記があります

  • package.json において本番環境に必要なパッケージのみ dependencies に含め、それ以外は devDependencies に含める
  • 依存パッケージのバージョンを固定するために yarn.lock (package-lock.json) がビルド時に含まれるようにする
  • yarn install (npm install) 時に本番環境のみ含めるオプションをつける

今回は Yarn を用いたやり方を説明します

Docker Build 時に必要なファイル以外をキャッシュに含めないよう .dockerignore を用意します

# Ignore everything
**

# Allowed files
!/.next/**
!/public/**
!/next.config.js
!/package.json
!/yarn.lock

!/.yarn/**
!/.yarnrc

.yarn.yarnrc は Yarn のバージョンの固定化のために必要なだけなのでなくても大きく問題にはなりません

この上で Dockerfile は下記のように記述します

# Node 16.x
ARG NODE_VERSION=16

# Build phase
FROM node:$NODE_VERSION AS builder

WORKDIR /app

# Prepare node_modules
COPY ./.yarn ./.yarn
COPY ./package.json ./yarn.lock .yarnrc .
RUN yarn install --frozen-lockfile --production

# Run phase
FROM gcr.io/distroless/nodejs:$NODE_VERSION AS runner

WORKDIR /app

COPY --from=builder /app .

# Copy artifacts
COPY ./next.config.js .
COPY ./public ./public
COPY ./.next ./.next

CMD ["./node_modules/next/dist/bin/next", "start"]

Multistage Build を用いて下記を別々のイメージで行うようにしています

  1. 依存パッケージのインストール
  2. 依存パッケージとビルド成果物をバンドルして起動スクリプトを仕込む

前者ではコピーしたファイルの内容が変わらなければ次回以降はキャッシュされたレイヤーが再利用されるため yarn install の処理はスキップされ時間が節約できます

また、yarn install 時に下記のオプションをつけています

  • --frozen-lockfile 依存パッケージのバージョンが完全に固定化される
  • --production dependencies のパッケージのみインストール

起動時のベースイメージとして gcr.io/distroless/nodejs を使っています

https://github.com/GoogleContainerTools/distroless

Distroless イメージの特徴として不要なあらゆるファイルが含まれていない(/bin/sh さえも)ため、イメージサイズの削減とセキュリティリスクの軽減のメリットがあります

npmyarn コマンドも利用できないため package.json のスクリプトを使用できません

起動スクリプトは実行ファイルを直接指定していますが、やっていることは yarn run start と同じです

さて、これにて Docker Image のビルドができましたが改めてこのビルドを CD 上で行うために CI の成果物として必要なファイル群は下記となります

/.next/
/.yarn/
/public/
/.dockerignore
/.yarnrc
/Dockerfile
/next.config.js
/package.json
/yarn.lock

ここで、Docker Image 構築を楽にする2つの方法を紹介します

Cloud Native Buildpacks を用いた Dockerfile 不要のイメージ作成方法

https://buildpacks.io/

Dockerfile を用意せずにプロジェクトを分析してビルド & イメージの作成を行ってくれる便利なソリューションです

pack コマンドを用いてローカルで実行できるため、CD 環境にも導入可能です

さまざまな言語・アーキテクチャ用のビルダーが用意されており、先ほどの Distroless 同様最終的な成果物サイズを削減してくれるのはもちろんのこと、ビルダーによっては起動時の最適化もおこなってくれます

Node.js 用のビルダーとして下記を利用します

https://paketo.io/docs/howto/nodejs/

利用方法としては下記のコマンドを実行するだけです

pack build "app-name" --buildpack paketo-buildpacks/nodejs

.node-version を検知して Node.js のバージョンを自動で切り替えたり、yarn.lock を検知して yarn を用いたコマンドを実行してくれます

非常に便利なソリューションですが、実際に利用していて下記の点が使いづらく感じました

  • ビルド時間が長い(2〜4分ほど)
    • ビルド用・起動用それぞれのパッケージのインストールの手順があるため、またキャッシュを再利用してくれないようで毎回インストールが発生してしまう
  • Yarn Berry (yarn v2~) に未対応
  • ランタイム時に必要ないファイルもイメージに含まれてしまっている

また他のビルダーと違い起動時の最適化に関するオプションはざっと調べたところあまり見受けられず、この点においてはメリットは薄いように感じました

Next.js standalone を用いた Docker build フローの簡略化

Next.js にはスタンドアローンビルドモードがあります

https://nextjs.org/docs/advanced-features/output-file-tracing

next.config.js に下記を追加することで有効になります

module.exports = {
  output: 'standalone',
}

このモードでビルドを行った場合 .next/standalone ディレクトリが成果物として追加され、依存するパッケージ情報が含まれるようになります

これにより別途 node_modules を用意する必要がなくなるほか、プロジェクトで実際に利用しているファイルのみ含まれるようになるため、ビルドサイズをさらに削減することができます

このモードを有効にすると .dockerignore と Dockerfile は下記のように変更できます

# Ignore everything
**

# Allowed files
!/.next/standalone/**
!/.next/static/**
!/public/**
!/next.config.js
# Node 16.x
ARG NODE_VERSION=16

FROM gcr.io/distroless/nodejs:$NODE_VERSION

WORKDIR /app

COPY ./next.config.js .
COPY ./public ./public
COPY ./.next/static ./.next/static

COPY ./.next/standalone .

CMD ["server.js"]

node_modules および yarn が不要となったため、記述量が大幅に削減できました

起動時は .next/standalone/server.js を node コマンドに渡すだけです(Distroless のエントリーポイントとして node コマンドが指定されています)

注意点として .next/standalone に必要な全てのファイルが含まれるわけではなく、下記ファイルは Image ビルド時に別途コピーする必要があります

/.next/static/
/public/
/next.config.js

モノレポ構成における Next.js standalone

例えば yarn workspace を用いた下記のような構成のプロジェクトの場合

.
├ node_modules/
├ packages/
├ ├ app/
│ │ ├ .next/
│ │ ├ public/
│ │ ├ next.config.js
│ │ └ package.json
│ └ lib/
│   └ package.json
├ package.json
└ yarn.lock

node_modules はルート直下にありますが、Next.js プロジェクトは packages 配下にあります

この状態で Next.js standalone を有効にしても Next.js は next.config.js のあるディレクトリ以下の依存チェックのみ行うため実行時に必要なファイルが不足します

対策としてプロジェクトルートから依存チェックを行うように追加のオプションを指定します

https://nextjs.org/docs/advanced-features/output-file-tracing#caveats

module.exports = {
  output: 'standalone',
  experimental: {
    outputFileTracingRoot: path.join(__dirname, '../../'),
  },
}

この上でプロジェクト直下に下記のように Dockerfile を用意します

# Node 16.x
ARG NODE_VERSION=16

FROM gcr.io/distroless/nodejs:$NODE_VERSION

WORKDIR /app

COPY ./packages/app/next.config.js .
COPY ./packages/app/public ./packages/app/public
COPY ./packages/app/.next/static ./packages/app/.next/static

COPY ./packages/app/.next/standalone .

CMD ["./packages/app/server.js"]

少しややこしいですが .next/standalone の中の構成もプロジェクトルートからのパスに置き換わるため、server.js はプロジェクトルートから見ると packages/app/.next/standalone/packages/app/server.js に存在するという点に注意が必要です

総括

Next.js はデプロイ後まで含めて豊富に機能があるため、非常に開発しやすくて助かります

Buildpacks は非常に強力なツールですが、ユースケースによっては自前でビルドファイルを用意する方が効率が良い場合があるので適宜技術選定を行うと良さそうです