Open36

Next.js アプリケーションの ECS へのデプロイについて考えるスレ

snakasnaka

考慮しておくとよさそうなこと

例外

snakasnaka

Next.js 公式の Dockerfile を読む

マルチステージビルドの構成

(依存関係)

(フローチャート)

マルチステージビルドって?

ChatGPT に訊いてみた

Dockerマルチステージビルドの利点

  1. イメージサイズの削減: 不要なビルドツールや依存関係を除外できるため、イメージサイズが小さくなります。
  2. セキュリティの向上: 最終イメージに不要な成果物が含まれないため、セキュリティリスクが軽減されます。
  3. Dockerfileの可読性とメンテナンス性の向上: 1つのDockerfileで複数のステージを定義できるため、可読性とメンテナンス性が向上します。
  4. ビルドキャッシュの効率化: 各ステージが個別にキャッシュされるため、ビルドプロセスが効率化されます。
snakasnaka

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
snakasnaka

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 ランタイムは利用しない見込みなので単純にコメントアウトすることで対応した。

snakasnaka

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' のようにカスタマイズしていると問題が出ているように見受けられる ... ?

該当箇所をコメントアウトしてデフォルトの状態にしたら解消した

snakasnaka

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
snakasnaka

next.config.js の distDir と tsconfig.json の outDir が一致していない件

なにか問題ある?

snakasnaka

潜在的な package.json の不備の対応

これまで潜在的だった各アプリに依存関係として package.json 記述するべきだったパッケージの不足が露呈したので、それらを追加していく作業が必要になった.

たとえば、アプリ A, B, C があったとして、アプリ A, B, C それぞれで利用しているパッケージとして @mui/icons-material があったが、package.json への アプリA のほうにしか記述していなかった。
しかし、アプリB, C でも問題なくパッケージを利用できていた。

standalone として、個別のパッケージの依存関係だけが抜き出されることによって不足が露呈することになった。

snakasnaka

Task Definition の変更

Before

standalone モード以前は next server を起動していた

["yarn", "workspace", "package/hoge", "start:production"]

After

["node", "package/hoge/server.js"]
snakasnaka

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

脚注
  1. OpenAPI の定義からクライアントコードを生成した結果をリポジトリにコミットしているため、定義ファイル自体はビルド段階では不要となっている。 ↩︎

snakasnaka

見過ごされがちだけど含めると良さそうなやつ

Dockerfile
.dockerignore

Dockerfile 書き換えながらビルドを改善してるときに Dockerfile 自身の変更がキャッシュを無効化させてしまうことがあるので ignore しておいた方がよさそう。
.dockerignore ファイル自身も同じ理由。

snakasnaka

Turborepo の Dockerfile でのビルド方法について見直す

公式 example を見る

https://github.com/vercel/turbo/tree/main/examples/with-docker

プロファイリングで現状を見直す

log 見るのとさほど変わらなかったので没

https://keeler.github.io/docker-build-profiling/

BuildKit 補足 ( 参考: moby/buildkit: concurrent, cache-efficient, and Dockerfile-agnostic builder toolkit )

  • LLB
    • コンテナビルドプロセスの依存関係グラフを表現した中間バイナリ
  • Frontend
    • 特定のビルド定義ファイルを LLB に変換するためのコンポーネント。 BuildKit には Dockerfile を扱うものとしてあらかじめ dockerfile.v0 が存在する。
  • --local オプション
    • クライアント側から builder に対してローカルのファイルを提供する。 Dockerfile のビルドについては contextdockerfile が指定できる。

プロファイルの結果

yarn turbo run build の 5 分が長いことがわかるが ... ビルドのログからわかってた

公式 Dockerfile の turbo prune について確認する

turbo prune - CLI Reference – Turborepo

  • ビルドターゲット ( --scope で指定したワークスペース ) のビルドに必要なソースコードだけを抽出したものを out フォルダとして吐き出す
  • ターゲットに必要な dependency のみを抽出した yarn.lock を出力する
  • --docker オプションを付与することで、 Docker の layer cache に最適化された形で out に出力される
snakasnaka

Dockerfile のベストプラクティスを見直す

https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

以下今のプロジェクトに関連しそうなのをあげておく

  • マルチステージビルドを利用する
  • レイヤー数を最小化する
    • レイヤーが作成されるのは RUN, COPY, ADD のみ
    • マルチステージビルドで最終的なイメージに必要なファイルだけをコピーすることで、レイヤーのサイズを小さくできる
  • 複数行の引数はアルファベット順にソートする
  • キャッシュを有効活用する
    • ADD, COPY の対象ファイルのチェックサムを検査するので、不必要なファイルを対象に含めない ( .dockerignore などで除外する )
  • apt-get updateapt-get install は同じ RUN コマンドの中に記述する
    • apt-get update 単独でキャッシュされると更新される機会が無い
    • apt-get install と同じ RUN コマンドに記述したときは、インストール対象が増えた場合や対象のバージョンが変更されたタイミングでキャッシュが破棄されて新しくキャッシュし直されることになる
    • 最終的に /var/lib/apt/lists/* を削除してレイヤーのサイズを削減する
  • ADD より COPYを利用する
  • ENTRYPOINT はその Image がコマンドそのものと振る舞うようなメインのコマンドを設定する

Node.js web アプリケーションにおける Docker 利用時のベストプラクティス

https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/

  • alpine イメージを使わない
    • セキュリティスキャナが依存関係に含まれる脆弱性を検出できない
    • node チームが公式にサポートしていない
    • node:xx.xx.x-xxxx-slim のような Debian 系の軽量イメージが公式サポートなので、それを利用する
  • npm ci --only=production or yarn 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 プロセスを実行する
    • npmyarn を経由するとコンテナ停止のシグナルがアプリケーション側に到達しない
    • CMD node xxx のような Shell form で記述するとシェル経由となり、そのときもシグナルがアプリケーション側に到達しない場合がある
    • exec form (JSON配列形式) で記述するとシェルを経由せずにプロセスを起動できるが、シグナルハンドラの登録の問題があるので dumb-init を経由することでそれを解決する
  • サーバサイドの機能をほとんど持っていないので Graceful shutdown については考慮しない
  • マルチステージビルドを利用する
  • シークレットについては build 時に渡していないので今回は考慮しない
Hidden comment
snakasnaka

ECS 以外を模索...

Next.js のサーバの性能として1つのコンテナ(1vCPU)あたりでさばけるリクエスト数が思ったより低い(20rps程度?)
そして内部的なアーキテクチャの問題なのか不明だが CPU 使用率低い状態で応答が詰まるような現象もあり、大きなリクエストを捌くにはそれに比例して並列化が必要そうに見える。

https://www.timesy.dev/posts/019529ed-463f-4619-ac40-b320f5886a71

ということから、Lambda@Edge + CloudFront の構成で動かせないかと考えている...

snakasnaka

Next.js のサーバの性能として1つのコンテナ(1vCPU)あたりでさばけるリクエスト数が思ったより低い(20rps程度?)

これがアプリケーション実装側 ( フレームワーク側? ) の問題が原因で、それを解消すると 1vCPU (mem:2G) あたり 200 〜 300 rps 程度まで耐えられることがわかり、当面 ECS でも大丈夫そう

snakasnaka

Node.js が利用可能なヒープメモリのサイズを指定する

サーバに負荷をかけ続けていると以下のエラーが発生した。

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

ECS のメトリクスでメモリ使用率を見る限り 30 % 程度しか利用していない。
以下の記事にあるようにヒープメモリの設定を行う。

https://zenn.dev/yusuke_docha/articles/f8c3cd88302d16

プロセス停止直前の 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 )

https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes

その他参照した記事

https://stackoverflow.com/questions/48387040/how-do-i-determine-the-correct-max-old-space-size-for-node-js

https://developer.ibm.com/articles/nodejs-memory-management-in-container-environments/

snakasnaka

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エラーが発生してしまう
      • キャッシュを残したままにしておくことで、デプロイによるナビゲーションエラー発生の機会を低減させる効果もある(?)
snakasnaka

トラブルシュート(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_SECONDSrevalidate にセットした数値 )

s-maxage=REVALIDATE_SECONDS, stale-while-revalidate

参考:

snakasnaka

トラブルシュート(2): i18n対応の path がキャッシュクリア対象から漏れていた

  • sub-path routing 方式で i18n 対応していた
  • デプロイ時に内容が変更される可能性がある path についてはキャッシュ破棄を行っている
    • /* でまるごと破棄するのが構成としては楽だけど、最大限キャッシュを生かしたいので細かく対象を指定していた
  • デフォルト言語の path については、デプロイ時のキャッシュ破棄の対応済みだったが、他の言語の path について対応がもれていた

(あとで詳しく)

snakasnaka

トラブルシュート(3): デプロイタイミングで static な asset にアクセスすると 404 のレスポンスが CDN にキャッシュされることがある

  • ECS がローリングアップデートになっている
  • static な asset を ECS の Next.js サーバから serve してる
  • デプロイ時にALBにぶら下がるECSインスタンスが一瞬、新旧のサービス両方がぶら下がっている状況になることがある
  • 新しいサービス側から HTML をダウンロードして、それに記述されてる js などを取得しにいくときに、古いサービス側につながるとそんなファイル無いので 404 が返却され、それがキャッシュされていた

対応:

(あとで詳しく)

snakasnaka

トラブルシュート(4): SSR 対応ページがキャッシュされない

getServerSideProps() を export しているページはデフォルトでキャッシュされないようになっているため、キャッシュさせたいときには明示的に Cache-Control ヘッダをセットする必要があった。

(あとで詳しく)

snakasnaka

_next/static を CDN からの配信するときに CORS エラーが出る

以下の設定が必要だったりする?

module.exports = {
    crossOrigin: 'anonymous'
}

(あとで詳しく)

snakasnaka

CloudFront 側のキャッシュキーに Cookie の該当項目が含まれていなかった

(あとで詳しく)

snakasnaka

ビルド結果の static なファイルをS3にアップロードしCloudFrontから配信する

(TBW)

snakasnaka

_next/static を CDN からの配信するときに CORS エラーが出ることがある

以下のような現象が出てた

https://qiita.com/Yuhsak/items/f9e829a2be88f5dfbab4#crossorigin設定の追加
別ドメインからのスクリプトを読み込んだ際にcross-origin属性が付与されていないと

  • エラー発生時にエラーの内容がコンソールに出力されず、全てScript Errorと出力される

以下の設定が必要だったりする?

module.exports = {
    crossOrigin: 'anonymous'
}

(あとで詳しく)

snakasnaka

画像最適化のために 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 --from=builder /usr/local/lib/node_modules /usr/local/lib/node_modules

ENV NEXT_SHARP_PATH /usr/local/lib/node_modules/sharp

参考

https://nextjs.org/docs/messages/sharp-missing-in-production