服务器上有个每天 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。