我用 cron 用了好几年,从没多想。直到我开始部署真正要紧的东西——这个博客、几个小
API、一个每晚的备份——cron 的失效模式就开始持续让我付出时间代价。任务静默消失;
一个「每晚都跑」的备份其实已经十一天没跑了;排查问题就是 grep /var/log/syslog
然后靠猜。
于是我把调度搬到了 systemd。不是因为信仰,而是因为这些问题的解法早就装在那台机器 上了。这就是我当初想先读到的、关于 systemd 定时器 vs cron 的诚实对比:同一个任务 两种写法、一条迁移路线,以及我如今仍然会用 cron 的场景。
systemd 定时器 vs cron:一句话版本
systemd timer 不是用来替代 crontab 的调度器,它是一个 .timer 单元,用来触发一个
.service 单元。真正的工作写在普通的 service 定义里,因此它顺带继承了 systemd 本来
就会做的一切:依赖排序、journal 日志、cgroup、以及按单元隔离的沙箱。这几件事 cron
都得各自准备一套答案,而大多数情况下它的答案是「默认什么都没有」。
代价也是真实的:cron 是一行,systemd 定时器是两个小文件。如果你的任务只是在一台玩具
机器上 echo hi >> /tmp/log,那 cron 赢在省事。但只要这个任务存在一个你必须看得见的
失效场景,这两个文件就开始回本了。
同一个任务,两种写法
任务是这样:每晚 03:30,把数据库备份并传输到别处。
cron 版本是 /etc/cron.d/backup 里的一行——这既是它的吸引力,也正是它的问题所在:
# /etc/cron.d/backup
30 3 * * * backup /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
systemd 版本是一个 service 加一个 timer。工作放在 service 里:
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=backup
Group=backup
ExecStart=/usr/local/bin/backup.sh
# 由 cgroup 强制执行的资源限制与超时
MemoryMax=512M
CPUQuota=80%
TimeoutStartSec=30min
# 零成本的加固
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/backup
timer 只负责调度:
# /etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
用 systemctl enable --now backup.timer 启用。注意这对文件给了你 cron 那行根本无法
表达的东西:内存上限、CPU 配额、硬超时、受限的文件系统、抖动、以及补跑——全都在单元
里,而不是在一个你必须记得去加固的 shell 脚本里。
日志:journalctl 对比「祈祷」
cron 会把 stdout/stderr 邮件到 MAILTO——在一台没有 MTA 的机器上,这封邮件直接
消失;在有 MTA 的机器上,它进了一个没人看的邮箱。那句话就是那个「十一天前的备份」
bug 的完整解释。
用定时器时,service 的输出会自动进入 journal,并带上单元名:
# 这个任务说过的所有话
journalctl -u backup.service
# 只看最近一次运行
journalctl -u backup.service --since "1 day ago"
# 测试时实时跟随
journalctl -fu backup.timer
--since、--until、-o json、跨重启的持久化存储、以及日志轮转,全都免费。这是我
换过来的最大理由:排查第一步从「grep syslog 然后祈祷」变成了「一条不可能出错的命令」。
依赖与排序
cron 里没有「等网络通了再跑,然后跑那个」这种概念。你要么用 && 把命令串起来,要么
加个 sleep 30 然后听天由命。这份听天由命,正是我见过的半数 crontab 都以一个 sleep
开头的原因。
systemd 定时器可以依赖真实的单元。上面 service 里的 After=/Wants=network-online.target
意味着:网络真的在线之前,systemd 不会启动备份。你同样可以对一个数据库、一个挂载点
或另一个服务做排序;而且一旦顺序不满足,systemd 会拒绝运行,而不是让它莫名其妙地
失败。
OnCalendar:能读懂的调度语法
cron 的五个字段紧凑到让 0 3 */2 * 5 变成一道谜题。OnCalendar 表达式用几个字符
换来了可读性:
OnCalendar=*-*-* 03:30:00 # 每天 03:30
OnCalendar=Mon *-*-* 04:00:00 # 每周一 04:00
OnCalendar=*-*-1 02:00:00 # 每月 1 号
OnCalendar=Mon..Fri 09:00 # 工作日 09:00
OnCalendar=*:0/15 # 每 15 分钟
它覆盖了 cron 的能力,还支持亚分钟间隔和星期名称,读起来就是从左到右「日期,然后
时间」。两个坑要记住:只写 OnCalendar 而不加 Persistent=true 的定时器,会静默
跳过机器关机期间本该触发的那一次;而多行 OnCalendar 是取并集叠加(每一行都是独立
触发器),而不是覆盖。
systemd-analyze calendar:那一个技巧
你不用猜某个表达式是不是你以为的意思。systemd 自带一个校验器,它会打印出人话解释 以及接下来几次的触发时间:
$ systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
Original form: Mon..Fri *-*-* 09:00:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-10-26 09:00:00 CST
(in UTC): Mon 2026-10-26 01:00:00 UTC
From now: 14h left
Normalized form 告诉你 systemd 实际解析成了什么——这是抓出星期或时区错误最快的
办法。Next elapse 证明调度就是你想要的那个。我在启用任何新定时器前都会先跑一遍,
它抓到的 bug 比任何 code review 都多。
Persistent=true:补跑错过的执行
这是我当初不知道自己需要的 cron 替代方案 特性。Persistent=true 让 systemd
记住定时器上次触发的时间;如果预定的时刻在机器关机或休眠时错过了,它会在下次开机时
补跑一次。
对于备份、证书续期或缓存预热来说,这才是正确行为:这个任务有活要干,而不是有截止 时间要守。用 cron 的话,一台在 03:30 关机的机器就是直接丢掉当天的备份;除非你主动 检查,否则一周内你都不会知道。
精度与 RandomizedDelaySec
还有两个旋钮,cron 只能靠 sleep 去伪造。AccuracySec 让 systemd 合并唤醒——默认是
1 分钟,放宽它能让内核批量处理定时器以省电。RandomizedDelaySec 加上抖动,当许多
机器跑同一个任务、你又不希望它们在同一秒一起打某个服务器时,这就很重要:
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=600
AccuracySec=1min
十分钟抖动,由调度器自己铺开,只要一行。cron 的等价物是脚本里的
$((RANDOM % 600)) 加一个 sleep,而且它会对你撒谎——你无法确知任务到底几点开始。
资源限制与环境变量
cron 跑你的任务时,环境几乎是空的。PATH 被截断,~/.bashrc 不会被 source,而最
经典的 bug 就是:脚本在你 shell 里能跑,在 cron 下就失败,因为 node、npm 或某个
virtualenv 不在 PATH 上。解法是在每个脚本里硬编码路径,或者每次 source 一份 profile。
systemd service 会显式声明它的环境,没有隐形状态要猜:
[Service]
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=-/etc/backup/env
EnvironmentFile=-(那个 - 表示「文件不存在也没关系」)就是放密钥和按主机取值的地方。
除了环境,单元还通过 cgroup 拿到真正的资源控制——MemoryMax=、CPUQuota=、
TasksMax=、IOWeight=——cron 对这些完全没有对应物。跑飞的进程会被杀掉,而不是把
整台机器吃掉。
systemd 定时器失败时,你看得见
失败可见性是两者差别最大的地方。cron 任务失败,就是日志里的一行,或者一封你从没配置 过的退信。而一个失败的 systemd 单元是一种一等状态:
systemctl list-timers --all # 下次何时触发,上次何时跑的
systemctl status backup.service # 退出码、退出原因、最后的日志
systemctl --failed # 机器上所有坏掉的单元
单是 list-timers 就值回迁移成本:它显示下次触发、上次触发、以及距今多久——这种调度
视图 cron 从来没给过你。而且因为失败的单元对整个系统可见,你可以给单元挂上
OnFailure= 去触发一个通知服务,或者在监控里对 systemctl --failed 报警。失败不再
是静默的了。
从 cron 迁移到 systemd 定时器
迁移是机械的,而且可以一个任务一个任务地做:
- 把脚本写好,让它的退出码诚实——
set -euo pipefail,失败时返回非零。systemd 靠 这个退出码判断成败。 - 每一行 crontab 转成一个运行该脚本的
.service,加一个OnCalendar与旧调度一致的.timer。 - 用
systemd-analyze calendar验证调度。 systemctl daemon-reload,然后systemctl enable --now name.timer。在信任它之前, 用journalctl -u name.service检查紧接着的第一次运行。- 注释掉(不要删)原来那行 cron,两个一起跑几天,再删掉它。在你摸清定时器脾气之前, 多一层保险。
一个定时器只放一个任务。把十个任务打包进一个 service,意味着一个任务失败就把整个单元
标记为失败,而你看不出是哪一个。如果你的旧调度用的是「每 N 分钟」或 @reboot,
对应的定时器写法就是 OnUnitActiveSec=1h 和 OnBootSec=。
什么时候 cron 依然是对的
我不是在劝你把 cron 从所有地方拆掉。下列情况下,cron 仍然是更好的选择:
- 系统很小或很精简。 Alpine、BusyBox、一个没有 systemd 的容器、一台路由器——
cron(或
crond)在,systemd 不在。当平台只支持一行时,两个文件比一行更糟。 - 你想要用户级 crontab。 每个用户的
crontab -e就以该用户身份运行,不必动 root 权限,在共享机器上确实方便。systemd 用户定时器是存在,但需要loginctl enable-linger和一个运行中的用户管理器,对个人crontab -e来说活动 部件更多。 - 它是用完就扔的。 实验用虚拟机上的一次性
@reboot,或者下周就删掉的任务, 不值得一个单元文件和一次daemon-reload。
我用的规则是:小而一次性、或没有 systemd 的场景用 cron;只要一个任务「悄悄停了」会让 我难受,就用 systemd timer。如果一个任务的失败会让我丢数据、错过续期、或者浪费一个 下午去排查,它就该待在单元里。
这就是我从 cron 搬走的全部理由。不是说 cron 坏了——它没坏——而是对于要紧的任务,
journal、依赖、Persistent=true 补跑,以及一个会说真话的 systemctl status,本来
就都在机器上。本站跑的就是同一套机制;如果你想看一台机器如何靠它完成所有这些,读
不用 Docker 的自托管 和
这个博客如何自己发布自己。