Worker 自動配信の仕組み
Hub の更新を fleet 全体に 1 コマンドで 伝搬する設計
git pull && docker compose restart hub するだけで、25 台の worker 全部が新コードに勝手に張り替わる。Worker hosts に SSH ループしてコマンドを撃つ必要なし。VERSION ファイルを手で bump する運用もなし。仕掛けは「ソースツリーの SHA-256 を version の正体にする」というシンプルな話。
1. 概要 #
Paprika は 1 hub + 25 worker の分散構成で動いている。コード更新が hub に入ったら、worker 全機に伝搬しないと意味がない。だが SSH で 25 台に git pull + docker compose restart を手で回すのは:
- 手間 (フルだと数分かかる)
- 取りこぼし発生源 (1 台失敗してもループは続行、後で気づく)
- 並列実行時のレース ―― hub と worker の version 差で job が壊れる
そこで hub が「俺は今このバージョン」と全 worker に handshake のたびに通知、worker は自分のバージョンと比較し、不一致なら hub から最新ソースを fetch して自殺再起動する仕掛けを内蔵した。Docker registry も Watchtower も不要、git push → hub restart で全機追従する。
2. なぜ自動配信が必要か #
2 つの "ある事件" が背景にある。
事件 1: VERSION ファイルが永遠に "dev"
古い設計では repo の VERSION ファイル を version の真実としていた。問題:
- repo にコミットされた VERSION は文字列リテラル
"dev" git pullしても VERSION は"dev"のまま (誰も bump コミットしない)- 比較関数に「片方が
"dev"なら一致とみなす」セーフガードがあり、結果 常に no-mismatch - つまり自動配信機構は実装されていたが 1 度も発火しなかった
気付かれぬまま運用者は SSH ループで手動配布していた。
事件 2: orphan runner が hub を麻痺させる
2026-05-16 に paprika-runner コンテナが 1 つ取り残された。そのスクリプト (LLM が書いたクロールコード) は session expired 例外を握り潰してリトライし続ける作りで、4 日間にわたって毎秒数百回 GET /sessions/<消えた sid>/state を hub に叩き続けた。
結果:
- hub log が同じ 404 で flood (1 時間あたり 160 万行)
- hub の CPU / event loop を消費、worker の WebSocket keepalive ping が timeout
- 25 worker のうち 22 個が連鎖切断、admin UI に 3 つしか映らなくなった
これらの実害を踏まえて、自動配信 + runner orphan 保護の両方を作り込んだ。本書はその設計を documentation する。
3. アーキテクチャ #
ポイント:
- fan-out は hub → worker (pull 型)。Worker hosts が自発的に GitHub を見に行くわけではない
- git registry / docker push 不要。Tarball は hub プロセスがメモリで gzip して serve
- 失敗時の影響範囲が 1 台。Worker A の自己更新が失敗しても他に伝染しない
4. 配信フロー (sequence diagram) #
所要時間の目安:
- Step 0 (hub restart): ~3 秒
- Step 1 (worker → hub WS): 即時 (heartbeat 間隔 10 秒以内)
- Step 4-5 (tarball fetch): ~1 秒 (~5 MB)
- Step 6 (extract): < 1 秒
- Step 7-8 (exit & restart): ~5 秒
- Step 9 (再接続): ~1 秒
git push → fleet 全体新コード化まで 10〜15 秒。
5. version の正体 (source hash) #
「俺のバージョン」 「お前のバージョン」を比較するために、両者は同じ計算式で ソースツリーから決定論的な短い ID を導出する。
def _compute_source_version() -> str:
"""SHA-256 of every .py file under /app/server and /app/core,
truncated to 12 hex chars."""
h = hashlib.sha256()
for root in (Path("/app/server"), Path("/app/core")):
for p in sorted(root.rglob("*.py")):
h.update(p.relative_to("/app").as_posix().encode())
h.update(b"\0")
h.update(p.read_bytes())
return h.hexdigest()[:12]
同じロジックが server/hub/app.py (_compute_hub_source_version) と server/worker/agent.py (_compute_source_version) の両方に置いてある。同じ入力 → 同じ出力なので、tarball 展開直後の worker は hub と同じ hash を返すようになる ―― 展開しても version が一致しないと無限ループする、という旧 VERSION 方式の罠を構造的に回避している。
fallback chain
- ソースツリー hash (新規・通常パス)
/app/VERSIONファイル (旧パス、後方互換)PAPRIKA_VERSION/WORKER_VERSION環境変数- 文字列
"dev"(sentinel: 「自分の version 不明」)
"dev" センチネルの新しい意味
旧設計では 片方が "dev" なら比較スキップだった。新設計では 両方 "dev" のときだけスキップに緩和。意味は:
| local | expected (hub) | 判定 | 解釈 |
|---|---|---|---|
| hash a | hash a | 一致 | 同期済 ✓ |
| hash a | hash b | 不一致 | 自動更新発火 |
"dev" | hash a | 不一致 | worker は自分を知らない、hub に従って更新 |
| hash a | "dev" | 不一致 | hub は自分を知らない、worker は念のため更新試行 |
"dev" | "dev" | スキップ | どちらも version 不明 → no-op |
6. コード参照点 #
| 場所 | 役割 |
|---|---|
server/hub/app.py_compute_hub_source_version()_hub_version() |
hub 側の hash 算出 + キャッシュ。handshake 応答の HubRegistered.expected_worker_version に乗せて全 worker に通知する。 |
server/worker/agent.py_compute_source_version()default_worker_version() |
worker 側の hash 算出。worker boot 時にキャッシュし、handshake で送信する。 |
server/worker/agent.py_versions_meaningfully_differ() |
比較関数。両方 "dev" なら no-op、それ以外は単純文字列比較。 |
server/worker/agent.py_fetch_and_apply_source_from_hub() |
tarball を hub から GET → 検証 → 展開 → 削除されたファイルの prune。50 MB cap、path traversal 拒否。 |
server/hub/app.py_build_worker_source_tarball()GET /worker-source.tar.gz |
hub 側で server/ + core/ + VERSION を gzip して serve。__pycache__ / *.pyc / 隠しファイル除外。 |
server/hub/runner.pysweep_orphan_runners() |
hub 起動時に paprika-runner-* 残骸を一掃。§10 参照。 |
7. 環境変数 #
デフォルトのままで動く。下記は無効化したい / 振る舞いを変えたいときだけ触る。
Worker 側
| 変数 | デフォルト | 説明 |
|---|---|---|
PAPRIKA_WORKER_AUTO_FETCH_SOURCE | 1 |
mismatch 検知時に tarball を取得・展開するか。0 にすると警告 banner を出すだけで自己更新しない (= operator 手動更新運用)。 |
PAPRIKA_WORKER_AUTO_EXIT_ON_VERSION_MISMATCH | 1 |
mismatch 検知時に sys.exit(42) するか。0 にすると warn のみで継続動作。 |
PAPRIKA_GITHUB_REPO | (未設定) | GitHub releases から最新タグを取得するセカンダリ check を有効化する (例: paps-jp/paprika)。hub 経由更新が機能しない孤立 worker 向け。 |
WORKER_VERSION | (未設定) | fallback chain の上書き。診断用。 |
Hub 側
| 変数 | デフォルト | 説明 |
|---|---|---|
PAPRIKA_VERSION | (未設定) | hash 算出が空を返した場合の fallback。診断用。 |
8. エンドポイント #
server/ + core/ + VERSION を gzip tarball で返す。worker が mismatch 時に hit する。
{status, store, workers, version}。version フィールドが hub の現在 hash。外部監視で fleet drift を検知するのに使える。
version フィールドを含む。hub の /health.version と比較すれば「まだ追従できていない worker」が分かる。
HubRegistered.expected_worker_version を返す。version 比較はここで起きる。
9. 運用 runbook #
通常 deploy (1 コマンド)
# Hub host (10.10.50.34) で:
ssh 10.10.50.34 'cd /opt/paprika && git pull --ff-only origin main && docker compose restart hub'
Worker への伝搬は 放置で OK。完了確認:
# 1. Hub の現在 version を取得
curl -s http://10.10.50.34:8000/health | python -c "import json,sys; print(json.load(sys.stdin)['version'])"
# → 378d62aaaa11 (例)
# 2. 全 worker の version を取得して drift をチェック
curl -s http://10.10.50.34:8000/workers | python -c "
import json,sys
d = json.load(sys.stdin)
versions = {}
for w in d['workers']:
versions.setdefault(w.get('version','?'), []).append(w['address'])
for v, ips in sorted(versions.items()):
print(f'{v}: {len(ips)} workers · {\", \".join(ips[:3])}{\"...\" if len(ips)>3 else \"\"}')
"
# → 378d62aaaa11: 25 workers · 10.10.50.140, 10.10.50.141, 10.10.50.142...
全 worker が hub と同じ hash を返していれば fleet 整合状態。
遅れている worker の追い込み
大抵は数十秒で自動追従するが、まれに network 一過性問題で取り残される個体がある。手動 trigger:
ssh root@10.10.50.<ip> docker compose -f /opt/paprika/docker-compose-worker.yml restart worker
再起動して fresh handshake すれば次の cycle で必ず追従する。
緊急: 全 worker 強制再起動
hub の log flood などで多数の worker が WS half-open に陥った場合 (実際に §10 の事件で発生):
ssh 10.10.50.34 'for IP in 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164; do
ssh -o ConnectTimeout=3 -o BatchMode=yes root@10.10.50.$IP \
"docker compose -f /opt/paprika/docker-compose-worker.yml restart worker" &
done; wait'
自動配信を一時的に止めたい (Worker single host 単位)
# 該当 host の .env に追記
PAPRIKA_WORKER_AUTO_FETCH_SOURCE=0
PAPRIKA_WORKER_AUTO_EXIT_ON_VERSION_MISMATCH=0
# Restart
docker compose -f /opt/paprika/docker-compose-worker.yml restart worker
Hub の VERSION が変わっても、この worker は警告 banner を出すだけで動き続ける。Debug 中に worker 上で手で vim したコードを保護したい時などに便利。
10. Runner orphan 防止 #
自動配信は worker 単位の話。だが paprika-runner (codegen-loop が LLM 生成スクリプトを実行するための ephemeral sandbox container) が orphan 化するリスクも別軸で存在する。前述の事件 (§2) はまさにこれが引き金になった。
orphan が消えなかった原因
execute_in_sandbox() の旧実装は timeout / cancel 時に proc.kill() で docker run CLI に SIGKILL を送るだけだった。
- SIGKILL は forward されない (CLI が瞬時に死んで signal を中身に伝達できない)
- 結果 inner container は生き続ける
--rmは clean exit でしか発火しない。無限ループのスクリプトには無力
対策 (commit 7edde33)
- spawn 時に
--name paprika-runner-<uuid12>を付与。後から name 指定で container を target できるようにする。 _docker_stop_best_effort(name)ヘルパー追加。timeout / cancel パスでproc.kill()の前にdocker stop -t 5→docker kill→docker rm -fを順に試行。これで inner container にも SIGTERM が tini 経由で届く。sweep_orphan_runners()を hub 起動時に呼ぶ。docker ps --filter name=paprika-runner-*で生き残りを列挙、全部 stop。前世の hub が残した残骸を automatic に回収。
thirsty_heisenberg のような docker auto-generated 名前) なので filter にマッチしない。1 回だけ手動で docker ps -a --filter ancestor=paprika-runner:latest で確認して掃除する必要がある。
11. 既知の制約 #
① Dockerfile の変更は自動伝搬しない
自動配信は server/ + core/ の Python ファイルのみを対象としている。Dockerfile / requirements.txt / 包含 binary が変わった場合は image rebuild が必要。その時は REBUILD=1 ./scripts/git-pull-workers.sh で全 worker に docker compose up -d --build worker を撒く。
② Worker host へは事前に SSH key 配布が必要
自動配信パス (= tarball 取得) は不要だが、緊急 restart 経路 (§9) のため hub host から各 worker host への passwordless SSH は事前に張る。設定は マニュアル §9 参照。
③ Bind-mount された ./server しか上書きしない
Worker compose は ./server, ./core, ./VERSION を bind-mount している。tarball 展開はこれらのみを対象とする (security: hub が tarball で /etc/passwd を上書きしようとしても拒否される)。.env やデータディレクトリは触らない。
④ ROLLBACK は git で
不具合のあるコードを deploy してしまったら、hub で git checkout <前のSHA> && docker compose restart hub するだけで全 worker が自動的に古いコードに張り替わる。「やらかし時の戻し」も同じ自動伝搬経路で動くので簡単。
12. FAQ #
Q. なぜ docker pull じゃダメなのか?
3 つの理由:
- Registry を立てる手間 (private な GHCR / Harbor を運用したくない)
- イメージ全体 (Chrome + Xvfb + noVNC で ~2 GB) を 25 host にコピーするのは遅い。実コード変更は数 KB
- Bind-mount + restart で十分速い (~10 秒)。docker pull だと数分かかる
Q. Watchtower でいいんじゃない?
Watchtower は registry 監視 + container 再起動の汎用ツール。Paprika は hub と worker の version drift を hub 自身が検知する = アプリ層で整合性を保証できる。Watchtower にこれは無理 (registry tag だけでは「hub-worker pair の整合」までは見えない)。
Q. tarball が改ざんされたら?
Hub と worker は同一 LAN 上の信頼境界内で動く前提。LAN 外から来る可能性のあるエンドポイントは別途 WORKER_SECRET でゲートしている。tarball endpoint そのものは認証なしだが、LAN 外公開しないこと (reverse proxy で /worker-source.tar.gz を blocklist してもよい)。
Q. 部分的に新コードを試したい (canary deploy)
v1 ではサポートしていない。Hub の hash は 1 つしかなく、全 worker が同じ目標値を見る。やるなら:
- テスト用 hub container を別ポートで立てて、テスト用 worker host を pointing させる
- または
PAPRIKA_WORKER_AUTO_FETCH_SOURCE=0でその host だけ自動更新を止め、SSH で手動 git checkout
Q. ハッシュ計算は性能負担にならない?
server/ + core/ の Python ファイル合計 ~28 個 / 数 MB を SHA-256。手元計測で ~50 ms。さらに hub / worker 共に boot 時に 1 度だけ計算してプロセス内キャッシュする。handshake 毎に再計算しない。
Q. デプロイ直前と直後で in-flight な job はどうなる?
Worker 再起動時に走っていた lane 上の Chrome は強制終了する。Hub は WS 切断を検知して、worker_id に紐づいた in-flight ジョブを失敗扱いに分類する (_recover_orphan_running_jobs 経路)。job 単位のステータスは failed、エラーメッセージは "orchestrator killed by hub restart"。重要な crawl 直前は時間帯を見て deploy する。