こんにちは、インフラエンジニアのryuです。
手元で叩けば問題なく動くスクリプトを、crontab に登録する。翌朝ログを見ると、何も起きていない。
この経験、一度はないでしょうか。私も新人のころ、原因が分からず小一時間ほど同じ行を眺めていたことがあります。
定期実行は、インフラの仕事のなかでも地味な部類です。ですが、バックアップもログの掃除も証明書の更新も、たいていは定期実行の上に乗っています。ここが静かに止まっていると、気づいたときには手遅れ、ということが起こります。
今日は cron と systemd timer を並べて、それぞれが何をしてくれるのか、失敗したときにどこを見ればいいのかを整理してみます。
cronで動かないとき、たいてい原因は環境です¶
cron は古くからある仕組みで、いまも多くのサーバーで現役です。1行書けば決まった時刻に処理が走るという手軽さは、やはり強いところです。
ただ、その手軽さの裏側で、いくつもの前提が省略されています。省略された前提に気づかないまま登録すると、静かに動かないという結果になります。
まず、いちばん多い失敗から見ていきます。
シェルで実行すれば動くのに、cron に登録すると動かない。このとき、スクリプトの中身が悪いことはほとんどありません。
cronが用意する環境は、ログイン時と別物です¶
crontab のマニュアルには、cron がジョブを実行するときに設定する環境変数が書かれています。SHELL は /bin/sh、HOME と LOGNAME は /etc/passwd の記述から、そして PATH は /usr/bin:/bin です。
ここが決定的です。普段ログインしたときの PATH には、/usr/local/bin や /opt 配下、あるいは各種のバージョン管理ツールが追加されています。cron にはそれがありません。
つまり cron は、ログインしたときとはまったく別の環境でジョブを走らせます。docker や aws や python3 を素のコマンド名で書いていると、cron からは見つからないのです。
# 手元では動く
$ which aws
/usr/local/bin/aws
# cron の PATH には /usr/local/bin が無いので見つからない
# → "aws: command not found" が root 宛のメールに届くだけで、ログには残らない
対処はふたつあります。スクリプト内でコマンドを絶対パスで書くか、crontab の先頭で PATH を明示することです。
# crontab の冒頭で環境を明示しておく
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * /usr/local/bin/backup.sh
もうひとつ、.bash_profile や .bashrc が読まれない点も覚えておいてください。cron はログインシェルを起動しないので、そこで定義した環境変数やエイリアスは一切効きません。
ここで多いのが、スクリプトの1行目に書いたシェルの指定を信じてしまう誤解です。#!/bin/bash と書いてあっても、そのスクリプトを呼び出すときの環境までは変わりません。シェルが何であれ、渡される PATH は cron が決めたものです。
もうひとつ、ユーザーごとの crontab とシステム側の設定では、書式が少し違う点も混乱のもとになります。切り分けるときは、自分がどちらを触っているのかを先に確かめておくと迷いません。
出力の行き先を決めていないと、失敗が見えません¶
cron のもうひとつの罠が、出力の扱いです。
ジョブが何かを標準出力や標準エラーに書くと、cron はそれをメールで送ろうとします。ところが多くのサーバーでは、メールの送信設定がありません。結果として、エラーメッセージはどこにも残らずに消えます。
ですから、出力は明示的にファイルへ落とします。
# 標準出力と標準エラーの両方をログに残す
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
この1行を足しておくだけで、調査の難易度がまるきり変わります。ログの読み方そのものに不安があるなら、ログの読み方を扱った記事も合わせてどうぞ。
なお、crontab の基本的な書き方や設定場所については、crontabの設定方法を解説した記事にまとまっています。書式を忘れたときはそちらが早いです。
systemd timerは、何が違うのでしょうか¶
いまのLinuxディストリビューションには、cron とは別に systemd timer という仕組みがあります。
考え方が少し違います。cron は「時刻が来たらコマンドを実行する」ものですが、systemd timer は「時刻が来たらサービス(unit)を起動する」ものです。実行したい処理は .service として定義し、それを起こす役目を .timer が担います。
2つのファイルに分かれるぶん、最初は面倒に感じます。ですが、この分離が効いてきます。
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
OnCalendar の書き方も、cron の5つの数字とは考え方が違います。曜日、年月日、時分秒という並びで、人が読める形に近くなっています。daily や weekly といった短い書き方も使えますし、Mon..Fri 09:00 のように曜日の範囲を指定することもできます。
書いた式が本当に意図どおりかを確かめる手段が用意されているのも、地味にありがたいところです。systemd-analyze calendar に式を渡すと、次に実行される時刻を教えてくれます。cron の式を暗算で検算していた時間が、ここで浮きます。
そして、timer は定義しただけでは動きません。systemctl enable --now backup.timer のように有効化して初めて起動します。ここを忘れて「設定したのに動かない」となるのは、最初によくやる失敗です。
電源が落ちていた時間を、取り戻せます¶
Persistent=true は、cron には無い機能です。
深夜3時に実行する設定で、その時刻にサーバーが止まっていたとします。cron なら、その日の実行は単に行われません。systemd timer は、次に起動したときに取りこぼした実行を1回走らせてくれます。
ノートPCや、夜間に停止する検証サーバーでは、この差が大きく出ます。
月末の集計のように、その日に必ず走ってほしい処理でも効いてきます。メンテナンスのために夜間停止していた、という理由で集計が丸ごと抜けるのは避けたいところです。cron でこれを補おうとすると、前回の実行時刻を自分で記録して比較する仕組みが必要になります。
実行時刻を、わざとばらけさせられます¶
RandomizedDelaySec=300 は、実行を0〜300秒のあいだでランダムにずらす指定です。
サーバーが1台なら意味はありません。ですが、同じ設定を100台に配った場合、cron だと全台がきっかり3時00分00秒に動き出します。バックアップ先のストレージや、パッケージの配信サーバーに負荷が集中します。
数分ばらけさせるだけで、この山がなだらかになります。台数が増えるほど効いてくる仕組みです。
cron 側にも、取りこぼしへの対策がまったく無いわけではありません。anacron という仕組みがあり、/etc/cron.daily などに置いたスクリプトについては、起動していなかった日のぶんを後から実行してくれます。ただし対象は日次以上の粒度に限られますし、時刻を細かく指定する用途には向きません。自分で書いた crontab の行は、この恩恵を受けられないという点も押さえておきたいところです。
ログが最初から構造化されています¶
systemd timer の実行結果は、そのまま journal に記録されます。
# 登録されているタイマーと、次回・前回の実行時刻を一覧する
systemctl list-timers --all
# そのジョブのログだけを時系列で見る
journalctl -u backup.service --since "3 days ago"
# タイマー自体の状態を確認する
systemctl status backup.timer
「前回いつ動いたか」「次はいつ動くか」「どんな出力が出たか」が、この3つのコマンドで揃います。cron でこれを実現しようとすると、自分でログを設計する必要があります。
systemd のユニットが思ったとおりに動かないときの調べ方は、systemdをsystemctlとjournalctlで読み解く記事で詳しく扱っています。timer のトラブルも、たどる手順はほぼ同じです。
実行ユーザーと作業ディレクトリも確認しておく¶
環境変数と並んで見落としやすいのが、誰の権限でどこから実行されるかです。
crontab -e で書いた行は、その編集したユーザーの権限で動きます。一方、/etc/crontab や /etc/cron.d/ に置く場合は、実行ユーザーを列として書く形式になります。同じ内容に見えて置き場所が違うと動かない、という事故はここから生まれます。
作業ディレクトリも要注意です。cron はホームディレクトリを起点にジョブを開始します。スクリプトのなかで相対パスを使っていると、手元で実行したときとは違うファイルを読みにいくことになります。
この種の問題は、原因を推測しても当たりません。確実なのは、事実を書き出させることです。
# 何が起きているかを、まずログに吐かせて確かめる
* * * * * { id; pwd; env; } >> /tmp/cron-debug.log 2>&1
1分おきに実行して結果を見れば、実行ユーザーも作業ディレクトリも環境変数も一度に分かります。確認が終わったら、この行は必ず消しておきましょう。切り分けの進め方そのものは、Linuxのトラブルシューティング手順の記事の考え方がそのまま使えます。
では、どちらを使えばいいのでしょうか¶
比較すると、こんな形になります。
| 観点 | cron | systemd timer |
|---|---|---|
| 書く量 | 1行で済む | ファイル2つ |
| 実行環境 | PATH が最小限で事故りやすい |
unit で明示的に定義する |
| 取りこぼし | 停止中の分は実行されない | Persistent=true で復帰時に実行 |
| 負荷の分散 | 仕組みが無い | RandomizedDelaySec で自動 |
| ログ | 自分で設計する | journal に自動で残る |
| 多重起動の防止 | 自分でロックを作る | 同じ unit は重ならない |
| 学習コスト | ほぼ不要 | systemd の理解が必要 |
表にすると systemd timer が優れているように見えますが、実際はそう単純ではありません。cron の「1行で書ける」という手軽さは、それ自体が大きな価値です。設定を読む人が少ない作業なら、短く書けることがそのまま運用のしやすさにつながります。
私の使い分けはこうです。
数行のスクリプトを1台のサーバーで回すだけ、しかも失敗してもすぐ気づける類の処理なら、cron で十分です。わざわざ unit を2つ書く理由がありません。
一方、次のような条件に当てはまるなら、systemd timer を選びます。
- 失敗したら困る処理(バックアップ、証明書の更新、集計バッチ)
- 複数台に同じスケジュールで配る処理
- 前回の実行が長引いたときに、重なって走ると困る処理
- 実行の記録を後から追いたい処理
逆に言えば、この条件に当てはまらない処理まで systemd timer へ寄せる必要はありません。移行にも学習にも時間がかかりますし、チームの誰もが読めない設定は、それ自体が属人化の種になります。仕組みの新しさではなく、その処理が止まったときの困り具合で選ぶのが素直です。
多重起動の話は、見落とされがちです¶
最後の観点について、少しだけ補足します。
毎時0分に実行する処理が、あるとき70分かかったとします。cron は前のプロセスの状態を見ないので、次の時刻になれば構わず2つ目を起動します。同じファイルを2つのプロセスが同時に書き換えて、壊れる。この事故は、忙しくなってきたサーバーで突然起こります。
systemd timer なら、同じ .service がすでに動いている場合、新しい起動は行われません。ロックファイルを自分で管理しなくてよいぶん、事故が減ります。
cron で同じことをするなら、flock を挟みます。
# 既に動いていれば何もせず終了する
0 * * * * /usr/bin/flock -n /var/lock/mybatch.lock /usr/local/bin/mybatch.sh
この1行を覚えておくと、既存の cron を安全にできます。
動かないときに見る順番¶
どちらを使っていても、止まったときの調べ方には順番があります。
| 手順 | cron の場合 | systemd timer の場合 |
|---|---|---|
| 登録の確認 | crontab -l で行があるか |
systemctl list-timers に出るか |
| 起動の確認 | /var/log/cron や journalctl -u crond |
journalctl -u 対象.service |
| 環境の確認 | env の内容をログへ出す |
unit の Environment= を確認 |
| 処理の確認 | 同じコマンドを手で実行する | systemctl start 対象.service で手動起動 |
この順番を守るだけで、調査にかかる時間が大きく変わります。とくに手動起動は効果的です。systemctl start で走らせて同じ失敗が再現するなら、タイマーではなく処理そのものの問題だと即座に切り分けられます。
いきなりスクリプトの中身を疑わないことです。まず「呼ばれているのか」を確定させる。呼ばれていないのか、呼ばれて失敗しているのかで、見るべき場所がまったく変わります。
ディスクが埋まって書き込めずに落ちているケースもあります。そのあたりの切り分けは、Linuxの容量トラブルを切り分ける記事が参考になります。
こうした定期実行の設計は、新人に任せる範囲を考えるうえでも扱いやすい題材です。新人インフラエンジニア研修にセキュリティを組み込む記事でも触れましたが、壊しても安全な場所で先に練習させておくと、本番での事故がぐっと減ります。
まとめ¶
要点を振り返ります。
cron で動かないときは、まず環境を疑います。PATH は /usr/bin:/bin しかなく、.bashrc も読まれません。そして出力を捨てていると、失敗の痕跡すら残りません。
systemd timer は、Persistent で取りこぼしを拾い、RandomizedDelaySec で負荷をばらし、journal に記録を残してくれます。同じ unit の多重起動も防いでくれます。
そのうえで、軽い処理は cron、止まると困る処理は systemd timer、という分け方をしておくと迷いません。既存の cron を全部置き換える必要はなく、大事なものから順に移していけば十分です。
もうひとつ、どちらを選ぶにしても効くことがあります。ジョブの最後に「正常に終わった」という記録を残すことです。
失敗のログだけを見ていると、ジョブがそもそも呼ばれなくなったことに気づけません。成功の記録があれば、それが途絶えた時点で異常だと分かります。監視の対象を「エラーが出ていないこと」ではなく「成功が続いていること」に変える、という発想です。
定期実行は、うまく動いているあいだは誰の目にも触れません。だからこそ、止まったときに気づける形にしておくこと。それが、この仕組みといちばん上手に付き合う方法だと思います。
もしLinuxの仕組みを順番に積み上げ直したいと感じたら、InfraAcademy のLinuxの学習ロードマップをのぞいてみてください。systemd やプロセスの話は、土台が固まっているほど理解が早くなります。



