Next.js アプリケーションの ECS へのデプロイについて考えるスレ
考慮しておくとよさそうなこと
- Next.js ドキュメント
- Dockerfile
- standalone モード
- CDNキャッシュ
- ロギング (JSON形式)
- エラー収集 (DataDog)
- ソースマップ
- SEO
- sitemap
- OpenTelemetry
- Node.js のヒープサイズ
- ECS ローリングアップデート中のアクセスエラーへの対処
例外
- コンテナ ( ECS ) をやめる
- FaaS (AWS Lambda / Lambda@Edge ) にデプロイする
Next.js 公式の Dockerfile を読む
マルチステージビルドの構成
(依存関係)
(フローチャート)
マルチステージビルドって?
ChatGPT に訊いてみた
Dockerマルチステージビルドの利点
- イメージサイズの削減: 不要なビルドツールや依存関係を除外できるため、イメージサイズが小さくなります。
- セキュリティの向上: 最終イメージに不要な成果物が含まれないため、セキュリティリスクが軽減されます。
- Dockerfileの可読性とメンテナンス性の向上: 1つのDockerfileで複数のステージを定義できるため、可読性とメンテナンス性が向上します。
- ビルドキャッシュの効率化: 各ステージが個別にキャッシュされるため、ビルドプロセスが効率化されます。
Next.js 公式ドキュメントを読む
- Docker を利用していて複数の環境にデプロイするケース
-
next.js/examples/with-docker-multi-env at canary · vercel/next.js · GitHub
-
.env.production
を環境毎に差し替える方法を取っている
-
-
next.js/examples/with-docker-multi-env at canary · vercel/next.js · GitHub
standalone モードでビルドする
Advanced Features: Output File Tracing | Next.js
ビルド & 起動
# monorepo ビルド
# monorepo として apps/ 配下に web というアプリケーションが存在していると仮定する
yarn turbo run build --filter=web --force
# 仮に ./server-test に実行用のファイルを格納する
# 実際の Dockerfile では multi-stage ビルドで、実行用の stage にファイルをコピーする
mkdir server-test
cp server-test
cp ../apps/web/next.config.js .
cp ../apps/web/package.json .
cp -r ../apps/web/.next/standalone/* .
cp -r ../apps/web/.next/static ./apps/store/.next/
cp -r ../apps/web/public ./apps/web/
# サーバ起動
node apps/web/server.js
runtime: 'experimental-edge'
な API がエラーになる
たとえば pages/api/fuga.ts
ファイルがあり、 Edge ランタイムで実行されるようなコードが実装されていたとすると
export const config = {
runtime: 'experimental-edge'
}
の記述があるため、このコードは Vercel の edge ノードに配置されるべきコードと判断されて standalone ファイルとして出力されない。
それによって、以下のようなエラーが引き起こされる。
Error: ENOENT: no such file or directory, open '.../packages/hoge/dist/src/.next/server/pages/api/fuga.js.nft.json'
対処
今回、デプロイ先が ECS であったため edge ランタイムは利用しない見込みなので単純にコメントアウトすることで対応した。
server.js 起動時に node-polyfill-fetch が無いというエラーが出る
$ node packages/app/server.js
node:internal/modules/cjs/loader:1078
throw err;
^
Error: Cannot find module './node-polyfill-fetch'
Require stack:
- ... /server-test/node_modules/next/dist/server/next-server.js
対処
どうも next.conf.js で distDir: 'dist/src/.next'
のようにカスタマイズしていると問題が出ているように見受けられる ... ?
該当箇所をコメントアウトしてデフォルトの状態にしたら解消した
Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly
が出る
Advanced Features: Output File Tracing | Next.js
このページに記載のとおり ImageOptimization が必要な場合 sharp
のインストールが必要
対処
sharp
パッケージをインストールしたら解消した
yarn add sharp
next.config.js の distDir と tsconfig.json の outDir が一致していない件
なにか問題ある?
潜在的な package.json の不備の対応
これまで潜在的だった各アプリに依存関係として package.json 記述するべきだったパッケージの不足が露呈したので、それらを追加していく作業が必要になった.
たとえば、アプリ A, B, C があったとして、アプリ A, B, C それぞれで利用しているパッケージとして @mui/icons-material
があったが、package.json への アプリA のほうにしか記述していなかった。
しかし、アプリB, C でも問題なくパッケージを利用できていた。
standalone として、個別のパッケージの依存関係だけが抜き出されることによって不足が露呈することになった。
Task Definition の変更
Before
standalone モード以前は next server
を起動していた
["yarn", "workspace", "package/hoge", "start:production"]
After
["node", "package/hoge/server.js"]
dockerignore を見直したい
なにが build context に含まれているか確認する
ncdu -X .dockerignore
以下のようにどのディレクトリにどれくらいファイルの容量のファイルが存在しているかがわかるので、これをみながら .dockerignore を調整していく。
その結果、 .git
, node_modules
, OpenAPI のリポジトリを参照している sub module [1] などローカルの不要なファイルが docker build 時に送信されていることがわかって除外することができた。
参考: dockerfile - Docker command/option to display or list the build context - Stack Overflow
-
OpenAPI の定義からクライアントコードを生成した結果をリポジトリにコミットしているため、定義ファイル自体はビルド段階では不要となっている。 ↩︎
見過ごされがちだけど含めると良さそうなやつ
Dockerfile
.dockerignore
Dockerfile 書き換えながらビルドを改善してるときに Dockerfile 自身の変更がキャッシュを無効化させてしまうことがあるので ignore しておいた方がよさそう。
.dockerignore ファイル自身も同じ理由。
Turborepo の Dockerfile でのビルド方法について見直す
公式 example を見る
プロファイリングで現状を見直す
log 見るのとさほど変わらなかったので没
BuildKit 補足 ( 参考: moby/buildkit: concurrent, cache-efficient, and Dockerfile-agnostic builder toolkit )
- LLB
- コンテナビルドプロセスの依存関係グラフを表現した中間バイナリ
- Frontend
- 特定のビルド定義ファイルを LLB に変換するためのコンポーネント。 BuildKit には Dockerfile を扱うものとしてあらかじめ
dockerfile.v0
が存在する。
- 特定のビルド定義ファイルを LLB に変換するためのコンポーネント。 BuildKit には Dockerfile を扱うものとしてあらかじめ
-
--local
オプション- クライアント側から builder に対してローカルのファイルを提供する。 Dockerfile のビルドについては
context
とdockerfile
が指定できる。
- クライアント側から builder に対してローカルのファイルを提供する。 Dockerfile のビルドについては
プロファイルの結果
yarn turbo run build
の 5 分が長いことがわかるが ... ビルドのログからわかってた
turbo prune
について確認する
公式 Dockerfile の turbo prune - CLI Reference – Turborepo
- ビルドターゲット (
--scope
で指定したワークスペース ) のビルドに必要なソースコードだけを抽出したものをout
フォルダとして吐き出す - ターゲットに必要な dependency のみを抽出した yarn.lock を出力する
-
--docker
オプションを付与することで、 Docker の layer cache に最適化された形で out に出力される
Dockerfile のベストプラクティスを見直す
以下今のプロジェクトに関連しそうなのをあげておく
- マルチステージビルドを利用する
- レイヤー数を最小化する
- レイヤーが作成されるのは
RUN
,COPY
,ADD
のみ - マルチステージビルドで最終的なイメージに必要なファイルだけをコピーすることで、レイヤーのサイズを小さくできる
- レイヤーが作成されるのは
- 複数行の引数はアルファベット順にソートする
- キャッシュを有効活用する
-
ADD
,COPY
の対象ファイルのチェックサムを検査するので、不必要なファイルを対象に含めない ( .dockerignore などで除外する )
-
-
apt-get update
とapt-get install
は同じRUN
コマンドの中に記述する-
apt-get update
単独でキャッシュされると更新される機会が無い -
apt-get install
と同じRUN
コマンドに記述したときは、インストール対象が増えた場合や対象のバージョンが変更されたタイミングでキャッシュが破棄されて新しくキャッシュし直されることになる - 最終的に
/var/lib/apt/lists/*
を削除してレイヤーのサイズを削減する
-
-
ADD
よりCOPY
を利用する -
ENTRYPOINT
はその Image がコマンドそのものと振る舞うようなメインのコマンドを設定する
Node.js web アプリケーションにおける Docker 利用時のベストプラクティス
- alpine イメージを使わない
- セキュリティスキャナが依存関係に含まれる脆弱性を検出できない
- node チームが公式にサポートしていない
-
node:xx.xx.x-xxxx-slim
のような Debian 系の軽量イメージが公式サポートなので、それを利用する
-
npm ci --only=production
oryarn install --production=true --frozen-lockfile
で production で必要なパッケージのみをインストールする -
NODE_ENV=production
を設定する - root 権限を利用しない
-
USER node
でプロセスの owner を切り替える ( official の Docker イメージにはnode
ユーザが含まれている ) -
COPY --chown=node:node
でファイルの owner を変更する
-
-
CMD [ "dumb-init", "node", "server.js" ]
のように、dumb-init 経由で node プロセスを実行する-
npm
やyarn
を経由するとコンテナ停止のシグナルがアプリケーション側に到達しない -
CMD node xxx
のような Shell form で記述するとシェル経由となり、そのときもシグナルがアプリケーション側に到達しない場合がある - exec form (JSON配列形式) で記述するとシェルを経由せずにプロセスを起動できるが、シグナルハンドラの登録の問題があるので
dumb-init
を経由することでそれを解決する
-
- サーバサイドの機能をほとんど持っていないので Graceful shutdown については考慮しない
- マルチステージビルドを利用する
- シークレットについては build 時に渡していないので今回は考慮しない
base image についてこの記事でも同じこと言ってる
Docker の Layer cache を考慮する
ECS 以外を模索...
Next.js のサーバの性能として1つのコンテナ(1vCPU)あたりでさばけるリクエスト数が思ったより低い(20rps程度?)
そして内部的なアーキテクチャの問題なのか不明だが CPU 使用率低い状態で応答が詰まるような現象もあり、大きなリクエストを捌くにはそれに比例して並列化が必要そうに見える。
ということから、Lambda@Edge + CloudFront の構成で動かせないかと考えている...
Next.js のサーバの性能として1つのコンテナ(1vCPU)あたりでさばけるリクエスト数が思ったより低い(20rps程度?)
これがアプリケーション実装側 ( フレームワーク側? ) の問題が原因で、それを解消すると 1vCPU (mem:2G) あたり 200 〜 300 rps 程度まで耐えられることがわかり、当面 ECS でも大丈夫そう
つづきは Timesy.dev で Lambda へのデプロイは別途研究してみる
Node.js が利用可能なヒープメモリのサイズを指定する
サーバに負荷をかけ続けていると以下のエラーが発生した。
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
ECS のメトリクスでメモリ使用率を見る限り 30 % 程度しか利用していない。
以下の記事にあるようにヒープメモリの設定を行う。
プロセス停止直前の GC のログには以下のように出力されていた
[39:0x7f90fcea7300] 4057231 ms: Scavenge 1004.6 (1038.0) -> 1002.7 (1038.7) MB, 4.8 / 0.0 ms (average mu = 0.294, current mu = 0.279) allocation failure;
このログの見方はわかっていないが... 約 1MB のメモリを確保(?)しているかのように見える
以下を参考に Node.js に割り当てるヒープのサイズを雑に決定 ( コンテナに割り当てられているメモリが 2GB なのでヒープは 1.5GB )
その他参照した記事
ECS で Task および Container に割り当てる CPU Unit について
CDN キャッシュを設定する
ECS でアプリケーションを稼働させるにあたって CDN は CloudFront を利用するのが定番なので、とりあえず CloudFront を利用する。
以下のようにキャッシュ設定する
Path | Cache Policy | Cache-Control Header | Note |
---|---|---|---|
/api/* |
Managed-CachingDisabled |
- | キャッシュしない |
/_next/image |
クエリパラメタを含める | public, max-age=60, must-revalidate |
next/image で最適化された画像 |
/_next/* |
Managed-CachingOptimized |
(非SSR) s-maxage=31536000, stale-while-revalidate (SSR) private, no-cache, no-store, max-age=0, must-revalidate
|
静的ファイル /_next/static/* ページデータ /_next/data/*
|
(default) | Managed-CachingOptimized |
- | 上記以外は path のみでキャッシュする |
- Origin Request は基本的に
AllViewer
を選択した - SSR ページはCache-Control ヘッダによってデフォルトでキャッシュが無効化されている
- SSR ページの中でもキャッシュを効かせたいページは、アプリケーション側で明示的に Cache-Control ヘッダをセットすることでキャッシュを有効化する
- Deploying: Going to Production | Next.js
-
/_next/image
はクエリパラメタに対象の画像や最適化方法に関するパラメタが付随するため、キャッシュキーにそれらを含める - リクエスト元に応じて返却するHTMLは変わらない作りになっているため、デフォルトの設定は最大限キャッシュが効くように Path のみをキャッシュキーとした
- ページによっては、fetch の条件をページ表示時のクエリパラメタに依存しているケースはあるが、それらは Client-side での処理であるためキャッシュキーとして含めなくても問題無いと判断した。
デプロイ時のキャッシュ無効化
デプロイのタイミングで変更の可能性があるキャッシュは無効化する
- Page の path
以下については無効化は不要と判断した
-
/_next/image
- 元画像はS3にアップロードされた画像であり、デプロイによって(プログラムの変更によって)その結果が変わるものでは無いため
-
/_next/staitc/*
,/_next/data/*
- path にビルドIDが含まれており、デプロイ毎にpathが更新される
- ビルドIDは Page の HTML に含まれている
- デプロイによって新しい Page が配信されるようになったとしても、ブラウザリロードしなければ古い Page のままアプリケーションは動作しようとする
- その際、該当 Page のビルドIDに対応するこれらのアセットがキャッシュに残っていないと404エラーが発生してしまう
- キャッシュを残したままにしておくことで、デプロイによるナビゲーションエラー発生の機会を低減させる効果もある(?)
トラブルシュート(1): エラーレスポンスが長期間キャッシュされる
原因
_error.tsx
ページで getStaticProps()
を export していた。
Next.js のレスポンスにセットされる Cache-Control ヘッダの仕様として getStaticProps()
が export されているページについては長期間 (staic なので) キャッシュが保持されるように、デフォルトで以下のような Cache-Control ヘッダがセットされている
s-maxage=31536000, stale-while-revalidate
対処
これを避けるには getStaticProps()
を export するのをやめるか、revalidate
を返却することで ISR を有効にする。
revalidate
を返却すると以下のようにヘッダが設定される ( REVALIDATE_SECONDS
は revalidate
にセットした数値 )
s-maxage=REVALIDATE_SECONDS, stale-while-revalidate
参考:
トラブルシュート(2): i18n対応の path がキャッシュクリア対象から漏れていた
- sub-path routing 方式で i18n 対応していた
- デプロイ時に内容が変更される可能性がある path についてはキャッシュ破棄を行っている
-
/*
でまるごと破棄するのが構成としては楽だけど、最大限キャッシュを生かしたいので細かく対象を指定していた
-
- デフォルト言語の path については、デプロイ時のキャッシュ破棄の対応済みだったが、他の言語の path について対応がもれていた
(あとで詳しく)
トラブルシュート(3): デプロイタイミングで static な asset にアクセスすると 404 のレスポンスが CDN にキャッシュされることがある
- ECS がローリングアップデートになっている
- static な asset を ECS の Next.js サーバから serve してる
- デプロイ時にALBにぶら下がるECSインスタンスが一瞬、新旧のサービス両方がぶら下がっている状況になることがある
- 新しいサービス側から HTML をダウンロードして、それに記述されてる js などを取得しにいくときに、古いサービス側につながるとそんなファイル無いので 404 が返却され、それがキャッシュされていた
対応:
-
static な asset は S3 にアップロードして、S3から serve する
-
関連: https://nextjs.org/docs/app/api-reference/next-config-js/assetPrefix
-
static な
(あとで詳しく)
トラブルシュート(4): SSR 対応ページがキャッシュされない
getServerSideProps()
を export しているページはデフォルトでキャッシュされないようになっているため、キャッシュさせたいときには明示的に Cache-Control ヘッダをセットする必要があった。
(あとで詳しく)
_next/static を CDN からの配信するときに CORS エラーが出る
以下の設定が必要だったりする?
module.exports = {
crossOrigin: 'anonymous'
}
(あとで詳しく)
Cookie の有無でSSR結果が異なる場合に Cookie の値と一致しない結果が返ってくることがある
CloudFront 側のキャッシュキーに Cookie の該当項目が含まれていなかった
(あとで詳しく)
ビルド結果の static なファイルをS3にアップロードしCloudFrontから配信する
(TBW)
_next/static を CDN からの配信するときに CORS エラーが出ることがある
以下のような現象が出てた
https://qiita.com/Yuhsak/items/f9e829a2be88f5dfbab4#crossorigin設定の追加
別ドメインからのスクリプトを読み込んだ際にcross-origin属性が付与されていないと
- エラー発生時にエラーの内容がコンソールに出力されず、全てScript Errorと出力される
以下の設定が必要だったりする?
module.exports = {
crossOrigin: 'anonymous'
}
(あとで詳しく)
画像最適化のために sharp をインストールする
モノレポ構成と standalone ビルドが影響しているのか、原因がよくわからないが package.json に追加されている sharp が使われていないようで、実行時にサーバ側で以下のエラーが発生していた。
⨯ Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production
Dockerfile に sharp のインストールを追加する
マルチステージビルドになっているので builder 側で以下のように sharp をインストールする
RUN npm install -g --os=linux --cpu=x64 sharp
runner 側ではインストール済みのファイルをコピーして、そのパスを参照できるように環境変数を設定する
COPY /usr/local/lib/node_modules /usr/local/lib/node_modules
ENV NEXT_SHARP_PATH /usr/local/lib/node_modules/sharp
参考
sharp をコンテナで扱う上でいろいろあった
next/image をECSでホストしていると早いうちにパフォーマンスの限界を迎えそうなので、Lambda にオフロードする方法を模索しておきたい。
(あとで読む)