朝、ダッシュボードは緑のままでした。プロセスは動いている、CPUもわずかに食っている、ログの最終行も「モデルに問い合わせ中」で止まっている——なのに、6時間前から1文字も先に進んでいませんでした。
私自身、個人開発で記事生成のエージェントを夜間に走らせています。その夜は外部APIの応答が返らず、リトライにも入らず、ただ待ち続けていました。プロセスは確かに生きていました。だから「生きているか」を見る監視は、ずっと緑を返し続けていたのです。問題は、生存と進捗を同じものとして扱っていたことでした。
「生きている」と「進んでいる」は別の問い
サーバ監視の習慣で、私たちはつい「プロセスが存在するか」「ポートが応答するか」を健全性の指標にします。短時間で終わるリクエストならそれで十分です。けれどバックグラウンドエージェントは、1回のジョブが数分から数十分かかり、その間ずっと「正常に存在しつつ、何も進めていない」状態になり得ます。
停滞の典型は、応答が返らない外部呼び出し、解けない依存関係を前にした無限の思考ループ、そして自分で自分を待つデッドロックです。どれもプロセスは生きています。生存監視(liveness)はこれらを一切捕まえられません。必要なのは**進捗監視(progress)**です。
| 監視の種類 | 答える問い | 停滞を捕まえられるか |
| 生存監視(プロセス存在・ポート応答) | 動いているか | 捕まえられない |
| ログ出力の有無 | 何か喋っているか | 「待機中」を吐き続けると誤魔化される |
| 進捗監視(前進した量) | 仕事が進んだか | 捕まえられる |
ログを吐いているかどうかで判断するのも危険です。「待機中…」を出し続けるコードは、喋ってはいても進んでいません。見るべきは饒舌さではなく、前進した量です。
進捗をマーカーとして外に出す
進捗で見張るには、まずエージェント自身が「ここまで進んだ」を外部から観測できる形で残す必要があります。単調増加するステップ番号と、最後に前進した時刻を、共有できる場所へ書き出します。
# progress.py — 進捗マーカーを共有ファイルに刻む
import json, os, tempfile, time
def mark_progress(path: str, step: int, note: str = "") -> None:
"""step は単調増加。前進したときだけ呼ぶ(待機中には呼ばない)。"""
payload = {"step": step, "note": note, "ts": int(time.time())}
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
with os.fdopen(fd, "w") as f:
json.dump(payload, f)
os.replace(tmp, path) # 原子的に差し替え
ここで大事なのは、前進したときだけ mark_progress を呼ぶという規律です。ハートビートを「定期的に必ず打つ」設計にすると、停滞していても鼓動だけは続き、結局は生存監視に逆戻りします。鼓動は「生きている合図」ではなく「進んだ合図」でなければ意味がありません。
エージェント本体では、意味のある区切りごとにステップを進めます。
mark_progress(P, 1, "収集完了")
# ... 外部API呼び出し(ここが固まりやすい) ...
mark_progress(P, 2, "下書き生成")
# ... 整形 ...
mark_progress(P, 3, "検証通過")
無進捗タイムアウトでウォッチドッグを作る
別プロセスのウォッチドッグが、この進捗マーカーを見張ります。判定はシンプルです。「最後に前進してからの経過時間」が閾値を超え、かつステップ番号が動いていなければ、停滞とみなします。
# watchdog.py — 無進捗が続いたら停滞と判定し、対象を止める
import json, os, signal, time
def watch(progress_path: str, pidfile: str, stall_sec: int = 600, poll: int = 30):
last_step, last_change = -1, time.time()
while True:
time.sleep(poll)
try:
d = json.load(open(progress_path))
except (FileNotFoundError, json.JSONDecodeError):
continue # まだ書かれていない・書き込み途中
if d["step"] != last_step:
last_step, last_change = d["step"], time.time() # 前進を観測
continue
if time.time() - last_change > stall_sec:
pid = int(open(pidfile).read().strip())
terminate(pid) # 無進捗が閾値超え → 停止
return
def terminate(pid: int):
# まず穏当に(後始末の猶予を与える)、それでも残れば強制終了
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
time.sleep(20)
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
stall_sec は「この作業なら、ここまで無音なら異常」という肌感覚で決めます。私の記事生成ジョブでは、最長のモデル呼び出しの実測がおおむね90秒だったので、その数倍に余裕を持たせて10分(600秒)にしています。短すぎると正常な長考を殺し、長すぎると朝まで気づけません。
止め方そのものを設計する
検知できても、止め方が雑だと別の事故を生みます。いきなり強制終了すると、書きかけの成果物が中途半端に残ります。だから二段構えにします。
- まず
SIGTERM を送り、エージェント側のシグナルハンドラに後始末(一時ファイルの削除・ロックの解放・進捗マーカーへの「中断」記録)をさせます。
- それでも一定時間内に終わらなければ
SIGKILL で確実に落とします。
エージェント本体には、後始末を引き受けるハンドラを仕込んでおきます。
cleanup() {
rm -f "$TMP_OUT" 2>/dev/null || true # 書きかけを残さない
release_lease 2>/dev/null || true # ロックを手放す
echo '{"step":-1,"note":"aborted"}' > "$PROGRESS"
exit 143 # 128 + SIGTERM
}
trap cleanup TERM INT
この「中断」を進捗マーカーに残しておくと、次回の起動時に「前回は停滞で落ちた」と分かり、原因調査の起点になります。本番運用では、この一行があるかないかで翌朝の調査時間がまるで変わりました。
よくある落とし穴と回避
ひとつ目の落とし穴は、ハートビートを進捗から切り離してしまうことです。タイマーで定期的に鼓動を打つと、停滞中も鼓動が続き、ウォッチドッグが永遠に発火しません。鼓動は必ず「前進」に紐づけてください。
ふたつ目は、ウォッチドッグをエージェントと同じプロセス内に置くことです。本体がイベントループごと固まると、内蔵のタイマーも一緒に固まります。ウォッチドッグは必ず別プロセスにし、共有ファイル越しに観測する構成を推奨します。
みっつ目は、stall_sec を全ジョブで一律にすることです。30秒で終わる整形ジョブと、5分かかる大規模生成ジョブに同じ閾値を当てると、どちらかが必ず誤判定します。ジョブ種別ごとに閾値を持つのが現実的で、Dolice Labs の運用では、ジョブ定義のメタデータに stall_sec を持たせてジョブごとに切り替えています。
どこから入れるか
最初から完璧な監視網を敷く必要はありません。実体験として効いたのは、まず一番長く固まると困るジョブ一本に進捗マーカーを3つだけ刻み、別プロセスのウォッチドッグを当てる、という最小構成でした。
一晩走らせれば、SIGTERM が一度でも飛んだかどうかで「沈黙した停滞が実際に起きているか」が分かります。起きていなければ閾値はそのまま、頻発するなら原因(多くは外部呼び出し)に手当てを入れる。緑のダッシュボードを信じ切らず、前進した量を見張る——この一点に切り替えるだけで、朝の「なぜか終わっていない」は確実に減らせると考えています。