为什么要在 2026 年重新学会 systemd timer
绝大多数站长第一次接触定时任务,都是从 crontab 开始的。五分钟学会 crontab -e,写一行 0 3 * * * /path/backup.sh,备份就跑起来了。cron 足够简单,也足够古老——它的设计可以追溯到 1975 年的 Unix V7,五十年来这套「分 时 日 月 周」的五段式语法几乎没变过。
但 cron 的问题也很明显:它不知道任务跑成功没有,出错了只会在本地发一封可能根本没人看的邮件;机器关机的时段它直接跳过,不会补跑;它没有日志(除非你自己重定向),没有资源限制,没有依赖顺序,也没有超时控制。当你的服务器越来越多、任务越来越重要,cron 的短板就会一个个暴露出来。
systemd timer 是 systemd 生态里对定时任务的重新设计。它把「定时」和「执行」拆成两个单元:timer 负责什么时候触发,service 负责触发之后跑什么。这样带来了一堆 cron 没有的能力:OnCalendar 支持比五段式强大得多的日历语法、失败自动重试、错过的任务开机补跑、随机延迟防雪崩、日志自动进 journald、可以用 cgroup 限制 CPU 和内存。这篇文章就把 systemd timer 在真实运维场景里的用法讲透,并给出从 cron 迁移的完整思路。
最小心智模型:timer 与 service 是一对
cron 是「一行的艺术」,systemd timer 则至少要写两个文件。以「每天凌晨三点跑备份」为例,你需要一个 service 描述「跑什么」:
[Unit]
Description=每日备份任务
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=backup
WorkingDirectory=/var/lib/backup
再写一个同名的 timer 描述「什么时候跑」:
[Unit]
Description=每天凌晨三点触发备份
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
两个文件放在 /etc/systemd/system/ 下,名字相同只是后缀不同,比如 backup.service 和 backup.timer。这样 timer 触发时会自动去找同名的 service。启用时,注意启用的是 timer 而不是 service:
systemctl daemon-reload
systemctl enable --now backup.timer
这里第一个新手坑就出现了:很多人习惯性写 systemctl enable backup.service,结果 service 被设成开机就立刻执行一次,而 timer 根本没人管。记住原则——timer 是调度器,service 是被调度者,你永远启用 timer。查询状态也要查 timer:
systemctl list-timers --all
systemctl status backup.timer
journalctl -u backup.service -n 50 --no-pager
list-timers 会告诉你每个 timer 上次触发时间(LAST)、下次触发时间(NEXT)、以及激活状态。这是排查「任务到底跑没跑」的第一入口。
OnCalendar 语法:比五段式强太多的地方
OnCalendar 的格式是人类可读的时间描述,基本形式是 星期 年-月-日 时:分:秒。它支持范围、列表、步长和通配,表达力远超 cron。下面这张表覆盖了日常 90% 的场景:
*-*-* 03:00:00每天凌晨三点*-*-* 00/6:00:00每 6 小时一次(0 点、6 点、12 点、18 点)Mon..Fri *-*-* 09:00:00周一到周五早上九点*-*-01 04:30:00每月 1 号四点半Sun *-*-* 02:00:00每周日凌晨两点*-*-* 03:00:00 Asia/Shanghai指定时区(默认跟随系统)
相比 cron 的五段式,最直观的优势是「星期和日期可以同时生效」。cron 里如果同时写了「日期」和「星期」,系统是「或」的关系——0 0 1 * 1 会在每月 1 号或者每周一都执行,很多人以为这是「且」,结果任务跑得比预期多好几倍。而在 systemd 里,星期和日期是「与」关系,语义清晰,不会踩这个坑。
另一个杀器是 OnCalendar 支持口语化简写和前缀:hourly、daily、weekly、monthly、yearly,以及带 - 前缀表示「相对上一次」,比如 OnUnitActiveSec=1h 表示「上次激活后一小时」。这类「相对定时」在 cron 里完全做不到。
写完日历表达式,强烈建议先验证一遍再启用。systemd 自带一个校验命令,会打印未来若干次触发时间:
systemd-analyze calendar "*-*-* 00/6:00:00"
输出里会显示 Next elapse: 和 Iteration of unit list being completed,把你期望的下几次时间对照一遍,能提前发现「我以为每 6 小时,结果写成了每小时」这类低级错误。这个命令几乎是写 timer 的必备动作。
Persistent=true:cron 永远做不到的补跑
假设服务器每天凌晨三点备份,但某天凌晨两点到四点你在做系统维护,机器是关着的。cron 会怎么办?什么也不做,这一次备份就永远丢了,你甚至不会知道。这在生产环境里是危险的——你可能连续几个月没有成功备份,直到真正需要恢复的时候才发现。
Persistent=true 就是为解决这个问题设计的。开启之后,systemd 会记录这个 timer 上次触发的时间戳(存在 /var/lib/systemd/timers/ 下)。如果机器在应该触发的时间点是关机或休眠的,那么等它下次开机、timer 被激活时,systemd 会立刻补跑一次错过的任务。
[Timer]
OnCalendar=daily
Persistent=true
对于备份、证书检查、数据同步这类「必须保证执行」的任务,Persistent=true 基本是标配。而对于「每小时刷新一次缓存」这种错过也无所谓的任务,则可以不开启,避免开机瞬间一堆任务同时挤进来。
这里有个容易忽略的细节:Persistent=true 的补跑是「合并」的。如果你错过了三次触发(比如关机三天),systemd 不会补跑三次,而是只补跑一次。因为它的语义是「保证这个时间点之后至少执行过一次」,而不是「精确重放每一次」。理解了这一点,就不会对补跑次数感到困惑。
RandomizedDelaySec:防止任务雪崩
这是 systemd timer 另一个很实用但常被忽视的参数。想象你有 20 台服务器,都配置了「每天凌晨三点跑一次清理脚本」。到了三点整,20 台机器同时开工,同时打满磁盘 IO、同时往日志服务器灌数据、同时调用外部 API——这就是定时任务的「惊群效应」。
RandomizedDelaySec 让每次触发在一个随机延迟窗口内错开:
[Timer]
OnCalendar=daily
RandomizedDelaySec=30min
这样任务会在三点到三点半之间的随机时刻执行,20 台机器的负载自然被摊平。配合 AccuracySec(精度,默认 1 分钟)一起理解会更透:AccuracySec 是 systemd 为了省电而允许的触发时间容差,它会把同一时间点的任务合并到一次唤醒里;RandomizedDelaySec 则是在此基础上再加一层主动的随机抖动。对单机任务来说,把 AccuracySec 调大(比如 AccuracySec=1h)能减少唤醒次数、省电;对集群任务来说,RandomizedDelaySec 是更直接的错峰手段。
依赖顺序、超时与资源限制
cron 里如果任务 A 依赖任务 B 先完成,你只能靠「把 B 定在 2 点、A 定在 2 点 30 分」这种脆弱的时间差来保证顺序,一旦 B 跑慢了就崩。systemd 用依赖关系原生解决:如果 backup.service 必须先跑完再跑上传,可以用 ExecStartPost 串起来,或者用 unit 之间的 After=、Requires=。
超时控制也是 systemd 的强项。Type=oneshot 的任务如果卡死(比如备份脚本卡在 NFS 挂载上),会一直占着不放。加一个超时就能自动杀掉:
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
TimeoutStartSec=2h
超时之后进程被终止,任务的失败状态会如实记录在 journald 里,systemctl status 会显示 failed,配合 OnFailure= 还能自动触发一个告警单元。这就补上了 cron「出错了没人知道」这块最大的短板。再搭配 cgroup 资源限制,防止一个失控的备份把整台机器的内存吃光:
[Service]
MemoryMax=1G
CPUQuota=50%
IOWeight=50
这几个参数是 systemd 服务通用的,写在定时任务的 service 里同样生效,等于免费得到了资源隔离。
服务类型:oneshot 与 forking 的选择
定时任务的 service 有一半以上都用 Type=oneshot——意思是「执行完就退出,退出即完成」。这是脚本类任务最合适的类型:systemd 会等待进程结束,把退出码当作成功/失败依据(退出码 0 为成功)。
但如果你的脚本内部又 fork 了一个子进程去后台跑,然后主进程立刻返回,就会出现「systemd 以为任务结束了,其实子进程还在跑」的情况。这时要么用 Type=forking 并配合 PIDFile=,要么(更推荐)改造脚本让它自己 wait 子进程。绝大多数情况下,把任务写成「前台执行、跑完才退出」的 oneshot 是最稳妥的,不要在里面再套一层 nohup 后台。
从 cron 平滑迁移的实操清单
如果你已经有一堆 crontab,不必一次性推倒重来,可以按下面的路线逐步迁移:
- 第一步:列出所有 crontab 任务,标记哪些是「必须保证执行」的(备份、证书、同步),优先迁移这些。
- 第二步:每个任务写一个
.service,把原来一行命令里的重定向、环境变量、工作目录显式写清楚(cron 的环境极度贫瘠,PATH 只有/usr/bin:/bin,很多脚本在 cron 里跑不起来就是因为 PATH 不同)。 - 第三步:写对应的
.timer,把五段式翻译成OnCalendar,用systemd-analyze calendar校验。 - 第四步:
enable --now启用 timer,先保留原 cron 任务观察几天,确认 systemd 版本运行正常后再删掉 cron。 - 第五步:给关键任务加
OnFailure=告警单元,接入 ntfy 或邮件,让失败可见。
迁移过程中最常见的报错是「service 能手动 systemctl start 成功,但 timer 触发时失败」。九成原因是环境变量差异——手动执行时你带着 shell 的 PATH 和 env,而 systemd 用的是干净环境。解决办法是在 service 里显式声明 Environment= 或在脚本开头设置好绝对路径。养成「脚本里所有命令都写绝对路径」的习惯,能从根上避免这类问题。
踩坑复盘:三个真实故障
第一个坑是修改 timer 后忘了 daemon-reload。systemd 不会自动感知 unit 文件的改动,你改了 OnCalendar 却不 reload,跑的还是旧配置,排查半天以为语法写错了。记住:改任何 unit 文件之后,第一件事永远是 systemctl daemon-reload,然后 systemctl restart xxx.timer。
第二个坑是把 timer 的时间理解成本地时区。如果系统时区是 UTC(很多云服务器默认如此),你写的 03:00 实际是北京时间上午 11 点。要么把系统时区设对(timedatectl set-timezone Asia/Shanghai),要么在 OnCalendar 里显式带时区后缀。备份任务跑到业务高峰时段,往往就是这个原因。
第三个坑是oneshot 任务的退出码。脚本最后一条命令失败,整个 service 就标记为 failed——哪怕前面所有步骤都成功了。如果脚本里有些「允许失败」的清理命令(比如删除临时文件,文件不存在就返回非 0),记得在末尾补一句 exit 0 或者用 || true,否则会在监控里制造大量误报。反过来说,如果你希望任何一步失败都让任务算失败,就在脚本开头加 set -e,让 systemd 能准确感知。
小结
systemd timer 不是「cron 的替代品」这么简单,它是把定时任务从「能不能跑」升级到「跑得可观测、可恢复、可隔离」。对个人站长来说,最值得立刻用上的三个能力是:Persistent=true 保证错过的任务能补跑、日志进 journald 让失败可见、RandomizedDelaySec 避免多机同时开工。这三条正好补上了 cron 最要命的三个盲区。
迁移不必一步到位,先把最关键的备份和证书任务换成 timer,用几天确认稳定,再逐步扩展。等到你的所有定时任务都跑在 systemd 下、失败会自动告警、错过的会自动补跑时,你会发现运维里那种「不知道昨晚任务跑没跑」的不安感,彻底消失了。