DevKitLab Logo DevKitLab
Cron / crontab / 定时任务 / 排障

为什么我的 cron 没跑起来?一份现场排查手册

「没跑」这三个字盖着三种完全不同的故障:压根没触发、触发了却在第一步就死、以及正常跑完但你看不到。这篇先教你分清是哪一种,第一步就从 cron 日志入手。

调度看着没问题。你一个字段一个字段读过,甚至粘进工具里确认过,它写的就是你想要的。可它该做的事没发生:没有文件,没有邮件,表里也没多出一行。于是你说,这个任务「没运行」。

问题恰恰出在这两个字上。它描述的是症状,却顺手替你认定了原因。「没跑」被当成一个问题、一种修法,可它其实是三种完全不同的故障,套着同一句话:

  1. 压根没触发。cron 从没调用过你的命令:那行不在你以为的地方、守护进程没起,或者这台机器根本没有 cron。
  2. 触发了,但命令第一步就死了。cron 按点跑了那行,命令却在第一步撞上错误退出,通常是因为 cron 的环境跟你测试时的 shell 差得远。
  3. 正常跑完了,你只是看不到证据。命令做得一点没错,输出去了你没在看的地方,站在你的角度就像什么都没发生。

这三种之间几乎没有共同点。该怀疑环境却去改调度,或者 cron 压根没触发你却在重写命令,一个小时就这么没了。在这三种之外,还有两种更隐蔽的变体:任务大多数日子都跑、偶尔漏一次,以及跑在了你没料到的钟点上,这两种文末会讲到。但第一步都不是去猜,而是先弄清你手里到底是哪一种。有一个地方能告诉你。

动手前先说清范围。这篇讲的主要是 Linux 上那套经典 crontab,也就是多数发行版自带的 Cronie / Vixie 式 cron;macOS、systemd、容器和托管平台会在遇到时单独点出来。思路到哪儿都通用,具体细节却不通用。如果你的任务跑在 GitHub Actions、Cloudflare 或 Kubernetes 的 CronJob 上,判断它有没有触发要去各自平台的运行记录里看,而不是系统日志,一切以对应平台的文档为准。

先看日志:一步就把问题劈成两半

改任何东西之前,先问问 cron 它到底做了什么。cron 守护进程通常每调用一次任务就记一行日志,一般长这样:(user) CMD (完整命令)。这也顺带确认了它读到的正是你以为的那一行。就这一条事实,能把三种情况劈成两半。(说通常,是因为并非绝对: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 不是你的 shell

一条正确的命令什么都没产出,这是最常见的原因,而它归结为一句值得记住的话:你手打时能跑的命令,在 cron 手里跑的时候,环境根本不是同一个。cron 不会启动你的 shell。它把你那行当成 /bin/sh -c '<命令>' 来跑,一个变量少得可怜的、非交互的最小进程,你交互式 shell 替你张罗的那一套,这里一样都没有。

大部分失败,都来自下面这几个后果:

PATH 短得可怜。cron 跑起来时的 PATH,常常只比 /usr/bin:/bin 多不了多少。于是一行直接写 nodepython3dockerawspsql 或你自己脚本名字的命令,在你那 PATH 丰富的终端里好好的,到了 cron 底下就报 “command not found”,而你还看不到,因为报错被 cron 邮到你没在看的地方去了(下一节)。凡是装在 /usr/local/bin、某个语言版本管理器、或项目 node_modules/.bin 里的东西,在这儿统统隐身。

你的 dotfiles 一个都不读。cron 的 shell 既非登录 shell 也非交互 shell,所以 ~/.bashrc~/.bash_profile~/.profile 都不会被 source。这些文件里张罗的一切都没了:nvmpyenvrbenvasdf 或 Homebrew 往 PATH 里加的路径,导出的密钥和配置,激活的 virtualenv 或 Conda 环境。你终端里「随手就能跑」的命令,往往正是某个你早忘了的 dotfile 里那一行才跑得起来。

工作目录是 $HOME,shell 是 /bin/sh。cron 从你的家目录起跑,所以任何相对路径(./datalogs/out.txtconfig.yml)都从错的地方开始找。而且除非你自己设,shell 就是 /bin/sh,在 Debian 和 Ubuntu 上那是 dash 不是 bash。于是 bash 专有的写法([[ … ]]、数组、source)就会报一句莫名其妙的错。

跑的甚至可能不是你这个账号。系统 crontab 以 root 或某个具名服务账号在跑,于是 $HOME 连同 ~/.ssh 密钥、known_hosts~/.aws、kubeconfig、gcloud 登录态,全指向那个用户的家目录,不是你的。一条在你终端里鉴权顺畅的 gitsshrsync 或云 CLI,到了 cron 底下就失败,因为它要找的凭据压根不在它用的那个家目录里。

解法是别再依赖任何交互式的东西。解释器和文件都用绝对路径,环境在 crontab 顶部显式写好,shell 也固定下来:

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——env 仍要靠 PATH 找到 bash,正是这里最不靠谱的东西),顶部 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 和 dotfiles。这还不完全等于 cron(cron 另外会设 LOGNAMEUSERSHELL,还会应用 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 没有 tty,所以任何要停下来问你的命令,无论是 ssh 确认陌生主机密钥、要 passphrase、sudo 要密码,还是 CLI 问「确定吗?」,都会卡住或直接失败。给它一个非交互的形式:ssh -o BatchMode=yes,让缺凭据时立刻失败而不是干等;配一把专用的最小权限自动化密钥,known_hosts 里有正经的记录;用一条限定范围的 sudoers 规则代替交互密码。无 passphrase 的密钥也能用,但那是你权衡后的选择,不该是默认做法。

跑了,你只是看不到

有时候 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 却没安装,就没人替你调度它。用 crontab -l 看 cron 手里到底有什么,用 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,它并不会选中某个用户。它成了命令的第一个词,于是 shell 通常就栽在执行 root 上(command not found)。

几个不太显眼的安装陷阱:

  • 末尾没换行。有些 cron 实现,如果文件末尾不是换行,就会忽略 crontab 的最后一行。crontab -e 一般会替你处理好;你手动丢进 /etc/cron.d/ 的文件可能不会。
  • 文件名被 cron 跳过。这条是 run-parts 的行为,不是所有 cron 通用的规则:在 Debian/Ubuntu 这类用 run-parts 驱动 /etc/cron.daily/etc/cron.hourly 这些目录的系统上,名字里带点的文件默认被忽略,于是那里的 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 不会在机器醒来后补跑那次错过的任务,直接跳过。而一个带 StartCalendarIntervallaunchd 代理把睡眠期间错过的任务在唤醒时补上。注意这条边界:补跑只管睡眠,不管关机,机器彻底断电时错过的那一次,事后不会补。所以笔记本晚上合盖,一个夜里的 cron 可能压根不触发,而 launchd 救得回睡眠这种情况,救不回断电那种。

现代 Linux 常用 timer,不用 cron。很多发行版改用 systemd timer 来调度,或者和 cron 并存。要是一个任务是按 timer 定义的,它不会出现在任何 crontab 里。用 systemctl list-timers 列出来;另外注意,带 Persistent=true 的 timer 会补跑机器关机期间错过的那一次,这是普通 cron 从来做不到的。

托管和无服务器调度器只跑部署上去的东西。GitHub Actions 的 schedule:、Cloudflare Workers 的 cron 触发器、Kubernetes 的 CronJob,跑在各自平台的调度器上,不在任何机器的 crontab 里。所以那行得真的部署到那儿去,而且每一种都有自己「安静地不跑」的方式:Kubernetes 的 CronJob 可能被 suspend: true,或因为 concurrencyPolicystartingDeadlineSeconds 跳过某些运行;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 要么拿到锁,要么立刻退出。所以如果上一次运行还占着锁,这次新触发的就立即退出,不会和仍在跑的那次重叠——慢的那次照样继续,被跳过的是接下来这一次。锁文件要放在这个任务自己的用户写得进、又不是所有人可写的地方,用像 /var/lock/myapp/ 这样的专用目录,别用大家共用的 /tmp/tmp 的权限和清理机制让它不适合放生产服务的锁。

窗口在机器关着的时候过去了。普通 cron 没有记忆。要是夜里三点那个任务该跑时,机器正好断电或在睡眠,cron 不会事后补跑,那一次就这么没了。这正是 anacron 当年要替日、周、月任务补上的那个缺口,也是 systemd timer 用 Persistent=true 补上的。常开的服务器上这事很少咬人;但凡会睡眠的机器,它就是「昨天还跑、今天没跑」的常见原因。

触发了,只是跑在了错的时间

还有最后一种,日志会把你引过去:记录是有的,只是落在一个你没料到的钟点上。既然有记录,那就说明任务触发了。于是问题既不在环境,也不在安装,而在调度本身:要么是表达式,要么是它跑在的那个时区。

表达式那类坑,《怎么读懂 Cron 表达式》从头讲到尾:像 */35 这种步进除不尽范围、日期字段和星期字段按「或」组合的规则,还有 cron 表达式自己不带时区,所以到底几点跑,是由服务器的时区和夏令时的挪动共同决定的。要最快看清一行应该怎么跑,就把它丢进 Cron 表达式工具:白话说明、拆开每个字段,按你选的时区列出接下来几次运行,挪了时间或被跳过,一眼就看得见。

等你把范围缩到「就是时区偏移」,也就是任务在对的时刻触发了、只是墙上的钟点不对,再用时区转换工具把服务器的时区和你的对齐;更底层的道理,《UTC、GMT、ISO 8601 与 Unix 时间戳有什么区别?》替你理清。

一份排查清单

当一个 cron 任务「没运行」,按这个顺序来:

  1. 先看 cron 日志grep CRON /var/log/syslogjournalctl -u cron/crond。对的分钟有一条记录,就证明触发了,接下来该查命令本身或输出;没有记录,多半是没触发,前提是你已经确认查的是这台机器真正在写的那份日志。
  2. 触发了却什么都没干:查环境和脚本。绝对路径,crontab 顶部显式的 PATHSHELL,不依赖 dotfiles,对的工作目录和用户;再确认脚本有可执行位、是 Unix 换行、不会停下来等输入。用 env -i 复现一遍。
  3. 触发了却没看到输出:把它重定向。加上 >> /path/log 2>&1,别再信 cron 的邮件。字面的 % 都转义成 \%
  4. 从没触发:查安装。用户任务用 crontab -l 看,/etc/cron.d/ 的任务留意那个多出来的用户字段,末尾换行,以及守护进程到底在不在跑。
  5. 这里没有 cron:换对的调度器。容器、Mac、systemd 机器、托管平台,各有各的调度方式;会睡眠的机器需要一个能补跑的。
  6. 跑在了错的时间:那是调度,不是配置。回到 Cron 表达式工具和那篇阅读指南,去看步进、日期字段和时区这几个坑。

照着走,「它没跑」就不再是谜,而变成一次简短、有序的收窄:从「到底触发没触发」,一路缩到它什么都没产出的那一个具体原因。