Hub スケーリング

Worker 200 台規模に対応するための Hub 側のパフォーマンス設計。 LogBatcher による Redis ops 50 分の 1 削減と、将来のマルチプロセス拡張パスを解説します。

概要
  • 1 Hub では数十 Worker 規模まで。それ以上は 複数 Hub を nginx ラウンドロビンで並べて水平スケール。
  • ジョブ・コンフィグは MariaDB、アセットは MinIO、Worker 調整は Redis という 3 ストア構成で Hub をステートレス化。
  • Worker は IP から決定的に w50<3rd><4th> 形式で worker_id を生成、ハッシュリングで Hub に均等分配。

概要

Paprika の Hub は単一の asyncio プロセスです。Worker が WebSocket で接続し、 ジョブのログ・ハートビート・セッション RPC を1プロセスで処理します。

Worker が 10 台程度の小規模構成では問題になりませんが、 200 台規模になるとメッセージ量が爆発し、 Hub のイベントループが詰まる可能性があります。

この問題の正体と、最小限のコード変更で解決する方法を説明します。

ボトルネック分析

WebSocket 接続数は問題ではない

asyncio は数万の WebSocket 接続を維持できます。200 本は余裕です。

メッセージの種類と量

メッセージ種別頻度 (Worker 200 台)Hub 負荷
Heartbeat200 台 / 10 秒 = 20 msg/sec軽い
Session RPC (click 等)ユーザー操作起点 = 数 msg/sec軽い
JobComplete / Failedジョブ終了時のみ = 数 msg/sec軽い
WorkerJobLog 2,000 〜 10,000 msg/sec 重い

WorkerJobLog が重い理由

各ジョブは実行中にログを出力します(yt-dlp の進捗、agent の操作ログ等)。 Worker はログを 1 行 = 1 WebSocket メッセージ で Hub に送信します。

Hub はそれを受信するたびに 2 回の Redis コマンドを発行します:

# workers.py — WorkerJobLog 1 行につき:
await state.store.append_log_line(job_id, line)   # Redis RPUSH
await state.store.publish_log(job_id, line)        # Redis PUBLISH

5,000 行/sec なら 10,000 Redis ops/sec。 各 await がイベントループに制御を返すため、 asyncio のスケジューリングコストが積み上がります。

ボトルネックは接続数ではなくメッセージ処理量 200 本の WebSocket を維持するコストは無視できます。問題は 1 本 1 本のソケット上を流れる大量のログメッセージが、1 行ごとに Redis 往復を発生させることです。

LogBatcher

LogBatcher は Hub 側でログ行をバッファし、 一定量たまるか一定時間経過したら Redis pipeline で一括書き込みするコンポーネントです。

トリガー条件(先に到達した方で発火)

条件デフォルト値意味
max_lines50 行1 ジョブにつき 50 行たまったらフラッシュ
max_wait_ms100 ms最初の行から 100 ms 経過したらフラッシュ

動作の仕組み

変更前(1 行 = 2 Redis ops)

Worker → WS msg → Hub: await RPUSH  (1 行)
                       await PUBLISH (1 行)
Worker → WS msg → Hub: await RPUSH  (1 行)
                       await PUBLISH (1 行)
...
# 5,000 行/sec = 10,000 Redis 往復/sec

変更後(バッチ = 2 Redis ops / 50 行)

Worker → WS msg → Hub: バッファに追加
Worker → WS msg → Hub: バッファに追加
Worker → WS msg → Hub: バッファに追加
... (50 行たまる or 100ms 経過)
Hub: pipeline {
       RPUSH 50 行を 1 コマンドで追加
       PUBLISH 改行結合の 1 メッセージ
     }
# 5,000 行/sec = ~100 pipeline/sec = ~200 Redis ops/sec
追加レイテンシは最大 100 ms 管理画面のライブログは人間が目で追う用途なので、100 ms の遅延は体感できません。 ジョブ完了を示す __JOB_DONE__ sentinel は即座にフラッシュされるため、 ライブログの終了検知に遅延はありません。

数字で見る効果

指標変更前変更後
Worker 台数200
同時ジョブ数~200
ログ出力5,000 行/sec
WS メッセージ5,000 msg/sec(Worker 側は変更なし)
Redis ops10,000 /sec~200 /sec
await 回数10,000 /sec~200 /sec
追加レイテンシ0 ms最大 100 ms

50 倍の削減により、Hub の asyncio イベントループは 主に WS の recv(JSON パース + list.append)を処理するだけになります。 これは CPU 的に軽い操作です。

設定

LogBatcher は --redis-url が設定されている場合のみ有効になります。

構成動作
--redis-url なしInMemoryJobStore を使用。LogBatcher は無効(不要)
--redis-url ありRedisJobStore + LogBatcher が自動で有効

特別な設定は不要です。Redis を使っていれば自動的にバッチ化されます。

Selenium Grid との比較

同じ構造の問題は Selenium Grid Hub (v2/v3) でも発生していました。

観点Selenium Grid HubPaprika Hub
ボトルネック 全 WebDriver コマンドが Hub をプロキシ経由 WorkerJobLog が Hub 経由で Redis へ
上限 ~80 ノード LogBatcher なし: ~50 Worker
LogBatcher あり: 200+ Worker
解決策 Grid 4 でコンポーネント分離(Router / Distributor / Session Map) バッチ化で解決(コンポーネント分離は不要)
根本的な違い 全コマンドが Hub 経由必須 ログだけが重い → バッチ化でバイパス可能

将来の拡張パス

LogBatcher で 200 台を捌けなくなった場合の次のステップ:

Step 1: Worker が直接 Redis に書く

ログの経路を Hub 経由から Worker 直接に変更すると、 Hub のイベントループからログ処理が完全に消えます。

# 現在:   Worker → WS → Hub → Redis
# Step 1: Worker → Redis (直接)

Hub の負荷は Heartbeat 20 msg/sec + Session RPC 数 msg/sec だけ。1,000 台でも余裕です。 ただし Worker 側のコード変更が必要です。

Step 2: Hub マルチプロセス化

codegen-loop(LLM 呼び出し + Docker 起動)が Hub の CPU を圧迫する場合、 Hub を複数プロセスに分散します。

Workers → nginx (L7) → Hub #1 (sticky WS)
                      → Hub #2 (sticky WS)
                      → Hub #3 (sticky WS)
Admin UI → nginx → round-robin → any Hub
                ↕
              Redis (IPC bus)

詳細な設計は hub-multiprocess.md に記載しています。