はじめに
僕は1人会社をほぼ全自動で回していて、現在 launchd ジョブが17個動いています。記事の自動公開、SNS配信、売上集計、朝ダイジェスト。自動化率は98%です。
その運用で一番高くついたバグが「多重起動」でした。Zenn は1日1コンテンツというレートリミットがあるのですが、ジョブが二重に走って同日に複数投稿し、デプロイが丸ごと失敗する事故を何度も踏みました。しかもこの手のバグは「たまにしか起きない」ので、ログを見ても原因が特定しづらい。
結論として、僕が全スクリプトに入れたのは mkdir 1行です。経営の話はここまでにして、以下は実装だけで構成します。
なぜ [ -f lock ] ではダメなのか
よく見かけるのがこれです。
# アンチパターン: TOCTOU レースがある
LOCK=/tmp/myjob.lock
if [ -f "$LOCK" ]; then
exit 0
fi
touch "$LOCK" # ← ここに到達するまでの間に他プロセスも通過できる
[ -f ] によるチェックと touch による作成が別のシステムコールなので、その隙間に他プロセスが割り込めます(TOCTOU: Time-of-check to time-of-use)。launchd の StartCalendarInterval が重なったとき、スリープ復帰で溜まったジョブが同時発火したとき、手動実行とスケジュール実行がかち合ったとき — 確率は低いですが必ず起きます。
一方 mkdir(2) は POSIX でアトミックと規定されています。ディレクトリが既に存在すれば EEXIST で失敗する、という判定と作成が1回のシステムコールで完結する。だから「成功した1プロセスだけが先に進む」が保証できます。
# 正しいパターン: 判定と取得が不可分
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
exit 0 # 取れなかった = 他が走っている
fi
mkdir -p にしないのが重要です。-p は既存ディレクトリでも成功してしまうので、ロックとして機能しません。ここは本当に間違いやすいポイントで、僕も一度やりました。
実装1: 日付付きロックで「1日1回」を保証する
これは当社で実際に動いている note 自動投稿のラッパーです(.company/scripts/note/cron-random-post.sh)。
#!/bin/bash
# 毎日1回、launchdから実行される
# mkdirによるアトミックロックで二重投稿を防止
LOCK_DIR="/tmp/note-post-$(date +%Y%m%d).lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$(date): Already posted today. Skipping." >> "$(dirname "$0")/post-note.log"
exit 0
fi
# 投稿実行
exec "$(dirname "$0")/auto-post-note.sh"
13行です。ポイントは3つ。
ロック名に日付を埋め込んでいる — これで「排他」と「1日1回」を同時に満たします。ジョブが1日に何度発火しても、mkdir に成功するのは最初の1回だけ。翌日は名前が変わるので自然にリセットされます。別途「今日実行済みか」のフラグファイルを管理する必要がありません。
意図的にロックを削除していない — 日付ロックは「実行済みマーカー」でもあるので、残すのが正解です。/tmp は macOS なら3日以上アクセスのないファイルが定期的に掃除される(/etc/periodic/daily/110.clean-tmps)ので、放っておいても溜まりません。気になるなら明示的に掃除します。
# 7日より古いロックを削除(起動時に1行)
find /tmp -maxdepth 1 -type d -name 'note-post-*.lock' -mtime +7 -exec rmdir {} + 2>/dev/null
exec で本体に置き換えている — 余計なシェルプロセスを残さず、終了コードもそのまま伝播します。
実装2: 長時間ジョブ向けの「解放する」ロック
「1日1回」ではなく「同時に1つだけ」を保証したいケース、たとえば数十分かかるビルドや記事生成ではロックを解放する必要があります。当社の共通ヘルパー(common.sh)に足している形がこれです。
#!/bin/bash
set -euo pipefail
# ---- アトミックロック(同時実行防止 + stale回収)----
acquire_lock() {
local name="$1"
local stale_min="${2:-60}" # この分数を超えたロックは異常とみなす
LOCK_DIR="/tmp/aiceo-${name}.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
# 中のPIDが生きているか確認
local owner
owner="$(cat "$LOCK_DIR/pid" 2>/dev/null || echo "")"
if [ -n "$owner" ] && kill -0 "$owner" 2>/dev/null; then
echo "[$name] running (pid=$owner). skip."
return 1
fi
# PIDが死んでいる、または mtime が古い → stale とみなして奪取
if [ -z "$owner" ] && [ -z "$(find "$LOCK_DIR" -maxdepth 0 -mmin "+$stale_min")" ]; then
echo "[$name] lock exists but young and ownerless. skip."
return 1
fi
echo "[$name] stale lock detected (pid=${owner:-none}). reclaiming."
rm -rf "$LOCK_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || { echo "[$name] lost reclaim race. skip."; return 1; }
fi
echo $$ > "$LOCK_DIR/pid"
# 正常終了・異常終了・SIGTERM すべてで解放
trap 'rm -rf "$LOCK_DIR"' EXIT INT TERM
return 0
}
acquire_lock "publish-zenn" 90 || exit 0
# ---- 以下、本処理(ここに来たプロセスは世界で1つだけ)----
echo "start: $(date)"
sleep 5
echo "done: $(date)"
設計の意図を補足します。
PIDファイルは mkdir の後に書く。 ロック取得(mkdir)と所有者記録(PID書き込み)は分離されますが、これで問題ありません。排他は mkdir だけで既に成立していて、PID は stale 判定のための補助情報にすぎないからです。逆に PID ファイルの存在チェックを排他の手段にすると、最初のアンチパターンに戻ります。
kill -0 で生存確認する。 シグナルを送らずにプロセスの存在だけを確認できます。ロックを持っていたプロセスが kill -9 やマシンの強制再起動で死ぬと、trap が走らずロックが残る(stale lock)。PID が生きていなければ奪取します。
PID 再利用を mtime で保険する。 kill -0 は PID が別プロセスに再利用されていると誤って「生きている」と判定します。だから「PID が生きている」だけを信じず、stale_min を超えて古いロックは異常として扱う二段構えにしています。閾値はジョブの最長実行時間の1.5倍程度が目安です。
trap ... EXIT INT TERM。 EXIT だけでも set -e による異常終了はカバーできますが、launchd が送る SIGTERM は別途捕まえる必要があります。SIGKILL は原理的に捕まえられないので、上の stale 回収が最後の砦になります。
launchd 側の設定
macOS では cron は使わないほうがいいです。僕は最初 cron で組んで、フルディスクアクセスの権限問題でジョブが全滅しました。crontab 経由のプロセスには Documents や Desktop への権限が降りず、エラーログも出ずに黙って失敗します。launchd に全面移行して解決しました。
<?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>com.joinclass.note-post</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/kyoagun/workspace/one-ceo/.company/scripts/note/cron-random-post.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>30</integer>
</dict>
<!-- 発火時刻にスリープしていた場合、復帰後に遅延実行する -->
<key>RunAtLoad</key>
<false/>
<!-- 多重起動の第一防衛線(ただしこれだけでは足りない)-->
<key>AbandonProcessGroup</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/kyoagun/workspace/one-ceo/.company/scripts/logs/note-post.log</string>
<key>StandardErrorPath</key>
<string>/Users/kyoagun/workspace/one-ceo/.company/scripts/logs/note-post.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/kyoagun/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>/Users/kyoagun</string>
</dict>
</dict>
</plist>
登録と確認。
# 登録(macOS 11+ は bootstrap 推奨、load は非推奨)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.joinclass.note-post.plist
# 状態確認(最後の終了コードが見える)
launchctl print gui/$(id -u)/com.joinclass.note-post | grep -E 'state|last exit'
# スケジュールを待たずに即実行してテスト
launchctl kickstart -k gui/$(id -u)/com.joinclass.note-post
# 解除
launchctl bootout gui/$(id -u)/com.joinclass.note-post.plist
落とし穴
PATH と HOME を plist で明示する。 launchd 配下のシェルは .zshrc を読みません。僕は common.sh の先頭で PATH と HOME を明示的に export する方式にしています(スクリプト側で持たせたほうが、plist が増えても一箇所で済む)。
export PATH="/Users/kyoagun/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/kyoagun"
スリープ復帰時の一斉発火。 StartCalendarInterval の時刻にマシンが寝ていると、launchd は復帰後に遅延実行します。複数の時刻指定を持つジョブや、何日も寝ていたマシンではこれが同時発火の主要因です。日付ロックがあれば、復帰後に何回発火しても実行は1回で済みます。
launchctl list の終了コードを見る。 Status 列が 0 以外なら前回失敗しています。78 なら plist の設定エラー(EX_CONFIG)、127 ならコマンドが見つからない = PATH の問題です。
検証: 本当に排他できているか確かめる
ロックのテストは「書いて終わり」にしがちですが、ここは必ず手を動かして確認します。100並列で叩いて、実行が1回だけかを見ます。
#!/bin/bash
# test-lock.sh — 100プロセス同時起動して、本処理が1回だけ走ることを検証
rm -rf /tmp/locktest-*.lock /tmp/locktest-counter
cat > /tmp/job.sh <<'EOF'
#!/bin/bash
LOCK_DIR="/tmp/locktest-$(date +%Y%m%d).lock"
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
echo "x" >> /tmp/locktest-counter
EOF
chmod +x /tmp/job.sh
for i in $(seq 1 100); do /tmp/job.sh & done
wait
echo "実行回数: $(wc -l < /tmp/locktest-counter)" # → 1
比較用に、冒頭のアンチパターン([ -f ] + touch)に差し替えて同じテストを回してみてください。環境によりますが、僕の M1 Mac では 100 並列で 3〜15 回ほど本処理が走りました。レースは理論上の話ではなく、普通に再現します。
stale lock の回収もテストできます。
# ロックを握ったまま SIGKILL される状況を再現
( acquire_lock "staletest" 1 && sleep 300 ) &
BG=$!
sleep 1
kill -9 $BG # trap が走らず /tmp/aiceo-staletest.lock が残る
ls -d /tmp/aiceo-staletest.lock # 残っていることを確認
# 1分待ってから再実行 → stale と判定して奪取できるはず
sleep 61
acquire_lock "staletest" 1 && echo "reclaimed OK"
flock との使い分け
Linux なら flock(1) が素直です。
# Linux: ファイルディスクリプタ単位のロック。プロセス終了で自動解放
exec 9>/var/lock/myjob.lock
flock -n 9 || exit 0
# 本処理
これは「プロセスが死んだらカーネルが自動で解放する」ので stale lock を自力で管理しなくて済み、確実に優れています。ただし macOS には flock コマンドが標準で入っていません(brew install flock で入りますが、Homebrew 依存が増えます)。また NFS 越しの挙動が歴史的に不安定です。
僕の基準はこうです。
| 状況 | 選択 |
|---|---|
| Linux、依存を増やしてよい | flock |
| macOS / launchd / 素の bash だけ | mkdir |
| 「1日1回」等の実行済みマーカーも兼ねたい |
mkdir + 日付入り名 |
| NFS / 共有ストレージ上 |
mkdir(flock より移植性が高い) |
mkdir の利点は、依存ゼロで Linux・macOS・BSD・Docker の軽量イメージまでそのまま動くことです。自動化スクリプトを何十個も持つと、この「どこでも動く」が効いてきます。
おわりに
自動化で怖いのは「動かないこと」ではなく「二重に動くこと」です。動かなければログを見て気づきますが、二重投稿や二重課金は外部に出てから気づきます。僕は Zenn のデプロイ失敗で何度か学びました。
追加したコードは実質1行です。仕組みの初期投資は大きく感じますが、一度入れれば自動で回り続ける。コストが10分の1になっても売上が10倍になるわけではないけれど、事故が起きない仕組みは確実に時間を返してくれます。
自動化をさらに深く
この記事で触れた launchd 運用やロック設計を含め、Claude Code を軸に業務を全自動化する設計パターンを『Claude Code 全自動化バイブル』にまとめています。Hooks、cron/launchd、承認パイプライン、コスト管理まで、当社で実際に17ジョブを回している構成をそのまま解説しています。