⏱️

systemd timer の RandomizedDelaySec= で同時刻一斉起動を防ぐ — Pi 5 運用記録

に公開

要点(先に結論)
systemd user timer を 10 本以上同居させると、OnCalendar= の同時刻トリガが Pi 5 では負荷スパイクと認証競合を生む。
RandomizedDelaySec= を全 timer に追加するだけで CPU ピークが 1/3 になり、Failed to acquire credential 系の偶発エラーがゼロになった、という実運用記録。

cron をやめて systemd user timer に全面移行して 1 ヶ月。timer の数が増えてきた頃に、Pi 5 で「決まった時刻だけ妙に重い」「ログに認証系の散発エラーが残る」現象に気付いた。原因は単純で、OnCalendar= を分単位で揃えていたために同時刻一斉起動(thundering herd)が起きていた、というだけの話なのだが、対策の RandomizedDelaySec= には地味な落とし穴がいくつかあるので運用記録として残しておく。

環境は Raspberry Pi 5(8GB)+ Ubuntu 24.04、systemd user session で 12 本の timer が稼働中。

1. 同時刻起動が起こすこと(Pi 5 実測)

最初に気付いたのは 17:30 ちょうどに走る daily_content.timer の挙動だった。単独で走らせると 6 分前後で終わるはずのジョブが、12〜15 分かかる日がランダムに混じる。journalctl --user --since=今日 を眺めても自身は淡々と動いているのに遅い。

systemctl --user list-timers を時系列で並べてみると、17:00 / 17:30 / 18:00 のような「キリの良い時刻」に複数の timer が重なっていた。Pi 5 で観測した影響は次の通り。

  • 17:00 ちょうどに 4 つの timer が走った日は load average の 1 分値が 3.8 まで上昇
  • journalctl --userdbus-broker: pending message timeout が散発
  • 重なった timer の実行時間は単独実行時の 1.4〜1.8 倍
  • claude -p を叩く重い service が同時に 2 本走ると、片方が API レスポンス待ちで滞留

特に厄介だったのが、Failed to acquire credential という認証系の偶発エラー。再現条件が掴めず数日悩んだが、要は systemd の credential 取得が DBus 経由で詰まっていただけで、本質はリソース競合だった。

systemd-analyze --user calendar 'Mon..Sun *-*-* 17:00:00' で次回時刻を確認できるが、複数 timer の重なりを一覧で見るツールはなく、list-timers を目視するしかない。

2. 解決策: RandomizedDelaySec=

systemd timer には OnCalendar= で指定した時刻に対してランダムな遅延を加える機能が組み込まれている。

[Timer]
OnCalendar=*-*-* 17:00:00
RandomizedDelaySec=300
Persistent=true

これだけで起動時刻が 0〜300 秒の範囲でランダムにジッタする。挙動の要点。

  • 遅延は timer ごと・起動ごとに独立してランダム決定される(同じ unit の次回は別の delay)
  • Persistent=true と併用しても問題なし(電源断後の追い実行も同様にジッタする)
  • systemctl --user list-timersNEXT には遅延を反映した最終時刻が表示される
  • マシン全体の起動直後は systemd.timer_random_delay_default の影響も受けるが、unit 側の指定が優先される

3. 落とし穴 1: ログを読む人が混乱する

「17:30 にスケジュールしたのに 17:33 に動いている」「昨日は 17:31 で今日は 17:34」が普通に起きる。自分しか見ないログでも、1 週間後の自分は他人なので「あれ、ズレてる」と一瞬迷う。

対策は単純で、各 unit の Description= または README に「実行時刻は ±N 秒のジッタあり」を明記すること。systemctl --user status で見える Description に書いておくと一番ノイズが少ない。

[Unit]
Description=Daily content generator (17:30 ±5min, randomized)

4. 落とし穴 2: 連動ジョブが壊れる

これが一番痛い。timer A の出力ファイルを timer B が読む構成にしていると、ジッタで A が B より遅れる日が必ず出る。私のケースでは「17:00 にデータ取得 → 17:05 に集計」を素朴に組んでいて、両方に RandomizedDelaySec=300 を入れた瞬間に、集計が空ファイルを掴む事故が再現した。

選択肢は 3 つある。

  1. 連動を捨てて、B 側で「最新ファイルが N 分以内に更新されているか」を待つループに書き換える
  2. A → B を 1 つの service の ExecStart 連結にしてしまう(ExecStart=A.sh && B.sh または ExecStartPost=B.sh
  3. B 側で After=A.service + Requires=A.service にして、A の完了後に B が動くようにする(ただし timer のジッタは消えない)

私は (2) を選んだ。連動するなら 1 unit にした方が状態が単純で、リトライ・通知・ログがまとまる。

5. 落とし穴 3: 全 timer に同じ delay 値を入れない

最初は楽をして全 timer に RandomizedDelaySec=300 を入れたが、これだと「分散の幅」だけは取れても、相対的な集中度はあまり下がらない。0〜300 秒の一様分布が 12 本あれば、確率的には数本がやはり近接する。

重さで段階分けすると効果が出やすい。私の配分は次のように調整した。

重さ RandomizedDelaySec
重(Claude API を叩く) daily_content, weekly_book_update 600
中(動画生成・アップロード) youtube_morning, youtube_daily 300
軽(トークンチェック、サムネ A/B) youtube_token_check, thumbnail_ab 60〜120

重いものほど大きく散らすと、CPU ピークが重ならない確率が上がる。逆に軽いものを大きく散らすと「ログのジッタ感」だけ増えて、観測コストが上がる。

6. RandomizedDelaySec を入れてはいけない timer

ジッタを許容できないケースもある。

  • 厳密な分単位の同期が必要なジョブ(取引時刻ピッタリのスナップショット、外部 API のレートリミット境界に合わせるジョブ)
  • 他のホストの timer と同期させたいジョブ
  • アラート用 timer のように「鳴った時刻自体が情報」になっているジョブ

こういう unit は RandomizedDelaySec=0(または未指定)のままにして、衝突しそうな時刻を最初から避ける設計にする方が安全。

7. 効果のまとめ

導入から 2 週間運用して観測できた変化。

  • 同時刻トリガで重なる確率が 1/3 程度に低下
  • dbus-broker: pending message timeout のログがほぼゼロに
  • Failed to acquire credential 系の偶発エラーがゼロ
  • 重い timer 単独の実行時間が単独計測値の 1.0〜1.1 倍に収束(以前は 1.4〜1.8 倍)

RandomizedDelaySec= は 1 行追加で済むので、timer が 5 本を超えた段階で入れておくと将来の事故を予防できる、というのが今回の結論。


重要事項: 本記事は技術運用の記録であり、特定の構成・スケジュールを推奨するものではありません。記載の数値は筆者の Raspberry Pi 5(8GB)+ Ubuntu 24.04 user session 環境での観測値であり、他環境での再現を保証しません。systemd のバージョンや負荷条件によって挙動は変わります。

GitHubで編集を提案

Discussion