服务器上有个每天 8 点的 cron 抓取新闻任务,日志里写着「新闻数据更新成功」。但我发现它从 6 月 10 日之后就没再动过——/var/log/news-update.log 最后一行时间戳停在一个月前,文件大小 230 字节。
日志说成功,日志也说不成功。因为它压根没跑。
我干脆在自己这台服务器上把 cron 常见的坑逐个复现了一遍,每一条都是真实报错。环境是 Ubuntu,/bin/sh 指向 dash,cron 版本 3.0pl1-184ubuntu2。
排查姿势先记住这一条:
sudo grep -i cron /var/log/syslog | tail -20
syslog 里 cron 每分钟都记录它实际执行了什么,格式是 CRON[pid]: (user) CMD (实际执行的命令)。下面结论都是靠它确认的。
坑一:PATH 跟你 SSH 登录时完全不一样
这是最常见的。cron 不读 ~/.bashrc,也不读 ~/.profile,只认 /etc/environment 里那个 PATH。
我实测的对比,cron 环境下探针脚本打印出来的是:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
SHELL=/bin/sh
HOME=/home/ubuntu
PWD=/home/ubuntu
同一台机器上 ~/.profile 里额外加的那两段 cron 看不到:
# ~/.profile 第 21 行和第 26 行
PATH="$HOME/bin:$PATH"
PATH="$HOME/.local/bin:$PATH"
所以 ~/.local/bin 里的东西,SSH 登录能跑,cron 里就是 command not found。我拿上篇文章 pip --user 装的 markdown_py(落在 ~/.local/bin)做了对照:
* * * * * markdown_py --version >> /tmp/round4.log 2>&1
* * * * * PATH=/home/ubuntu/.local/bin:$PATH markdown_py --version >> /tmp/round4.log 2>&1
日志里两条命令的结果:
/bin/sh: 1: markdown_py: not found
markdown_py 3.10.2
同一条命令,一个报 not found,一个正常出版本号。修法两个,我一般用第二个:
# 方案一:每行开头显式加 PATH
PATH=/home/ubuntu/.local/bin:/usr/local/bin:/usr/bin:/bin markdown_py --version
# 方案二:在 crontab 顶部统一设一次
PATH=/home/ubuntu/.local/bin:/usr/local/bin:/usr/bin:/bin
* * * * * markdown_py --version
注意 PATH 是 crontab 内置变量,等号左右不能有多余空格。
坑二:% 不转义,命令会被当场腰斩
这个坑杀了我最久。Vixie cron 把 % 当成「换行符」——crontab 里写 %,cron 认为你想让命令换行,于是把 % 之后的内容全截断,只执行前半段。
我实测两条:
* * * * * /bin/date "+%Y-%m-%d %H:%M:%S" >> /tmp/pct-unescaped.log 2>&1
* * * * * /bin/date "+\%Y-\%m-\%d \%H:\%M:\%S" >> /tmp/pct-escaped.log 2>&1
结果很说明问题:
=== pct-unescaped.log ===
cat: /tmp/pct-unescaped.log: No such file or directory
=== pct-escaped.log ===
2026-11-04 08:19:01
未转义那条的日志文件根本没被创建。syslog 里的真相:
CRON[1785499]: (ubuntu) CMD (/bin/date "+)
命令在第一个 % 处就被砍断了,>> /tmp/pct-unescaped.log 这个重定向因为在 % 后面,也一起消失。转义后的那条完整执行。
转义方式就是给 % 加反斜杠,写成 \%。脚本里凡是 date +%s、date -d '+%Y-%m-%d' 这类格式串都得挨个转。
更阴的是 % 出现在 $(...) 里面:
* * * * * echo "output at $(date '+%T')" >> /dev/null; echo "job ran" >> /tmp/round3.log
syslog 记录的是:
CRON[1786556]: (ubuntu) CMD (echo "output at $(date '+)
; echo "job ran" 那半截完全没执行。脚本"跑了",但只跑了一半,后半段的副作用全丢了。## 坑三:CRLF 换行符,报错信息完全误导
从 Windows 上传脚本,十有八九带着 \r。Linux 认不出来,报的错会让人一头雾水:
/bin/sh: 1: /tmp/crlf-probe.sh: not found
文件明明在,ls 看得见,权限是 755,为什么说找不到?
我 od -c 拆开看了下:
0000000 # ! / b i n / b a s h \r \n # C
那个 \r——shebang 变成了 #!/bin/bash\r。内核执行解释器时会去找一个名字里带 carriage return 的 /bin/bash\r,找不到,于是报「not found」。错误信息里完全没提换行符,根本猜不到是 CRLF 的锅。
上传后统一转一下:
sed -i 's/\r$//' /tmp/your-script.sh # dos2unix 没装的话用 sed
坑四:内联命令用 bash 语法,dash 直接不认
Ubuntu 的 /bin/sh 是 dash 的软链接,不是 bash。而 crontab 里的内联命令走 SHELL,也就是 /bin/sh。
我实测把两个 bash 专属语法直接写在 crontab 里:
* * * * * [[ 1 == 1 ]] && echo "inline bashism ok" >> /tmp/round3.log 2>&1
* * * * * source /tmp/sourced-vars.sh && echo "inline source ok" >> /tmp/round3.log 2>&1
/tmp/round3.log 里这两条什么都没写。而同样的逻辑放进带 #!/bin/bash shebang 的脚本文件,跑得好好的:
double-bracket works
sourced value=hello-from-sourced-file
差别就一个:脚本文件有自己的 shebang,内联命令没有。[[ ]] 是 bash 的 [[ 关键字,dash 不支持;source 是 bash 内建命令,dash 只有 POSIX 的点号 .。
解法两个,我一般选第二个:
# 方案一:crontab 顶部指定 SHELL
SHELL=/bin/bash
* * * * * [[ 1 == 1 ]] && echo "ok"
# 方案二:内联命令别用 bash 语法
* * * * * . /tmp/sourced-vars.sh && echo "ok"
* * * * * test 1 -eq 1 && echo "ok"
方案二更稳,不依赖 cron 实现细节。
坑五:相对路径,脚本找不到自己的数据文件
cron 执行任务时的工作目录是用户的家目录,不是脚本所在的目录。我实测 pwd 的结果:
=== cwd-probe.log ===
/home/ubuntu
所以脚本里写 ./data.txt 会去 /home/ubuntu/data.txt 找,而不是脚本旁边。交互式 cd /tmp && ./relpath-probe.sh 能读到 local-data,放进 cron 就找不到。
我在脚本开头统一加一行:
#!/bin/bash
set -e
cd "$(dirname "$0")" || exit 1
数据文件多的时候,我更倾向于用变量统一管理:
BASE_DIR="/var/www/myapp"
LOG_FILE="/var/log/myapp.log"
DATA_FILE="${BASE_DIR}/data/config.json"
坑六:没有 MTA,失败信息直接蒸发
cron 执行任务时,如果任务有输出(stdout/stderr 没重定向),cron 会尝试把输出发到本地用户邮箱。没装 MTA 的话,输出就没了。
我服务器上 sendmail 和 mail 都不存在,syslog 里明明白白写着:
CRON[1786552]: (CRON) info (No MTA installed, discarding output)
discarding output——丢弃。任务失败了,错误信息被扔进垃圾桶,你什么都看不到。
对比一下就明白差别。我写了一条会报错的命令:
* * * * * ls /nonexistent-path-xyz >> /tmp/round3.log 2>&1
日志里老老实实出现了:
ls: cannot access '/nonexistent-path-xyz': No such file or directory
2>&1 把 stderr 合并到 stdout,两个一起重定向进日志文件,错误就留痕了。
所以每条 cron 任务我都会加日志重定向:
* * * * * /usr/local/bin/your-task.sh >> /var/log/your-task.log 2>&1
日志别放 /tmp,也别忘配 logrotate。
坑七:任务执行时间重叠,前一次没跑完下一次又来了
如果任务要跑 5 分钟,而 crontab 是每分钟触发一次,前一次还在跑,新的又起来了,最后同时跑十几个实例,数据库被连接池打满、任务互相覆盖写入。
最常见的解法是 flock。root 的 crontab 里就有一条现成的:
*/5 * * * * flock -xn /tmp/stargate.lock -c '/usr/local/qcloud/stargate/admin/start.sh > /dev/null 2>&1 &'
flock -x 是排他锁,-n 是拿不到锁就立刻退出而不是排队等待。前一个实例还活着的话,这次直接跳过。
我自己的写法:
* * * * * flock -n /tmp/my-task.lock /usr/local/bin/my-task.sh >> /var/log/my-task.log 2>&1
比 -c 写法少一层引号嵌套,日志重定向也更好控制。
收尾:一份能直接抄的模板
综合上面这些,我现在写 cron 都按这个格式来:
# ===== 环境变量 =====
SHELL=/bin/bash
PATH=/home/ubuntu/.local/bin:/usr/local/bin:/usr/bin:/bin
MAILTO=""
# ===== 任务 =====
# 抓取脚本:绝对路径 + 日志重定向 + flock 防重叠
0 8 * * * flock -n /tmp/fetch-news.lock /usr/local/bin/fetch-news.sh >> /var/log/news-update.log 2>&1
# 带百分号的命令:记得转义
*/10 * * * * /bin/date "+\%Y-\%m-\%d \%H:\%M:\%S" >> /var/log/heartbeat.log 2>&1
MAILTO="" 是显式关掉邮件投递,有些环境会发邮件骚扰你,设成空字符串最省事。
排查顺序我一般是这个:
# 1. 确认任务还在
crontab -l
# 2. 看 cron 到底执行了什么
sudo grep -i cron /var/log/syslog | tail -20
# 3. 看业务日志有没有内容
tail -50 /var/log/your-task.log
# 4. 手动跑一遍,看交互式和 cron 的差异
sudo -u ubuntu env -i HOME=/home/ubuntu PATH=/usr/bin:/bin /usr/local/bin/your-task.sh
# 5. 确认时区,服务器是 UTC 但日志是本地时间就会对不上
timedatectl
第 4 步关键是 env -i,清空所有环境变量模拟 cron 的干净环境,能复现出 cron 里的问题。
改 crontab 之前先备份:
sudo cp /var/spool/cron/crontabs/ubuntu /tmp/crontab.bak
这个文件默认 600 权限且属主 root:crontab,普通用户 cat 会 Permission denied,得用 sudo 读。恢复时也有个坑:
sudo crontab /tmp/crontab.bak # 装到 root 的 crontab!
sudo crontab -u ubuntu /tmp/crontab.bak # 装到 ubuntu 的 crontab
我恢复时就踩了——sudo crontab 不带 -u 是给 root 装的,crontab -l 当然一片空白。正确的是第二个。
实时跟日志还有 journalctl -u cron -f。