脚本终端能跑放进 crontab 就没了反应:环境变量、flock 与排查顺序
crontab 的用法简单到只需要一行配置,很多人因此从没认真研究过它:定时备份、清理日志、跑采集脚本、续证书,全都往里面塞。直到某天发现——脚本在终端里跑得好好的,放进 crontab 就什么都不发生。
下面按时间语法、环境变量、日志排查、并发控制的顺序过一遍,重点放在那些「看起来正常但就是不工作」的地方。
五个字段的准确含义
# ┌───────── 分钟 (0-59)
# │ ┌─────── 小时 (0-23)
# │ │ ┌───── 日 (1-31)
# │ │ │ ┌─── 月 (1-12)
# │ │ │ │ ┌─ 星期 (0-7,0 和 7 都是周日)
# │ │ │ │ │
* * * * * command
几个容易记错的规则:
*/5表示「每 5 个单位」。*/5 * * * *是每 5 分钟,0 */6 * * *是每 6 小时整点。- 日和星期是「或」的关系,不是「且」。
0 3 1 * 1的语义是「每月 1 号 3 点,或者每周一 3 点」,而不是「每月 1 号且是周一的 3 点」。这是最经典的认知偏差,如果你要的是后者,必须在脚本里自己判断日期。 @reboot、@daily、@weekly、@monthly这些快捷写法,各版本 cron 支持程度不一(BusyBox 的 crond 就不支持@reboot)。要稳妥就写全五个字段。- cron 没有「每 90 分钟」这种表达。
0,30 */3 * * *出来的间隔并不均匀,真要精确间隔得靠脚本自己 sleep 循环,或者用 systemd timer。
坑一:环境变量完全不同
这是 crontab 第一大坑。cron 执行任务时用的不是你登录 shell 的环境,而是一个极其精简的默认环境:
PATH通常只有/usr/bin:/bin——你装在/usr/local/bin的 php、node、docker 全都找不到。- 没有加载
.bashrc/.bash_profile,你自定义的 alias、函数、导出的变量一律不存在。 SHELL默认是/bin/sh,在 Debian/Ubuntu 上这是 dash 而不是 bash,[[ ... ]]、数组、source这些 bash 语法会直接报错。- 没有
LANG/LC_ALL,中文路径和输出会变成乱码,PHP 脚本里mb_*相关函数可能行为异常。
解决办法是在 crontab 顶部显式声明:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=zh_CN.UTF-8
LC_ALL=zh_CN.UTF-8
MAILTO=""
# 之后的任务都能用上面这套环境
0 3 * * * /usr/local/bin/php /www/backup.php >> /var/log/backup.log 2>&1
MAILTO="" 的作用是关掉 cron 的邮件通知。默认情况下 crontab 会把任务的输出(stdout/stderr)作为邮件发出去,但服务器上多半没装 MTA,结果就是大量邮件堆在 /var/spool/mail 或者干脆静默失败。把它设为空,然后自己在命令里重定向到日志文件,这才是可控的做法。
坑二:命令要用绝对路径,且别忘了重定向
反例:
0 3 * * * cd /www && php backup.php
问题有三层:php 可能不在 PATH 里;最致命的是没有重定向输出,脚本报错你完全看不见。
正确的写法:
0 3 * * * cd /www && /usr/bin/php /www/backup.php >> /var/log/backup.log 2>&1
几个细节:2>&1 必须写在重定向目标的后面,顺序反了只会把 stdout 重定向而 stderr 还是走邮件;>> 是追加、> 是覆盖,备份类脚本用追加更安全;日志文件记得配 logrotate,否则一年下来能涨到几个 G。
全局重定向示例:
0 3 * * * /www/backup.sh >> /var/log/cron-$(date +\%Y\%m\%d).log 2>&1
注意这里的 \%。百分号在 crontab 里有特殊含义——它会被解释成换行符,命令里所有从第一个未转义 % 开始的内容都会作为标准输入传给命令。想用字面量的 %(比如 date +%Y%m%d),就必须写成 \%。这个坑极其隐蔽,很多人看到「date 命令输出为空」找了半天才反应过来。
坑三:任务重叠执行
假设你配了 */5 * * * * 跑一个数据同步脚本,正常情况下 30 秒跑完。但某个周末源站挂了,脚本卡在超时等待上,5 分钟没结束,第二个实例又启动了。两个实例同时写同一份数据 → 数据损坏;同时拉同一个远端 → 被封 IP。
标准解法是用 flock 加文件锁,只允许一个实例运行:
*/5 * * * * /usr/bin/flock -n /var/lock/sync.lock -c '/www/sync.sh >> /var/log/sync.log 2>&1'
-n 表示「拿不到锁就立刻退出,不等待」,这正是我们要的:上一轮还在跑,这一轮直接跳过。如果希望排队等上一轮结束再跑,去掉 -n 即可(但不建议,容易堆积)。
另一个场景是「任务必须在上一次结束后再等固定时间」,用 flock 就有点别扭,此时可以在脚本内部用 while true; do ...; sleep 300; done 常驻进程,然后用系统的 supervisor/systemd 管理它,而不是交给 cron。
坑四:cron 不认你的时区
服务器和你本地不在同一时区,是最容易造成「数据对不上」的原因。查看系统时区:
timedatectl
date
ls -l /etc/localtime
cat /etc/timezone
如果服务器是 UTC 而你想要北京时间 8 点执行,要么把系统时区改掉(timedatectl set-timezone Asia/Shanghai),要么在 crontab 里减 8 小时。推荐改系统时区——把两个时区混着写在配置里,几个月后你自己都看不懂。
注意 Debian/Ubuntu 上的 crond 会读取 /etc/timezone,改完之后必须重启 cron 服务才生效:systemctl restart cron。
坑五:Docker 容器里的 cron 不工作
容器里的 crontab 有几个特殊之处:
- cron 默认不写日志。很多基础镜像编译时省略了 syslog,crond 的日志直接丢失。排查时先跑
cron -f前台运行看输出。 - 容器时区默认 UTC。必须挂载
-v /etc/localtime:/etc/localtime:ro或设置TZ=Asia/Shanghai,否则任务在北京时间凌晨 3 点不跑。 - 容器重启后 crond 不会自动启动。如果你的镜像 ENTRYPOINT 是应用的启动脚本,必须在里面拉起 crond,或者用 s6-overlay、supervisord 这类进程管理器。
- 环境变量不会自动继承。宿主机的环境变量需要通过
docker -e或 env_file 显式传入。
RUN apt-get update && apt-get install -y cron tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
COPY myjobs /etc/cron.d/myjobs
RUN chmod 0644 /etc/cron.d/myjobs
CMD cron && exec /usr/sbin/nginx -g 'daemon off;'
/etc/cron.d/ 下的文件格式和 crontab -e 略有不同:必须在命令前多写一个用户名,写成 */5 * * * * root /www/sync.sh,否则任务不会被执行。文件权限要 0644、属主 root,并且结尾要有空行。
排查套路:任务到底跑没跑
# 1. cron 服务在跑吗
systemctl status cron
ps aux | grep -w [c]ron
# 2. 看 cron 自己的日志(Debian/Ubuntu 默认进 syslog)
grep CRON /var/log/syslog | tail -50
journalctl -u cron --since "1 hour ago"
# 3. 看任务是否被调度到了
grep "cmd" /var/log/syslog | grep CRON | tail -20
# 正常会看到:CRON[12345]: (root) CMD (/www/sync.sh)
# 4. 看任务自己的日志(这就是为什么必须重定向)
tail -f /var/log/sync.log
# 5. 检查 crontab 语法错误
crontab -l
# 有语法错误时 syslog 里会有 "Error: bad minute" 之类的提示
如果 syslog 里完全没有 CRON 记录,说明 cron 服务本身没起来,或者任务根本没被加载。这时候检查:crontab -l 是否有内容、/etc/cron.d/ 下文件权限是否是 0644 且属主 root、以及 crontab 文件结尾是否有换行(最后一行没有换行符,cron 会忽略这一行——这是极其隐蔽的坑)。
如果 CRON 记录有、命令也执行了,但「什么都没发生」,那就回到坑一和坑二:环境变量不对或者重定向没写,导致脚本在一开始就退出了,而错误信息全被 cron 吞掉。此时最快的确认方法是在命令里加 env > /tmp/cron-env.txt 或者 whoami > /tmp/cron-who.txt,看看 cron 到底看到了什么。
几条实践经验
- 所有定时任务都写成独立脚本再调度,不要把复杂逻辑塞在 crontab 那一行里。脚本可以进 Git、可以做版本管理、可以单独测试。
- 加日志、加日志、加日志。定时任务是无人值守的,没有日志就等于没有可观测性。
- 给任务配监控:最简单的做法是脚本跑完后
touch一个时间戳文件,再用另一个 cron 检查这个文件是否在 24 小时内被更新过,超时就发通知。这能抓出「脚本静默失败」这类最讨厌的问题。 - 错开执行时间。别让五个任务都写在
0 3 * * *,那会造成瞬间的 IO 和 CPU 尖峰。分散到0 3、15 3、30 3更温和。 - 备份你的 crontab:
crontab -l > ~/crontab-backup-$(date +%F).txt,重装系统时你会感谢自己。
crontab 的坑几乎全部集中在「环境不一致」这一件事上。记住三个关键词:绝对路径、显式环境变量、输出重定向。再加上 flock 防重叠、注意 % 的转义、注意时区和行尾换行,大部分定时任务故障都能避开。剩下的部分,靠日志和 env 对比来定位。