用 systemd timer 替代 cron:更可控的定时任务
定时任务几乎是每台服务器的标配:备份、清理、同步、拉取证书。多数人第一反应是 crontab -e,写一行就走人。cron 确实简单,但当任务变多、出错、需要排查时,它的短板会集中暴露——脚本执行环境和交互式 shell 不同、输出被邮件吞掉、任务重叠执行、机器关机期间的计划直接丢失。
systemd 已经是主流发行版的 init 系统,它自带的 timer 单元可以完整替代 cron,并且把定时任务纳入统一的服务管理与日志体系。本文记录一套可直接套用的写法。
与 cron 对比
| 维度 | cron | systemd timer |
|---|---|---|
| 配置形式 | 单行表达式 | 两个单元文件(.service + .timer) |
| 日志 | 默认发邮件,常常无人查看 | 统一进 journal,journalctl -u 直接看 |
| 执行环境 | 极简 PATH,与登录 shell 不同 | 显式声明,行为可预期 |
| 错过的计划 | 关机期间直接丢失 | Persistent=true 开机后补跑 |
| 任务重叠 | 上次没跑完也会再起一个 | 同一 service 天然串行,不会重入 |
| 随机延迟 | 需要自己 sleep $RANDOM | RandomizedDelaySec= 一行搞定 |
| 资源限制 | 无 | 可用 cgroup 限制 CPU / 内存 |
| 依赖关系 | 无 | 可声明 After=、Requires= |
| 手动触发 | 只能手工执行脚本 | systemctl start xxx.service |
| 上次/下次执行 | 需自行记录 | systemctl list-timers 一览 |
代价是配置变啰嗦了:一件事要写两个文件。换来的是可观测性和确定性,任务一多就很划算。
一个完整示例
假设需要每天凌晨做一次备份脚本。先写 service 单元,描述「做什么」:
[Unit]Description=Daily backup jobAfter=network-online.targetWants=network-online.target
[Service]Type=oneshotUser=backupGroup=backupEnvironment="LANG=C.UTF-8"WorkingDirectory=/srv/backupExecStart=/usr/local/bin/backup.shTimeoutStartSec=30min
# 简单加固PrivateTmp=trueProtectSystem=strictProtectHome=trueNoNewPrivileges=trueReadWritePaths=/srv/backup注意 Type=oneshot:跑完就退出,不是常驻服务。没有 [Install] 段是刻意的,这个 service 不需要开机自启,由 timer 拉起。
再写 timer 单元,描述「什么时候做」:
[Unit]Description=Run backup daily
[Timer]OnCalendar=*-*-* 03:30:00Persistent=trueRandomizedDelaySec=15minAccuracySec=1minUnit=backup.service
[Install]WantedBy=timers.target两个文件同名(backup),systemd 会自动关联,Unit= 其实可省略,写出来更直白。
启用:
sudo systemctl daemon-reloadsudo systemctl enable --now backup.timer
# 看下次触发时间systemctl list-timers backup.timer
# 不等定时,立刻手动跑一次验证sudo systemctl start backup.service
# 看这次跑的输出journalctl -u backup.service -n 50 --no-pagerOnCalendar 的语法比 cron 表达式易读得多,写完可以用 systemd-analyze 校验:
systemd-analyze calendar "Mon..Fri 09:00"systemd-analyze calendar "*-*-01 04:00:00"systemd-analyze calendar "hourly"它会打印规范化结果和下一次触发时间,比在脑子里推算 0 3 * * * 靠谱。
除了绝对时间,还有相对触发方式,适合「开机后 10 分钟跑一次,之后每 6 小时一次」这类需求:
[Timer]OnBootSec=10minOnUnitActiveSec=6h常见坑
1. 改了单元文件没有 daemon-reload。
systemd 读的是缓存后的配置,改完必须 systemctl daemon-reload,否则你会对着「明明改了却没生效」发呆。
2. enable 错了对象。
要 enable 的是 .timer 而不是 .service。启用 service 意味着开机就跑一次,和你的意图无关。
3. 以为 service 里能用 shell 语法。
ExecStart= 不经过 shell,管道、重定向、通配符、&& 都不会被解析。需要这些就调用脚本文件,或者显式写成:
ExecStart=/bin/bash -c 'foo | bar > /var/log/out.log'4. PATH 和环境变量与登录 shell 不同。
service 里的 PATH 很短,node、pnpm、docker 之类常因为版本管理器装在用户目录而找不到。稳妥做法是在 ExecStart 里写绝对路径,或用 Environment= 显式补全。
5. Persistent=true 的补跑行为要想清楚。
它会在开机后立即补执行错过的任务。对备份、证书续期是好事;对「每天推送一次通知」类任务,可能在开机瞬间一次性触发一堆,需要评估。
6. 相对时间的基准容易记混。
OnUnitActiveSec= 从上次启动时刻算起,OnUnitInactiveSec= 从上次结束时刻算起。任务耗时长时两者差别明显。
7. 忘了任务超时。
Type=oneshot 默认会等任务结束,卡住的进程可能一直挂着。用 TimeoutStartSec= 给个上限,超时自动杀掉,不至于挡住下一轮。
8. 用户级 timer 的存活问题。
放在 ~/.config/systemd/user/ 下的 timer 由 systemctl --user 管理,用户退出登录后默认会被清掉。需要常驻就执行一次 loginctl enable-linger <用户名>。
9. 集群里所有机器同一秒触发。
多台机器同时拉取同一个上游,容易把对方打崩。RandomizedDelaySec= 把触发点打散,比在脚本里 sleep $((RANDOM % 300)) 干净得多。
排查手册
几条日常够用的命令:
# 所有 timer 的上次/下次执行systemctl list-timers --all
# 某个任务的历史日志(含退出码)journalctl -u backup.service --since "3 days ago"
# 实时跟踪journalctl -u backup.service -f
# 看单元最终生效的完整配置(含 drop-in 覆盖)systemctl cat backup.servicesystemd-analyze verify /etc/systemd/system/backup.servicesystemctl status backup.service 会显示上次退出码,脚本里记得让失败路径 exit 非零,否则 systemd 认为一切正常。
小结
cron 依然适合「一行搞定、跑挂了也无所谓」的临时任务。但只要任务需要被观测、需要处理错过与重叠、需要限制资源或声明依赖,systemd timer 的多写几行就非常值得。
迁移路径也不激进:不必一次性清空 crontab,可以先把最关键、最容易出问题的那几个任务改成 timer,跑上两周看看日志是否更好排查,再决定要不要继续推进。
—— 小鱼人
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













