最近のプロジェクトではフロントエンド開発のフレームワークとして 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 を用いて下記を別々のイメージで行うようにしています
- 依存パッケージのインストール
- 依存パッケージとビルド成果物をバンドルして起動スクリプトを仕込む
前者ではコピーしたファイルの内容が変わらなければ次回以降はキャッシュされたレイヤーが再利用されるため yarn install
の処理はスキップされ時間が節約できます
また、yarn install
時に下記のオプションをつけています
--frozen-lockfile
依存パッケージのバージョンが完全に固定化される--production
dependencies のパッケージのみインストール
起動時のベースイメージとして gcr.io/distroless/nodejs
を使っています
https://github.com/GoogleContainerTools/distroless
Distroless イメージの特徴として不要なあらゆるファイルが含まれていない(/bin/sh
さえも)ため、イメージサイズの削減とセキュリティリスクの軽減のメリットがあります
npm
/ yarn
コマンドも利用できないため 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 不要のイメージ作成方法
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 は非常に強力なツールですが、ユースケースによっては自前でビルドファイルを用意する方が効率が良い場合があるので適宜技術選定を行うと良さそうです