ブログ

重いチャット1つが、私たちのAPIを停止させかねなかった話

9月28日、リリースから約17分後に、本番のAPIサーバー2台が同時にメモリ不足になり、再起動しました。その後約5分間、ロードバランサーには正常なサーバーが1台もなく、トラフィックを送れる先がありませんでした。最初の対策では、APIのメモリを2倍にしました。本当の原因は、その日の後半に判明しました。ある特定のチャットを開くと、APIがメッセージ53 MBを1回のリクエストで読み込もうとしていたのです。チャット履歴は今、件数だけでなくサイズでも上限を設けたページ単位で読み込まれます。そのチャットは、クラッシュではなく、5 MBの最初のページで開くようになりました。

何が停止したのか

APIは、2台のサーバーそれぞれでコンテナとして動き、その前にロードバランサーがあります。各コンテナのメモリ上限は384 MiBでした。通常時の使用量は130〜140 MiBなので、上限には十分な余裕があるように見えていました。

両方のコンテナが同時に上限に達し、「JavaScript heap out of memory」でクラッシュしました。ヒープとは、Node.jsプロセスが扱うオブジェクトのために使うメモリのことです。両方のサーバーが同時に落ちたため、リクエストを回す先がなくなりました。2台構成では、これが最悪の種類の障害です。冗長化は、障害が同時に起きないときにしか役に立たないからです。

最初の対策:余裕を増やす

タイミングは、デプロイを指していました。リリースの後、デプロイによって中断されたエージェントのターンが再開され、そのトランスクリプトを保存し直します。その作業の集中がメモリの急増の原因として、一番ありそうに見えました。

そこで最初の変更として、APIの上限を384 MiBから768 MiBに引き上げました。その日のうちに両方のサーバーに手作業で適用し、次のデプロイで384に戻ってしまわないよう、デプロイ設定にもコミットしました。手作業でサーバー上に加えた修正を、次のデプロイが元に戻してしまうことは、同じ障害が再び起きる典型的な原因です。

メモリの増設は、妥当な安全マージンでした。しかし、説明にはなっていませんでした。

本当の原因:1件のリクエストで71 MB

その日の後半、本番の古いメモリ上限を使い、あるチャットのコピーで同じクラッシュを再現しました。そのコピーは、デプロイとは無関係に、APIを単独でクラッシュさせました。

プロジェクトを開くと、ウェブアプリは、そのチャットの履歴をAPIに要求します。エンドポイントは、最新の30件のメッセージをそのまま返していました。大半のチャットでは、30件は小さな量です。しかし、このチャットでは、最新の30件がデータベース内で53 MB、レスポンスは71 MBでした。エージェントの返信の一部は、それぞれ4〜10 MBでした。その大半は、サブエージェントのトランスクリプトでした。サブエージェントとは、メインのエージェントが、タスクの一部を並行して処理させるために起動するヘルパーのエージェントで、その完全な作業内容が返信と一緒に保存されています。

このレスポンスを組み立てるには、APIが持つヒープよりも多くのメモリが必要でした。しかも、プロジェクトを開くたび、または再読み込みするたびに、リクエストが送られていました。1つのチャットが、それが当たったどちらのサーバーでもクラッシュさせ、ページは再読み込みできるため、両方のサーバーに届きえたのです。

「30件」は、サイズに対する実際の上限では一度もありませんでした。通常のチャットでは、たまたま小さい値だった件数の上限にすぎなかったのです。

修正:件数だけでなくサイズでも区切ったページ

これで、履歴の1ページは、30件または約4 MBのうち、先に達したほうまでになりました。

処理は2段階で行われます。

  1. まずサイズを問い合わせます。 APIは、最新のメッセージのIDと保存されたサイズだけを、中身を読まずにデータベースに問い合わせます。このクエリは、メッセージがどれだけ大きくても安く済みます。
  2. 収まるものだけを読み込みます。 最新から古い方へ順に、次のメッセージを加えると4 MBを超えるところまで足していきます。そのうえで、選ばれた行だけを全文読み込みます。

安全のために、2つのルールがあります。

  • 最新のメッセージは、サイズにかかわらず必ずページに含めます。 何も返さないページがあると、チャットは止まってしまい、過去にスクロールして戻る手段がなくなるからです。
  • 何も切り捨てません。 メッセージを途中で切ったり、落としたりはしません。残りのチャットは、古いメッセージにすでに使われていた「以前のメッセージを表示」の操作で、1ページずつ読み込まれます。

もう1か所、同じ形の問題が見つかりました。ターンを停止すると、APIは最後の数件の返信を確認し、空のものがあれば整理すべきかどうかを判断していました。そのために、8件のメッセージを丸ごと読み込んでいたのです。数MBの返信が並ぶチャットでは、小さな形で同じ問題が起きます。今では、データベースに「このメッセージには中身がありますか?」というはい/いいえの問い合わせだけを送り、中身そのものは読み込まないようにしました。

検証方法

古い384 MiBの上限でコピーのチャットを動かして、次のように確認しました。

  • 最初のページは5 MBです。以前は、同じリクエストでAPIがクラッシュしていました。
  • そのチャットを同時に5つのブラウザタブで開くと、メモリのピークは154 MBでした。
  • チャットを最後までさかのぼると、190件のメッセージがすべて、1回ずつ、正しい順序で返ってきました。

ページの区切りロジックにも単体テストがあります。そのチャットの最新メッセージの実際のサイズから作ったテストも含みます。最初のページには1件のメッセージが入り、さらに続きがあることを示し、次のページは、その次のメッセージから始まります。

この件から学んだこと

  • 件数は、サイズの上限ではありません。 無制限に増えうるものの「最新のN件」を返すエンドポイントは、バイト単位の予算も必要です。データはいずれ、その隙間を見つけます。
  • いちばん疑わしいものが、必ずしも原因とは限りません。 デプロイは確かに追加の作業のきっかけでしたし、メモリの増設にも価値はありました。しかし、クラッシュはデプロイなしでも再現しました。再現しなければ、メモリの増設で止まっていたでしょう。
  • 2台のサーバーは、同じリクエストを受けると、一緒に落ちます。 冗長化は、1台のサーバーが落ちることから守ります。しかし、どのサーバーに届いてもそれを落とすリクエストからは守ってくれません。特に、ユーザーが再読み込みで自然に再試行してしまうものは、そうです。
  • 設定を直してください。ボックスの中だけでなく。 メモリの増設は、その日のうちにデプロイ設定に入れたので、次のデプロイでも維持されました。

これは、エージェントの出力に関わる、最近の2つ目のメモリの件です。1つ目は、690 MBの文字列をキューに積んでいた状態ラベルでした。Mythexでサブエージェントを使う場合、その仕組みはドキュメントで読めます。

Mythex · Pricing · AIでなんでも作ろう · ウェブサイト・アプリのテンプレート · Mythexを比較 · AIアプリ開発用語集 · AIアプリ開発の無料ツール · English

English አማርኛ العربية Български বাংলা Bosanski Català Čeština Dansk Deutsch Ελληνικά Español Español (España) Eesti فارسی Suomi Français Français (Canada) ગુજરાતી हिन्दी Hrvatski Magyar Հայերեն Bahasa Indonesia Íslenska Italiano ქართული Қазақ тілі ಕನ್ನಡ 한국어 Lietuvių Latviešu Македонски മലയാളം Монгол मराठी Bahasa Melayu မြန်မာ Norsk bokmål Nederlands ਪੰਜਾਬੀ Polski Português (Brasil) Português (Portugal) Română Русский Slovenčina Slovenščina Soomaali Shqip Srpski Svenska Kiswahili தமிழ் తెలుగు ไทย Tagalog Türkçe Українська اردو Tiếng Việt 简体中文 繁體中文(香港) 繁體中文(台灣)