ファイナンス、情報通信技術のスキル・アグリゲーション・サイト

' . iseeit.jp 情報通信技術 . '
 
Message Queue / RabbitMQ ③

WebSocket × RabbitMQ
実運用の落とし穴

設計は正しかった。ローカルでは完璧に動いた。ところが本番——Apache TLS終端の奥にキューを載せた瞬間、 別の問題が次々と噴き出しました。切れる・黙る・古い・ずれる。実運用でしか出ない罠を、原因と対処のセットで残します。

Apache ProxyTimeout ping/pong websockets v16 Management API asyncio

§ 0 — 導入

動かなかったのは、設計の先だった

1本目で「なぜキューが要るか」を、 2本目で「なぜ request / reply / cancel に分けるか」を書きました。 設計としては、これで筋が通っています。ローカル環境では、実際に完璧に動きました。

問題は、本番に載せた瞬間に始まりました。Apache が TLS を終端し、その奥の WebSocket サーバーへプロキシし、 さらに RabbitMQ を挟んで Consumer が LLM を回す——この長い経路のあちこちで、 設計図には出てこない罠が待っていました。

本番の罠は「境界」に棲む。 コンポーネントの内部ではなく、Apache ↔ WebSocket ↔ RabbitMQ ↔ asyncio という 継ぎ目にこそ、実運用の問題は集中します。この記事はその継ぎ目の記録です。
§ 1 — タイムアウトの壁

LLM推論中に、接続が切れる

症状 / 短い質問はちゃんと返る。ところが長い生成の途中で、WebSocket がぷつりと切れる。

犯人は Apache のリバースプロキシでした。TLS終端を Apache が担い、その奥の WebSocket サーバーへプロキシしています。 その Apache 側のタイムアウトが、LLMの生成時間より短かったのです。

短い質問は数秒で返るので問題になりません。しかし長い生成では、トークンが流れ続けていても、 Apache から見た「1往復」が長くなりすぎて、待ちきれずに接続を切ってしまう。下の経路図で、まさにこの瞬間を再現します。

Interactive · Where does it break
どこで切れるか — 経路図
「短い質問」と「長いLLM生成」を流して、経路のどこで切れるかを見ます。ProxyTimeout を延ばすと結果が変わります。
ProxyTimeout 300s
🌐
ブラウザ
client
🔐
Apache
TLS終端 · TO 60s
🔌
WebSocket
ws_server.py
📮
RabbitMQ
3-queue
🧠
Consumer
LLM推論
ボタンで通信を流してください。まずは「短い質問」、次に「長いLLM生成」。その後トグルで ProxyTimeout を延ばして再挑戦を。

対処 — タイムアウトを延ばし、心拍を送る

やることは2つ。Apache 側の待ち時間を延ばし、あわせて WebSocket 側から定期的に「生きている」を伝えます。

# Apache(リバースプロキシ設定)
ProxyTimeout      300
KeepAliveTimeout  300
# WebSocket サーバー側:心拍を送る
serve(handler, host, port, ping_interval=10)
§ 2 — 沈黙を、埋める

「生きている」を、どう伝え続けるか

なぜ心拍(ping)が要るのか。長い生成の間には、データが流れない沈黙の時間が生まれます。 LLM が次のトークンを考えている間、経路上には何も流れません。

経路の途中にいる機器やプロキシは、この沈黙を見て「もう死んだ接続だ」と判断し、勝手に切ってしまう。 これはアイドル(暇な状態)とは違います。働いているのに黙っている状態です。

ping は「まだ生きている」の合図。 ping_interval=10 で10秒ごとに心拍を送り、沈黙を作らない。 生成中の無言を、切断の理由にさせないための延命策です。
§ 3 — 世代差という罠

動いていたコードが、動かない

古い記事のスニペットや、以前は動いていたコードが、最新のライブラリでそのまま動くとは限りません。 websockets の v16 では、いくつかのAPIが変わっていて、実際に踏みました(要点のみ)。

① ws.closed / ws.open が無くなった

接続の生死を属性で見られなくなりました。送信を try/except で包み、例外が出たら切れていると判断します。

- if ws.open:
-     await ws.send(msg)
+ try:
+     await ws.send(msg)
+ except Exception:
+     pass          # 既に切断されている

② connection.reject() が無くなった

接続の拒否は、Response オブジェクトを返す形になりました。

- connection.reject(403, "Forbidden")
+ return Response(403, "Forbidden",
+                 Headers([("Content-Length", "0")]))

③ オリジン検査は process_request で

許可しないオリジンには Response(403) を、許可するなら None を返します。

def process_request(connection, request):
    if origin not in ALLOWED:
        return Response(403, "Forbidden", ...)
    return None   # 許可
教訓。 バージョンは固定し、上げるときは変更履歴を読む。 「動いていたコード」は資産であると同時に、負債にもなる
§ 4 — 件数は、キューに聞くな

「いま何件待っているか」を正しく取る

1本目のキュー可視化を、実運用でもやりたくなりました。「いま何件待っているか」をユーザーに表示したい。 ところが、キュー宣言時に返る件数(message_count)は、パージ直後などに古い値のまま返る場面がありました。

症状 / キューを空にしたのに、待ち件数が以前の数字のまま表示される(ステイル)。

正確な件数は、キュー本体ではなく RabbitMQ の Management HTTP API に問い合わせて取ります。

# Management API に問い合わせて正確な待ち件数を取る
GET  http://localhost:15672/api/queues/%2F/rag_request
     # → JSON の messages フィールドが現在の件数
関心を分ける。 キューは「処理の器」。件数の観測は「管理APIの仕事」。 同じ RabbitMQ でも、役割の違う窓口を使い分けます。
§ 5 — 非同期の地雷原

asyncio の、細かい罠

これは原因と対処が一対一で対応する、リファレンス的な一覧です。async の文脈でよく踏みます。

重い同期関数を await 文脈でそのまま呼んだ
埋め込み計算やLLM呼び出しでイベントループが固まる。 await asyncio.to_thread(fn, ...) で別スレッドに逃がす。
その to_thread() に、async def の関数を渡してしまった
対象を間違えるとかえって壊れる。 非同期関数は素直に await fn(...) で呼ぶ。to_thread は同期関数専用。
asyncio.get_event_loop() が RuntimeError
Python 3.10+ で実行中ループの取得法が変わった。 asyncio.get_running_loop() を使う。
メッセージの寿命(TTL)を文字列で渡した
aio_pika の expiration は文字列を受けない。 timedelta で渡す。

どれも一度踏めば忘れませんが、初見では原因が見えにくい。だからこそ、こうして書き残す価値があります。

§ 6 — 総括

罠は、境界に棲む

3本を通して見えたのは、実運用の問題がコンポーネントの内部ではなく継ぎ目に集中するという事実でした。 切れる(Apache↔WS)・黙る(生成中の沈黙)・古い(件数のステイル)・ずれる(ライブラリ世代差)。 どれも、単体では正しく動くものどうしのあいだで起きています。

Message Queue シリーズ — 総括
  1. なぜWebアプリにメッセージキューが必要か
    重い・同時・非力の3条件と prefetch_count=1
  2. 3キュー設計 — request / reply / cancel
    役割の違う流れは、別の箱に分ける
  3. 実運用の落とし穴(この記事)
    切れる・黙る・古い・ずれる — 境界の罠

「なぜ要るか」→「どう分けるか」→「本番で何にハマるか」。この3段を越えて、 いま sasagawa.tokyo の AIコンシェルジュが、このキュー基盤の上で動いています。

この記事のまとめ

  • 長い生成で切れるのは Apache のタイムアウト。ProxyTimeout 300ping_interval=10 で延命する。
  • 生成中の沈黙は「死」ではない。ping で「生きている」を伝え続ける。
  • ライブラリ世代差(websockets v16)は実際に踏む。バージョン固定+変更履歴。
  • 正確な待ち件数はキューではなく Management API に聞く。
  • asyncio は to_thread の対象・get_running_loop・timedelta が定番の罠。

関連記事