Web検索WorkerをVMからM1へ移したら、拍子抜けするほど何もなかった ― env駆動設計と「先客が道を通しておいてくれた」話

家庭内の分散AI推論基盤「Coordinator」を作り続けている連載です。今回は、母艦VMに同居していたWeb検索Workerを、少し前にクラスタへ参加させた中古の MacBook Air M1 へ引っ越しさせました。タスクとしてはずっと「いつかやる」の棚に置いていたもので、正直なところ手こずる覚悟をしていたのですが、ふたを開けてみたらコードもCoordinator本体も一切いじらずに終わってしまったのです。今回はその「拍子抜け」が、なぜ拍子抜けで済んだのかという話を書きます。地味な回ですが、自分にとっては大事な学びがありました。

何を、どこからどこへ移したのか

Coordinatorには、SearXNG(自前メタ検索エンジン)で検索して結果をローカルLLMに要約させる「Web検索Worker」(worker_web.py)がいます。これまでは Coordinator API やデータベースが載っている母艦VM「AI-Core」(192.168.0.40)に同居していました。要約には小型日本語モデル LFM2.5-1.2B を使っているのですが、AI-CoreはCPU推論なので1件あたり30秒ほどかかります。母艦は本来Coordinatorの司令塔に専念させたいので、この要約負荷を外に出したい、というのが移設の動機です。

移設先は、先月クラスタに参加させた MacBook Air M1(192.168.0.43)。M1のMetal GPUは、同じ小型モデルなら桁違いに速い見込みでした。

身構えていた理由

着手前、私はこの移設をやや面倒な作業だと思っていました。理由は単純で、worker_web.py の冒頭コメントに、過去の自分がこう書いていたからです。

現在はAI-Core上で稼働。
将来はmacbookairに移設予定(web-worker.serviceのEnv書き換え+launchd化)。

「AI-Core同居前提の部分」があるような気がしていて、要約用Ollama・SearXNG・データベースへの到達性を全部見直す必要があるだろう、macOSの例の「ローカルネットワーク許可」でまたハマるかもしれない、と。ところが、移す前に一度コードとルーティングを精査してみたら、その心配はほぼ杞憂でした。

精査してわかった「純移設」になる3つの理由

1. 接続先がすべて環境変数で外出しされていた

worker_web.py は、Redis・PostgreSQL・Ollama・SearXNG・Coordinator のすべての接続先を os.getenv() から読んでいました。つまりコードを1行も書き換えなくても、環境変数を渡すだけで接続先を切り替えられる構造です。過去の自分が(おそらく将来の移設を見越して)そう書いておいてくれたおかげでした。さらにこのWorkerは共通ロジック worker_base.py に依存しない単独構造なので、他ノードのライブラリ差にも巻き込まれません。

2. Web検索は「共有キュー1本」でホストに依存していなかった

Coordinatorのルーティングを読み返すと、通常の推論タスクはWorkerごとのキュー(tasks:cpu:macbookair のような名前)に振り分けられますが、Web検索だけは tasks:web という共有キュー1本に積まれ、空いているWeb検索Workerが拾う方式でした。タスクを積むときの worker 指定は "default" 固定。しかも結果を書き込む際、データベースの worker 列には実際の処理ホスト名ではなく常にリテラルの "web-worker" が入る設計です。

これが意味するのは、どのホストがWeb検索を処理してもCoordinator側の挙動は一切変わらないということ。だからCoordinatorのコードは1文字も触る必要がありませんでした。

3. 「先客」が道を全部通しておいてくれた

これが一番ありがたかった点です。新しいノードをクラスタに追加するとき、私はこれまでOSごとに必ず1つは地雷を踏んできました。中でも凶悪なのが pg_hba.conf の許可行漏れで、以前これに気づかず3日間気づかぬまま動き続けた苦い経験があります(連載で何度か書きました)。

ところが今回の移設先M1には、すでに推論Workerが先月から同居して動いていました。その推論Workerは、M1(192.168.0.43)から母艦のRedis・PostgreSQL・Coordinatorへ問題なく書き込めています。ということは——

  • pg_hba.conf192.168.0.43/32 許可行 → すでに追加済み
  • RedisへのLAN到達 → すでに開通済み
  • macOSの「ローカルネットワーク」許可 → Web検索Workerは推論Workerと同じvenvのpythonバイナリで起動するので、許可は同一バイナリ分を共用。再許可不要

新規ノード追加の最大の地雷が、先に参加していた自分自身の手で全部踏み消されていたわけです。「過去の自分に救われる」という、地味だけれど報われる瞬間でした。

移設の判断ポイント:要約モデルをどうするか

ひとつだけ設計判断がありました。AI-Core版は「llama3.2:3bだとCPUで長文がタイムアウトするから」という理由でLFM2.5-1.2Bを要約モデルに使っていました。M1ではその制約が消えるので、より大きいモデルに上げる選択肢もあります。

ですが今回は、まずはモデルを変えずに「ホストだけが変わった」状態で移設を完了させる方針にしました。これは連載で何度も痛い目を見てきた「変数は一度に1つだけ」という原則です。M1にLFM2.5を ollama pull して要約挙動を以前と同一にしておけば、もし問題が起きてもそれは「移設のせい」だと一発で切り分けられます。モデルのアップグレードは、移設が安定してからの別作業に回します。

実際の手順

デプロイ順序は「移設先を立ち上げて検証 → 確認できてから旧側を止める」を徹底しました。

# 1. M1にファイルとモデルを用意(AI-Coreからscp)
scp ~/coordinator/worker_web.py hogehoge@192.168.0.43:~/coordinator/
# M1側で要約モデルをpull
ollama pull hf.co/LiquidAI/LFM2.5-1.2B-JP-GGUF:Q4_K_M

# 2. まずフォアグラウンドで起動して疎通だけ確認(接続先はEnvで上書き)
REDIS_HOST=192.168.0.40 \
PG_DSN="host=192.168.0.40 dbname=coordinator user=coordinator password=coordinator" \
SEARXNG_URL=http://192.168.0.1:8888 \
OLLAMA_HOST=http://localhost:11434 \
OLLAMA_MODEL=hf.co/LiquidAI/LFM2.5-1.2B-JP-GGUF:Q4_K_M \
COORDINATOR_URL=http://192.168.0.40:8000 \
WORKER_ID=web-worker-macbookair \
WORKER_HOST=192.168.0.43 \
./venv/bin/python worker_web.py

ログに Redis OK / SearXNG OK / Ollama OK / register / Waiting on queues ['tasks:web'] が並べば、接続先の差し替えは成功です。

続いて、旧側(AI-Core)のサービスを止めてから、テストを1本流します。

# 3. 旧Workerを止める(Web検索Workerが二重に拾わないように)
#   AI-Core側で:
sudo systemctl stop web-worker.service

# 4. 指名テスト1本 → 結果をAPIで直接確認
curl -s -X POST http://192.168.0.40:8000/route \
  -H 'Content-Type: application/json' \
  -d '{"prompt":"今日の東京の天気は?","task_type":"web_search"}'
# 返ってきた task_id で:
curl -s http://192.168.0.40:8000/task/<task_id> | python3 -m json.tool

結果は status: done、回答に実際の天気が入り、フォールバック注記なし。作成から完了まで約4秒でした(AI-CoreのCPU版は約30秒)。この速度差そのものが「M1が処理した」動かぬ証拠です。

常駐化:macOSのlaunchdに2人目のWorkerを登録する

M1ではsystemdではなくlaunchdでサービスを常駐させます。すでに推論Worker用のエージェントが動いているので、別のLabel・別のログファイルでWeb検索Worker用のエージェントを追加しました。systemdと違って、環境変数に空白を含む値(PostgreSQLの接続文字列)もplistのstring値として素直に書けるのが地味に楽でした。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>jp.mapleharp.coordinator-web-worker</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/hogehoge/coordinator/venv/bin/python</string>
    <string>/Users/hogehoge/coordinator/worker_web.py</string>
  </array>
  <key>WorkingDirectory</key><string>/Users/hogehoge/coordinator</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>REDIS_HOST</key><string>192.168.0.40</string>
    <key>PG_DSN</key><string>host=192.168.0.40 dbname=coordinator user=coordinator password=coordinator</string>
    <key>SEARXNG_URL</key><string>http://192.168.0.1:8888</string>
    <key>OLLAMA_HOST</key><string>http://localhost:11434</string>
    <key>OLLAMA_MODEL</key><string>hf.co/LiquidAI/LFM2.5-1.2B-JP-GGUF:Q4_K_M</string>
    <key>COORDINATOR_URL</key><string>http://192.168.0.40:8000</string>
    <key>WORKER_ID</key><string>web-worker-macbookair</string>
    <key>WORKER_HOST</key><string>192.168.0.43</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>/Users/hogehoge/coordinator/web-worker.log</string>
  <key>StandardErrorPath</key><string>/Users/hogehoge/coordinator/web-worker.log</string>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/jp.mapleharp.coordinator-web-worker.plist
launchctl print gui/$(id -u)/jp.mapleharp.coordinator-web-worker | grep -E "state|pid"
#   → state = running / pid = 29899

常駐化したあと、もう1本テストを流して同じく done・約4秒を確認。最後に旧側を恒久的に無効化します。

# AI-Core側で
sudo systemctl disable web-worker.service
systemctl is-enabled web-worker.service   # → disabled

地味だけど外せない検証:自己修復が効かないタスクは自分で確かめる

Coordinatorには、死んだWorkerやスタックしたタスクを検出して別Workerへ投げ直す「Reaper」という自己修復機構があります。ところがWeb検索とRAGのタスクは、このReaperの対象外です(代替先が1台しかないので投げ直しても意味がないため)。

これは諸刃の剣で、もし新しいホストからのPostgreSQL書き込みが(pg_hba漏れなどで)静かに失敗していても、Reaperが隠してくれない=逆に失敗がそのまま露出するということです。普段は自己修復に助けられているのに、ここだけは助けが来ない。だからこそ、移設の確定条件を「指名テストを1本流して、データベース上でそのタスクが done になっていることを直接確認する」に置きました。今回は推論Workerのおかげでpg_hbaが通っていたので一発で done を確認できましたが、この一手間は省けません。

もうひとつ確認したのが、M1上でWorkerが二重起動していないこと。フォアグラウンドで動かしたプロセスを止め忘れると、launchd版と合わせて2つの worker_web.py が同じキューを奪い合います。これは過去にesxiで「同一Workerの二重起動はReaperの全トリガーをすり抜ける」という死角として痛い目を見た箇所なので、念のため数えました。

pgrep -fl worker_web.py
#   → 29899 ...worker_web.py   ← 1行だけ。二重起動なし

結果と、ひとつの小さな反省

移設後、Web検索は1件あたり約4秒で安定して回るようになり、母艦VMは要約推論の負荷から解放されました。狙いどおりです。

ひとつだけ反省点を残しておくと、移設後のログに Web Search Worker starting (v2). と出ていて、引継ぎ資料側の記録(v2.1)と表示がずれていました。フォールバック機能は実際に動いているので機能的にはv2.1で正しく、起動バナーの文字列だけが古いままという、実害のない食い違いです。とはいえ「動いているコードと記録は一致させる」という自分ルールに沿って、次回ついでに1文字直しておきます。こういう小さなズレを放置すると、いつか別の場所で足をすくわれるのを何度も経験してきたので。

まとめ:拍子抜けは、過去の積み重ねの果実だった

今回の移設が「何もなかった」で済んだのは、運が良かったからではありませんでした。設定を環境変数で外出ししておいたこと、Web検索を共有キュー方式にしてホスト非依存にしておいたこと、そして先にM1へ推論Workerを参加させてネットワークの道を通しておいたこと——いずれも過去のセッションでの設計判断や作業の積み重ねです。

分散システムをひとりで育てていると、たいていは新しい問題に頭を抱える時間のほうが長いのですが、ごくたまに、こうして過去の自分の判断にそっと助けられる瞬間があります。地味な回でしたが、そういう「効いてくる設計」を確認できたのは、個人的にはなかなか嬉しい移設でした。

次は、せっかくM1のMetal GPUに載せたので、要約モデルをLFM2.5から一段上げてみるか——あるいは別の宿題に手をつけるか。引き続きゆっくり育てていきます。