重いチャット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段階で行われます。
- まずサイズを問い合わせます。 APIは、最新のメッセージのIDと保存されたサイズだけを、中身を読まずにデータベースに問い合わせます。このクエリは、メッセージがどれだけ大きくても安く済みます。
- 収まるものだけを読み込みます。 最新から古い方へ順に、次のメッセージを加えると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でサブエージェントを使う場合、その仕組みはドキュメントで読めます。