Google App EngineへのNext.jsアプリケーションデプロイで気付いた課題と対策
はじめに
今回は、Google App Engine に Next.js アプリケーションをデプロイした際に遭遇した課題と、それぞれの対応について紹介します。
npx create-next-app
コマンドの実行後にデプロイした際に、以下の課題に遭遇しました。
- next/image を利用している画像へのリクエスト時に App Engine 上で書き込みエラーが発生する
- App Engine のイメージサイズが400MB弱程度と大きい
- App Engine のパフォーマンス改善のためのウォームアップリクエストの構成方法が分からない
1. next/image を利用している画像へのリクエスト時に App Engine 上で書き込みエラーが発生する
next/image
を利用することで、WebP 対応や画質の調整、画像のサイズに応じた配信など簡単に実現することができます。詳しくは、Next.js の公式ドキュメントを参照してください。
しかし、App Engine にデプロイした際に、next/image
を利用している画像(SVG 以外の画像最適化処理が発生する PNG, JPG, GIF ファイルなど)へのリクエスト時に、App Engine のログに以下のようなエラーが発生しました。
Failed to write image to cache uWMUG7XySLA2rI+bcHWmDuWP7Bt4LUSGBKIB2Vd-X08= [Error: ENOENT: no such file or directory, mkdir '/workspace/.next/cache/images'] {
検証用コード:
- https://github.com/shoito/nextjs-appengine-experiments/tree/main/nextjs-appengine-next-image-cache-error
- https://github.com/shoito/nextjs-appengine-experiments/tree/main/nextjs-appengine-next-image-unoptimized
こちらは、next/image
が画像をキャッシュする際に、キャッシュディレクトリの作成ができないというエラーです。
公式ドキュメントには、以下のように記載があり、App Engine の場合は /workspace/.next/cache/images
がキャッシュディレクトリとして利用されます。
しかし、App Engine Standard 環境では、 /tmp
ディレクトリ以外には書き込みが許可されていないため、next/image
が利用するキャッシュディレクトリの /workspace/.next/cache/images
には書き込みができません。
Caching Behavior - Next.js
Images are optimized dynamically upon request and stored in the <distDir>/cache/images directory.
一時ファイルの読み取りと書き込み - Google App Engine
https://cloud.google.com/appengine/docs/standard/nodejs/using-temp-files?hl=ja
App Engine でのファイルの読み取りと書き込みに推奨されるソリューションは Cloud Storage です。ただし、アプリで一時ファイルの書き込みだけが必要な場合は、標準の Node.js メソッドを使用して、/tmp という名前のディレクトリにファイルを書き込むこともできます。
next.config.js で images.unoptimized
を true
にし、画像の最適化処理を無効化することでエラーを回避することはできますが、最適化処理が無効化されるため、画像のサイズが大きくなり、Web Core Vitals のスコアに悪影響を与える可能性があります。
しかし、最適化済み画像のキャッシュがされないことによる、リクエストの都度の最適化処理のコストの発生を抑止することはできます。
const nextConfig = {
images: {
// Disable Next.js' default behavior of optimizing images.
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
unoptimized: true,
}
}
module.exports = nextConfig
カスタムローダーを利用することで、クラウドプロバイダーを利用して画像の最適化処理を外部に任せることもできるので、そちらで対応するのが良いかもしれません。
ちなみにキャッシュディレクトリを /tmp
以下に指定できないか調べましたが、現在(2023/07/18)は以下のようにハードコーディングされていて、next.config.js で変更することはできないようです。
vercel/next.js
リポジトリ上でのキャッシュディレクトリについてのディスカッション。
Allow for external cache/images dir for next/image processing #38066
https://github.com/vercel/next.js/discussions/38066
2. App Engine のイメージサイズが400MB弱程度と大きい
npx create-next-app
コマンドの実行後の状態でデプロイした際に、App Engine のイメージサイズが400MB弱程度と大きいことが分かりました。
検証用コード
- https://github.com/shoito/nextjs-appengine-experiments/tree/main/nextjs-appengine-initial-setup
- https://github.com/shoito/nextjs-appengine-experiments/tree/main/nextjs-appengine-standalone
以下のような流れで、 npx create-next-app
から gcloud app deploy
, gcloud app browse
までを実行して確認しました。
$ npx create-next-app
Need to install the following packages:
create-next-app@13.4.10
Ok to proceed? (y)
✔ What is your project named? … nextjs-appengine-initial-setup
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
Creating a new Next.js app in ~/Workspaces/nextjs-appengine-experiments/nextjs-appengine-initial-setup.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
- typescript
- @types/react
- @types/node
- @types/react-dom
- tailwindcss
- postcss
- autoprefixer
- eslint
- eslint-config-next
added 350 packages, and audited 351 packages in 36s
131 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success! Created nextjs-appengine-initial-setup at ~/Workspaces/nextjs-appengine-experiments/nextjs-appengine-initial-setup
App Engine 設定用の app.yaml
を作成。
$ cat <<EOF > app.yaml
runtime: nodejs20
instance_class: F1
service: default
EOF
App Engine へのデプロイとブラウザでの確認。
$ gcloud app deploy
$ gcloud app browse
Next.js のデフォルト設定では、主に node_modules
のディレクトリが大きく、App Engine のイメージサイズに影響しています。
こちらに対応するためには、next.config.js で以下のように設定し、standalone モードを利用してみます。
const nextConfig = {
output: 'standalone'
}
module.exports = nextConfig
Automatically Copying Traced Files
Next.js can automatically create a standalone folder that copies only the necessary files for a production deployment including select files in node_modules.
https://nextjs.org/docs/pages/api-reference/next-config-js/output
次に、standalone モードでビルドし、App Engine へデプロイしてみます。
必要なファイルのみをコピーするため、 npm run build
の後、 .next/standalone/{.next, node_modules, package.json, server.js}
, .next/static
, public
ディレクトリなどをデプロイ用にまとめ、その後、 gcloud app deploy
します。
mkdir dist
# Build Next.js app for standalone deployment
npm ci && npm run build
cp -rf .next/standalone/. dist/
cp -rf .next/static/. dist/.next/static/
cp -rf public/. dist/public/
# Copy app.yaml for App Engine
cp app.yaml .gcloudignore package*.json dist/
# Deploy to App Engine
gcloud app deploy dist/app.yaml
しかし、これだと期待したようなデプロイ結果にはなりません。
デフォルトでは、 gcloud CLI が node_modules フォルダをアップロードせず、デプロイ中に npm install が行われるため、 standalone モードでビルドした場合でも、 App Engine のイメージサイズが400MB程度と大きいままでした。
Node.js ランタイム環境 - App Engine
依存関係
デプロイ中に、ランタイムによって npm install コマンドを使用して依存関係がインストールされます。
注: デフォルトでは、gcloud CLI は node_modules フォルダをアップロードしません。
https://cloud.google.com/appengine/docs/standard/nodejs/runtime?hl=ja
Cloud Run だと簡単なのに...
gcloud app deploy
後に実行される Cloud Build のログを確認したところ、確かに npm install
が実行されていることが分かりました。
挙動的には、 package-lock.json
がディレクトリにある場合は、 npm
が実行され、 yarn.lock
がある場合は yarn
が実行されるようです。
standalone モードを使いたい場合でも、App Engine の上記の振る舞いにより、 gcloud app deploy
でデプロイすると、 npm install
が実行されてしまい、イメージサイズが大きくなってしまいます。
そこで、 gcloud app deploy
でデプロイする際に、package.json
, package-lock.json
を削除することで、 npm install
が実行されないようにし、standalone モードでビルドした node_modules
を .gcloudignore
ファイルでアップロード対象に含めることで、イメージサイズを小さくしようと考えました。
なお、 App Engine はデフォルトでは node server.js
によるアプリケーションの実行、または package.json があれば npm run start
によるアプリケーションの実行を行います。今回は standalone モードでビルドされた server.js
を実行するため、package.json
は不要の想定でした。
アプリケーションの起動
デフォルトでは、ランタイムは node server.js を実行してアプリケーションを起動します。package.json ファイルで start スクリプトを指定すると、ランタイムは代わりに指定された起動スクリプトを実行します。
https://cloud.google.com/appengine/docs/standard/nodejs/runtime?hl=ja#application_startup
しかし、これは App Engine の割り当て上限である、 アプリあたりのデフォルトファイル数: 10,000 ファイル
に引っかかってしまう可能性があります。実験的に npm モジュールをいくつかインストールした場合に、10,000ファイルを超えてしまい、デプロイに失敗しました。また、この上限を回避できたとしても、ファイル数が多いためアップロード/デプロイに時間がかかります。
そこで、 node_modules
をアーカイブし、node_modules.tar.gz
ファイルとしてアップロードすることで、ファイル数を減らし、App Engine の割り当て上限に引っかからないようにします。
また、デプロイ中に npm install
が実行されないように、 package.json
から dependencies
, devDependencies
を削除します(無理やり感)。
#!/bin/bash
rm -rf dist .next
mkdir dist
# Build Next.js app for standalone deployment
npm ci && npm run build
cp -rf .next/standalone/. dist/
cp -rf .next/static/. dist/.next/static/
cp -rf public/. dist/public/
# Copy app.yaml for App Engine
cp app.yaml .gcloudignore package*.json dist/
# Change directory to dist for gcloud app deploy command
cd dist
# Archive node_modules for skipping npm install on App Engine
tar -zcvf node_modules.tar.gz node_modules
# Remove dependencies from package.json for skipping npm install on App Engine with jq command
jq 'del(.dependencies)' package.json > package.json.tmp && mv package.json.tmp package.json
jq 'del(.devDependencies)' package.json > package.json.tmp && mv package.json.tmp package.json
jq 'del(.optionalDependencies)' package.json > package.json.tmp && mv package.json.tmp package.json
# Remove scripts from package.json for skipping npm run start on App Engine with jq command
jq 'del(.scripts.start)' package.json > package.json.tmp && mv package.json.tmp package.json
最後に、アップロードされた node_modules.tar.gz
を gcloud app deploy
のデプロイフローの中で展開するように、カスタムビルドステップを利用します。
カスタム ビルドステップの実行 - App Engine
デフォルトでは、package.json ファイルで build スクリプトが検出されると、node.js ランタイムは npm run build を実行します。アプリケーションを開始する前にビルドステップをさらに制御する必要がある場合は、カスタム ビルドステップを指定できます。カスタム ビルドステップを実行するには、package.json ファイルに gcp-build を追加します。
https://cloud.google.com/appengine/docs/standard/nodejs/running-custom-build-step?hl=ja
package.json
の scripts
に以下のように gcp-build
を追加します。
{
"name": "nextjs-appengine-standalone",
"version": "0.1.0",
"private": true,
"scripts": {
...
"gcp-build": "tar zxvf node_modules.tar.gz && rm node_modules.tar.gz"
},
...
}
これで、 gcloud app deploy
でデプロイすると、 standalone モードでビルドした node_modules
が展開されるようになりますし、 npm install
が空振りするようになります。
最終的にイメージサイズは 397.3MB から 31.5MB になりました🚀
ちゃんと動いているか確認するために、 gcloud app browse
でデプロイしたアプリケーションにアクセスしてみます。
OKそう!
3. パフォーマンス改善のためのウォームアップリクエストの構成方法が分からない
Google App Engine では、ウォームアップリクエストを送信することで、アプリケーションのパフォーマンスを向上させることができます。
app.yaml
の autoscaling
設定で min_instances
や min_idle_instances
を設定したい場合はウォームアップリクエストを受けつけるように設定する必要があります。
そのため、アプリケーションに /_ah/warmup
リクエストエンドポイントの追加が必要となります。
検証用コード
今回はビルド時に standalone モードで使われるカスタムサーバに、 /_ah/warmup
リクエストエンドポイントを追加してみます。
# Inject warmup endpoint to server.js
awk '{
if ($0==" await nextHandler(req, res)") {
print " if (req.url === \"/_ah/warmup\" && req.method === \"GET\") {"
print " res.statusCode = 200"
print " res.end(\"\")"
print " return"
print " }"
}
print
}' server.js > tmp && mv tmp server.js
ビルド用のスクリプトは以下のようになります。
Next.js のアップデートで server.js
の実装が変わってしまうと、このスクリプトも修正する必要があるので、カスタムサーバを実装しておいて、ビルド時に server.js
を置き換える方が良さそうです。
まとめ
App Engine への Next.js アプリケーションデプロイ時に遭遇した課題とそれぞれの対応について紹介しました。
なお、今回は App Engine のデフォルトの挙動をハックする対応をしたため、今後の Next.js や App Engine のアップデートにより状況が変わる可能性があります。
Discussion