一、现象与影响面:定时任务的失败是沉默的
症状:crontab -l 里明明写着任务,到点没有任何动静;或者更典型的——”我手动跑一切正常,cron 跑就失败”。定时任务的故障面很特殊:它排错的价值不在当下,而在滞后暴露。备份任务静悄悄失败了三天,等你需要恢复数据时才发现,这是定时任务问题里最贵的一种。
动手之前先立一条纪律:cron 失败的默认形态是沉默——任务的输出(包括报错)不会弹到你面前,它被寄往本地邮箱,或者被你自己重定向进了黑洞。反模式一:在 crontab 行尾挂 >/dev/null 2>&1——stdout 与 stderr 一起吞掉,任务失败时你连一行证据都没有。排查期间先把这条删掉,换成写入日志文件。
排查主线:先从 cron 日志拿到”跑没跑”的证据,把问题切成”没调度”和”调度了但失败”两半 → 前者查守护进程与时间表,后者按五个坑逐个排除 → 长期方案是把关键任务迁到 systemd timer。所有命令默认在运行任务的那台服务器上执行。
二、第一步:先找 cron 日志,分辨”没调度”还是”跑了但失败”
cron 的调度日志按发行版落在不同地方:
| 发行版 | 日志在哪看 | 看什么 |
|---|---|---|
| Debian / Ubuntu | grep CRON /var/log/syslog,或 journalctl -t CRON |
每次调度一行 (user) CMD (命令) |
| RHEL / CentOS | /var/log/cron |
同上 |
| 通用(systemd 发行版) | journalctl -u cron(RHEL 系服务名是 crond) |
守护进程自身的启停与报错 |
日志把问题切成两半:
| 日志里的证据 | 判定 | 去哪 |
|---|---|---|
| 到点有 CMD 行,任务结果不对 | 调度正常,环境不对 | 第三到六节的五个坑 |
| 到点没有 CMD 行 | 调度层就没跑 | 本节继续 |
“没调度”按顺序核三件事:守护进程活着没有(systemctl status cron);加载的是不是你以为的那张表(crontab -l 看的是当前用户,root 的表要用 sudo crontab -l,见第五节);时间对不对(timedatectl 核时区,服务器是 UTC 而你按北京时间写凌晨三点,任务会准点跑在另一个钟点——这是”时间写错”而不是”没跑”的经典乌龙)。调度日志正常、任务行为不对,进坑一。
三、坑一与坑二:环境变量与相对路径
cron 用非交互 shell 执行你的命令:不加载 .bashrc、.profile,PATH 极短(Debian 系默认仅 /usr/bin:/bin,其他实现略有出入,以 man 5 crontab 为准),你配置的 PYTHONPATH、virtualenv、各种 export 一概不存在。坑二是它的孪生兄弟:cron 的工作目录是用户家目录,脚本里所有相对路径都从那里起算——手动在项目目录跑没问题,cron 跑产物全落在家目录。
与其猜差在哪,不如让任务自己交体检报告——在脚本开头临时加两行,到点看现场:
pwd > /tmp/cron-check.txt # 它认为的工作目录
which python env 2>&1 | sort >> /tmp/cron-check.txt # 它看到的 PATH 与解释器
修法有两条纪律:脚本内一律绝对路径(命令用完整路径如 /usr/bin/rsync,数据文件写 /data/…);需要环境就在脚本内 source(. /opt/myapp/venv/bin/activate 之类)。crontab 顶部也可以写 PATH=... 这样的环境变量行,但变量替换等复杂逻辑放脚本里远比塞在 crontab 行内可靠。反模式二:手动跑成功就宣布任务没问题——交互 shell 与 cron 的环境差异正是根因本身,要复现就用最小环境:env -i /bin/sh -c '命令',它几乎不带任何环境变量,跑不通才算复现了 cron 的处境。
四、坑三:% 在 crontab 里是特殊字符
这一坑藏在 crontab 文件解析层,报错形态极具迷惑性。man 5 crontab 规定:命令行里第一个 % 之后的所有内容,会被当作 stdin 喂给命令,% 自身还会变成换行。于是带 % 的命令会当场截断:
# 错误写法:date +%F 里的 % 让命令从 % 处断开
0 3 * * * tar czf /backup/app-$(date +%F).tgz /var/www
1. 实际执行的是 tar czf /backup/app-$(date +,剩下的喂给了 stdin——什么都不会发生
1. 正确写法:% 必须转义成 \%
0 3 * * * tar czf /backup/app-$(date +\%F).tgz /var/www
验证方法现成:调度日志里的 CMD 行会原样显示截断后的命令,对着它看一眼就知道 % 有没有转义。更省心的做法是把含 % 的逻辑整个搬进脚本文件,crontab 里只留一行 bash /opt/scripts/backup.sh——顺带把坑一坑二也一起关掉了。
五、坑四:root 的表和你的表是两张表
crontab 按用户隔离,同一台机器上有好几份互不可见的任务清单:每个用户 crontab -e 出来的一张,root 的一张,还有 /etc/cron.d/ 下的系统级文件。排查时最常见的浪费是看着 A 表猜 B 表的任务。三处都要核对:
crontab -l # 当前用户的表
sudo crontab -l # root 的表——用 sudo 切身份,不要用 su 再套一层
cat /etc/cron.d/* # 系统级任务;注意每行多一个用户字段,格式不同
1. /etc/cron.d/myapp 的格式(分钟 之后先写以哪个用户身份跑):
1. 0 3 * * * myapp /opt/myapp/bin/backup.sh >> /var/log/myapp-cron.log 2>&1
用户差异还带来权限与归属问题:root 的 cron 写出的产物属主是 root,后续普通用户的处理步骤就会读不动;反向地,普通用户任务读不到 root 的 600 权限文件。验证一律落到目标用户身上:sudo -u myapp /opt/myapp/bin/backup.sh 原样试跑,再 ls -l 检查产物归属。
六、坑五:错过不补,cron 没有补偿机制
前四个坑是”跑了但跑坏”,坑五是”根本没跑”:到点的瞬间机器关机、休眠、或 cron 守护进程不在,这次任务直接消失,开机后不补跑。cron 的时间表只认”这个分钟到了吗”,不认”上一次成功了吗”。笔记本与不常开机的机器上,”每天凌晨 3 点”约等于”每周漏掉大部分日子”;云服务器上也可能因为维护重启恰好错过。
部分发行版用 anacron 弥补 daily/weekly 这类粗粒度周期任务,但它默认只覆盖系统自带的周期任务目录,你自己 crontab 里的条目不在其列(覆盖范围以发行版文档为准)。需要”错过多久都补上”的语义,正解是 systemd timer 的 Persistent 选项,下一节展开。
七、长期方案:关键任务换 systemd timer
timer 不是 cron 的花哨替身,它把 cron 的五个坑在机制层面逐一关掉:
| 维度 | cron | systemd timer |
|---|---|---|
| 执行环境 | 非交互 shell,PATH 极短 | unit 文件里显式声明环境与工作目录 |
| 日志 | 寄本地邮箱,易成黑洞 | stdout/stderr 全进 journalctl,journalctl -u myjob 直接看 |
| 错过补跑 | 无 | Persistent=true,错过后开机补上 |
| 依赖与顺序 | 无 | 可与数据库、网络等 unit 声明依赖 |
| 调度粒度 | 分钟 | 秒级(OnCalendar 含秒)或按间隔 |
一份最小可用配置,两个文件加两条命令:
# /etc/systemd/system/myjob.service
1. [Unit]
1. Description=myapp nightly backup
1. [Service]
1. Type=oneshot
1. ExecStart=/opt/myapp/bin/backup.sh ← 环境、路径、% 全都不用操心
1. /etc/systemd/system/myjob.timer
1. [Unit]
1. Description=run myjob nightly
1. [Timer]
1. OnCalendar=*-*-* 03:00:00
1. Persistent=true ← 关机的凌晨三点,开机后补跑
1. [Install]
1. WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now myjob.timer
systemctl list-timers myjob.timer # 验证:能看到下次触发时间
journalctl -u myjob.service # 看某次执行的全部输出
诚实边界:存量机器上几十条 crontab 不必一夜全迁——迁移本身会引入新故障。务实的节奏是:新任务一律用 timer,老任务谁再出一次环境类故障,就迁谁。
八、根因清单:按出现频率排序
| 根因 | 典型特征 | 一行验证 |
|---|---|---|
| PATH/环境变量缺失 | 日志里 command not found | 脚本内用绝对路径后复测 |
| 相对路径写错位置 | 产物出现在用户家目录 | ls ~ 与 pwd 自证法 |
| % 未转义,命令被截断 | 调度日志 CMD 行只显示半截命令 | 对照 grep CRON /var/log/syslog |
| 看错了任务表 | 改了 A 表,跑的是 B 表 | 当前用户与 sudo crontab -l、/etc/cron.d 三处都核对 |
| 错过不补 | 缺跑时刻与关机/重启时刻重合 | 对照系统 uptime 与调度日志 |
| 时区差 8 小时 | 任务准点跑在”另一个钟点” | timedatectl 对照 crontab 时刻 |
| 守护进程未运行 | 调度日志完全没有新行 | systemctl status cron |
| 权限与文件归属 | 手动跑成,目标用户跑败 | sudo -u 用户 命令 原样试跑 |
九、预防
| 措施 | 做什么 | 边界 |
|---|---|---|
| 任务必带日志重定向 | 每条 crontab 输出写入带日期的日志文件,禁止只丢 /dev/null | 日志文件自己要配轮转,别让救命的日志变成盘满的元凶 |
| 心跳文件巡检 | 任务成功末尾 touch /var/run/myjob.last-run,监控比对时间戳 |
只能发现”没跑成”,跑错方向的逻辑错误查不出 |
| 新任务用 timer | Persistent=true + journalctl 日志,五个坑一次性关掉大半 | 老任务迁移要逐个验证,勿批量切换 |
| 时区写明 | 部署文档里写清服务器时区与任务的对应钟点 | 跨时区团队协作时尤其重要 |
这套走法收拢成一句:调度日志回答”到没到点”,任务日志回答”跑没跑成”,五个坑的共性全是”非交互、无环境、不补偿”——与其在 crontab 行内跟环境搏斗,不如把命令收进脚本、把关键任务迁给 timer。
延展阅读:本文排查用到的命令行基本功,见《Linux 常用命令》;”开发机上好好的、服务器上不跑”这类跨环境差异的根源,见《开发环境》。
版本核实(以官方文档为准):cron 的 % 转义规则与环境变量行支持出自 man 5 crontab,为长期稳定行为;cron 调度日志路径与格式随发行版与 cron 实现(Debian cron / RHEL cronie)而异,以发行版文档为准;anacron 默认覆盖范围因发行版而异;systemd timer 的 OnCalendar、Persistent、systemctl list-timers 与 journalctl -u 为长期稳定接口,unit 语法细节以 systemd 官方文档为准。