#Next.js#undici#Node.js#MLインフラ

Next.js APIプロキシが長時間ML推論で502になる — undiciのタイムアウト沼


Next.js のフロントから、別プロセスの推論サービス(FastAPI)へ API ルート経由でリクエストを中継する——よくある BFF(Backend for Frontend)構成です。ところが、CPU で 44.1kHz モデルの音声生成を走らせると、数分〜数十分かかることがある。そして、ある時間を境にこうなりました。

生成が終わっていないのに、フロントには 502 が返ってくる。

推論サービス側のログを見ると、処理はまだ元気に動いている。落ちているのは推論ではなく、あいだに挟まっているプロキシのほうでした。この記事は、その犯人が Next.js のグローバル fetch(= undici)の既定タイムアウトで、それを Node 標準の http/https に書き直して回避した記録です。

症状:長い生成だけ502、しかも一定時間で切れる

切り分けのポイントはこうでした。

  • 短い生成(数秒〜数十秒)では問題なし
  • 長い生成だけ 502 になる
  • しかも「毎回だいたい同じくらいの時間」で切れる

推論サービス自体は生き続けているのに、プロキシ層が先に見切りをつけている。「一定時間で切れる」というのは、どこかにハードコードされたタイムアウトがある兆候です。

原因:グローバル fetch(undici)の既定タイムアウト

Next.js の API ルートで素直に fetch() を使うと、その実体は Node に組み込まれた undici です。undici にはヘッダー受信までの既定タイムアウト(およそ300秒程度)があり、応答が返ってこないまま超過するとコネクションを切ります。推論が5分を超えると、まさにここで切られて 502 になっていました。

「なら undici のタイムアウトを無効化すればいい」——そう考えて AgentheadersTimeout/bodyTimeout を触ろうとしたのですが、この構成では undici を直接 import して差し込むのが難しいという別の壁がありました。グローバル fetch の内部実装に手を入れるのは筋が悪く、環境によっては差し替えも効きません。

症状を握りつぶすために「タイムアウトを長くする」だけでは、いつか別の長い生成でまた踏みます。応急処置ではなく、タイムアウトの前提が違うレイヤーで書き直すのが正解でした。

解決:Node標準の http/https でプロキシし直す

undici をやめて、Node 標準の http/https モジュールで自前のプロキシを書きます。標準モジュールなら、タイムアウトの挙動を自分で完全に制御できます。

設計の要点は「接続の確立は打ち切る、応答待ちは無制限」という切り分けです。

  • 相手が生きているかどうか(TCP 接続)は短時間で判定したい → 接続確立は30秒で打ち切り
  • 接続さえできれば、あとは何分かかろうと待つ → 応答待ちはタイムアウトなし

このタイムアウト設計がキモです。

// 応答待ちは無制限(長時間生成対応)。接続確立のみ30秒で打ち切る。
preq.setTimeout(0);
preq.on('socket', (s) => {
  s.setTimeout(30_000, () => {
    if (!s.destroyed && (s.connecting || !s.writable)) s.destroy(new Error('connect timeout'));
  });
  s.once('connect', () => s.setTimeout(0));
});

読み解くとこうです。

  • preq.setTimeout(0) — リクエスト全体のタイムアウトを無効化(応答をいつまでも待つ)。
  • ソケットに setTimeout(30_000, ...) を張り、まだ接続中(connecting)か書き込み不能の状態でタイムアウトしたときだけ destroy する。つまり「相手につながらない」ケースだけを30秒で見切ります。
  • s.once('connect', ...)接続できた瞬間に setTimeout(0) に切り替え、以後は待ち続けます。

こうすると、「サービスが落ちている(つながらない)」は速やかに 502 で返しつつ、「つながったが処理が長い」は延々と待つ、という望ましい挙動になります。エラー時のフォールバックも入れておきます。

preq.on('error', (e) => {
  if (!res.headersSent) res.status(502).json({ error: `backend API unreachable: ${String(e)}` });
  resolve();
});

もうひとつの罠:Next 側の応答監視警告

タイムアウトを解いても、Next.js の API ルートには「ハンドラが応答を返すのが遅い」ことを検知する仕組みがあり、長時間処理では警告が出ます。これは externalResolver: true を宣言して、「このルートの応答は外部(プロキシ先)が解決するので Next は監視しなくてよい」と伝えることで抑止します。

export const config = {
  api: {
    bodyParser: { sizeLimit: '25mb' },
    responseLimit: '25mb',
    externalResolver: true, // 長時間処理でNextの応答監視警告を抑止
  },
};

ついでに、音声データを扱うのでボディサイズ上限も引き上げています(既定のままだと大きな録音データで弾かれます)。

落とし穴・学び

  • fetch の実体は undici。API ルートで何気なく使っている fetch に既定タイムアウトがあることを忘れると、「サービスは動いているのにプロキシが502」という切り分けの難しいバグになります。
  • タイムアウトは一律に消すのではなく、意味で分ける。「接続確立」と「応答待ち」を分けたことで、“つながらない”は速く失敗させつつ“長い”は待てる、という両立ができました。全部無制限にすると、今度は死んだサービスへの接続で固まります。
  • 標準モジュールに降りると制御が戻る。高機能なクライアントの内部設定と格闘するより、http/https に降りて自分でソケットを握るほうが、この手の要件では確実で読みやすくなります。
  • 長時間処理を返す API ルートでは externalResolver: true を忘れずに。

まとめ

  • Next.js の API ルートで長時間ML推論をプロキシすると、グローバル fetch(undici)の既定タイムアウト(≈300秒)で502になる
  • undici を直接 import して差し替えるのが難しい構成では、Node標準の http/https でプロキシを書き直すのが確実
  • タイムアウトは「接続確立は30秒で打ち切り、応答待ちは無制限(setTimeout(0)」に分けるのが要
  • 接続できた瞬間に setTimeout(0) へ切り替えると、“つながらない”だけを速く失敗させられる
  • 長時間応答のルートでは externalResolver: true で Next の応答監視警告を抑止し、音声データ向けにボディ上限も調整する
← 記事一覧へ