定时任务不执行:cron 的五个坑和 systemd timer
「手动跑没问题,放进 cron 就不执行」——这个现象我遇到过至少五次,原因基本都在下面五条里。
一、字段含义
# ┌ 分 (0-59)
# │ ┌ 时 (0-23)
# │ │ ┌ 日 (1-31)
# │ │ │ ┌ 月 (1-12)
# │ │ │ │ ┌ 周 (0-7,0 和 7 都是周日)
# │ │ │ │ │
30 3 * * * /path/to/script.sh
「日」和「周」是或的关系:如果两个字段都不是 *,
满足任意一个就会触发。写 0 0 1 * 1 的本意可能是「每月 1 号且是周一」,
实际结果是「每月 1 号,以及每个周一」,差得很远。
另外支持几个简写:@reboot、@daily、@hourly、
@weekly、@monthly。步进用 */5(每 5 分钟)、
1-5(周一到周五)、1,15(1 号和 15 号)。
二、坑 1:PATH 只有两个目录
cron 执行任务时的环境和你登录后的 shell 完全不同,PATH 通常只有
/usr/bin:/bin。你在 ~/.bashrc 里配的 node、conda、pyenv 全都不在。
# 写法一:在 crontab 顶部显式声明
PATH=/usr/local/bin:/usr/bin:/bin:/home/alin/.local/bin
# 写法二:命令里用绝对路径(更保险)
30 3 * * * /usr/bin/python3 /opt/job/run.py
还有一层:~/.bashrc 一般在非交互式 shell 里不会被加载,
所以脚本里 source ~/.bashrc 也不一定管用。要在脚本里自己设好环境。
三、坑 2:% 必须转义
在 crontab 里 % 是特殊字符,表示「换行」,后面的内容会被当成标准输入。
所以 date +%Y%m%d 这种写法会出怪事:
# 错:后半段被当输入,命令行为不可预期
0 2 * * * /opt/job/backup.sh > /var/log/b-$(date +%Y%m%d).log
# 对:把 % 转义,或者把整段逻辑放进脚本里
0 2 * * * /opt/job/backup.sh >> /var/log/backup.log 2>&1
最省心的做法是:crontab 里只调用一个脚本,所有逻辑(包括日期拼接)都写在脚本里。
四、坑 3:没有输出,也没有报错
cron 默认会把任务的标准输出和错误「攒着发邮件」,但机器上往往没配邮件服务, 于是输出直接丢了。任务失败你完全不知道。
# 重定向到文件,方便回溯
30 3 * * * /opt/job/run.sh >> /var/log/job.log 2>&1
# 或者全部丢掉,只靠脚本自己写日志
30 3 * * * /opt/job/run.sh > /dev/null 2>&1
五、坑 4:时区
cron 用的是系统时区。服务器常是 UTC, 于是「凌晨 3 点备份」在北京时间其实是上午 11 点。查一下:
timedatectl # 看系统时区
cat /etc/timezone # Debian 系的另一种看法
# 只在某个任务的 crontab 里改时区(部分 cron 实现支持)
CRON_TZ=Asia/Shanghai
30 3 * * * /opt/job/run.sh
六、坑 5:任务重叠
如果上一次还没跑完,下一次又起来了,轻则重复处理,重则数据错乱。
用 flock 加一把文件锁:
*/10 * * * * /usr/bin/flock -n /tmp/job.lock /opt/job/run.sh >> /var/log/job.log 2>&1
-n(nonblocking)表示拿不到锁就立刻退出,不排队等待。这正是我们想要的:
本次跳过,等下一轮。
七、什么时候该换 systemd timer
下面这些需求 cron 做不了或做不好:
- 秒级精度(cron 最小粒度是 1 分钟)。
- 错过的时间点要补跑(比如机器当时关机了)。
- 要能看到任务状态、执行结果、上次运行时间。
- 要限制内存和 CPU,或者要和其它服务建立依赖顺序。
# /etc/systemd/system/job.service
[Unit]
Description=Nightly job
[Service]
Type=oneshot
ExecStart=/opt/job/run.sh
User=alin
# /etc/systemd/system/job.timer
[Unit]
Description=Run nightly job at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now job.timer
systemctl list-timers --all # 看下次触发时间
journalctl -u job.service -n 50 # 看上次执行的输出
Persistent=true 就是「关机期间错过的,开机后补一次」。
这一条是 cron 完全没有的能力,也是我迁移的主要理由。
排查顺序建议:先手动用
env -i /bin/sh -c '你的命令'跑一遍—— 这个环境接近 cron 的极简环境,能复现出大部分 PATH 类问题。
一句话总结:cron 里没有你 shell 的 PATH,% 要转义,输出要重定向;需要秒级、需要补跑、需要看状态时换 systemd timer。