凌晨三点的备份任务,手动执行一次成功,放进 crontab 之后连续一周没有产物。翻 /var/log/cron,执行记录明明白白写着「已运行」。
问题不在 cron 有没有触发,而在它以什么环境触发。cron 启动的是一个非交互、非登录的 shell,你 ~/.bashrc 里那些 export PATH、conda activate、别名,它一概看不到。
现象:三种失败,三种原因#
| 现象 | 大概率原因 |
|---|---|
| 日志里完全没有执行记录 | cron 服务没启动、语法错误、文件权限或 CRLF 换行 |
| 有执行记录,但没有任何产物 | 工作目录不对、相对路径失效、权限不足 |
报 command not found |
PATH 里没有 /usr/local/bin、nvm、conda |
| 脚本中途退出,日志只有半截 | 用了 set -e 且某条命令失败;或 % 未转义被截断 |
| 输出消失 | stdout/stderr 没重定向,被当成邮件投递或丢弃 |
第二种最坑:脚本里写 gzip > backup.gz,cron 的工作目录是家目录,文件其实生成了,只是不在你以为的地方。
容器里跑 cron 还有两个额外问题:多数基础镜像没有 syslog,日志必须自己重定向;镜像默认时区往往不是 UTC+8,0 3 * * * 实际会在本地时间上午十一点执行,而日志看起来完全正常。
还有一种「假成功」:任务跑了,退出码 0,但业务数据没变。常见于脚本里某条命令失败但没有 set -e,后面继续执行,最后正常退出。cron 只记「跑过了」,不记「跑对了」。
权限问题也常被忽略:用 root 的 crontab 跑本应属于业务用户的脚本,产物属主变成 root,后续普通用户读不到;反过来,普通用户的 crontab 访问不了 /root 下的密钥文件。
定位:把 cron 的真实环境导出来#
最直接的办法是让 cron 自己交代。临时加一行,一分钟内就能拿到:
# 每分钟把 cron 的环境落盘,观察完记得删掉
* * * * * env > /tmp/cron.env 2>&1; echo "PWD=$PWD" >> /tmp/cron.env
# 对比交互式登录 shell 的环境
diff <(sort /tmp/cron.env) <(env -i bash -lc 'env' | sort) | head -40
差异通常集中在 PATH、HOME、SHELL、LANG 这几项。看到 PATH=/usr/bin:/bin 就基本能确认:你的 node、python3、mysqldump 如果装在 /usr/local/bin 或用户目录下,cron 找不到。
再看 cron 自己的日志,确认有没有触发:
grep CRON /var/log/syslog | tail -20 # Debian / Ubuntu
journalctl -u cron --since '10 min ago' # systemd 系统
tail -50 /var/log/cron # RHEL / CentOS
systemctl status cron # 或 crond
crontab -l
如果日志里根本没有这一行,先查服务状态和语法;如果行尾是 CMD (...),说明命令已经交给 shell 了,问题在环境或脚本内部。
按这张清单逐项对照,一轮就能定位:
- PATH 是否包含脚本里用到的命令(
which node、which mysqldump) - 脚本是否显式
cd到正确目录 - 输出是否重定向到了可写路径
- 容器时区是否与预期一致
- 上一次是否还没结束,导致本次被锁挡住
想要执行轨迹而不只是结果,把命令换成 /bin/bash -x /opt/scripts/x.sh,或者干脆在脚本里用 set -x 临时打开。cron 的日志只告诉你「执行了」,-x 才告诉你「执行到哪一步」。
根因:cron 用的是最小环境#
cron 执行任务时只注入很少的变量:SHELL 一般是 /bin/sh,PATH 通常是 /usr/bin:/bin,HOME 是用户家目录,LOGNAME 是用户名。它不会加载 .bashrc、.bash_profile、.profile。
由此引出四个连锁问题:
- 版本管理工具装的可执行文件不在 PATH 里,
node、npm、python全部找不到。 - 工作目录不是脚本所在目录,相对路径写到别处,或者直接因目录不存在而失败。
- 时区取系统时区。容器里常是 UTC,你以为的凌晨三点其实是上午十一点。
- 环境变量没有继承。数据库密码、代理设置、
LANG全部缺失,程序可能因编码问题报错。
还有三个纯配置坑。% 在 crontab 里有特殊含义,未转义时被当成换行符,命令从中间截断,而语法检查不会报错;文件用 Windows 编辑器保存、带 CRLF 换行时,每行末尾多一个 \r,命令变成 xxx.sh\r;/etc/cron.d 下的文件名带点通常会被忽略,配置放了却不生效。
命令没输出也别急着断定没跑。默认情况下 cron 会把输出通过邮件发给用户,机器上没配 MTA 时就直接丢弃。加一行 MAILTO="" 或者重定向到文件,才能留住现场。
修复:让脚本自带环境#
原则是:不依赖调用者的环境,脚本自己把 PATH、目录、日志、锁全部定死。
#!/usr/bin/env bash
set -euo pipefail
# 1) 显式 PATH,覆盖 cron 的最小环境
export PATH="/usr/local/bin:/usr/bin:/bin:/opt/app/bin"
export LANG="C.UTF-8"
# 2) 固定工作目录,相对路径才有意义
cd /opt/app
# 3) 所有输出进日志,不依赖邮件
exec >>/var/log/app-backup.log 2>&1
echo "[$(date -Is)] start"
# 4) 防重叠:上一次没跑完就跳过,并说明原因
flock -n /tmp/app-backup.lock -c '
/usr/bin/mysqldump --defaults-file=/opt/app/my.cnf app \
| gzip > /opt/app/backup/db-$(date +%F).sql.gz
' || echo "skip: previous run still active"
对应的 crontab 用绝对路径调用,并显式重定向。环境变量也可以写在文件顶部,Vixie cron 与 cronie 都支持:
# crontab 顶部声明环境
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
# 分 时 日 月 周 命令
30 2 * * * /bin/bash /opt/scripts/app-backup.sh >> /var/log/app-backup.cron.log 2>&1
脚本里用 flock -n 而不是 flock -w,是为了让重叠的那一次直接跳过,而不是排队堆积。备份任务排队几次之后,往往就跑不完了。
调用时用绝对路径的解释器更稳:/bin/bash /opt/scripts/x.sh 不依赖 shebang 和执行位,挂载卷导致的权限丢失也不会让任务静默失败。
日志本身也要管:exec >> 追加写入的文件不会自动切割,跑一年就是几个 GB。用 logrotate 给它配一份规则,或者按月分文件,别等磁盘满了才发现。
密码不要直接写进脚本。mysqldump 用 --defaults-file 指向权限 600 的配置文件,比在命令行里写 -p密码 安全,也不会出现在 ps 的输出里。
防复发#
- 统一脚本模板:PATH、
cd、日志重定向、flock、set -euo pipefail五件套。 - 上线前用最小环境模拟一次:
env -i /bin/sh -c '/opt/scripts/app-backup.sh',能过再进 crontab。 - 加心跳:脚本成功结束前打一条日志或请求一个探针地址,监控没收到就说明没跑。
- 能换就换:systemd timer 支持
Environment=、WorkingDirectory=、OnCalendar=,日志进 journal,排查成本低得多。 - 容器场景显式设置
TZ,并在脚本里打印date用于核对时区。 - 把 crontab 纳入版本管理,别只在服务器上
crontab -e。
结论#
cron 不执行,先分清是「没触发」还是「触发了但失败」。看日志有没有记录,两分钟就能判断。
触发了但失败,九成是环境问题:PATH 只有 /usr/bin:/bin,工作目录是家目录,.bashrc 根本没被读取。让脚本自带 PATH 和 cd,把输出重定向到文件,问题就收敛了。
两个高频细节别忘:crontab 里的 % 必须转义成 \%;容器里先确认时区,否则任务会在错误的时间「成功」执行。
判断任务是否真的成功,别只看 exit code。加一条心跳上报,比翻日志可靠。
完