なぜ cron ジョブが動かないのか:気づきにくい失敗の切り分けガイド
「動かなかった」という一言は、まったく別の三つの失敗を覆い隠します。そもそも起動しなかったのか、起動したが最初の一歩で失敗したのか、正常に動いたのに見えていないだけなのか。まずはそれを切り分けます。出発点は cron のログです。
スケジュールは正しく見える。フィールドを一つずつ読み返し、ツールに貼って確かめもした。書いてあるのは、まさに意図したとおりだ。ところが、やるはずのことが起きていない。ファイルもなく、メールもなく、テーブルに行も増えていない。だからジョブは「動かなかった」ことになる。
つまずきは、その一言にある。それは症状を名づけながら、原因を勝手に決めつけている。「動かなかった」は一つの問題、一つの直し方として扱われがちだが、実際には同じ一文をまとった、まったく別の三つの失敗だ。
- そもそも起動しなかった。cron はコマンドを一度も呼んでいない。その行が思っている場所にない、デーモンが動いていない、あるいはこのマシンに cron がない。
- 起動したが、コマンドがすぐに失敗した。cron は時刻どおりにその行を走らせたのに、コマンドは最初の一歩でエラーに当たって終了した。たいていは cron の環境が、試したシェルとまるで違うからだ。
- 正常に動いたが、その証拠が見えない。コマンドはやるべきことを正確にやった。出力は自分が見ていない場所へ行き、こちらの立ち位置からは何も起きていないように見える。
この三つにはほとんど共通点がない。環境を疑うべき場面でスケジュールをいじる。あるいは、そもそも cron が起動していないのにコマンドを書き直す。そうやって一時間が消える。さらに、この三つの上に、より分かりにくい二つの変種が乗っている。ほとんどの日は動くのに一度だけ抜ける場合と、思っていない時刻に動く場合だ。どちらも最後に扱う。だが最初の一手は、それを推理することではない。手元にあるのがどれなのかを突き止めることだ。それを教えてくれる場所が一つある。
始める前に、範囲をはっきりさせておく。この記事が主に扱うのは、Linux の古典的な crontab、つまり多くのディストリビューションが積む Cronie / Vixie 系の cron だ。macOS、systemd、コンテナ、ホスティング環境は、出てきたところで個別に触れる。考え方はどこでも通じるが、細部は通じない。ジョブが GitHub Actions、Cloudflare、Kubernetes の CronJob で動くなら、起動したかどうかは各プラットフォーム自身の実行履歴で確かめる。システムログではない。判断はそのプラットフォームのドキュメントに従う。
まずログを見る:原因を二つに切り分けられる
手を動かす前に、cron が何をしたのかを cron 自身に尋ねる。cron デーモンは、ジョブを呼び出すたびに、たいてい一行を記録する。多くは (user) CMD (実際のコマンド) の形だ。だからログは、cron が読んでいたのが自分の思っているその行だったことも、同時に裏づけてくれる。(たいてい、であって必ずではない。Cronie では crontab の行を - で始めると、その syslog への記録を止められるし、ログの出力先も環境によって違う。記録が「ない」こと自体は証拠にならない、もう一つの理由だ。)この一事実だけで、原因が二つに切り分けられる。ログの在り処はシステムによる。
grep CRON /var/log/syslog # Debian / Ubuntu
journalctl -u cron # Debian / Ubuntu(systemd)
journalctl -u crond # RHEL / Fedora / Alma(systemd)
cat /var/log/cron # RHEL / CentOS
動くはずだった分に、自分のコマンドを名指す行があるかを探す。見えたものが、次に進む先を決める。
- 自分のコマンドのログがある。cron は起動した。スケジュールは問題ない、もう疑わなくていい。原因はその後の処理にある。コマンドが失敗した(次節)か、動いたのに出力が見えない(その次)かだ。これが最も多いケースで、たった一つのコマンドで推測の半分が消える。
- その時刻に記録がまったくない。多くの場合、cron はその行を呼んでいない。ただ、先に自分が正しいログを見ているかを確かめる(下の注意を参照)。そのうえで「そもそも起動しなかった」の節へ飛ぶ。
はっきりさせておきたいことが二つ。第一に、一致する CMD (…) の行は、cron がジョブを呼び出したことだけを証明する。それは呼び出しであって終了コードではない。だからこの一行は、「そもそも動いたのか」から「動いたあとに何が起きたのか」へと進めてくれる。まさに欲しい分岐だ。逆に、記録がないことは、より弱い証拠でしかない。ディストリビューション、ログ設定、コンテナイメージ、権限の違いで、cron が自分の見ている場所に書いていないことは十分ありうる。だから「起動しなかった」と結論する前に、いま見ているのがこのホストが実際に使うデーモンとログバックエンドかを確かめる。第二に、syslog デーモンのない切り詰めたマシンでは、これらの行は systemd ジャーナルにしかない。ログファイルではなく journalctl を使う。(macOS は cron を統合ログに流す。log show --predicate 'process == "cron"' --last 1h がおおよその相当だが、Mac ではそもそも cron が適した道具ではない、というのがより確からしい答えだ。後述する。)
ログがまったく当てにならないとき、たとえば権限を固めたホスト、不慣れなディストロ、コンテナのなかでは、いっそ迂回する。自分のファイルに書き込むプローブを一行足して、増えていくかを見ればいい。
* * * * * date >> /tmp/cron-probe.log 2>&1
毎分一行ずつ増えるなら、デーモンは生きていて crontab も読んでいる。ファイルがいつまでも現れない、または増えなくなったら、動いていない。システムログに頼らずに「cron がそもそも自分の crontab を回しているのか」に答えてくれる。答えが出たら、この行は消す。
起動したのにコマンドが落ちる:cron はあなたのシェルではない
正しい行が何も生み出さない、最も多い原因がこれだ。そして一つの、覚えておく価値のある考えに行き着く。自分でタイプすれば動くコマンドは、cron が動かすときには同じ環境で動いていない。cron はあなたのシェルを起動しない。その行を /bin/sh -c '<コマンド>' として走らせる。変数のほとんどない、非対話の最小プロセスだ。対話シェルが裏でやってくれる支度は、ここでは何一つ起きない。
失敗の大半は、次のいくつかの帰結から来る。
PATH が貧しい。cron が動くときの PATH は、/usr/bin:/bin より少し多い程度のことが多い。だから node、python3、docker、aws、psql、あるいは自作スクリプトを名前だけで呼ぶ行は、PATH の豊かな自分のプロンプトでは動き、cron の下では “command not found” で落ちる。しかもそれは見えない。エラーは自分が見ていない場所へ cron がメールしているからだ(次節)。/usr/local/bin、言語のバージョン管理ツール、プロジェクトの node_modules/.bin に入ったものは、すべて姿を消す。
dotfile は一切読まれない。cron のシェルはログインでも対話でもないので、~/.bashrc、~/.bash_profile、~/.profile は source されない。それらが用意するものは全部消える。nvm、pyenv、rbenv、asdf、Homebrew が PATH に加えるパス、エクスポートした秘密や設定、有効化した virtualenv や Conda 環境。ターミナルで「ただ動く」コマンドは、忘れていた dotfile の一行のおかげで動いていることが多い。
作業ディレクトリは $HOME、シェルは /bin/sh。cron はホームディレクトリから走るので、相対パス(./data、logs/out.txt、config.yml)はどれも間違った場所を起点にする。そして自分で設定しない限り、シェルは /bin/sh だ。Debian と Ubuntu では、それは bash ではなく dash になる。すると bash 専用の書き方([[ … ]]、配列、source)は、わけの分からないエラーで落ちる。
そもそも自分のアカウントですらないかもしれない。システム crontab は root や名前つきのサービスユーザーで走る。すると $HOME は、~/.ssh の鍵、known_hosts、~/.aws、kubeconfig、gcloud のログイン状態もろとも、そのユーザーのホームを指す。自分のものではない。ターミナルでは問題なく認証できる git、ssh、rsync、クラウド CLI が cron の下で失敗するのは、探しにいく資格情報が、使っているホームディレクトリにないからだ。
直し方は、対話的なものへの依存をやめることだ。インタプリタもファイルも絶対パスで書き、環境は crontab の先頭で明示し、シェルも固定する。
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * cd /srv/app && /usr/local/bin/node scripts/nightly.js
コマンドが一つで収まらないなら、スクリプトにまとめる。shebang は絶対パスで書く(#!/bin/bash。#!/usr/bin/env bash は bash を見つけるのに PATH を頼るので、ここでは避ける)、先頭に set -euo pipefail、中は絶対パス。そのうえで cron にそのスクリプトを呼ばせる。crontab の行は単純なまま、壊れやすい支度は一つのテスト可能なファイルに収まる。デプロイ前に、cron の切り詰めた環境を近似で再現できる。「自分の環境では動く」を、本当のテストに変えられる。
env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c 'cd "$HOME" && exec /srv/app/scripts/nightly.sh'
この cd "$HOME" が効く。cron は各ジョブを所有者のホームディレクトリから始めるので、これを抜くと、相対パスに依存するスクリプトがテストでは通り、cron の下では落ちる(逆もある)。しかも、実際にそのジョブを所有するユーザーのホームで走らせる。自分のログインではなく、root やサービスアカウントであることが多い。cron が使うのはそのユーザーの $HOME と dotfile だからだ。これは cron そのものではない(cron は LOGNAME、USER、SHELL も設定し、crontab 先頭の変数も適用する)。それでも、たいてい壊れる部分は再現できる。自分のプロンプトで落ちるなら、cron の下でも落ちていたということだ。
環境がすべて正しくても、スクリプト自体が動けないことがある。コマンドが最初の一歩でつまずく原因は、環境が切り詰められていることだけではない。あと三つ、cron がその行に届いた瞬間にコマンドを失敗させるものがある。しかもエラーを捕まえるまで、どれも見えない(次節)。
- スクリプトに実行ビットがない。crontab に
/srv/app/job.shと書いてchmod +xしていなければ、cron はpermission deniedになる。実行ビットを付けるか、インタプリタを明示して呼ぶ。/bin/bash /srv/app/job.shのように。 - Windows の改行。CRLF 改行で保存したスクリプトは、shebang が
#!/bin/bash\rになり、cron はbad interpreter: /bin/bash^M: no such file or directoryを返す。「Windows で編集して Linux にデプロイ」の定番の事故だ。dos2unix job.shで直る。 - 問いかける相手がいない。cron に端末はない。だから立ち止まって尋ねるもの、たとえば
sshが未知のホスト鍵を確認する、パスフレーズを聞く、sudoがパスワードを求める、CLI が最終確認を促す、そういったものはすべて固まるか、そのまま失敗する。非対話の形を与える。ssh -o BatchMode=yesで、資格情報が欠けたら固まらず即座に失敗させる。専用の最小権限の自動化鍵を用意し、known_hostsにはまっとうなエントリを置く。対話的なsudoパスワードは、範囲を絞ったsudoersルールに置き換える。パスフレーズなしの鍵も使えるが、それは意図した上での妥協であって、既定ではない。
動いていた。ただ見えていないだけ
ときには、cron はその行を起動し、コマンドも成功していた。それでも何も起きていないように見える。「動いたか」を、自分がつないでいない結果で判断しているからだ。典型は、出力が stdout のテキストだけ、というコマンドだ。cron はその出力を受け取り、慣習としてジョブの所有者にメールする。だが MTA を設定していないサーバー(いまはそういうサーバーも多い)では、そのメールは行き場がなく、黙って捨てられる。ジョブは “Done” と出した。その “Done” が蒸発した。
だから cron のメールを当てにしない。出力は自分が管理する場所へ送り、エラーも一緒に捕まえる。
0 * * * * /srv/app/hourly.sh >> /var/log/hourly.log 2>&1
順序が効く。>> file 2>&1 は、まず stdout をファイルへ向け、それから stderr を stdout のいまの向き先へ向ける。2>&1 >> file と書くと、stderr は元の宛先のままだ。両方のストリームが読めるログに入れば、「動かなかった」はたいてい、一分で直せる具体的なエラーに変わる。
コマンド文字列そのものに潜む罠が一つあり、症状はまさにこの「動いたのに、役に立つことは何もしていない」だ。crontab のコマンド内で、エスケープしていない % は文字どおりのパーセント記号ではない。cron は % を改行に変換し、最初の % より後ろは、コマンドの一部ではなく標準入力になる。だから次の行は、見た目どおりには動かない。
0 0 * * * pg_dump mydb > /backup/db-$(date +%F).sql
cron は % でコマンドを断ち切るので、実際に走るのは pg_dump mydb > /backup/db-$(date + だ。壊れたコマンドに、F).sql が入力として流し込まれる。日付は展開されず、バックアップは空か、そもそも作られない。文字どおりの意味で使いたい % は、すべてバックスラッシュでエスケープする。
0 0 * * * pg_dump mydb > /backup/db-$(date +\%F).sql
そもそも起動しなかった:crontab は思っているものではない
ログが定刻に何も記していなければ、cron はあなたの行に届いていない。行は存在する。ただ、動いている cron デーモンが読む場所にないだけだ。
まず要らない可能性が高いものが一つ。再起動だ。ふつうに crontab -e で編集したあとは、Cronie が spool の更新時刻を見て自分で変化に気づく。だから反射的な systemctl restart cron は、たいてい習慣であって直し方ではない。絶対の規則ではない。シンボリックリンク越しに参照される crontab のような例外は、その更新時刻チェックを外すこともある。それでも、ふつうの編集が効かないなら、再起動の前に下の原因を見る。
編集したのはファイルであって、その crontab ではない。ユーザー crontab は crontab -e で管理し、cron はそれを自分の spool ディレクトリから読む。どこかに保存したファイルからではない。その行を適当な crontab.txt に書いてインストールしていなければ、誰もそれをスケジュールしない。cron が実際に持っているものは crontab -l で確かめ、ファイルは crontab ファイルパス でインストールする。
システム crontab はフィールドが一つ多く、間違えやすい。ユーザー crontab(crontab -e)は、五つの時刻フィールドのあとにコマンドが来る。だがシステムのファイル、つまり /etc/crontab と /etc/cron.d/ の中のファイルは、スケジュールとコマンドのあいだにユーザーフィールドを一つ挟む。
# /etc/cron.d/backup —— コマンドの前の root フィールドに注意
0 3 * * * root /srv/app/backup.sh
ふつうの五フィールドの行を /etc/cron.d/ に置くと、cron はコマンドの最初の語をユーザー名として読む。ジョブは動かず、欲しかった結果ではなく、「不明なユーザー」というエラーがログに残る。逆向きの間違いもあり、症状は違う。同じ root フィールドを crontab -e に貼っても、ユーザーは選ばれない。それはコマンドの最初の語になり、シェルはたいてい root を実行しようとして落ちる(command not found)。
見落としやすい設置時の落とし穴がいくつか:
- 末尾の改行がない。cron の実装によっては、ファイルが改行で終わっていないと crontab の最後の行を無視する。
crontab -eはふつうこれを処理してくれる。自分で/etc/cron.d/に置いたファイルは、そうとは限らない。 - cron が飛ばすファイル名。これは
run-partsの挙動であって、すべての cron に共通の規則ではない。/etc/cron.dailyや/etc/cron.hourlyなどをrun-partsで回す Debian/Ubuntu 系では、名前にドットを含むファイルは既定で無視される。だからそこのbackup.shは動かず、backupが動く。run-partsを使わないシステムに、この制限はない。 - デーモンが動いていない、あるいは再起動後に動かない。切り詰めたコンテナや、立てたばかりのマシンは、そもそも cron が起動していないことがある。まず
systemctl status cron(またはcrond)で今動いているかを見る。ただしstatusは今の状態しか映さないので、起動時に立ち上がるかはsystemctl is-enabled cronで確かめ、無効ならsystemctl enable --now cronで両方まとめて有効にする。 - crontab のインストールを許されていなかった。
/etc/cron.allowと/etc/cron.denyが決めるのは、crontabコマンドを使えるのは誰か、であって、インストール済みのジョブが動くかどうかではない。だからこの二つが関わるのは、そのユーザーがそもそも crontab をインストールも置き換えもできなかった場合だけだ。インストール済みのユーザー crontab の起動を止めはしない。以前crontab -eが権限エラーを出していたなら、行が保存されなかったのはそれが理由だ。
そもそもここに cron がない、あるいはスケジューラが別物
ときには、思い描いているものがそもそも存在しないから、スケジュールは起動しなかった。そしてこれが、ますます本当の答えになりつつある。
コンテナはタダで cron を回してくれない。Docker のベースイメージには、動いている cron デーモンがない。イメージに crontab を足しても、そのコンテナで cron をインストールして起動しない限り、何も起きない。しかも起動したところで、環境の問題をもう一つ抱え込む。コンテナの PATH や入っているツールは、ホストと違うからだ。定時の仕事なら、ホストやオーケストレーターの側からスケジュールするほうが、コンテナの中で cron デーモンの面倒を見るより、たいてい楽だ。
macOS では、cron を標準の選択肢にすべきではない。Mac にも cron はまだあるが、Apple は代わりに launchd を勧める。乗り換える実際的な理由もある。定刻に Mac がスリープしていると、cron は目覚めたときに逃したジョブを走らせない。ただ飛ばす。一方、StartCalendarInterval を持つ launchd エージェントは、スリープ中に逃したジョブを、目覚めたときに追いつかせて走らせる。境界に注意する。この追いつきはスリープを扱うが、シャットダウンは扱わない。Mac が完全に電源オフのあいだに逃したジョブは、あとから走らない。だから夜にふたを閉じるノートでは、夜間の cron はまったく起動しないことがある。launchd はスリープの場合は救うが、電源オフの場合は救わない。
現代の Linux は、cron ではなく timer を使うことが多い。多くのディストリビューションは、cron の代わりに、あるいは cron と併存して、systemd timer でスケジュールする。ジョブが timer で定義されていれば、それはどの crontab にも現れない。systemctl list-timers で一覧できる。そして Persistent=true の timer は、マシンが落ちているあいだに逃した一回を追いつかせる。ふつうの cron には決してできないことだ。
ホスティングやサーバーレスのスケジューラは、デプロイしたものしか動かさない。GitHub Actions の schedule:、Cloudflare Workers の cron トリガー、Kubernetes の CronJob は、どのマシンの crontab でもなく、そのプラットフォームのスケジューラで動く。だからその行は、実際にそこへデプロイされていなければならない。そして、それぞれに、それと気づかないまま動かなくなるパターンがある。Kubernetes の CronJob は suspend: true になっていたり、concurrencyPolicy や startingDeadlineSeconds で実行を飛ばしたりする。GitHub Actions のスケジュールはベストエフォートで、負荷が高いと遅れたり落とされたりする。そして Windows には cron がまったくない。あちらはタスクスケジューラや schtasks を使う。
ほとんどの日は動く。ときどき一回抜ける
もっと分かりにくいケース。ジョブはたいてい動き、そして抜ける。原因になる仕組みは二つ。
実行が重なって積み上がる。一回の実行が次までの間隔より長引くと、cron は前が終わるのを待たず、次をそのまま始める。ふだん 20 秒、ときどき三分かかるジョブを毎分で組むと、いくつものコピーが同じファイルや同じロックを奪い合う羽目になる。前の実行がまだ続いているなら始めない、という形で防ぐ。Linux では flock(util-linux のツール)が使える。
* * * * * /usr/bin/flock -n /var/lock/myapp/sync.lock /srv/app/sync.sh
flock -n はロックを取るか、取れなければ即座に終了する。だから前の実行がまだロックを握っていれば、今回の新しい起動のほうが即座に抜け、実行中のものと重ならない。遅い実行はそのまま走り続け、飛ばされるのは次の起動のほうだ。ロックファイルは、そのジョブ自身のユーザーが書けて、かつ誰でも書けるわけではない場所に置く。共有の /tmp ではなく、/var/lock/myapp/ のような専用ディレクトリがよい。/tmp は権限とクリーンアップの都合で、本番サービスの置き場には向かない。
マシンが落ちているあいだに、その窓が過ぎた。ふつうの cron に記憶はない。夜中の三時にそのジョブが動くはずだったとき、マシンがちょうど電源オフかスリープなら、cron はあとから走らせない。その一回は消える。これは anacron が日次・週次・月次のジョブのために埋めようとした、まさにその追いつきの穴だ。systemd timer が Persistent=true で埋める穴でもある。常時稼働のサーバーではめったに噛まないが、スリープするものでは、「昨日は動いたのに今日は動かない」のよくある原因になる。
起動していた。ただ時刻が違っただけ
最後にもう一つ、ログが導いてくれるケースがある。記録はあるのに、思っていない時刻に載っている。記録があるということは起動はしたわけで、原因は環境でも設置でもない。スケジュールそのもの、つまり式か、それが動くタイムゾーンのどちらかだ。
式の罠は「Cron 式の読み方」で最初から最後まで扱っている。*/35 のように範囲を割り切れないステップ、日付と曜日のフィールドが「または」で組み合わさる規則、そして crontab の式そのものがタイムゾーンを持たず、実際に何時に動くかはサーバーのゾーンとサマータイムのずれで決まること。設定行がどう動くべきかを最も速く見るには、Cron 式ツールに貼ればいい。スケジュールを平易な言葉で示し、各フィールドを分解し、選んだタイムゾーンで次の実行を並べるので、ずれた実行や飛ばされた実行が見える。
原因がタイムゾーンのずれだと絞り込めたら、つまりジョブは正しい瞬間に起動したのに壁時計だけが違うなら、タイムゾーン変換ツールでサーバーと自分のゾーンを突き合わせる。その下の仕組みは、「UTC、GMT、ISO 8601、Unix 時間の違い」がほどいてくれる。
切り分けチェックリスト
cron ジョブが「動かなかった」ときは、この順で当たる。
- まず cron のログを読む。
grep CRON /var/log/syslogかjournalctl -u cron/crond。正しい分に記録があれば起動した証拠で、原因はその後の処理にある。記録がなければ多くは未起動だが、それはこのホストが実際に書いているログを見ていると確かめたうえでの話だ。 - 起動したのに何もしていない:環境とスクリプトを見る。絶対パス、crontab 先頭の明示的な
PATHとSHELL、dotfile に頼らないこと、正しい作業ディレクトリとユーザー。そのうえで、スクリプトに実行ビットがあり、Unix 改行で、入力を待たないことを確かめる。env -iで再現する。 - 起動したのに出力が見えない:リダイレクトする。
>> /path/log 2>&1を付け、cron のメールを当てにするのをやめる。文字どおりの%は\%にエスケープする。 - そもそも起動しなかった:設置を見る。ユーザージョブは
crontab -l、/etc/cron.d/のジョブは余分なユーザーフィールド、末尾の改行、そしてデーモンがそもそも動いているか。 - ここに cron がない:正しいスケジューラを使う。コンテナ、Mac、systemd のマシン、ホスティングは、それぞれ独自の方法でスケジュールする。スリープするマシンには、追いつけるものが要る。
- 時刻が違って起動した:設定ではなくスケジュールの問題。ツールと読み方ガイドに戻り、ステップ、日付フィールド、タイムゾーンの罠を見る。
そうすれば、「動かなかった」は謎ではなくなる。短く、順序だった絞り込みに変わる。「そもそも起動したのか」から、何も生み出さなかったその一つの具体的な理由まで。