Claude Media
Claude Codeをsystemd timerで定期実行 — OnCalendarとPersistentの書き方

Claude Codeをsystemd timerで定期実行 — OnCalendarとPersistentの書き方

claude -pをsystemd timerで定期実行するときの、OnCalendarの式、Persistentによる取りこぼしの救済、実行時刻のばらつきと重なりの扱いをまとめます。

claude -pを決まった時刻に回すなら、cronの代わりにsystemd timerを使う手があります。cronとの大きな違いは、実行時刻の書き方(OnCalendar=)と、止まっていた間の取りこぼしを拾うPersistent=にあります。この記事では.timerファイルの書き方に絞って、Claude Codeの定期実行で実際に効く指定を順に見ていきます。

最小の構成は、同名の.serviceと.timerの2ファイルです。.serviceをType=oneshotで書く手順はClaude CodeでsystemdのUnitファイルを作成する手順にあるので、ここでは.timer側の指定を扱います。

最小のtimerと、有効にする対象

foo.timerは、同名のfoo.serviceを起動します。起動先を変えたいときだけUnit=で別の名前を指定でき、指定しなければ拡張子以外が同じサービスが選ばれます。

# /etc/systemd/system/claude-report.timer
[Unit]
Description=Run claude-report.service every morning
 
[Timer]
OnCalendar=*-*-* 07:30:00 Asia/Tokyo
Persistent=true
 
[Install]
WantedBy=timers.target

enableするのはサービスでなくタイマーです。サービスだけを有効にしても、時刻になっても何も起きません。

sudo systemctl daemon-reload
sudo systemctl enable --now claude-report.timer
systemctl list-timers claude-report.timer

list-timersの表にNEXTの時刻が出ていれば、次回の予定が登録されています。表に出てこないときは、.timerをenableし忘れているのが最も多い原因です。

OnCalendarの式はどう読むか

OnCalendar=には、曜日 年-月-日 時:分:秒の形でカレンダー上の時刻を書きます。曜日は省略でき、日付を省くと*-*-*(毎日)、時刻を省くと00:00:00になります。秒を省いたときは:00が補われます。

各要素には次の書き方が使えます。

  • *: どの値にも一致する
  • 1,15: 値をカンマで並べる
  • Mon..Friや9..17: ..で範囲を指定する
  • *:0/15: /の後ろが繰り返し幅で、開始値から幅ずつ進んだ値に一致する(分なら0・15・30・45分)
  • ~: 月末から数える(*-02~03は2月の末尾から3日目)

よく使う形を、systemd.timeの説明にある正規化後の形と並べます。

書き方正規化後意味
daily正規化後*-*-* 00:00:00意味毎日0時
weekly正規化後Mon *-*-* 00:00:00意味毎週月曜0時
hourly正規化後*-*-* *:00:00意味毎時0分
monthly正規化後*-*-01 00:00:00意味毎月1日0時
05:40正規化後*-*-* 05:40:00意味毎日5時40分

Mon..Fri *-*-* 09:00:00のような平日だけの指定は、cronの0 9 * * 1-5に近い書き方です。複数の時刻を走らせたいときは、OnCalendar=を行ごとに複数書けます。どれかの式に当たるたびにタイマーが働きます。

書き方

OnCalendarで引っかかりやすい点

  • タイムゾーンは式の末尾に書く

    UTCのほか、Asia/TokyoのようなIANA形式の名前を末尾に置けます。書かなければ、ローカルのタイムゾーンで解釈されます。サーバーのタイムゾーンがUTCのまま日本時間の朝に回したいときは、末尾にAsia/Tokyoを付けます。

  • 時刻だけの式は毎日になる

    07:30と書けば、日付は*-*-*として扱われます。日付を書き忘れた式は、意図せず毎日の実行になります。

  • 曜日は英語名で書く

    MonやWednesdayのような英語の名前で、大文字小文字は区別されません。日本語の曜日は使えません。

式が自分の意図どおりか不安なときは、systemd-analyze calendarに式を渡すと、正規化後の形と次に発火する時刻を表示してくれます。登録の前に必ず一度通すと、曜日の書き間違いを防げます。

systemd-analyze calendar "Mon..Fri *-*-* 09:00:00 Asia/Tokyo"

Persistentで取りこぼしを拾う

Persistent=trueを付けると、サービスを最後に起動した時刻がディスクに保存されます。タイマーが有効化されたとき、停止していた間に予定が1回以上あれば、サービスがすぐに起動します。マシンが電源オフだった夜のレポートを、朝の起動直後に拾いたい場面で効きます。

押さえておく条件が3つあります。

  1. Persistent=が効くのはOnCalendar=を使うタイマーだけです。OnBootSec=などの相対指定では何も起きません
  2. 追いつきは「停止中に少なくとも1回予定があれば、すぐ起動する」という動きです。止まっていた日数ぶんの回数を再生する仕組みではありません
  3. 追いつきの起動にもRandomizedDelaySec=の遅延は掛かります

claude -pの定期実行では、この「1回だけ」が都合のよい挙動です。止まっていた日数ぶんを一度に叩き直すことがないため、費用の見積もりが立てやすくなります。

タイマーをアンインストールするときは、保存された時刻のファイルも消しておきます。systemctl clean --what=stateをタイマーに対して実行すると消えます。

sudo systemctl disable --now claude-report.timer
sudo systemctl clean --what=state claude-report.timer

実行時刻をずらす指定を使い分ける

意図せず時刻がずれる原因は、AccuracySec=とRandomizedDelaySec=の2つです。名前は似ていますが、目的は反対です。

くらべる

AccuracySecとRandomizedDelaySecの違い

まとめて起こす

AccuracySec

既定は1分です。指定した時刻から、この長さまでの窓のどこかで発火します。窓の位置はホストごとに決まり、複数のタイマーで揃います。CPUの起床回数を減らすための設定で、秒単位で正確に走らせたいときは1usを指定します。

ばらけさせる

RandomizedDelaySec

既定は0です。0から指定値までの乱数ぶん、発火を遅らせます。同じ設定のタイマーが一斉に動いて負荷が集中するのを避けるための設定です。

複数台から同じ時刻にClaude Codeを呼ぶ構成では、RandomizedDelaySec=を数分から数十分に設定すると、同時起動の集中が和らぎます。ただし、再起動のたびに遅延が引き直されるため、遅延が長いと「起動のたびに延びて、いつまでも走らない」という問題が起きえます。週次で動かすノートPCのように、再起動が頻繁な環境では、RandomizedOffsetSec=のほうが向いています。こちらは固定のオフセットを足すだけなので、周期が再起動で崩れません。ただしRandomizedOffsetSec=はsystemd 258で追加された設定で、古いディストリビューションでは使えません。

次のように書けば、毎朝7時台のどこかに分散させつつ、1台の中では毎日同じ位置に固定できます。

[Timer]
OnCalendar=*-*-* 07:00:00 Asia/Tokyo
RandomizedOffsetSec=1h
Persistent=true

使っているsystemdの版はsystemctl --versionで確かめられます。

claude -pが次の予定より長引くとき

claude -pは、プロンプト次第で数分から数十分かかります。ここで効くのが、.timerの仕様に書かれた2つの挙動です。

  • 起動先のサービスがすでに動いているときは、再起動されず、そのまま走り続けます。新しいインスタンスは増えません
  • 実行が予定の間隔より長引いた場合、既定ではサービスの終了直後にすぐ次の実行が始まります

後者は、OnCalendar=の次回予定が前回の発火時刻を基準に決まるためで、過去になった予定を終了後に消化するからです。毎分のように短い間隔でclaude -pを回していると、長い実行のあとに連続して走る形になります。これを避けるのがDeferReactivation=trueです。有効にすると、次回の予定をサービスが終了した時点を基準に決め直すので、終了直後の連続実行がなくなり、次のカレンダー時刻まで待ちます。この設定はsystemd 257で追加されました。

[Timer]
OnCalendar=*:0/15
DeferReactivation=true

RemainAfterExit=yesのサービスは、タイマーの起動先に向きません。プロセスが終わっても「動いたまま」と扱われるため、一度起動したあとは二度と起動されなくなります。claude -pは終了する処理なので、Type=oneshotにしてRemainAfterExit=は付けない形にします。

失敗がどこに出るか

claude -pは成功すると終了コード0で終わり、実行中に失敗した場合は0以外で終わります。Type=oneshotのサービスでは、この終了コードがそのままサービスの成否になります。認証が切れていたような失敗は、標準出力に結果として出ます。

そのため、journalctl -u claude-report.serviceで出力を、systemctl status claude-report.serviceで成否を見るのが基本の確認です。無人の実行では、権限の確認で止まらないように--permission-prompts noneも候補になります。確認が必要な操作は拒否され、Claudeには再試行しないよう伝わります。このフラグはv2.1.259以降で使えます。認証は--bareを使うならANTHROPIC_API_KEYが必要で、サブスクリプションのログインは読まれません。

SIGTERMで止めた場合は、終了コード143になり、実行中だったターンの結果は記録されません。タイムアウトや手動のkillで止める運用では、この点を頭に置くと、失敗ログを読み違えにくくなります。

失敗の通知や、「実行されていない日」を履歴から拾う方法は、Claude Codeのcron失敗を調査する手順と失敗時だけ通知する書き方にまとめています。claude-code-actionをcronで回す構成はclaude-code-actionをcronで回し、毎朝のコミットとIssueを要約するが扱っています。

クロックが合っていないとき

OnCalendar=を持つタイマーは、システムの時計が正しく設定されるまで起動を待ちます。time-sync.targetの後に並ぶ依存が自動で付くためです。バッテリー駆動のRTCを持たないマシンでは、systemd-time-wait-sync.serviceを有効にして、時刻が同期されてから予定が組まれるようにしておくとよいとされています。

小型ボードなどで夜間のジョブを回すときは、NTPの同期を先に確かめておくと安全です。

サスペンドからの復帰では挙動が分かれます。スリープ中にカレンダー上の予定が過ぎた場合、復帰後に追いつきの起動が1回だけ行われます。スリープ中でも起こしてほしいならWakeSystem=trueですが、権限が必要で、基本的にはシステム側のサービスマネージャーでのみ使えます。

設定を入れる前の点検

timerを有効にする前の確認

  • .timerをenableしたか(.serviceだけでは動かない)
  • OnCalendar=の式をsystemd-analyze calendarに通したか
  • タイムゾーンを式の末尾に書いたか
  • Persistent=trueを付けるかを、取りこぼしの扱いから決めたか
  • 実行が間隔より長引くならDeferReactivation=trueを検討したか
  • サービスはType=oneshotで、RemainAfterExit=を付けていないか

まとめ

claude -pの定期実行では、OnCalendar=で時刻を決め、Persistent=trueで停止中の取りこぼしを1回だけ拾う形が基本になります。複数台で動かすならRandomizedOffsetSec=、長引く実行にはDeferReactivation=trueと、使えるsystemdの版に応じて足していく構成が現実的です。cronの5つのフィールドより長く書く手間はありますが、systemd-analyze calendarで次回時刻を確認でき、journalctlで実行履歴まで追える点は、無人のAI実行では大きな利点です。

参照した一次ソース

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

  1. freedesktop.org/software/systemd/man/latest/systemd.timer.html
  2. freedesktop.org/software/systemd/man/latest/systemd.time.html
  3. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn