家庭内AIクラスタに『使う時だけ起こして、終わったら寝る』を実装した ― Wake-on-LAN 指名起動と Idle Shutdown

この記事では、使うときだけWake-on-LANでWorkerを起動し、
アイドル一定時間後に自動シャットダウンする省電力設計の実装を解説します。
常時稼働なしで「呼んだら起きる」ローカルAI環境を作りたい方向けです。

 

家庭内に組んでいる分散AI基盤の続き。これまで、複数のローカルLLMを抱えたWorkerに対して、タスクの特性・実績・負荷を見てモデルを振り分けるCoordinatorを育ててきた。前回はWorkerの死活を監視して自動で別Workerへ振り直す自己修復機能(Dead Worker Retry)を入れた。

今回のテーマは電気代だ。GPUを積んだ rtx3070ti はアイドルでもそれなりに電力を食うので、普段は電源を落としている。CPU推論機の moon も24時間動かす必要はない。理想は「使いたい時だけ起こして、終わったら勝手に寝てくれる」こと。これを Wake-on-LAN(WoL)による指名起動と、Idle Shutdown(一定時間アイドルで自分で停止)で実装した。

今回やったこと

  • Coordinatorが動くAI-Core自身を、常時起動の軽量CPU Workerとして登録(cold start解消)
  • /route でWorkerを指名すると、寝ていればCoordinatorがWoLで起こす
  • 起動待ちの間に自己修復機能(reaper)がタスクを横取りしないよう保護する仕組み
  • 仕事がなくなって一定時間アイドルが続いたWorkerは、自分でRedis登録を消してから停止

なぜ「寝ているWorkerを起こす」のは素直に書けないのか

Coordinatorのルーティングは、Redisに生存登録されているWorkerの中から選ぶ。つまり電源が落ちているWorkerは、そもそも候補に挙がらない。これが最初の壁だった。

さらにもう1つ、自分の機材構成ならではの事情がある。moon が載せている軽量モデル(llama3.2:1b / llama3.2:3b など)は、実は他のWorkerも全部持っている。つまり「このモデルが必要だから moon を起こす」という理由が作れない。放っておくと moon は永遠に起きてこない。

そこで起動は明示的な指名で行うことにした。/route に「このWorkerで処理してくれ」という指定を渡せるようにし、指定されたWorkerが寝ていたらWoLで起こす、という流れだ。

設計 ― 3層構成にする

「全部落として必要な時だけ起こす」を素朴にやると、最初の1台が起きるまで毎回数十秒待つことになる。そこで常時起動を1台だけ確保し、残りをオンデマンドにした。しかも、その常時起動の1台はすでに手元にあった。Coordinatorが動いているAI-CoreはどのみちOllamaもRedisも常駐しているので、ここに軽量Workerを相乗りさせれば追加の電力コストはほぼゼロで済む。

ノード 起動 守備範囲 役割
ai-core 常時 軽量CPU(小型3モデル) 既定の受け皿・即応・フェイルオーバー先
moon 指名時にWoL 軽量CPU(同上) 必要な時だけ起こす
rtx3070ti 必要時のみ GPU重量級モデル 大きいモデルを使う時だけ

常時起動の ai-core が1台いることで、全Worker停止状態(cold start)でも軽量タスクは確実にこの1台が拾う。後述するフェイルオーバーの引き継ぎ先も、起動待ちゼロで確保できる。

WoL指名起動

Coordinator側に「今は寝ているが起こせるWorker」の静的なカタログを持たせた。Redisの生存登録(一定時間で消える動的な情報)とは別レイヤの、消えない構成情報だ。

WAKEABLE_WORKERS = {
    "moon": {
        "mac":              "xx:xx:xx:xx:xx:xx",
        "models":           ["llama3.2:1b", "llama3.2:3b", ...],
        "wake_timeout_sec": 150,
    },
}

/route でWorkerを指名したとき、そのWorkerが生存登録になければカタログを見て「起こせる」と判断し、wakeonlan でマジックパケットを送る。タスクはそのWorker宛のキューに積んでおき、起きてきたWorkerが拾う。AI-Coreから対象機にWoLが届くこと(同一セグメントであること)は事前に確認済みだ。

いちばんの肝 ― 起動待ちを自己修復機能から守る

ここが今回いちばん神経を使った部分だ。WoLを撃ってから対象機が起動してRedisに登録されるまでには数十秒かかる。その間、タスクは「担当Workerがいないのに処理されていない」状態に見える。

前回入れた自己修復機能(reaper)は、まさにこの状態を「Workerが死んだ」と判定して別Workerへ振り直す。つまり放っておくと、moonを起こしている最中に、常時起動のai-coreがタスクを横取りしてしまう。せっかく moon を起こした意味がなくなる。

対策として、タスクに「起動を待つ期限(wake_deadline)」を持たせ、reaperの監視対象から外した。

-- reaperの走査クエリに1行追加
AND (wake_deadline IS NULL OR now() > wake_deadline)

期限内(起動待ち中)はreaperが手を出さない。そして期限を過ぎても起きてこなければ、そこで初めて通常の自己修復処理に戻り、常時起動のai-coreが肩代わりする。つまりこの期限は「起動失敗時の自動フォールバックの境界」も兼ねている。起こすのに成功すれば指名先が処理し、失敗すれば常時起動機が拾う。どちらに転んでも止まらない。

Idle Shutdown ― 終わったら自分で寝る

起こす仕組みができたら、次は寝る仕組みだ。Worker側に監視スレッドを1本足し、次の条件が一定時間続いたら自分で shutdown を打つようにした。

  • 自分が処理中のタスクがゼロ
  • 自分宛のキューが空
  • この状態が15分続いた

この監視は設定を持つWorkerだけが起動する。moon にだけ有効化し、常時起動の ai-core とメインPCの rtx3070ti は設定を持たないので絶対に寝ない。共通コードは全ノードで同じものを使いつつ、振る舞いは設定で切り替わる。

2つ気をつけた点がある。1つはフラッピング防止。起こされた直後にすぐ寝てしまうと、また起こす、の往復になる。起動からの最低稼働時間を設け、駆け込みで寝ないようにした。もう1つは意図的な停止と障害死の区別。Workerが自分で寝るときは、停止直前にRedisの自分の登録を自分で消してから落ちる。そうしないとreaperが「登録が消えた=死んだ」と誤認してしまう。意図的に寝るのか、事故で落ちたのかを、登録を自分で消すかどうかで区別している。

ちなみにWorkerプロセスが shutdown を打てるよう、sudoではshutdownコマンドだけをパスワードなしで許可している。Worker全体にroot権限を与えるのではなく、必要な1コマンドに限定するのがポイント。

実機で動かす

moonを落とした状態で、moonを指名してタスクを投げる。

curl -X POST http://localhost:8000/route \
  -H "Content-Type: application/json" \
  -d '{"prompt":"自己紹介して","models":["llama3.2:3b"],"worker":"moon"}'
# → {"worker_id":"moon", ..., "waking":true, "wake_sent":true}

ログには [wol] sent to moon が出て、マジックパケットが飛ぶ。あとはタスクの結果を見るだけだ。

curl http://localhost:8000/task/<task_id>
# status: done / worker: moon / retry_count: 0

タイムスタンプを見ると、WoL送信から処理完了まで約64秒だった。電源投入 → OS起動 → Ollama読み込み → 登録 → 自分宛キューを拾って推論、までが1分強。そして retry_count: 0 が、起動待ちの間にreaperが一度も介入しなかった証拠になっている。狙いどおりだ。

Idle Shutdownの方は、本番の15分を待つのは長いので、確認用に短い値(60秒)で前景起動して観察した。

[idle] monitor started (idle_shutdown=60s ...)
[idle] idle window started (running=0, queues empty)
[idle] deregistered worker:moon from Redis
[idle] idle threshold reached → shutting down: sudo shutdown -h now
The system will power off now!

アイドル検知 → Redis登録を自分で削除 → 停止、の順序で走り、実際に電源が落ちた。登録削除がshutdownより先に来ている点も設計どおりだ。

まとめと次回

これで「普段は落としておいて、必要な時だけ指名で起こし、使い終わったら自分で寝る」という運用が一通り動くようになった。常時起動の軽量1台がベースラインと安全網を担い、重い処理や特定用途のWorkerはオンデマンドで起動する。reaperの自己修復とも、wake_deadlineという1つの仕掛けできれいに両立できた。

残っているのは入口の整備だ。今はコマンドラインから指名しているが、普段使っているチャットUI(OpenWebUI)のモデル選択から moon を指名できるようにしたい。起動待ちの数十秒を「起動中…」「処理中…」と段階表示する、というところまで設計は固まっているので、次回はそこを実装する予定だ。