🤖

Google App EngineへのNext.jsアプリケーションデプロイで気付いた課題と対策

2023/07/18に公開

はじめに

今回は、Google App Engine に Next.js アプリケーションをデプロイした際に遭遇した課題と、それぞれの対応について紹介します。
npx create-next-app コマンドの実行後にデプロイした際に、以下の課題に遭遇しました。

  1. next/image を利用している画像へのリクエスト時に App Engine 上で書き込みエラーが発生する
  2. App Engine のイメージサイズが400MB弱程度と大きい
  3. 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'] {

検証用コード:

こちらは、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.unoptimizedtrue にし、画像の最適化処理を無効化することでエラーを回避することはできますが、最適化処理が無効化されるため、画像のサイズが大きくなり、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

カスタムローダーを利用することで、クラウドプロバイダーを利用して画像の最適化処理を外部に任せることもできるので、そちらで対応するのが良いかもしれません。
https://nextjs.org/docs/app/api-reference/next-config-js/images

ちなみにキャッシュディレクトリを /tmp 以下に指定できないか調べましたが、現在(2023/07/18)は以下のようにハードコーディングされていて、next.config.js で変更することはできないようです。
https://github.com/vercel/next.js/blob/0084166caaba402c1f8218da48e32855bc7ee48b/packages/next/src/server/image-optimizer.ts#L290

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弱程度と大きいことが分かりました。

appengine image size

検証用コード

以下のような流れで、 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 だと簡単なのに...
https://zenn.dev/team_zenn/articles/nextjs-standalone-mode-cloudrun

gcloud app deploy 後に実行される Cloud Build のログを確認したところ、確かに npm install が実行されていることが分かりました。
挙動的には、 package-lock.json がディレクトリにある場合は、 npm が実行され、 yarn.lock がある場合は yarn が実行されるようです。
https://cloud.google.com/docs/buildpacks/nodejs?hl=ja
https://github.com/GoogleCloudPlatform/buildpacks/blob/main/cmd/nodejs/npm/main.go

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ファイルを超えてしまい、デプロイに失敗しました。また、この上限を回避できたとしても、ファイル数が多いためアップロード/デプロイに時間がかかります。
https://cloud.google.com/appengine/quotas?hl=ja#Files

そこで、 node_modules をアーカイブし、node_modules.tar.gz ファイルとしてアップロードすることで、ファイル数を減らし、App Engine の割り当て上限に引っかからないようにします。
また、デプロイ中に npm install が実行されないように、 package.json から dependencies, devDependencies を削除します(無理やり感)。

https://github.com/shoito/nextjs-appengine-experiments/blob/main/nextjs-appengine-standalone/build-standalone.sh

#!/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.gzgcloud 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.jsonscripts に以下のように 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 が空振りするようになります。

standalone

最終的にイメージサイズは 397.3MB から 31.5MB になりました🚀

ちゃんと動いているか確認するために、 gcloud app browse でデプロイしたアプリケーションにアクセスしてみます。
screen
OKそう!

3. パフォーマンス改善のためのウォームアップリクエストの構成方法が分からない

Google App Engine では、ウォームアップリクエストを送信することで、アプリケーションのパフォーマンスを向上させることができます。
app.yamlautoscaling 設定で min_instancesmin_idle_instances を設定したい場合はウォームアップリクエストを受けつけるように設定する必要があります。
https://cloud.google.com/appengine/docs/standard/nodejs/configuring-warmup-requests?hl=ja

そのため、アプリケーションに /_ah/warmup リクエストエンドポイントの追加が必要となります。
https://nextjs.org/docs/app/api-reference/next-config-js/basePath

検証用コード

今回はビルド時に 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 を置き換える方が良さそうです。

https://github.com/shoito/nextjs-appengine-experiments/blob/main/nextjs-appengine-standalone-warmup/build-standalone.sh

まとめ

App Engine への Next.js アプリケーションデプロイ時に遭遇した課題とそれぞれの対応について紹介しました。
なお、今回は App Engine のデフォルトの挙動をハックする対応をしたため、今後の Next.js や App Engine のアップデートにより状況が変わる可能性があります。

Discussion