Paprika — Worker 自動配信

マニュアル GitHub

Worker 自動配信の仕組み

Hub の更新を fleet 全体に 1 コマンドで 伝搬する設計

対象: paprika fleet を運用する人 / hub と worker の関係を理解したい人 · 関連 commit: 378d62a (source hash 化) · 7edde33 (orphan runner 防止)
TL;DR hub に 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 を手で回すのは:

そこで hub が「俺は今このバージョン」と全 worker に hand­shake のたびに通知、worker は自分のバージョンと比較し、不一致なら hub から最新ソースを fetch して自殺再起動する仕掛けを内蔵した。Docker registry も Watchtower も不要、git push → hub restart で全機追従する。

2. なぜ自動配信が必要か #

2 つの "ある事件" が背景にある。

事件 1: VERSION ファイルが永遠に "dev"

古い設計では repo の VERSION ファイル を version の真実としていた。問題:

気付かれぬまま運用者は SSH ループで手動配布していた。

事件 2: orphan runner が hub を麻痺させる

2026-05-16 に paprika-runner コンテナが 1 つ取り残された。そのスクリプト (LLM が書いたクロールコード) は session expired 例外を握り潰してリトライし続ける作りで、4 日間にわたって毎秒数百回 GET /sessions/<消えた sid>/state を hub に叩き続けた。

結果:

これらの実害を踏まえて、自動配信 + runner orphan 保護の両方を作り込んだ。本書はその設計を documentation する。

3. アーキテクチャ #

.------------------------. | Hub | git pull + docker compose restart hub | (paprika-hub:latest) | ← operator はここだけ更新する | /app/server, /app/core| | hash = sha256(.py)[:12]| '------------+-----------' ▲ WebSocket | HubRegistered(expected_worker_version=<hash>) handshake/HB | | (worker が自分の hash と比較) | .-------------------------+-------------------------. ▼ ▼ ▼ .------------. .------------. .------------. | Worker | | Worker | ... | Worker | | 10.10.50.140| | 10.10.50.141| | 10.10.50.164| '------+-----' '------+-----' '------+-----' | mismatch | mismatch | (match → no-op) | | | ▼ GET /worker-source.tar.gz ▼ | (server/ + core/ + VERSION) (継続稼働) | ▼ 展開 /app/server, /app/core ▼ exit 42 ▼ docker restart-policy: unless-stopped が再起動 ▼ 新コードで boot → hash 一致 → handshake 完了

ポイント:

4. 配信フロー (sequence diagram) #

Hub paprika-hub:latest Worker (1台) paprika-worker:latest Docker daemon restart: unless-stopped 0 operator: git pull + docker compose restart hub hub boots, hash="378d62aaaa11" 1 WS dial /workers/<wid>/link 2 HubRegistered { expected_worker_version: "378d62aaaa11" } 3 compare: local "5897f71xxxx" ≠ "378d62..." 4 GET /worker-source.tar.gz 5 200 OK · gzip(server/ + core/ + VERSION) ~few MB 6 extract → /app/server, /app/core 7 sys.exit(42) 8 restart-policy 発火 → container 再起動 9 WS dial · 今度は hash 一致 → 通常稼働へ
1 台の worker から見た 1 サイクル。25 台同時に同じことが起きる。

所要時間の目安:

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

  1. ソースツリー hash (新規・通常パス)
  2. /app/VERSION ファイル (旧パス、後方互換)
  3. PAPRIKA_VERSION / WORKER_VERSION 環境変数
  4. 文字列 "dev" (sentinel: 「自分の version 不明」)

"dev" センチネルの新しい意味

旧設計では 片方が "dev" なら比較スキップだった。新設計では 両方 "dev" のときだけスキップに緩和。意味は:

localexpected (hub)判定解釈
hash ahash a一致同期済 ✓
hash ahash 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.py
sweep_orphan_runners()
hub 起動時に paprika-runner-* 残骸を一掃。§10 参照。

7. 環境変数 #

デフォルトのままで動く。下記は無効化したい / 振る舞いを変えたいときだけ触る。

Worker 側

変数デフォルト説明
PAPRIKA_WORKER_AUTO_FETCH_SOURCE1 mismatch 検知時に tarball を取得・展開するか。0 にすると警告 banner を出すだけで自己更新しない (= operator 手動更新運用)。
PAPRIKA_WORKER_AUTO_EXIT_ON_VERSION_MISMATCH1 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. エンドポイント #

GET/worker-source.tar.gz ★ 自動配信の本体 ★ hub の server/ + core/ + VERSION を gzip tarball で返す。worker が mismatch 時に hit する。
GET/health {status, store, workers, version}version フィールドが hub の現在 hash。外部監視で fleet drift を検知するのに使える。
GET/workers 各 worker の version フィールドを含む。hub の /health.version と比較すれば「まだ追従できていない worker」が分かる。
WS/workers/{wid}/link handshake で hub が 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 を送るだけだった。

対策 (commit 7edde33)

  1. spawn 時に --name paprika-runner-<uuid12> を付与。後から name 指定で container を target できるようにする。
  2. _docker_stop_best_effort(name) ヘルパー追加。timeout / cancel パスで proc.kill() の前に docker stop -t 5docker killdocker rm -f を順に試行。これで inner container にも SIGTERM が tini 経由で届く。
  3. sweep_orphan_runners() を hub 起動時に呼ぶ。docker ps --filter name=paprika-runner-* で生き残りを列挙、全部 stop。前世の hub が残した残骸を automatic に回収。
互換性メモ: 既存の orphan (この修正前に取り残されたもの) は次回 hub 起動時の sweep で殺される。だが古い container は無名 (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 つの理由:

  1. Registry を立てる手間 (private な GHCR / Harbor を運用したくない)
  2. イメージ全体 (Chrome + Xvfb + noVNC で ~2 GB) を 25 host にコピーするのは遅い。実コード変更は数 KB
  3. 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 が同じ目標値を見る。やるなら:

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 する。