← 全部笔记

定时任务不执行:cron 的五个坑和 systemd timer

2026-10-30 系统与运维 约 8 分钟

「手动跑没问题,放进 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。