😸

AWS App RunnerでNext.jsのstandaloneモードを動かす時のTips(ないしは失敗談)

2023/12/30に公開

概要

Next.jsを諸事情によりVercelではなくAWSで動かす事になり、半年ほど運用をしていたのですが、コンテナイメージのサイズが大きく、デプロイにも時間がかかるため、ダイエットできないかなあということでstandaloneモードでビルドしたものに変更しようとした際にいくつかハマったところがあったのでここで供養しようと思います。
Next.jsのビルドの問題というよりはApp Runner固有の問題がメインです。

既存のイメージについて

アプリケーションは小規模なNext.jsのアプリケーションですが、既存のビルドサイズは1.3GBあります。マルチステージビルドは既に導入済みで、Baseのイメージはaplineのnode18を利用しています。

ベースのイメージを小さくするというアプローチも検討はしたのですが、どのイメージを選んだとしても結局node_modulesが入ってきた時点で爆発するので大きな差異はないのでは、と思って今回はスコープ外としています。

Next.jsのstandaloneモード

Next.jsにはbundleサイズを削減するためのstandalone modeというものがあります。

https://nextjs.org/docs/pages/api-reference/next-config-js/output

通常のビルドの場合、ビルドで生成される.next/ディレクトリに加えて、/node_modulesディレクトリも含む必要があります。standalone modeでビルドした場合、.next/配下にstandaloneディレクトリが生成され、その配下にアプリケーションに必要なファイル群が全て押し込まれ、.next/standalone/sever.jsを起動すればアプリが動きます。

standalone modeに関する詳しい記事は以下に綺麗にまとまっていたのでこちらを参考にしてください。
https://zenn.dev/team_zenn/articles/nextjs-standalone-mode-cloudrun

今回はこちらを活用してイメージサイズのダイエットを図ります。

Dockerfileの修正

マルチステージビルドで実行環境用のステージのスクリプトを以下のように書き換えます。
.next/static, /public,.next/serverは別途コピーします。

...ビルドステージは省略...

FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/server ./.next/server

USER nextjs

EXPOSE 3000

CMD ["node", "server.js"]
% docker images
REPOSITORY   TAG     IMAGE ID       SIZE
standalone   latest  93be030d7f8f   402MB

元サイズが1.3GBだったので約70%のサイズ削減に成功しています。この変更でローカルで動作確認をしたところ問題なく動作していたため早速App Runnerにもデプロイしてみます。

App Runnerで設定していた開始コマンドに起因する問題

とりあえずデプロイをしてみたら…見事にロールバックしました。ログを見ると以下のようにDockerfileのCMDが無視されており、通常ビルド時のnext startを実行しようとしています。standaloneモードでビルドした場合はこのコマンドでは起動できず、server.jsを直接起動する必要があるためこれだと動きません。

> app@0.1.0 start
> next start
sh: next: not found

App RunnerはApp Runnerの設定側で開始コマンドを指定することができ、DockerfileのCMDを上書きすることができる機能があります。私の環境ではこれを以前にnpm run startに設定していたため、そちらのコマンドを利用するように動いてしまいロールバックしていました。(この設定をしていない場合はこの問題は発生しないと思います)

簡単には変えられない開始コマンド

では開始コマンドを変えればいいのではないかと思うかもしれませんが、そう簡単ではありません。仕様上、起動コマンドを変更した時点で再デプロイが走ってしまいます。オートスケールなどでデプロイがいつ行われるかわからないため、設定をアップデートした際には即時に反映し正常性をチェックする必要があるからです。当然、既存のイメージは新しい起動コマンドを受け付けてくれないので、こちらの設定変更も反映できずロールバックしてしまいます。

# 起動コマンドを`node server.js`に変えようとした際に再デプロイが走って出てきたログ。
# 通常ビルドのイメージなのでserver.jsは無い(当たり前)
node:internal/modules/cjs/loader:1080
  throw err;
  ^
Error: Cannot find module '/app/server.js'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1077:15)
    at Module._load (node:internal/modules/cjs/loader:922:27)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at node:internal/main/run_main_module:23:47 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}
Node.js v18.17.1

一度設定したら消せない開始コマンド

では、開始コマンドを消去してしまえばいいのではと思い調べたところ、設定を直接deleteするAPIはなかったので、updateのAPIでstartCommandの値を空にすることで設定のクリアを試みました。

% aws apprunner update-service \
--service-arn "arn:aws:apprunner:ap-northeast-1:xxxx:service/app/xxxxxx" \
--source-configuration '{"ImageRepository": {"ImageIdentifier": "xxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest", "ImageRepositoryType": "ECR","ImageConfiguration": {"StartCommand": ""}},"AutoDeploymentsEnabled": true}' 

An error occurred (InvalidRequestException) when calling the UpdateService operation: 1 validation error detected: Value at 'sourceConfiguration.imageRepository.imageConfiguration.startCommand' failed to satisfy constraint: Member must satisfy regular expression pattern: [^\x0a\x0d]+

これもエラー。 Member must satisfy regular expression pattern: [^\x0a\x0d]+とあるので、「改行 (LF) とキャリッジリターン (CR) を除く、任意の文字が1回以上続くパターン」が要求されており、空文字を設定することはできませんでした。

強引な迂回路: 「スペース一文字」

こうなるとサービスを作り直したほうが楽なような気もしてきますが、自分のアプリはVPCリソースとの接続もありVPC Connectorの設定とかが面倒だったため、最後のあがきでスペース一文字で設定したらどうなるか試してみました。スペース一文字であれば、AppRunenr内部のコマンドの存在確認のコードの書き方によっては無視される可能性もあるかも?という読みです。

ダメ元でやってみたところこちらがなんと成功し、スペース一文字の開始コマンドの設定は無視されDockerfile内のCMDが利用される用になりました。これでようやく新しいイメージのデプロイに成功しました。

イメージのデプロイ時に設定も含めて諸々変更できるとありがたかったのですが、サクッと簡易にやりたいApp Runnerであれば、そういった根本の部分が変わるならサービスごと変えようね、という設計思想も理解できるため、ここらへんはトレードオフかと思います。

ヘルスチェックは通るがアクセスができない問題

起動コマンドの問題が解決し無事デプロイには成功したのですが、そのまますんなりとは動いてくれませんでした。ヘルスチェックは通っているにも関わらず、upstream request timeoutで接続できないという状態となり、アプリケーションにアクセスができません。

デプロイログはこのようなログを吐き出していました。

- [36minfo[39m Loaded env from /app/.env
Listening on port 3000 url: http://ip-10-0-173-39.ap-northeast-1.compute.internal:3000

urlがhttp://ip-10-0-173-39.ap-northeast-1.compute.internal と、コンテナにアタッチされているENIのプライベートDNSが直接指定された形になっています。

next startで起動した場合のログと比較してみます。こちらはhttp://localhost:3000で起動されていますね。

> app@0.1.0 start
> next start
- ready started server on 0.0.0.0:3000, url: http://localhost:3000

server.jsの中身を見ると、

const hostname = process.env.HOSTNAME || 'localhost'

となっており、環境変数にHOSTNAMEがある場合はそちらを使う形になっています。自身の環境変数設定でHOSTNAMEを指定はしていなかったため、App Runnerの環境では環境変数HOSTNAMEに対して、暗黙的にアタッチされているENIのprivate DNSが指定されるようです。

ドキュメントも確認してみます。App Runnerの内部のネットワーキングについては以下のブログに詳しく解説されています。
https://aws.amazon.com/jp/blogs/news/deep-dive-on-aws-app-runner-vpc-networking/

Fargate タスクは awsvpc ネットワークモードで起動され、App Runner VPC と相互接続されます。つまり各 Fargate タスクには、App Runner VPC の Elastic Network Interface (ENI) がアタッチされます。これを Fargate タスクの Primary ENI と呼びます。リクエストルーターは、その Primary ENI のプライベート IP アドレスを介して、各 Fargate タスクにプライベートアクセスします。


パブリックネットワーキングモードのアーキテクチャ

内部で動かしているFargateタスクはawsvpcモードで動いているため、localhostで起動すれば相互通信できるとあります。

そうなるとシンプルにstandaloneモードで起動する場合もurlがlocalhostになるように変更することで解決できそうです。HOSTNAMEにlocalhostを入れたらlocalhost:3000で起動するはずなので、App Runnerの環境変数にHOSTNAME:localhostを追加して構成をアップデートしてみます。起動ログは以下のようになりました。localhostで起動していますね。

- [36minfo[39m Loaded env from /app/.env
Listening on port 3000 url: http://localhost:3000

この状態でアクセスをしたら、問題なくアプリケーションに接続することができました。

疎通できなかった原因の考察

ここからは推測ですが、private DNSをホスト名として起動した場合に疎通できなかったのは、private DNSでの名前解決に失敗しているからだと思っています(確かめる術がないので検証はしていません)。もう一度ブログの文章を見てみます。

各 Fargate タスクには、App Runner VPC の Elastic Network Interface (ENI) がアタッチされます。これを Fargate タスクの Primary ENI と呼びます。Primary ENI のプライベート IP アドレスを介して、各 Fargate タスクにプライベートアクセスします。

ここで言うプライベートアクセスは、FargateタスクのVPCとApp RunnerのVPC間におけるPrivateLinkのことだと理解しています。そうなると、App Runner側のVPCのENIをホスト名としてFargateタスクを起動しても、Fargate側のVPCでは解決できないDNS名になってしまうため、上手く動かなかったのではないかと思っています(合っている自信はない)。いわゆるサービスディスカバリのような機能が内包されていれば問題ないのかもしれませんが、App Runnerでそこまで複雑なことをやることは無いでしょうし、素直にlocalhostで起動するようにしておくのがベターなのではと思います。

個人的に一番の謎はそういう状態であってもヘルスチェックが通ってしまったということなのですが…。これも推測になりますがApp RunnerのヘルスチェックはあくまでECSタスク単体のヘルスチェックをそのまま流用しており、タスクの内部での疎通確認に留まっているのではないかというのが今の見立てです(有識者求む)。

まとめ

Next.jsのStandaloneモードは積極的に使っていこう

ビルドサイズが70%減というのはかなり魅力的です。node_modulesを保持しなくていいのはセキュリティの観点からも嬉しいですし、動作に問題がなければ積極的に使っていくべきと思いました。
今回は絡めてないですがstaticディレクトリやpublicディレクトリはCDNに置くべきという思想なのでそういった最適化も含めてまだまだ改善余地はありそうです。

App RunnerでstandaloneモードのNext.jsをデプロイする場合は環境変数にHOSTNAME:localhostを設定しよう

App Runner側と内部で起動しているFargateタスクの通信ができなくなるので必須の設定です。
server.jsを書き換えてlocalhostオンリーになるようにするのであればこの限りではないですが、ビルド後のスクリプトに手を入れるべきではないと思いますし、この設定を入れておくのが良いかと思います。

App Runnerの起動コマンドは基本使うべきではない

起動のコマンドはイメージに依存するにも関わらず別で管理する必要はないです。今回はなんとなく設定してしまって痛い目にあいましたが、使う理由がないのであれば触らないことをオススメします。

終わりに

App Runnerもまだまだ発展途上のサービスなので、これから状況が変わってくるかもしれませんが、ネットワーク周りの仕組みは大きくはかわらないと思います。Next.jsに限らずApp Runnerにコンテナをホストする際はhttp://localhost:{port}でアクセスできる形になっているかどうかを意識しておくのが良いかなと思いました。この記事が誰かの何かの役に立てば幸いです。

Discussion