はじめに
こんにちは、mori (@morimori) です。
突然ですが、systemd timer って知っていますか? お恥ずかしながら、私は最近まで知りませんでした。
もともと Unix / Linux を中心にサーバエンジニアをやってきたのですが、定期実行といえば長らく cron 一択。そこから知識がアップデートできていませんでした。最近は AI と壁打ちしながら作業することが多く、「そんなやり方があるのか!」と気づかされることがよくあります。systemd timer もそのひとつでした。
きっかけは、Forgejo(セルフホストの Git)の Runner ホストで、Docker のゴミを定期削除する仕組みが必要になったこと。cron の代わりに systemd timer で組んでみたら、これが思いのほか快適でした。せっかくなので、今日は systemd timer を深掘りしていきます。本記事ではそのやり方を TIPS としてまとめます。
ちなみにこの Forgejo、いま会社で活用を進めているものです。セルフホストに乗り換えてライセンスコストを削減する取り組みで、詳しくは会社ブログにまとまっています。
systemd timer とは
ざっくり言うと、systemd の仕組みで定期実行するためのもので、cron の代わりになります。2 つのユニットを組み合わせて使います。
- service ユニット: 何を実行するか
- timer ユニット: いつ実行するか
timer が時間になったら対応する service を起動する、という関係です。実行した結果(標準出力・エラー)は journald に流れ、journalctl でいつでも確認できます。
cron が「時刻 + コマンド」を 1 行に詰め込むのに対し、systemd timer は 「いつ」と「何を」を別々のユニットに分けるのが特徴です。
cron と何が違うのか
同じことは cron でもできます。それでも systemd timer を選ぶ理由は、ざっと挙げてもこれだけあります。
-
ログが自動で残る: 標準出力・エラーが journald に保管され、
journalctl -u <service名>で確認できる。ローテーションも自動。cron のようにメール転送やファイルへのリダイレクト + logrotate を自前で用意しなくていい -
実行結果を追跡できる: 成功 / 失敗・終了コード・前回の実行時刻を systemd が記録する。
systemctl status <service名>で直近の結果がすぐ見えるので、「動いてるつもりで止まっていた」を防げる -
取りこぼしをキャッチアップ:
Persistent=trueを付けると、マシン停止中に実行時刻を逃しても起動後に 1 回だけ補完実行してくれる -
依存関係を書ける:
After=docker.service/Requires=で「あのサービスが起きてから動かす」という順序・依存を宣言できる。cron にはない強み -
実行時刻を散らせる:
RandomizedDelaySec=で起動時刻を一定範囲でランダムにずらせる。多数のホストで同じジョブを回すとき、一斉実行による負荷の山を避けられる -
手動テストが楽: 「何を」が service として独立しているので、
systemctl start <service名>でスケジュールを待たずに単発実行・動作確認できる -
管理が一貫する: 起動・停止・状態確認はすべて
systemctl。他のサービスと同じ流儀で扱える - cron 非標準の環境でも動く: ディストリビューションによっては cron(cronie)が入っていないが、systemd があればそのまま使える
「1 行書いて終わり」の手軽さは cron に分があります。ただ、ログ・実行結果・取りこぼし対応まで含めてきちんと回したい定期ジョブなら、timer のほうが圧倒的に扱いやすいです。
実装してみる
例として、Forgejo Runner ホストに溜まる Docker のゴミ(停止コンテナ・未使用イメージ・ビルドキャッシュ)を毎日 03:30 に掃除する設定を作ります。実行内容の部分は、任意のコマンドに置き換えて構いません。
1. タイムゾーンを JST にする
timer の OnCalendar はシステムのタイムゾーンで解釈されます。先に JST にしておくと、指定がそのまま JST になります。
timedatectl status # 現在のタイムゾーン確認
sudo timedatectl set-timezone Asia/Tokyo
2. 実行内容をスクリプトにする
sudo tee /usr/local/bin/forgejo-runner-cleanup.sh > /dev/null <<'SCRIPT'
#!/bin/bash
# 停止コンテナ・未使用イメージ・ビルドキャッシュを掃除する
# 出力は systemd journal に残る
set -u
echo "===== $(date '+%F %T %Z') cleanup start ====="
docker container prune -f
docker image prune -a -f
docker builder prune -a -f
docker system df
echo "===== $(date '+%F %T %Z') cleanup end ====="
SCRIPT
sudo chmod +x /usr/local/bin/forgejo-runner-cleanup.sh
3. service ユニット(何を実行するか)
# /etc/systemd/system/forgejo-runner-cleanup.service
[Unit]
Description=Forgejo Runner cleanup
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/forgejo-runner-cleanup.sh
Type=oneshot は「実行して終わる」処理向けです。After / Requires で docker に依存させています(docker を使わないジョブなら不要)。
4. timer ユニット(いつ実行するか)
# /etc/systemd/system/forgejo-runner-cleanup.timer
[Unit]
Description=Run cleanup daily at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
Unit=forgejo-runner-cleanup.service
[Install]
WantedBy=timers.target
service と同名(拡張子だけ違う)にしておくと、timer が自動でその service を起動します。Persistent=true が取りこぼしキャッチアップのポイントです。
5. 有効化
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner-cleanup.timer
# 次回実行時刻を確認
systemctl list-timers --all | grep forgejo
NEXT に次回実行時刻が出ていれば成功です。
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-05-25 03:30:00 JST 14h left - - forgejo-runner-cleanup.timer forgejo-runner-cleanup.service
6. 動作確認
スケジュールを待たず、手動で 1 回流せます。
sudo systemctl start forgejo-runner-cleanup.service # 手動実行
sudo journalctl -u forgejo-runner-cleanup.service --since "1 hour ago" # ログ確認
cron と違い、実行ログがそのまま journald に残るので、確認はこれだけです。
まとめ
- systemd timer は cron に代わる定期実行の仕組み。「service(何を)」+「timer(いつ)」の 2 ユニットで組む
- cron との主な差は、ログ・実行結果が残る/
Persistent=trueで取りこぼしをキャッチアップ/依存関係を書ける/systemctlで一貫管理できること - 手軽さは cron、運用しやすさは timer。きちんと回したい定期ジョブなら timer がおすすめ
個人的には、依存関係を組めるのが cron にはない一番のポイントだと感じました。あわせて、ログが標準で残るおかげで「リダイレクトの設定を忘れていて何も記録されていなかった」といった漏れがないのも、地味にうれしいところです。
世の中的にも cron から systemd timer へ移っていく流れのようで、こうした細かいところも、私自身どんどんアップデートしていかないといけませんね。私と同じように、これまで定期実行は cron 一択だったという方の参考になれば幸いです。