はじめに — なぜ今またタイマーなのか
最近、Hacker Newsのフロントページに「You don't love systemd timers enough」という記事が上がり、数百件のコメントが付くほど話題になりました。タイトルからして挑発的です。systemd論争はLinuxコミュニティの永遠のネタですが、この記事の主張は意外なほど素直なものでした。「cronでスケジューリングして味わう苦痛の大半は、systemdタイマーが10年前にすでに解決している。なのになぜ今もcrontabを開いているのか?」というわけです。
コメント欄の反応も興味深いものでした。「タイマーユニットを2つ作るのはcrontab1行より面倒だ」という反論と、「その1行が静かに失敗していることに半年後に気づくんだ」という再反論がぶつかり合いました。2026年現在、AIコーディングエージェントがサーバー設定ファイルまで生成してくれる時代になり、「ユニットファイル2つの作成」のコストは実質ゼロに収束しました。残るのは運用品質の差だけです。
この記事では、cronの構造的限界から始めて、systemdタイマーのユニット構造、OnCalendar文法、見逃した実行のキャッチアップ、失敗通知、ユーザータイマー、そしてcronからタイマーへの移行マッピングまで、実務の観点から整理します。
cronの構造的限界
cronは1975年に生まれたツールです。50年生き延びたこと自体が偉大ですが、現代の運用環境では次の問題が繰り返し足を引っ張ります。
1. ロギングの不在
cronジョブの出力はデフォルトでローカルメールとして送信されます。MTAが設定されていないサーバー(ほとんどのクラウドインスタンス)では、出力はそのまま消えます。だからみんなこう書くわけです。
crontabのよくある光景 — ログを手動でファイルに押し込む
0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
この方式の問題は明白です。ログローテーションを別途管理しなければならず、タイムスタンプもスクリプト自身が出力する必要があり、「最後の実行が成功したか」を知るにはログファイルをパースするしかありません。
2. 依存関係を表現できない
「ネットワークが上がった後に」「マウントが完了した後に」「他のサービスが動いている時だけ」といった条件をcronは表現できません。その結果、スクリプトの中にsleepとリトライループが入り込み、スケジューラがやるべき仕事をシェルスクリプトが背負い込むことになります。
3. 見逃した実行は永遠に失われる
深夜3時にバックアップが動くよう設定したのに、その時間にサーバーが落ちていたら? cronは何もしません。anacronという補完策はありますが、日単位の解像度という限界があり、もう一つ別のツールを管理する必要があります。
4. 環境変数の罠
cronはほぼ空の環境変数でジョブを実行します。PATHが違うせいで「ターミナルでは動くのにcronでは動かない」というデバッグ地獄は、すべてのシステム管理者の通過儀礼です。
5. 同時実行制御の不在
前回の実行がまだ終わっていないのに次のスケジュールが到来すると、cronはそのままもう一度実行します。flockで包むイディオムが必要になります。
この5つを表にまとめるとこうなります。
| 問題 | cron | systemdタイマー |
| --- | --- | --- |
| ロギング | 手動リダイレクト | journaldが自動収集 |
| 依存関係 | 不可能 | After、Requiresなどのユニット依存 |
| 見逃した実行 | 消失 | Persistent=trueでキャッチアップ |
| 環境 | 空の環境、PATHの罠 | ユニットファイルに明示的に定義 |
| 同時実行 | flockで手動処理 | サービスユニットはデフォルトで単一インスタンス |
| 失敗通知 | メール(通常未設定) | OnFailureハンドラ |
| リソース制限 | 不可能 | CPUQuota、MemoryMaxなど |
タイマーユニットの構造 — .timerと.serviceのペア
systemdタイマーは2つのユニットファイルで構成されます。「いつ」を定義する.timerユニットと、「何を」を定義する.serviceユニットです。名前が同じなら自動的にペアになります。
backup.timer ──────トリガー──────▶ backup.service
(いつ実行するか) (何を実行するか)
│ │
│ OnCalendar=... │ ExecStart=/opt/backup.sh
│ Persistent=true │ User=backup
│ RandomizedDelaySec=... │ MemoryMax=1G
▼ ▼
systemctl list-timers journalctl -u backup.service
実際の例を見てみましょう。毎日深夜3時にバックアップを実行する構成です。
/etc/systemd/system/backup.service
[Unit]
Description=Nightly backup job
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
User=backup
Nice=10
IOSchedulingClass=idle
MemoryMax=1G
TimeoutStartSec=2h
/etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
有効化はタイマー側だけで構いません。サービスはタイマーがトリガーするので、enableしません。
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
今すぐ一度実行してみたい場合はサービスを直接起動
sudo systemctl start backup.service
サービスユニットにType=oneshotを使う理由は、「実行して終わるジョブ」であることをsystemdに伝えるためです。また上の例のように、Nice、IOSchedulingClass、MemoryMaxといったリソース制御を添えれば、バックアップジョブがプロダクショントラフィックを妨害しないようにできます。cronでは夢にも見られない機能です。
OnCalendar文法の詳細
OnCalendarはcron式より読みやすく、表現力も高いです。基本形式は次のとおりです。
曜日 年-月-日 時:分:秒
(すべてのフィールドは省略可能、* はワイルドカード、/ は間隔、, は列挙、.. は範囲)
例をたくさん見るのが一番の近道です。
毎日深夜3時
OnCalendar=*-*-* 03:00:00
毎時0分
OnCalendar=hourly
上と同じ明示的表現
OnCalendar=*-*-* *:00:00
15分ごと
OnCalendar=*:0/15
平日(月〜金)午前9時
OnCalendar=Mon..Fri *-*-* 09:00:00
毎週月曜と木曜の06:30
OnCalendar=Mon,Thu *-*-* 06:30:00
毎月1日の深夜0時
OnCalendar=*-*-01 00:00:00
毎月最終日の23時 (~ 接頭辞は月末からの逆算)
OnCalendar=*-*~01 23:00:00
四半期の初日 (1,4,7,10月の1日) 深夜2時
OnCalendar=*-01,04,07,10-01 02:00:00
2時間ごと、ちょうどではなく30分に (00:30, 02:30, ...)
OnCalendar=00/2:30:00
1つのタイマーに複数スケジュール — OnCalendarを複数行書くとORで結合される
OnCalendar=Mon..Fri 09:00
OnCalendar=Sat,Sun 11:00
書いた式が意図どおりに解釈されるか、必ず検証してください。systemd-analyze calendarが次回の実行時刻まで計算してくれます。
$ systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
Original form: Mon..Fri *-*-* 09:00:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Fri 2026-06-12 09:00:00 KST
(in UTC): Fri 2026-06-12 00:00:00 UTC
From now: 14h left
次の5回分の実行時刻を一度に確認
$ systemd-analyze calendar --iterations=5 "*-*-01 02:00"
モノトニックタイマー — 起動・活性化基準の相対時間
カレンダー基準ではなく、「ある時点からどれだけ後」と定義するタイマーもあります。
[Timer]
起動15分後に初回実行、以降は前回実行の終了1時間後ごとに繰り返し
OnBootSec=15min
OnUnitActiveSec=1h
OnUnitActiveSecは「前回の実行が終わった時点が基準」なので、ジョブの所要時間がばらついても実行間隔が重なりません。ポーリング型のジョブに特に有用です。
Persistent=true — 見逃した実行をキャッチアップする
タイマーのキラー機能です。Persistent=trueを設定すると、systemdはタイマーが最後にトリガーされた時刻をディスクに記録します。システムが落ちていてスケジュールを逃した場合、次回起動直後にすぐ一度実行してくれます。
[Timer]
OnCalendar=daily
Persistent=true
ノートPCや断続的に電源が入る開発マシンで「毎日バックアップ」を保証したいとき、anacronなしで解決できます。記録は次のパスに残ります。
$ ls /var/lib/systemd/timers/
stamp-backup.timer stamp-fstrim.timer stamp-logrotate.timer
スタンプファイルのmtimeが最後のトリガー時刻
$ stat -c '%y' /var/lib/systemd/timers/stamp-backup.timer
2026-06-11 03:07:42.000000000 +0900
注意点が1つあります。起動直後にはキャッチアップ対象のタイマーが一斉に実行される可能性があります。そのため、次に説明するRandomizedDelaySecと組み合わせるのが定石です。
RandomizedDelaySecとAccuracySec
複数のサーバーが同じ時刻に一斉に外部APIを叩いたり、バックアップストレージに殺到する「thundering herd」を避けるには、遅延をランダム化します。
[Timer]
OnCalendar=daily
0〜4時間のランダム遅延 — サーバー群が時間帯に均等に分散される
RandomizedDelaySec=4h
同じマシンで毎回同じオフセットを使いたい場合 (systemd 247+)
FixedRandomDelay=true
AccuracySecは逆に「どれだけ正確に起こすか」です。デフォルト値が1分であることを知らないと驚くことになります。秒単位の精密実行が必要なら明示的に下げてください。
[Timer]
OnCalendar=*-*-* 09:00:00
デフォルト1min → 1秒単位の精度に
AccuracySec=1s
逆に省電力が重要な環境(ノートPC、組み込み)では、AccuracySecを増やしてCPUウェイクアップをまとめることができます。
モニタリング — list-timersとjournalctl
cronに対するタイマーの最大の強みは可観測性です。まず全タイマーの状態を1画面で見ます。
$ systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES
Fri 2026-06-12 03:00:00 KST 8h left Thu 2026-06-11 03:11:02 KST 12h ago backup.timer backup.service
Fri 2026-06-12 00:00:00 KST 5h left Thu 2026-06-11 00:00:01 KST 15h ago logrotate.timer logrotate.service
NEXT(次回実行)、LAST(前回実行)が一目で分かります。crontabではこの情報を得るには自分で計算するしかありませんでした。個々のジョブのログはjournaldに自動で蓄積されます。
バックアップサービスの全ログ (stdout/stderr含む)
journalctl -u backup.service
昨日以降のログのみ、新しい順に
journalctl -u backup.service --since yesterday -r
最後の実行の終了コードを確認
systemctl status backup.service
実行時間の統計が気になるなら
journalctl -u backup.service -o json | jq -r '.MESSAGE' | grep -i finished
タイムスタンプ、ログローテーション、終了コードの追跡がすべて無料で付いてきます。
失敗通知 — OnFailureハンドラ
「バックアップが静かに失敗したまま3か月経った」という事故を防ぐ核心パターンです。サービスユニットにOnFailureを掛けておけば、そのユニットが失敗状態で終わったとき、指定したユニットが起動されます。
/etc/systemd/system/backup.service に追加
[Unit]
Description=Nightly backup job
OnFailure=notify-failure@%n.service
通知ハンドラはテンプレートユニットとして作り、すべてのジョブで再利用します。%nは失敗したユニット名に置換されます。
/etc/systemd/system/notify-failure@.service
[Unit]
Description=Send failure notification for %i
[Service]
Type=oneshot
ExecStart=/opt/scripts/notify-failure.sh %i
#!/usr/bin/env bash
/opt/scripts/notify-failure.sh — Slack Webhookで失敗通知
set -euo pipefail
UNIT="$1"
HOST="$(hostname -f)"
失敗直前のログ20行を添付
LOG="$(journalctl -u "$UNIT" -n 20 --no-pager --output=cat)"
PAYLOAD="$(jq -n --arg t "[FAIL] $UNIT on $HOST" --arg l "$LOG" \
'{text: ($t + "\n" + $l)}')"
curl -sf -X POST -H 'Content-Type: application/json' \
-d "$PAYLOAD" "$SLACK_WEBHOOK_URL"
テストも簡単です。わざと失敗するサービスをトリガーしてみればよいのです。
sudo systemd-run --unit=failtest --property=OnFailure=notify-failure@failtest.service /bin/false
journalctl -u notify-failure@failtest.service
成功通知が必要なら、systemd 249から追加されたOnSuccess=も同じパターンで使えます。
ユーザータイマー — rootなしで自分のジョブを回す
個人的なジョブ(dotfilesバックアップ、メール同期、開発DB掃除)はシステム領域に触れる必要はなく、ユーザータイマーで回します。
ユニットファイルの場所
mkdir -p ~/.config/systemd/user
~/.config/systemd/user/mail-sync.service と mail-sync.timer を作成した後
systemctl --user daemon-reload
systemctl --user enable --now mail-sync.timer
systemctl --user list-timers
journalctl --user -u mail-sync.service
ここで最もハマりやすい罠がlingeringです。ユーザータイマーはデフォルトでそのユーザーのセッションが生きている間だけ動きます。SSHからログアウトすると同時にタイマーも止まります。ログイン状態と無関係に回すにはlingeringを有効にする必要があります。
ログインなしでもユーザーsystemdインスタンスを維持
sudo loginctl enable-linger "$USER"
確認
loginctl show-user "$USER" --property=Linger
サーバーで「自分のアカウントで回る個人cron」を置き換えるとき、必ず覚えておくべき1行です。
cronからタイマーへ — 移行マッピングテーブル
既存のcrontabエントリをOnCalendarに移すときの対応表です。
| cron式 | OnCalendar式 | 意味 |
| --- | --- | --- |
| `0 3 * * *` | `*-*-* 03:00:00` | 毎日03:00 |
| `*/15 * * * *` | `*:0/15` | 15分ごと |
| `0 * * * *` | `hourly` | 毎時0分 |
| `0 9 * * 1-5` | `Mon..Fri 09:00` | 平日09:00 |
| `0 0 1 * *` | `*-*-01 00:00:00` | 毎月1日の0時 |
| `0 2 1 1,4,7,10 *` | `*-01,04,07,10-01 02:00` | 四半期初日の02:00 |
| `@reboot` | `OnBootSec=1min` | 起動1分後 |
| `@daily` | `daily` | 毎日0時 |
| `@weekly` | `weekly` | 毎週月曜0時 |
同じ内容をコピーしやすい形でも残しておきます。
cron: 0 3 * * * → OnCalendar=*-*-* 03:00:00
cron: */15 * * * * → OnCalendar=*:0/15
cron: 0 * * * * → OnCalendar=hourly
cron: 0 9 * * 1-5 → OnCalendar=Mon..Fri 09:00
cron: 0 0 1 * * → OnCalendar=*-*-01 00:00:00
cron: 0 2 1 1,4,7,10 * → OnCalendar=*-01,04,07,10-01 02:00
cron: @reboot → OnBootSec=1min
cron: @daily → OnCalendar=daily
移行手順は次の順番をおすすめします。
1. crontab -lで全リストをダンプし、ジョブごとに分類します。
2. 各ジョブについて.serviceを先に作り、systemctl startでの手動実行が成功することを確認します。この段階でPATHや環境の問題をすべて潰します。
3. .timerを作り、systemd-analyze calendarで式を検証します。
4. タイマーをenableし、該当するcrontab行はコメントアウトしたまま1サイクル並行観察します。
5. journalctlで正常動作を確認した後、crontab行を削除します。
一回限りのジョブはsystemd-run — transientタイマー
ユニットファイルを作るまでもない一回限りのジョブは、systemd-runで即席タイマーを作ります。atコマンドの現代的な代替です。
30分後に一度実行
systemd-run --on-active=30m /opt/scripts/cleanup.sh
今日の23:00に一度実行
systemd-run --on-calendar="23:00" /usr/bin/systemctl restart myapp
ユーザー権限で、2時間ごとに繰り返し
systemd-run --user --on-unit-active=2h --unit=poll-feed ~/bin/poll-feed.sh
作成したtransientタイマーの確認と取り消し
systemctl list-timers
systemctl stop run-r1a2b3c4.timer
transientユニットは再起動すると消えます。「今からとりあえず一時的に」という意味に正確に合致しますね。
タイムゾーンの罠
スケジューラとタイムゾーンの組み合わせは常に事故の温床です。知っておくべきことが3つあります。
第一に、OnCalendarはデフォルトでシステムのローカルタイムゾーンとして解釈されます。サーバーごとにタイムゾーンが違えば、同じユニットファイルが違う時刻に動きます。明示的にタイムゾーンを指定できます。
[Timer]
UTCに固定 — マルチリージョンのサーバー群で推奨
OnCalendar=*-*-* 03:00:00 UTC
特定地域の時間で指定 (tzdata名を使用)
OnCalendar=*-*-* 09:00:00 Asia/Tokyo
第二に、DST(サマータイム)切り替え日には「存在しない時刻」と「2回存在する時刻」が生じます。たとえばヨーロッパのサーバーで02:30のスケジュールは、春の切り替え日にスキップされる可能性があります。重要なジョブはDSTの影響がないUTC固定が安全です。
第三に、Persistentのキャッチアップとタイムゾーン変更が重なると直感に反する動作になることがあるので、タイムゾーンを変えた後はsystemctl list-timersでNEXTが意図どおりか必ず確認してください。
式がどのタイムゾーンでいつ動くか即座に検証
systemd-analyze calendar "*-*-* 03:00:00 UTC"
timedatectl # システムのタイムゾーン確認
実践レシピ集
レシピ1 — 証明書の更新 (certbot)
ディストリビューションのパッケージが提供するcertbot.timerがすでにベストプラクティスです。自作する場合の骨格はこうです。
certbot-renew.timer
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=12h
Persistent=true
[Install]
WantedBy=timers.target
1日2回の試行+最大12時間のランダム遅延は、Let's Encryptが推奨する負荷分散パターンです。更新失敗が蓄積すると証明書切れという大事故になるので、OnFailure通知は必須です。
レシピ2 — DBバックアップとアップロードの分離
バックアップ生成とリモートアップロードを別ユニットに分けて依存関係で結べば、失敗箇所の特定が容易になります。
db-dump.service — ダンプ生成
[Service]
Type=oneshot
ExecStart=/opt/scripts/pg-dump.sh
ダンプ成功時のみアップロードサービスを起動
ExecStartPost=/usr/bin/systemctl start db-upload.service
レシピ3 — 夜間バッチ、リソース制限付き
nightly-batch.service
[Service]
Type=oneshot
ExecStart=/opt/batch/run-nightly.sh
CPUQuota=50%
MemoryMax=2G
IOSchedulingClass=idle
4時間超過で強制終了し失敗扱い → OnFailure通知が発動
TimeoutStartSec=4h
バッチが暴走してもプロダクションサービスのCPU・メモリ・IOを侵食しないよう、cgroupで隔離する構成です。
アンチパターン — こう使ってはいけない
1. **サービスユニットをenableすること。** タイマートリガーのジョブなのにサービス自体をenableすると、起動のたびに実行されます。enableは.timerだけに。
2. **Type=simpleで長時間バッチを回すこと。** oneshotでないと、systemdは「開始した」ことを即座に成功とみなし、失敗検知が鈍くなります。
3. **タイマーの中でシェル1行ですべてをやること。** ExecStartにbash -cのワンライナーパイプラインを押し込むと、cron時代の可読性問題がそのまま再現されます。スクリプトファイルに分離しましょう。
4. **Persistent=trueの乱用。** 起動直後に即実行されても安全なジョブだけに付けてください。ネットワークが必要なジョブなら、サービス側にAfter=network-online.targetも明記する必要があります。
5. **AccuracySecのデフォルト(1分)を知らずに精密スケジュールを期待すること。**
6. **ユーザータイマーでlingeringを忘れること。** SSHを切った瞬間にタイマーも死にます。
7. **モニタリングなしで移行だけすること。** タイマーに移す価値の半分はOnFailureとjournaldから生まれます。通知ハンドラのない移行は半人前です。
批判的視点 — それでもcronが正しい場合
公平を期して反対側の論拠も整理します。HNのコメント欄で説得力があった主張です。
- **移植性。** crontabはBSD、macOS、コンテナの中、どこでも同じです。systemdのない環境(alpineベースのコンテナなど)が混在する組織なら、cronで統一する方がシンプルかもしれません。
- **1行の美学。** 本当に単純なジョブ1つにファイル2つ+デーモンリロードは過剰だという感覚は正当です。ただしsystemd-runの1行で折衷できます。
- **学習コスト。** チーム全体がcron文法に慣れているなら移行コストがかかります。反論は「AIエージェントにユニットファイル生成を任せればいい」となるでしょうが、レビューできる知識は依然として人間に必要です。
- **分散スケジューリングはどちらもできない。** 複数ノードにまたがるジョブのオーケストレーションはcronもタイマーも答えではありません。その領域はKubernetes CronJobやワークフローエンジンの仕事です。
要するに、単一ホストの定期ジョブならタイマーがほぼすべての面で優れており、移植性が支配的な要件であるときだけcronが残る、というのが合理的な結論です。
おわりに
cronからsystemdタイマーへの移行は「スケジューラの交換」というより「定期ジョブの一級市民への昇格」に近いものです。ログはjournaldへ、失敗はOnFailureへ、依存関係はユニットグラフへ、リソースはcgroupへ — すでにシステムにあるインフラに定期ジョブを編入させるわけです。
今日すぐできる最初の一歩を提案します。crontab -lを開いて、最も重要なジョブを1つだけ選び、.service + .timerのペアに移してみてください。systemd-analyze calendarで式を検証し、OnFailure通知まで付ければ完了です。一度やってみれば、残りを移すのは時間の問題でしょう。
参考資料
- You don't love systemd timers enough (原文): https://blog.tjll.net/you-dont-love-systemd-timers-enough/
- Hacker News ディスカッション: https://news.ycombinator.com/
- systemd.timer 公式マニュアル: https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html
- systemd.time — カレンダーイベント文法: https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html
- systemd.service 公式マニュアル: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
- systemd-run マニュアル (transientユニット): https://www.freedesktop.org/software/systemd/man/latest/systemd-run.html
- Arch Wiki — systemd/Timers: https://wiki.archlinux.org/title/Systemd/Timers
- loginctl マニュアル (lingering): https://www.freedesktop.org/software/systemd/man/latest/loginctl.html
- systemd.exec — リソース制御オプション: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html
- GeekNews: https://news.hada.io/
현재 단락 (1/235)
最近、Hacker Newsのフロントページに「You don't love systemd timers enough」という記事が上がり、数百件のコメントが付くほど話題になりました。タイトルからし...