Claude Media
Claude Codeのcronをflockで多重起動させない書き方と確認手順

Claude Codeのcronをflockで多重起動させない書き方と確認手順

前回のclaude -pが終わる前に次のcronが走る問題を、flock -nとタイムアウトで防ぐ書き方と、重なりを再現して確かめる手順をまとめます。

crontabからclaude -pを呼ぶジョブは、1回の実行が予定の間隔より長引くことがあります。cronは前回の実行が終わったかどうかを見ずに次を起動するので、同じジョブが2本、3本と重なります。重なった分だけAPIの利用量が増え、同じファイルを書き換える処理は互いの成果物を壊します。

防ぎ方は、cronの行をflock -nで包む1手です。この記事では、その書き方、ロックを取れなかった回の扱い、長引いたジョブを打ち切る組み合わせ、そして重なりを意図的に作って効いているかを確かめる手順を示します。

なぜclaude -pのジョブは重なりやすいのか

通常のシェルスクリプトと違い、claude -pの所要時間はプロンプトの内容とターン数で大きく変わります。昨日は2分で終わった「ログを要約して」が、今日はログが多くて20分かかる、ということが起きます。毎10分起動のジョブなら、それだけで2本が同時に動きます。

重なりが起きたときの症状は、ジョブ側に何も書いていないと見分けにくいものです。

  • 同じ時間帯にclaudeのプロセスが複数並ぶ
  • 出力ファイルが途中で別の実行に上書きされ、中身が混ざる
  • 認証やレート制限のエラーが、重なった時間帯にだけ増える

見つけ方は、実行ごとに開始と終了の時刻を残しておくのが近道です。開始だけを記録した履歴では、重なりは読み取れません。失敗の痕跡を残す書き方はClaude Codeのcron失敗を調査する手順にまとめてあり、ここではその次の段階として、重なり自体を起こさない側を扱います。

flock -nで包めば2本目は起動しない

flockは、ロックを取ってからコマンドを実行するユーティリティです。ロックを取れなければ、既定では取れるまで待ちます。待たずに諦めさせるのが-n(--nonblock)で、ロックを取れなかったときは失敗の終了コードで抜けます。

# 5分ごとに実行。前回がまだ動いていれば、この回は何もせず終わる
*/5 * * * * /usr/bin/flock -n /var/tmp/daily-report.lock \
  /usr/local/bin/claude-job.sh daily-report "昨日のログを要約して"

ロックはロックファイルに対して取られ、ファイルが無ければ作られます。作成には書き込み権限が要るので、cronを動かすユーザーが書けるディレクトリを選んでください。ディレクトリ自体をロック対象に指定することもできます。

ロックはファイルが閉じられると自動で外れます。ジョブが異常終了しても、ロックだけが残り続ける「古いロック」にはなりません。この点が、ロック用のファイルを作って消す方式との大きな違いです。

スクリプトの中でロックを取る書き方

cronの行を短く保ちたいときは、ラッパーの先頭でロックを取ります。manページが紹介している、ファイル記述子を使う形です。

#!/usr/bin/env bash
# claude-job.sh の先頭に置く
exec 9>/var/tmp/claude-job-"$1".lock
flock -n 9 || { echo "skip: $1 is still running" >&2; exit 75; }
 
# ここから先がロックを持った状態の本体
/usr/local/bin/claude --bare -p "$2" --output-format json --max-turns 20

exec 9>で記述子9番にロックファイルを開き、flock -n 9でその記述子のロックを取ります。ロックはスクリプトが終わって記述子が閉じられるまで続きます。ジョブ名をファイル名に入れておけば、別のジョブ同士は互いを止めません。

スキップした回を失敗と数えない

flock -nでロックを取れなかったときの終了コードは、既定で1です。claude自体の失敗も非0で終わるので、そのままでは「重なって飛ばした回」と「claudeが落ちた回」が区別できません。失敗時だけ通知する仕組みを組んでいると、飛ばした回のたびに通知が飛びます。

-E(--conflict-exit-code)で、ロックを取れなかったときの終了コードを変えられます。

*/5 * * * * /usr/bin/flock -n -E 75 /var/tmp/daily-report.lock \
  /usr/local/bin/claude-job.sh daily-report "昨日のログを要約して"

75は、sysexits.hの「一時的な失敗」にあたる値として知られている番号です。flockは終了コードにsysexits.hの値を使うので、同じ体系に合わせておくと意味が読み取れます。0〜255の範囲なら何でも指定できますが、claudeが返しうる値と混ざらない番号にしてください。

通知側では、75のときだけ別扱いにします。

終了コード意味通知
0意味正常終了通知しない
75意味前回が動いていて飛ばした通知しない。履歴にだけ残す
それ以外意味claudeが失敗通知する

飛ばした回が続く状況は、それ自体が異常のサインです。履歴に75が並び続けているなら、ジョブが予定間隔に収まっていないことを意味します。通知を鳴らさず、履歴を見て間隔かタイムアウトを見直す運用が、通知の埋もれを防ぎます。

待つか捨てるか: -nと-wの使い分け

-nは「ロックを取れなければ捨てる」方針です。ジョブの性質によっては、捨てずに少し待つほうが合います。-w(--timeout)は、指定した秒数だけ待ち、間に合わなければ失敗で抜けます。0秒は-nと同じ扱いです。

くらべる

重なったときの2つの方針

定期的な集計向き

-n: 捨てる

次の回で同じ内容を処理できるジョブに向きます。「昨日のログを要約」のように、遅れても内容が変わらないものです。

取りこぼしを避けたい場合

-w 秒数: 少し待つ

直前の実行がもうすぐ終わる見込みなら、待ってから実行します。待ち時間の上限は、次の起動時刻より短く取ります。

-wで待つ上限を次の起動時刻より長くすると、待機中のプロセスが積み上がって重なりの再発になります。待つ秒数は「次回起動までの間隔より十分短く」が条件です。

長引いたジョブを打ち切って、ロックを解放する

flock -nは重なりを防ぎますが、1回が止まったまま終わらないと、以降の回がずっと飛ばされ続けます。ロックを持つプロセスが生きている限り、ロックは外れないためです。

そこで、timeoutで実行時間に上限を付けます。

*/5 * * * * /usr/bin/flock -n -E 75 /var/tmp/daily-report.lock \
  /usr/bin/timeout 240 /usr/local/bin/claude-job.sh daily-report "昨日のログを要約して"

起動間隔の5分より短い240秒で打ち切るので、止まった実行が次の回を何度も飛ばす事態を避けられます。打ち切られた回は75以外の非0で終わるため、claudeの失敗と同じく通知の対象になります。claude側でも--max-turnsでターン数に上限を付けると、暴走を二重に止められます。

子プロセスがロックを握り続ける落とし穴

落とし穴

ジョブが終わっているのにロックが外れない

ジョブが終わっても、そこから起動された子プロセスが残っていると、その子もロックの記述子を引き継いでいます。ロックはファイルが閉じられたときに外れるため、子が生きている間は飛ばされ続けます。flockの-o(--close)は、コマンドを実行する前にロックの記述子を閉じるオプションで、子にロックを渡したくない場合に使います。ただし重なり防止の目的では、子を残さない作りに直すほうが確実です。

claude -pの実行中に長いバックグラウンド処理を起動させるようなプロンプトは、この落とし穴に当たりやすいといえます。プロンプトは、処理を同期的に終えて抜ける指示にしておきます。

重なりを作ってflockが効いているか確かめる

設定を書いただけでは、効いているかどうかは分かりません。claudeを呼ばない代用ジョブで、重なりを意図的に作ります。

# 端末1: 30秒かかるジョブを起動(ロックを持つ)
flock -n -E 75 /tmp/overlap-test.lock sleep 30
 
# 端末2: 続けて同じコマンドを実行
flock -n -E 75 /tmp/overlap-test.lock sleep 30; echo "exit=$?"

端末2が即座にexit=75を返せば、2本目は起動されずに終わっています。端末1の30秒が過ぎてから端末2を実行すれば、exit=0になります。待つ方針の-wも、同じ形で確かめられます。

# 端末2を「最大5秒だけ待つ」に変えると、5秒後に75で抜ける
time flock -w 5 -E 75 /tmp/overlap-test.lock sleep 30; echo "exit=$?"

--verboseを付けると、ロックを取るまでにかかった時間や、取れなかった理由が出力されます。cronの行を書き換えた直後は、短い間隔と代用ジョブで1サイクル回して、履歴に75が出るかを見てください。

flockが使えない環境

flockはLinuxのutil-linuxに含まれるコマンドです。macOSではwhich flockが何も返さない環境があり、その場合はHomebrewなどで別途入れるか、別の方式を取ります。

  • NFSやCIFS上のロックファイルは避ける。manページは、これらのファイルシステムではflockの実装が限定的で、常に失敗する場合があると注意している
  • どうしてもflockを使えない環境では、mkdirでロック用ディレクトリを作る方式が代替になる。mkdirは既に存在すれば失敗するため排他に使えるが、異常終了時に残ったディレクトリを自分で消す仕組みが要る

また、flockはデッドロックを検出しません。1つのジョブが複数のロックを順に取る構成にすると、互いを待って止まる可能性があります。ジョブごとに1本のロックに留めておけば、この問題は起きません。

Claude Codeにこの修正を任せるときの権限

ここまでの修正を、Claude Codeに任せて行わせることもできます。その場合、実行コマンドの権限が1点つまずきやすくなります。

Bashルールの照合では、timeout・time・nice・nohup・stdbufなどのラッパーは取り除かれてから判定されます。一方で、watch・setsid・ionice・flockは、プレフィックスのルールで自動承認できないコマンドとして挙げられています。Manualモードでは確認プロンプトが出ます。

つまり、Bash(claude *)を許可していても、flock -n lock claude ...の形は通りません。承認したい特定の呼び出しがあるなら、*を含まない完全一致のルールを書きます。

{
  "permissions": {
    "allow": [
      "Bash(flock -n -E 75 /tmp/overlap-test.lock sleep 30)"
    ]
  }
}

検証用の呼び出しだけを個別に許可し、本番のcron行はclaudeの外側(crontab)でflockが包む構成にしておくのが単純です。承認できる人がいない非対話の実行(定期ジョブなど)には--permission-prompts noneがあり、v2.1.259以降で使えます。このフラグを付けると、確認が必要になる呼び出しは許可されず拒否され、Claudeには「承認できる人がいないので再試行しない」と伝わって実行は続きます。つまり、flockをclaude -pの内側に置いた形は、許可ルールに完全一致で書いていない限り通りません。

作業を任せるときの依頼文は、確かめる手順まで含めると結果を読みやすくなります。

crontabのdaily-report行をflock -n -E 75で包んでください。
修正後、sleep 30を2本重ねる検証を実行し、2本目の終了コードが75になることを
出力で見せてください。claude本体は呼ばないでください。

Claude Codeが実際に実行した終了コードを読み取れるので、「直しました」という報告だけで終わらず、効いたことの根拠が会話に残ります。

まとめ

重なりの防止は、cronの行をflock -n -E 75で包む1手から始まります。スキップした回の終了コードを変えておけば、失敗の通知と混ざりません。止まったままのジョブにはtimeoutで上限を付けておくと、以降の回が飛ばされ続ける事態を避けられます。

仕上げは、sleepの代用ジョブで重なりを作り、75が返ることを目で確かめる手順です。実行間隔の決め方はcron式のリファレンスに、claude -pの呼び出し方は非対話実行の解説にあります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/permissions
  2. code.claude.com/docs/en/headless
  3. man7.org/linux/man-pages/man1/flock.1.html
この記事を共有:XはてブLinkedIn