前の晩に仕込んだ処理が、翌朝には一行だけログを残して終わっていました。エラーではありません。「別のプロセスが実行中のため、今回は見送ります」という、私自身が書いた分岐です。
けれども、そのとき動いていたプロセスはひとつもありませんでした。
個人開発の作業を無人のスケジュール実行に任せるようになってから、同じ連携フォルダを複数の枠が触る場面が増えました。二重起動だけは避けたいと考えてロックを置いたのですが、その置き場所を「いちばん確実に共有されている場所」——連携フォルダそのもの——にしたことが原因でした。
排他は効いていました。効きすぎて、解除できなくなっていたのです。
排他制御そのものは、正しく動いていました
最初に疑ったのはロックの取得側です。競合していないのに競合したと判定しているのなら、取得の条件がおかしいのではないか。そう疑って、まず排他が本当に働いているかを確かめました。
# 1本目が3秒間ロックを保持している間に、2本目を投げる
( flock -n 9 && sleep 3 ) 9> "$SHARED/slot.lock" &
sleep 0.5
( flock -n 9 && echo "2本目が取得(想定外)" \
|| echo "2本目はブロック(想定どおり)" ) 9> "$SHARED/slot.lock"
wait
結果は 2本目はブロック(想定どおり) でした。連携フォルダの上でも、flock の排他はきちんと働いています。ネットワーク越しのファイルシステムでロックが効かない、という古典的な話ではありませんでした。
問題は取得ではなく、その後にありました。
削除だけが、静かに拒まれていました
同じ場所で、ファイルの一生を分解して試しました。作成、追記、読み取り、改名、ディレクトリ作成、そして削除です。
T="$SHARED/probe_test"
echo x > "$T" # 作成
echo y >> "$T" # 追記
cat "$T" > /dev/null # 読み取り
mv "$T" "$T.renamed" # 改名
rm -f "$T.renamed" # 削除
mkdir "$SHARED/probe_dir" && rmdir "$SHARED/probe_dir"
私の環境で返ってきた結果です。
書けるかどうかだけを確かめていたら、この場所は合格します。実際、私は合格させていました。作れて、追記できて、名前まで変えられるのですから、書き込み権限の観点では何の問題もありません。
拒まれるのは削除だけです。そして二重起動防止のロックは、削除できて初めて成り立つ仕組みでした。
三つのロック方式を、同じ場所で二度ずつ回す
ロックの実装方式は主に三つあります。同じ連携フォルダで、それぞれを続けて二回走らせました。二回目が動くかどうかが、無人運用では決定的です。
# ① mkdir ロック(存在ベース)
for run in 1 2; do
if mkdir "$SHARED/mkdir.lock" 2>/dev/null; then
echo "run$run: 取得"
rmdir "$SHARED/mkdir.lock" 2>/dev/null \
&& echo "run$run: 解放" || echo "run$run: 解放できず"
else
echo "run$run: 取得できず"
fi
done
はじめの二つは、ロックの正体が「そのファイルやディレクトリが存在すること」です。削除できない場所では、一度掛けたロックが二度と外れません。一回目の実行がロックを掛けた瞬間に、その枠は将来のすべての実行を締め出したことになります。
flock だけが違う理由は、排他の所在がファイルの存在ではなく、開いているファイル記述子にあるからです。プロセスが終われば記述子は閉じ、ロックは自動的に解けます。ファイルが消せなくても、次の実行はきちんと取得できました。残るのは中身ゼロのファイル一つだけです。
無人運用で選ぶべき方式が、ここではっきりしました。削除できない場所に置かざるを得ないなら、存在ベースのロックは使えません。
すでに存在ベースのロックで動いている処理を移すときは、次の順で進めると本番運用を止めずに済みました。
- 現在のロックの置き場所で、削除が通るかを確かめます。通るのであれば、急いで直す必要はありません
flock 方式へ差し替え、置き場所だけをローカルの専有領域へ移します。取得の判定ロジックには触れません
- 旧方式の残骸が締め出しを起こしていないか、台帳の見送り回数で確かめます
順番を逆にすると、残骸を抱えたまま新方式へ移ることになり、切り分けが難しくなります。私はここでいちど回避策を挟もうとして、かえって確かめるべき状態を増やしてしまいました。存在ベースのロックを採用する場合は、削除が通る場所であることを先に確かめることを推奨します。
「取得できなかった」と「前が動いている」は別の事実です
もう一段やっかいなのは、失敗の見え方でした。
存在ベースのロックは、生きているロックと死んだロックを区別しません。mkdir が失敗したとき、返ってくる情報は「すでに存在する」だけです。それが三秒前に別プロセスが掛けたものなのか、三週間前の残骸なのかは分かりません。
そして多くの実装は、この状態を「前の実行がまだ動いているのだろう」と解釈して、正常終了します。終了コードは 0、ログには一行、通知は飛びません。私の朝がまさにそれでした。処理は止まっているのに、記録の上では何も壊れていないのです。
ロックファイルに PID を書けば区別できるのでは、と考えたのですが、この構成では役に立ちませんでした。無人実行は毎回別のサンドボックスで走るため、記録された PID を現在のプロセス一覧と突き合わせても意味がありません。存在しない番号が返るだけです。
見送りを正常終了として扱わないこと。ここが分かれ目でした。無音の見送りをどう記録に残すかは、Cowork スケジュールタスクが黙って止まる理由と、自動で立ち直る仕組みの作り方でも扱っています。
なぜ削除だけが拒まれるのか
この挙動を、環境の不具合として片付けたくなる瞬間がありました。けれども調べていくと、理にかなった設計だと分かります。
連携フォルダは、こちらが渡した実際の作業フォルダです。そこにあるのは、原稿であり、設定であり、記録です。自動で走る処理に対して、作成や追記は許しても、削除だけは人の判断を通す——そう区切っておけば、取り返しのつかない事故はまず起きません。改名が通るのも同じ線引きの内側です。名前が変わったファイルは探せますが、消えたファイルは探せません。
つまりこれは、防ぎたい事故の側から引かれた線です。恨む対象ではなく、前提として設計に織り込むべきものでした。
同じ前提は、連携フォルダに何を置くかという判断全体に効いてきます。私がフォルダを渡す前に中身を数えた記録はフォルダを AI に渡す前に鍵を数えたら、ファイル名で見つかるのは18件中5件でしたにまとめました。置いてよいものと、置いた瞬間に取り返せなくなるものは、別々に考える必要があります。
寿命で置き場所を分ける
原因が分かってからは、置き場所の判断基準を「共有されているか」から「寿命が短いか」に変えました。
台帳とログは、むしろ削除できない場所にあるほうが安心です。追記しかしないのですから、削除が拒まれても困りません。実際、私の環境では連携フォルダへの追記は問題なく通りました。
一方でロックは、実行のたびに生まれては終わる、寿命の短いものです。後始末の通らない場所には置けません。
そのまま動く実装
ロックの置き場所を、実行前に判定してから決めるようにしました。存在するかでも、書けるかでもなく、後始末まで通るかを確かめます。
#!/usr/bin/env bash
# 無人実行の二重起動を防ぐロック。
# 「削除まで通る場所」以外にはロックを置かない。
# ロックファイル自体は消さない(理由は後述の「二本同時に走らせたら」)。
set -uo pipefail
SLOT="${1:?slot name required}" # 例: nightly-report
SHARED_LEDGER="${SHARED_LEDGER:-}" # 追記のみ。連携フォルダで構わない
# ロック置き場の候補。専有かつローカルなものから順に見る
lock_root_candidates() {
printf '%s\n' \
"${XDG_RUNTIME_DIR:-}" \
"/run/user/$(id -u 2>/dev/null || echo 0)" \
"${TMPDIR:-/tmp}" \
"$HOME/.cache"
}
# 作成と削除が両方通ることを確かめる。
# 検査用ディレクトリは PID つき(同時起動どうしがぶつからない)。
# 後始末に失敗した場所には、固定名の目印を一つだけ残す
supports_full_lifecycle() {
local dir="$1" probe
if [ -z "$dir" ] || [ ! -d "$dir" ] || [ ! -w "$dir" ]; then return 1; fi
# 目印がある = ここは後始末が通らない場所だと確定済み
if [ -e "$dir/.no_cleanup_here" ]; then return 1; fi
probe="$dir/.lifecycle_probe.$$"
if ! mkdir "$probe" 2>/dev/null; then return 1; fi
if ! rmdir "$probe" 2>/dev/null; then
: > "$dir/.no_cleanup_here" 2>/dev/null
return 1
fi
return 0
}
pick_lock_root() {
local d
while IFS= read -r d; do
if supports_full_lifecycle "$d"; then printf '%s\n' "$d"; return 0; fi
done < <(lock_root_candidates)
return 1
}
LOCK_ROOT="$(pick_lock_root)" || {
echo "FAILED: ロックを置ける場所がありません(削除まで通る領域が見つかりません)" >&2
exit 3
}
LOCK_FILE="$LOCK_ROOT/unattended-$SLOT.lock"
exec 9> "$LOCK_FILE" || exit 3
if ! flock -n 9; then
echo "SKIPPED: $SLOT は別プロセスが保持中です" >&2
[ -n "$SHARED_LEDGER" ] && printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date +%FT%T%z)" "$SLOT" "$$" "skipped" >> "$SHARED_LEDGER"
exit 2 # 0 で終わらせない
fi
trap 'flock -u 9 2>/dev/null' EXIT # ファイルは消さない
[ -n "$SHARED_LEDGER" ] && printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date +%FT%T%z)" "$SLOT" "$$" "acquired" >> "$SHARED_LEDGER"
# ---- ここに本来の処理を書く ----
[ -n "$SHARED_LEDGER" ] && printf '%s\t%s\t%s\t%s\n' \
"$(TZ=Asia/Tokyo date +%FT%T%z)" "$SLOT" "$$" "released" >> "$SHARED_LEDGER"
exit 0
一本目が保持している状態で二本目を投げると、二本目は次のように終わります。
SKIPPED: nightly-report は別プロセスが保持中です
2本目 exit=2
台帳側には、こう積み上がります。
2026-08-29T12:14:57+0900 nightly-report 22 acquired
2026-08-29T12:14:57+0900 nightly-report 34 skipped
2026-08-29T12:15:00+0900 nightly-report 22 released
書き終えてから気づいたことがあります。この検査関数は、後始末が通らない場所では検査痕そのものを片付けられません。「後始末できないことを調べる処理」が後始末を必要とするという、少し可笑しな構造になっています。
だから、後始末に失敗した場所には固定名の目印を一つだけ残すことにしました。次回はその存在を見て、何も作らずに同じ結論を返します。実行するたびに痕跡が増えるのと、生涯で一つだけ残るのとでは、共有フォルダにとって意味がまったく違いました。
ただし、この形に落ち着くまでに一度、最初の版で大きな穴を踏んでいます。次の節で書き残します。
存在チェックと実際に試す検査の差については、存在チェックが通っても書けない — 無人実行の前提を能力プローブで測るでも別の角度から扱っています。
二本同時に走らせたら、最初の版が先に壊れました
ここまでの試験は、どれも一本ずつ走らせたものでした。ロックの実装を確かめるのに、一本しか走らせていなかったのです。恥ずかしい話ですが、二本を同時に起動して初めて、私が最初に書いた版には穴が二つあると分かりました。
試験は小さなものです。本処理の代わりに、同時に二本が入ったことを検出するだけの処理を置きます。
# 本処理の代わり: すでに誰かが入っていれば OVERLAP を記録する
if ! mkdir "$OUT/guard" 2>/dev/null; then
echo OVERLAP >> "$OUT/overlap.log"
else
sleep 0.01; rmdir "$OUT/guard"
fi
# 2 本を同時に起動し、200 回繰り返す
for i in $(seq 1 200); do ./lock.sh t & ./lock.sh t & wait; done
私の環境(4 コアの Linux サンドボックス、ローカルの専有領域 2 か所を候補にしました)での結果です。
一つ目の穴は、検査用ディレクトリを固定名にしたことでした。二本が同時に mkdir すると、片方は「検査痕がすでにある」側へ落ちて、その場所を不合格と判定します。結果、二本が別々の場所にロックを置き、互いの存在に気づかないまま同時に走りました。「痕跡を増やさない」ための固定名が、排他そのものを壊していたことになります。
二つ目は、終了時にロックファイルを rm していたことです。一本目が解放して消した瞬間に、すでに古いファイルを開いていた二本目が、消えたファイルの上でロックを取れてしまいます。そこへ三本目が同じ名前で新しいファイルを作り、そちらでもロックを取ってしまいます。同じ名前のロックが二つ存在する状態です。flock はファイルの存在に依存しない、と本記事で書いてきましたが、その flock を使いながら、存在に頼る後始末を足していたのでした。
修正は小さなものでした。検査用ディレクトリの名前に PID を入れ、ロックファイルの削除をやめました。それだけです。
ロックは、一本で通ったあと、二本同時に走らせて初めて合格です。
この試験はローカルの領域で行ったものです。削除が拒まれる場所での分岐(目印を残して次の候補へ進む動き)は、先に示した連携フォルダの実測を前提にしています。件数は環境によって変わりますので、数字よりも「0 にならない」という事実のほうを見ていただければと思います。
見送りを、記録の残る結果にする
終了コードを分けたのは、無音で止まる期間をなくすためでした。
台帳を一行ずつ数えれば、異常はすぐに見つかります。acquired に対応する released がない行が積み上がっていれば、掴んだまま落ちた実行があるということです。skipped だけが並んでいれば、掴んでいる相手がいないのに見送り続けている状態です。
awk -F'\t' '
$4=="acquired" { open[$3]++ }
$4=="released" { open[$3]-- }
$4=="skipped" { skip++ }
END {
for (p in open) if (open[p] > 0) print "未解放:", p
print "見送り回数:", skip+0
}
' "$SHARED_LEDGER"
私はこの出力を朝の確認に混ぜています。数字が二桁になっていたら、その枠は動いていないと判断して構いません。
何を自動化しないか
直したあとで、あえて実装しなかったものを書き残しておきます。
古いロックを自動で壊す仕組みは入れませんでした。flock を使う限り必要がないからです。記述子ベースのロックは、プロセスが消えれば解けます。壊す処理を持たせると、その処理が誤爆したときに二重起動を招きます。必要のない権限は持たせないほうが安全でした。
連携フォルダの掃除も自動化していません。削除に人の判断を通す設計を、こちらの都合で迂回させたくないからです。残った検査痕は生涯で一つ、中身は空です。それくらいは残しておいて、消すかどうかは自分で決めれば十分だと考えています。
他のセッションが置いたファイルにも触れないことにしました。同じ機械の上で別の無人実行が走っている可能性があり、その一時ファイルを掃除して困るのは向こうです。
次に確かめる一つのこと
もし無人で走る処理をお持ちなら、今日試せることが一つあります。ロックファイルが置かれている場所で、rm を一度だけ実行してみてください。
: > "$(dirname "$YOUR_LOCK_FILE")/.rm_check" && \
rm -f "$(dirname "$YOUR_LOCK_FILE")/.rm_check" && \
echo "削除まで通ります" || echo "この場所にロックは置けません"
通れば、今の構成のままで問題ありません。通らなければ、二度目の実行はもう始まっていない可能性があります。私はこれを確かめるのに、一晩と少しを使いました。同じ時間を使わずに済む方がいれば嬉しく思います。