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 負荷 |
|---|---|---|
| Heartbeat | 200 台 / 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 のスケジューリングコストが積み上がります。
LogBatcher
LogBatcher は Hub 側でログ行をバッファし、
一定量たまるか一定時間経過したら Redis pipeline で一括書き込みするコンポーネントです。
トリガー条件(先に到達した方で発火)
| 条件 | デフォルト値 | 意味 |
|---|---|---|
| max_lines | 50 行 | 1 ジョブにつき 50 行たまったらフラッシュ |
| max_wait_ms | 100 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
__JOB_DONE__ sentinel は即座にフラッシュされるため、
ライブログの終了検知に遅延はありません。
数字で見る効果
| 指標 | 変更前 | 変更後 |
|---|---|---|
| Worker 台数 | 200 | |
| 同時ジョブ数 | ~200 | |
| ログ出力 | 5,000 行/sec | |
| WS メッセージ | 5,000 msg/sec(Worker 側は変更なし) | |
| Redis ops | 10,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 Hub | Paprika 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 に記載しています。