ある朝、生成されたはずの成果物が半分だけ古い内容で上書きされていました。ログを追うと、同じスケジュールジョブが2台のマシンからほぼ同時刻に起動し、片方が書き終わる前にもう片方が書き始めていたのです。
私自身、個人開発で複数のブログ運用をバックグラウンドのエージェントに任せています。1台が眠っているあいだに別の1台が拾う、という二重化のつもりでした。ところが両方が起きていた瞬間に、両方が同じジョブを掴んでしまった。これは「ロックを取り忘れた」話ではありません。ロックを取っていても起こります。その理由と、止め方を順に書きます。
なぜ排他ロックだけでは二重起動が止まらないのか
直感的には「ジョブ開始時にロックを取り、終了時に解放すればよい」と思えます。しかしバックグラウンドエージェントの世界では、この前提が静かに崩れます。
ロックを保持したまま、プロセスが長時間停止することがあるからです。ガベージコレクションの長いポーズ、OSのスリープ復帰、重いモデル呼び出しの待ち。その間にロックのTTLが切れ、別のマシンが正当にロックを取得します。最初のマシンは「自分はまだロックを持っている」と思い込んだまま目を覚まし、書き込みを始めます。この瞬間、世界には2人のロック保持者がいます。
つまり問題は排他の取得ではなく、保持の継続を保証できないことにあります。ここを取り違えると、TTLを延ばす・ハートビートを足すといった対症療法を重ねても、確率を下げるだけで根絶できません。
リース(lease)という考え方
そこで「ロック」ではなく「リース(期限付きの貸与)」として捉え直します。リースは必ず失効する前提の所有権です。保持者は失効前に明示的に更新(renew)し続けなければなりません。更新が途切れた瞬間、所有権は自動的に手放されたものとみなされます。
重要なのは、リースが失効するたびに発行されるフェンシングトークン(fencing token)が単調増加することです。誰かがリースを取得するたびにトークンは必ず大きくなります。これにより「古い所有者」と「新しい所有者」を、書き込みの直前に数値の大小だけで判定できるようになります。
| 観点 | 単純な排他ロック | リース+フェンシングトークン |
| 停止からの復帰 | 古い保持者が書き込んでしまう | 古いトークンは書き込み側で拒否される |
| 所有権の判定 | 「持っているつもり」に依存 | 数値の大小で機械的に判定 |
| 時計ずれの影響 | TTL判定が狂うと破綻 | 順序はトークンで決まり時計に依存しない |
| 必要な前提 | 全員が誠実に振る舞うこと | 書き込み先がトークンを検証できること |
リースの取得と更新を実装する
まず、共有ストレージ上にリースを表現します。ここでは説明のため、ファイルベース(共有ディレクトリや小さなKV)で示します。要点は、取得時にトークンを単調増加させ、保持中はハートビートで更新することです。
#!/usr/bin/env bash
# acquire_lease.sh — リース取得(取得できなければ非ゼロで終了)
set -euo pipefail
LEASE_DIR="${LEASE_DIR:-/shared/leases}"
JOB="$1" # ジョブ名
HOLDER="$(hostname)-$$" # マシン名+PID
TTL_SEC="${TTL_SEC:-120}" # リースの寿命
NOW="$(date +%s)"
LEASE="${LEASE_DIR}/${JOB}.lease"
TOKENF="${LEASE_DIR}/${JOB}.token"
mkdir -p "$LEASE_DIR"
# 既存リースが生きているか確認
if [ -f "$LEASE" ]; then
EXP="$(awk -F= '/^expires=/{print $2}' "$LEASE")"
if [ "${EXP:-0}" -gt "$NOW" ]; then
echo "lease held until ${EXP}, now ${NOW}" >&2
exit 11 # まだ有効 → 取得を諦める
fi
fi
# 失効していたので取得。トークンを単調増加させる(これが要)
TOKEN="$(( $(cat "$TOKENF" 2>/dev/null || echo 0) + 1 ))"
echo "$TOKEN" > "$TOKENF"
cat > "${LEASE}.tmp" << LEASEDATA
holder=${HOLDER}
token=${TOKEN}
expires=$(( NOW + TTL_SEC ))
LEASEDATA
mv -f "${LEASE}.tmp" "$LEASE" # 原子的に差し替え
echo "$TOKEN" # 取得したトークンを返す
mv -f による原子的な差し替えと、トークンファイルの単調増加が肝です。トークンは「リースが新しく発行された回数」であり、決して減りません。
保持中は別プロセス(または同プロセスのバックグラウンド)でハートビートを打ち、expires を前へ伸ばし続けます。
#!/usr/bin/env bash
# renew_lease.sh — 自分が保持者である場合のみ TTL を延長
set -euo pipefail
LEASE="${LEASE_DIR}/$1.lease"; HOLDER="$2"; MY_TOKEN="$3"; TTL_SEC="${TTL_SEC:-120}"
CUR_HOLDER="$(awk -F= '/^holder=/{print $2}' "$LEASE")"
CUR_TOKEN="$(awk -F= '/^token=/{print $2}' "$LEASE")"
# 保持者でもトークンでもズレていたら、自分はもう所有者ではない
if [ "$CUR_HOLDER" != "$HOLDER" ] || [ "$CUR_TOKEN" != "$MY_TOKEN" ]; then
echo "lost lease (holder/token changed)" >&2
exit 12
fi
cat > "${LEASE}.tmp" << LEASEDATA
holder=${HOLDER}
token=${MY_TOKEN}
expires=$(( $(date +%s) + TTL_SEC ))
LEASEDATA
mv -f "${LEASE}.tmp" "$LEASE"
更新が exit 12 を返したら、その時点で作業を中断します。リースを失ったあとに成果物を書くのは、まさに二重起動が成果物を壊す瞬間だからです。
書き込み側でフェンシングトークンを検証する
ここが設計の中心です。リースを取れたことは「書いてよい」を意味しません。書き込み先が、自分の知る最大トークン以上であることを確認したときだけ書く。これで停止から復帰した古い保持者を確実に弾けます。
# fenced_write.py — トークン検証つきの成果物書き込み
import json, os, tempfile
def fenced_write(target_path: str, token: int, payload: bytes) -> bool:
"""token が、これまで観測した最大トークン以上のときだけ書く。"""
guard = target_path + ".fence" # 最後に書いたトークンを記録
last = 0
if os.path.exists(guard):
last = int(open(guard).read().strip() or "0")
if token < last:
# 古い保持者(停止から復帰したマシン)の書き込みを拒否
raise PermissionError(f"stale token {token} < last {last}; refusing write")
# 先にフェンスを進めてから本体を原子的に置き換える
with open(guard, "w") as g:
g.write(str(token))
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(target_path) or ".")
with os.fdopen(fd, "wb") as f:
f.write(payload)
os.replace(tmp, target_path) # 原子的差し替え
return True
token < last を拒否することで、たとえ古いマシンがまだ「自分が保持者だ」と信じていても、その書き込みは届きません。所有権の真偽を書き込みの瞬間に、数値の大小だけで決められるのが、このパターンの強さです。
ジョブ全体をひとつの流れにまとめる
取得・更新・検証つき書き込みを直線で並べると、エージェントのジョブ本体はこう包めます。
TOKEN="$(LEASE_DIR=/shared/leases ./acquire_lease.sh daily-publish)" || {
echo "another holder is running; exiting cleanly"; exit 0; }
HOLDER="$(hostname)-$$"
# バックグラウンドでハートビート
( while sleep 45; do ./renew_lease.sh daily-publish "$HOLDER" "$TOKEN" || exit; done ) &
HB=$!
trap 'kill "$HB" 2>/dev/null || true' EXIT
# ここでエージェント本体の作業(生成・整形)を行う
run_agent_job
# 成果物の書き込みは必ずトークン検証経由で
python3 fenced_write.py --token "$TOKEN" --target /shared/out/today.json
取得に失敗したら**静かに正常終了する(exit 0)**のがポイントです。「自分の番ではなかった」だけなので、失敗として騒ぐ必要はありません。エラー通知が鳴り続ける運用は、本当の異常を埋もれさせます。
個人開発の運用で効いた小さな判断
私が2台のMacでスケジュール実行を回していて学んだのは、検証ゲートをできるだけ下流に置くことの大切さでした。最初はジョブ開始時のロック取得だけで満足していて、書き込み直前には何の確認もしていませんでした。事故はいつも、開始と書き込みのあいだの長い時間に起きます。
もうひとつは、フェンスファイル(.fence)を成果物と同じ場所に、同じ権限で置くことです。別の場所に置くと、片方だけが同期されてトークンの記憶が巻き戻り、検証そのものが嘘になります。Dolice Labs の運用では、成果物・フェンス・リースを必ず同一の共有先にまとめ、バックアップ対象からフェンスだけ外す、という地味な取り決めで安定しました。
段階的に導入する手順
いきなり全ジョブへ広げる必要はありません。私の場合は、次の順番で本番運用に乗せました。
- まず最も壊れて困る成果物ひとつに
.fence 検証だけを足し、取得や更新は従来のまま様子を見ます。古い書き込みが弾かれるログが出るかを確認します。
- 次にリース取得を導入し、取得失敗時は静かに正常終了する挙動へ変えます。ここで「自分の番ではない」起動が騒がなくなります。
- 最後にハートビートでの更新を足し、更新失敗(リース喪失)を検知したら本番運用の作業を即座に中断するようにします。
この順で進めると、各段階で何が変わったかを切り分けられます。一気に全部入れると、不具合が出たときに原因の切り分けができず、かえって回避が難しくなります。段階導入を強く推奨します。
どこまでやるかの線引き
すべてのジョブにこの仕組みが要るわけではありません。冪等な読み取り専用ジョブや、最終結果が「最後に書いた者勝ち」で構わないものには、リースは過剰です。費用対効果で言えば、フェンシングが報われるのは「途中まで書くと壊れる成果物」「2台以上から起動しうるスケジュール」「停止・スリープが日常的に起こる端末」の3つが重なる場面です。
逆に、この3つが揃っているのに排他ロックだけで運用していると、事故は時間の問題です。確率を下げるのではなく、古い書き込みを構造的に到達不能にする——それがリースとフェンシングトークンの役割だと考えています。
次の一歩として、まずは一番壊れて困る成果物ひとつだけにフェンス検証を足してみてください。一晩動かせば、.fence の数値が静かに増えていくのを見て、二重起動が起きていたかどうかが分かります。