cron から systemd timer へ移行して得た3つの気づき(Raspberry Pi 実例付き)
先月末、Raspberry Pi 5 で回していた cron ジョブ 12本のうち、3本が 2週間前から無言で止まっていたのに気づいた。原因はシェルスクリプト内の相対パス。環境変数が違うだけで cron は何も言わずにこける。メールも飛ばない。ログも薄い。
その日のうちに systemd timer への移行を決めた。
結論から書くと、移行して良かった。ただし全部 timer に置き換えるのは正解ではなかった、というのも正直な感想。この記事では移行の動機、書き換え手順、つまずいたポイントを実例で共有する。
cron が静かに死ぬ問題
cron の設計は枯れていて美しい。だからこそ、動かなくなっても静かに死ぬ。
自分がハマった3つのパターン。
- スクリプト内で
python3呼び出しに venv を読み込んでおらず、モジュール import でこける。stderr は MAILTO 設定しないと闇に消える - ロック取得に失敗して早期 return しているが、その return を成功扱いしていた
- ディスク逼迫で一時ファイルが書けず、ジョブだけが失敗し続けている
全部、運用者のミス。ただ、cron の側に「失敗を前提とした仕組み」が薄い、という側面もある。
以前書いたClaude Code を cron で自動実行するときに気をつけることでも触れたが、cron で何かを無人運用するなら、ロギングと通知を別レイヤーでがっちり固める必要がある。
systemd timer の基本
systemd timer は .timer ユニットと .service ユニットの2つを書く。呼び出しは timer、実際の処理は service、という分離だ。
最小構成はこう。
# /etc/systemd/system/daily-backup.service
[Unit]
Description=Daily backup job
[Service]
Type=oneshot
User=trader
WorkingDirectory=/home/trader/backup
ExecStart=/home/trader/backup/run.sh
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/daily-backup.timer
[Unit]
Description=Run daily backup at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=60
[Install]
WantedBy=timers.target
有効化はこの2行。
sudo systemctl daemon-reload
sudo systemctl enable --now daily-backup.timer
Persistent=true の破壊力
Persistent=true は地味に強い。Raspberry Pi の電源が落ちていた時間帯に実行予定があった場合、起動直後に取りこぼしを自動で一回実行してくれる。
cron だとこれをやるのに anacron を足す、もしくはスクリプト側で last-run ファイルを見るロジックを自作することになる。最初から組み込まれているのは地味にありがたい。
RandomizedDelaySec で負荷分散
ジョブを複数持っていると、同じ分に起動が集中しがち。RandomizedDelaySec=60 を足すだけで開始がランダムに散る。しょうもない工夫だけど効く。
cron との決定的な違い 3つ
1. ログが journal に勝手に溜まる
これが一番でかい。StandardOutput=journal にしておけば、スクリプトが echo で吐いたものも含めて journalctl -u daily-backup.service で全部見られる。
journalctl -u daily-backup.service --since "1 hour ago"
journalctl -u daily-backup.service -f # tail -f 的な使い方
cron の場合、自分でリダイレクトを書かないとログは消える。書いたら書いたでローテーションを自分で管理することになる。journal は勝手に圧縮してローテしてくれる。
2. 依存関係と前提条件が書ける
ネットワークが上がってから実行したい、というケースは意外と多い。systemd なら [Unit] セクションに After=network-online.target を書くだけで済む。
[Unit]
Description=API sync job
After=network-online.target
Wants=network-online.target
cron で同じことをやろうとすると、スクリプト内で ping ループを書くことになる。あれ、地味に嫌いなんですよね。
3. 失敗検知と自動リスタート
Restart=on-failure と RestartSec=30 を足せば、失敗時に 30秒後に自動再実行させられる。oneshot タイプでも使える。
さらに、失敗を ntfy.sh やメールに流したいなら、OnFailure=notify-failure@%n.service でフォールバックユニットを指定すれば、失敗時だけ別のサービスが走る。これは cron には真似できない。Raspberry Pi を無人運用するなら、このあたりの仕組みは一度組んでおくと安心感が段違い。
実際の移行ワークフロー
自分の Pi 5 の crontab には12本のジョブが並んでいた。全部を一気に移行しようとすると必ずどこかでミスる。1本ずつ、週に2〜3本ペースで移した。
ステップ1: crontab の棚卸し
まず何が動いているかを書き出す。
crontab -l | tee ~/cron_inventory.txt
この時点で、「あ、これもう使ってないな」というジョブが2本見つかった。移行前の整理で3割は消える、というのが自分の実感。
ステップ2: service ファイルから書く
timer を先に書きたくなるけど、service から書いた方が事故らない。systemctl start my-job.service で手動起動して、想定通り動くかを確認してから timer を足す。
ステップ3: timer を足して dry-run
systemd-analyze calendar "*-*-* 03:30:00" で次回起動時刻をプレビューできる。これ、意外と知られていないけど超便利。
systemd-analyze calendar "Mon..Fri 09:00"
# Next elapse: Mon 2026-04-20 09:00:00 JST
ステップ4: 旧 cron 行をコメントアウト
いきなり消さない。2週間は両方残して、新 timer が想定通り走っていることを journal で確認する。ここを怠ると、ジョブの二重実行で冷や汗をかく。
それでも cron が便利な場面
全部を timer にしたわけではない。以下のような用途では cron のままにした。
- ユーザー権限で軽く動かしたい雑用(ログの grep、簡単なバックアップ)
- 他のマシンで動いているものとスケジュールを揃えたい、一行で済む処理
- root 権限を使わずに書きたいワンライナー
systemd ユニットを書くのは、シンプルなジョブに対してはやりすぎ。Makefile で個人タスクを整理する記事で書いたけど、ツールは目的に合うものを選べばいい。timer 信者になる必要はない。
Raspberry Pi での運用で気づいたこと
Pi 5 で systemd timer を使うと、SD カードへの書き込み回数が気になる、という相談を何度か受けた。journal が書き込みを増やすのでは、という心配だ。
実測した範囲では、journalctl --disk-usage で見ると自分の Pi では 80MB 前後で安定している。デフォルト設定でも sane な上限が効いているので、過剰に心配する必要はなさそう。気になるなら /etc/systemd/journald.conf で SystemMaxUse=100M のように上限を明示しておけば安心。
Pi の運用全般についてはRaspberry Pi 5 をホームサーバーにするときの設定メモに細かいチューニングを書いた。そちらも合わせてどうぞ。
VPS でも同じ構成が使える
手元に Pi がなくてもこの構成は使える。自分は本番に近い処理を VPS にも載せていて、お名前.comの高性能VPS
の Ubuntu 環境でも同じ systemd timer 構成がそのまま動いている。Pi で試して VPS に持っていく、という流れが個人的に気に入っている。
移行して変わったこと
移行後2週間で、ジョブの失敗を把握できる速度が劇的に変わった。以前は「気づいたら止まっていた」だったのが、「失敗した瞬間にスマホに通知が飛ぶ」になった。
あと、ログを grep する癖がついた。journalctl -u ジョブ名 --since yesterday | grep ERROR の一行で過去24時間の異常を拾える。cron + ファイルログの頃は、どこに何が出ているかを毎回思い出していた。
一方で、ユニットファイルの記法を覚えるコストは確実にある。ここは正直、最初の数本はドキュメントを開きっぱなしで書いた。慣れれば 5分で1ユニット書ける。
自動売買システムをこの構成に載せている話をZenn の Bookで詳しく書いている。失敗時の ntfy.sh 連携や、複数ジョブの依存関係の組み方まで踏み込んで解説しているので、Pi で本格運用を考えている人は覗いてみてほしい。
最後に
cron は 1975年に生まれて、今も現役。それはすごいこと。ただ、失敗を前提にした運用が当たり前になった現代では、systemd timer の「ログと失敗通知が最初から組み込まれている」設計の方が精神衛生にいい、というのが自分の結論。
全部を置き換える必要はない。新しいジョブから少しずつ。それで十分に楽になる。