ローカルで動いたSSEが本番で止まる?タイムアウトの落とし穴

に公開

背景:LLM時代のAPI設計とSSEの必要性

私がWeb開発でこれまで関わってきた仕事においては、長らく「APIはミリ秒〜数秒でレスポンスを返すもの」という前提がありました。処理が重ければ非同期処理やバッチ処理に逃がすのが一般的で、それで十分に成立していたからです。

しかし近年、特にLLMを組み込んだAPIを中心に、レスポンスの生成に時間がかかるケースが増えてきています。従来の「一括でレスポンスを返す」設計ではユーザーに大きな待ち時間を強いることになります。

UXを向上させるために考えられる手段のひとつが、Server-Sent Events (SSE) です。処理の途中経過をクライアントに逐次送信でき、「待ち時間を進行中の体験に変える」ことができます。SSEを用いた実装はChatGPT等でも使われています。

ただし実際にSSEを実装してみると、意外な落とし穴に直面します。タイムアウトです。私も最近の仕事でSSEを実装したのですが、ローカル環境では正常に動作していたものが、公開用の環境にデプロイしてみるとレスポンスが途中で止まる現象に遭遇しました。公開用の環境で設けられているタイムアウトの制約にひっかかっていたのです。
処理が終わってから一括でレスポンスするAPIの場合に、レスポンスが返ってこない時間が長くなるとタイムアウトが発生するというのは分かっていたのですが、SSEのように何らかのデータが送られている場合だと、どこの時間を見てタイムアウトになるのか確認したくなりました。

今回は私がよく使ういくつかの環境に関して、どのようなタイムアウト設定ができるのかということと、それらの実際の挙動を検証しました。

環境別タイムアウトの検証環境構築

タイムアウトの検証のために、リクエストされるとデータを送信し続ける最小限のSSE実装を用意しました。

Node.jsによる最小SSEサーバ

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.url === '/my-sse-endpoint') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*' // 検証用の緩い設定
    });

    const startTime = Date.now();
    setInterval(() => {
      const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
      res.write(`data: ${elapsedSeconds} 秒経過\n\n`);
    }, 1000);
  }

  // ヘルスチェック用のエンドポイントも実装してあるが記事の趣旨から外れるので省略
});

server.listen(3000, '0.0.0.0');

上記のコードをNode.jsが動くDockerコンテナに配置してデプロイし、 curl -N https://{検証環境ドメイン}/my-sse-endpoint のようにアクセスし、各環境でタイムアウトによる切断が発生するかを確認しました。

今回検証を行ったのは次の環境です。

  • AWS の ALB + ECS
    • ALBで「接続アイドルタイムアウト(awsコマンドにおけるidle_timetout.timeout_seconds)」というパラメータが設定可能
  • AWS の App Runner
    • App Runnerでは現時点でタイムアウトを調整できそうなパラメータ設定が見当たらなかったのでデフォルトでの挙動を確認
  • Google Cloud の Cloud Run
    • 「リクエストのタイムアウト(gcloudコマンドにおけるtimeout)」というパラメータが設定可能

各環境でのタイムアウト挙動の検証結果

環境ごとに観測できたタイムアウトの挙動を整理すると、次の表のようになりました。

環境 タイムアウトの意味 デフォルト値 / 設定可能範囲 挙動
AWS ALB + ECS アイドルタイムアウト デフォルト 60s / 1〜4000s レスポンス中にデータ送信が途絶えてこの時間を超えると 504。継続的に送信があれば維持可能。
AWS App Runner レスポンス完了までの時間のタイムアウト 固定 120s(変更不可) データ送信の途絶の有無に関わらず120秒経過で強制終了。
Cloud Run レスポンス完了までの時間のタイムアウト デフォルト 300s / 最大 3600s データ送信の途絶の有無に関わらず設定したタイムアウト時間で強制終了。

AWS ALB + ECSはデータ送信が途絶えてからの経過秒数でタイムアウトが発生する仕組みだったので、先に挙げた検証コードではタイムアウトが発生せず、止まることがありませんでした(2時間経過しても止まらなかったので切り上げました)。そこで、検証コードに修正を行い、SSEレスポンスのデータ送信の途中で何もデータ送信が行われない時間を発生させるようにしました。すると「接続アイドルタイムアウト」に設定した時間だけデータ送信が止まったときにタイムアウトが発生することを確認しました。

App RunnerとCloud Runでは、途中でデータ送信が途絶えるかが関係なく、レスポンス全体が完了するまでの秒数でタイムアウト発生が決まりました。

ちなみにNginxをリバースプロキシとして使用する場合もローカル環境で検証しました。この場合は proxy_read_timeout(デフォルト60秒)がアイドルタイムアウトとして機能します。

今回の検証で気付かされたのは、環境によって設定できるタイムアウトの種類が異なり、設定できるタイムアウト値の範囲も様々であること、そしてそもそもタイムアウト設定が変更できない環境もあるということです。
プロトタイプやPoCではそこまで凝ったSSE実装はせずにインフラの制約を緩めて済ませることも多いと思うので、使う環境でどのようなタイムアウト設定ができるかどうかは把握しておきたいところです。

まとめ

AIアプリケーションのAPIを実装するとき、レスポンスの完了までに時間がかかりがちです。しかし、レスポンスが長時間返ってこない状態は、ユーザーにとってはエラーと変わりません。ユーザーに安心感を与える手段としてSSEによるストリーミングは有効な選択肢の一つです。
ただし、SSEを実装するにあたってタイムアウトの存在を意識する必要があります。環境によって「どのようなタイムアウトが設定できるのかが違う」「環境によっては設定変更ができないこともある」ということは念頭に置いておきたいところです。

本記事ではタイムアウトに絞って紹介しましたが、SSE実装においては再接続の仕組みやクライアント側の設計も重要です。それらについてはまた別の機会に触れたいと思います。

参考情報


株式会社エクスプラザ

Discussion