把 cron 任务迁移到 systemd timer
团队的服务器上一直用 crontab 跑各种定时任务:数据库备份、清理临时文件、同步报表。cron 用了很多年,本身没什么问题,但随着任务变多,有几件事越来越让人头疼。今年夏天花了两个晚上,把大部分任务迁到了 systemd timer,这里记录一下过程。
为什么要迁
- 日志难找。cron 的输出默认发邮件,而服务器上根本没配邮件,结果就是任务失败了也没人知道。每个脚本自己重定向日志,格式和位置又各不相同。
- 环境变量不一致。手动执行正常、放进 cron 就失败,十有八九是
PATH不同。 - 没有防重入。备份脚本偶尔跑得慢,下一轮又启动了,两个进程同时写同一个文件。
- 错过就错过了。机器在维护窗口关机,那一次的任务就直接跳过。
这些问题 systemd timer 基本都有现成的解决办法。
基本写法
一个定时任务由两个文件组成:一个 .service 描述要做什么,一个同名的 .timer 描述什么时候做。以每天凌晨的数据库备份为例,原来的 crontab 是:
30 3 * * * /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1
改写后,/etc/systemd/system/db-backup.service:
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
User=backup
ExecStart=/opt/scripts/db-backup.sh
Nice=10
IOSchedulingClass=idle
/etc/systemd/system/db-backup.timer:
[Unit]
Description=Run db-backup daily at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=5min
Persistent=true
[Install]
WantedBy=timers.target
然后启用 timer(注意启用的是 timer,不是 service):
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now db-backup.timer
几个关键配置
Type=oneshot 表示这是一次性任务,执行完就结束。同一个 service 在运行中时,timer 再次触发不会启动第二个实例,防重入问题自然就解决了。
Persistent=true 会记录上次执行时间。如果机器在 03:30 处于关机状态,开机后会补跑一次。
RandomizedDelaySec 给触发时间加一点随机偏移。如果多台机器在同一时刻往同一个存储写备份,这个设置可以把压力错开。
写 OnCalendar 表达式时不确定对不对,可以先验证:
$ systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
它会直接告诉你下一次触发的具体时间。
日志和状态
这是迁移后最直接的收益。任务的标准输出和错误输出都会进入 journal,不再需要在脚本里手动重定向:
$ journalctl -u db-backup.service --since today
$ systemctl list-timers
list-timers 能一眼看到所有定时任务上次和下次的执行时间。任务失败时 service 会进入 failed 状态,接入监控也很方便,只需要检查 systemctl --failed 的输出是否为空。
需要注意的地方
- systemd 启动的服务环境非常干净,脚本里用到的命令最好写绝对路径,或者在 service 里用
Environment=显式设置。 - 测试时可以直接
systemctl start db-backup.service手动触发一次,不用等到时间点。 - 迁移完成后记得把 crontab 里对应的行删掉,否则同一个任务会跑两遍。
当然,cron 并不是不能用了。对于个人机器上一两行的小任务,crontab 依然最省事。但在需要可靠性和可观测性的服务器上,timer 值得多写那几行配置。