用 systemd 定时器替换 Cron 任务
了解如何用systemd定时器替换cron任务:创建service和timer单元、验证OnCalendar计划、补跑错过的任务,并检查日志与错误。
要用 systemd 定时器替换 cron 任务,需要编写两个 unit 文件:一个负责运行脚本的 .service,以及一个指定运行时间的 .timer。然后用 systemctl enable --now 启用该定时器。
如果你曾在早上 9 点打开 /var/log/backup.log,却无法判断昨晚的任务是否执行成功,那么你应该已经明白人们为什么要弃用 cron 了。本文将把一条真实的 crontab 条目迁移为定时器:
30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
每一节分别替换这条条目中的一部分:调度表达式变为 OnCalendar=,输出重定向交给 journal 处理,而“到底有没有运行?”则可以通过 systemctl status 来回答。如需了解 cron 本身的背景知识,请参阅如何自动化 cron 任务。文中的命令适用于当前任何基于 systemd 的发行版。
要点速览
- systemd 定时器是一个
.timerunit,其[Timer]段决定对应的.service何时运行。service 定义运行什么,而需要启用的是 timer。 - 由定时器触发的 service 不需要
[Install]段。定时器通过WantedBy=timers.target挂接到开机启动流程中。 systemd-analyze calendar可以检查OnCalendar=表达式能否被正确解析,并输出下一次触发的时间。Persistent=true会在定时器再次激活后,立即补跑因关机而错过的任务。- systemd 定时器没有与 cron 的
MAILTO等效的内置功能。如需失败告警,请使用OnFailure=。
Cron 做不到的事
Cron 只负责按计划启动命令,除此之外几乎什么都不管。以下这些短板,通常就是迁移任务的理由:
- 除了邮件发送的输出外没有任何日志。 你要么手动将输出重定向到文件,要么依赖
MAILTO。定时器任务会自动写入 journal(参见“查看日志”一节)。 - 任务之间无法建立依赖。 定时器任务就是普通的 unit,因此可以依赖其他 systemd unit。
- 计划时间点若处于关机状态,任务就不会运行。
Persistent=true可以补跑错过的任务(参见 Persistent 一节)。 - 没有便捷的方法查看昨晚的运行结果。
systemctl status会显示最近一次运行情况及其退出码。
从零构建一个 systemd 定时器
一个可用的定时器只需要 /etc/systemd/system/ 下的两个文件,外加一条命令:sudo systemctl enable --now backup.timer。先从脚本开始。
#!/usr/bin/env bash
# /usr/local/bin/backup.sh
set -euo pipefail
SRC=/srv/app/data
DEST=/var/backups/app
tar -czf "$DEST/app-$(date +%F).tar.gz" -C "$SRC" .
find "$DEST" -name 'app-*.tar.gz' -mtime +14 -delete
set -euo pipefail 使脚本在任一命令失败时立即以非零状态退出,而 systemd 记录的正是这个退出码。为脚本添加可执行权限,并创建目标目录。如果你对权限位不太熟悉,可以参阅 Linux 文件权限指南。
sudo chmod +x /usr/local/bin/backup.sh
sudo install -d -o deploy -m 750 /var/backups/app
接下来是 service 文件 /etc/systemd/system/backup.service:
[Unit]
Description=Nightly tarball of /srv/app/data
[Service]
Type=oneshot
User=deploy
ExecStart=/usr/local/bin/backup.sh
systemd.service(5) 中的 Type=oneshot 适用于运行至结束的脚本。只有在脚本退出后,systemd 才会认为该 unit 已完成启动。除非添加 RemainAfterExit=,否则该 unit 随后会直接回到 inactive 状态,永远不会显示为“active”。请将 deploy 替换为一个已存在的、能够读取 SRC 并写入 DEST 的非 root 账户。如果不设置 User=,任务将以 root 身份运行。该 service 没有 [Install] 段,也没有引用定时器。Arch Wiki 的定时器页面解释了原因:需要启用的是定时器,由它来激活同名的 service。
然后是定时器文件 /etc/systemd/system/backup.timer:
[Unit]
Description=Run backup.service daily at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
timers.target 是一个特殊的 target,systemd 在开机时通过它拉起所有应当运行的定时器。重新加载 systemd,手动运行一次任务,然后再设置定时调度:
sudo systemctl daemon-reload
sudo systemctl start backup.service # test run now; returns when the script exits
systemctl status backup.service # check the result
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
由于每个定时任务都是普通的 service,你可以随时通过 systemctl start backup.service 按需运行并调试,无需等到 02:30。单独使用 enable 只会创建符号链接,而 systemctl(1) 中的 --now 选项还会同时启动定时器。list-timers 接受 unit 匹配模式,加上 --all 则会包含未激活的定时器。两次运行之间,service 显示为 inactive,这属于正常现象。
OnCalendar 与五字段 Cron 表达式对比
在将任何 OnCalendar= 表达式写入定时器文件之前,先用 systemd-analyze calendar 检查一遍。它会确认表达式能够被正确解析,并输出下一次触发的时间。systemd.time(7) 中定义的语法为 DayOfWeek Year-Month-Day Hour:Minute:Second。任何日期或时间字段都可以是匹配所有值的 *、以逗号分隔的值列表,或用 .. 表示的范围。星期字段可以完全省略;如果保留,请使用英文星期名称,如 Mon 或 Monday,而不能使用 *。
常见的 cron 调度与 OnCalendar= 表达式的对应关系如下。表中 cron 部分的详细说明可参阅 Linux cron 速查表:
| 调度 | cron | OnCalendar |
|---|---|---|
| 每天 02:30 | 30 2 * * * | *-*-* 02:30:00 |
| 工作日 22:30 | 30 22 * * 1-5 | Mon..Fri 22:30 |
| 每周日 03:00 | 0 3 * * 0 | Sun *-*-* 03:00:00 |
| 每月 1 日零点 | 0 0 1 * * | *-*-01 00:00:00 |
| 每 15 分钟 | */15 * * * * | *:00/15 |
cron 条目 30 22 * * 1-5 对应 OnCalendar=Mon..Fri 22:30,因为 cron 中星期数字 1 到 5 表示周一至周五。/15 后缀表示“每 15 个单位重复一次”。使用 systemd-analyze calendar 验证表达式:
systemd-analyze calendar "Mon..Fri 22:30" --iterations=3
示例输出(日期为占位符):
Original form: Mon..Fri 22:30
Normalized form: Mon..Fri *-*-* 22:30:00
Next elapse: Mon YYYY-MM-DD 22:30:00 CEST
(in UTC): Mon YYYY-MM-DD 20:30:00 UTC
From now: 5h 12min left
Iteration #2: Tue YYYY-MM-DD 22:30:00 CEST
Iteration #3: Wed YYYY-MM-DD 22:30:00 CEST
Normalized 一行展示了简写形式展开后的完整语法。(in UTC) 一行仅在本地时区不是 UTC 时出现。systemd 253 之前的版本会将后续运行标记为 Iter. #2 而非 Iteration #2。--iterations= 需要 systemd 242 或更高版本。
关于精度有一点需要注意:定时器的 AccuracySec 默认值为一分钟,因此 02:30:00 的任务并不保证恰好在该秒启动。如果需要 systemd 所能提供的最精确的触发时间,请在 [Timer] 段中添加 AccuracySec=1us。
Persistent=true:为笔记本量身定做的特性
设置 Persistent=true 后,如果定时器因机器关机而错过了计划运行,它会在再次激活后立即启动对应的 service,通常也就是下次开机时。普通 cron 没有对应的功能:如果笔记本在 02:30 处于关机状态,cron 会直接跳过该任务。虽然有一个独立的 anacron 工具专门用于补跑错过的任务(在 RHEL 系发行版上随 cronie 一同提供),但它只能以整天为单位调度,无法指定具体时刻。systemd.timer(5) 会记录每个持久化定时器的上次触发时间。Persistent= 对未设置 OnCalendar= 的定时器不起作用,并且补跑同样会等待 RandomizedDelaySec= 设定的延迟。
OnBootSec= 和 OnUnitActiveSec= 等单调定时器(monotonic timer)的行为有所不同:除非设置了 WakeSystem=,否则它们在系统挂起期间会停止计时。对于与一天中具体时刻相关的任务,请使用 OnCalendar= 配合 Persistent=true。
查看日志与上次运行状态
要确认昨晚的任务是否成功,运行 systemctl status backup.service。它会显示该 service 上次运行的时间、成功还是失败,以及进程的退出码。journalctl(1) 则会显示脚本输出的全部内容:
systemctl status backup.service
journalctl -u backup.service --since yesterday
journalctl -u backup.service --since "-2h"
--since 接受 yesterday、today、绝对时间戳,以及以 - 为前缀的相对偏移量。注意,yesterday 指的是昨天零点,因此输出中也会包含今天的日志条目。
不妨故意让 systemd 定时任务失败一次,亲眼看看效果。将脚本修改为 SRC=/srv/app/missing,然后运行:
sudo systemctl start backup.service
systemctl status backup.service
journalctl -u backup.service -n 20
tar 执行失败,set -e 使脚本以非零状态退出,unit 最终进入 failed 状态,其状态信息中会包含该退出码。journal 中则保存了 tar 的错误信息本身。完成后记得将 SRC 改回原值。
什么情况下 Cron 依然够用?
对于没有任何依赖的单个任务,cron 依然够用,而且更简单。一个任务只需 crontab 中的一行,cron 几乎随处可用,一个没有依赖的夜间任务在它上面也能运行得很好。而定时器需要两个文件和几条 systemctl 命令,并且没有与 MAILTO 等效的内置功能。如果希望任务失败时收到通知,可以将 service 的 OnFailure= 指令指向一个负责发送告警的 unit。
总结
从 cron 切换到 systemd 定时器,可以获得普通 cron 无法提供的三项能力:可按需运行和检查的任务、无需重定向即可写入 journal 的输出,以及停机后的补跑机制。挑选一个你最常担心的 crontab 任务,为它编写 service 和 timer 文件,用 systemd-analyze calendar 验证调度表达式,用 systemctl start 进行测试,待 systemctl status 报告一次成功的计划运行后,再删除对应的 crontab 条目。
常见问题
可以不使用 root,以普通用户身份运行 systemd 定时器吗?
可以。将 .service 和 .timer 文件放在 ~/.config/systemd/user/ 中,并使用 systemctl --user 进行管理,例如 systemctl --user enable --now backup.timer。默认情况下,用户的 systemd 实例会在该用户最后一个登录会话关闭后停止,定时器也会随之停止触发。运行 sudo loginctl enable-linger 并在其后加上用户名,即可让用户实例在开机时启动,并在没有会话的情况下保持运行。
AccuracySec 和 RandomizedDelaySec 有什么区别?
AccuracySec 为 systemd 提供了合并定时器事件的余地,从而减少唤醒 CPU 的次数。定时器可以在计划时间到计划时间加上 AccuracySec 之间的任意时刻触发,该时间窗口默认为 1 分钟。RandomizedDelaySec 的作用则与合并相反:它会将每次运行随机推迟一段时间,最长不超过你设定的值(默认为 0),使相同调度的定时器错开触发,而不是同时触发。如果同时设置两者,会先应用随机延迟,再由 systemd 将该事件与其他事件对齐合并。
如何在不编写 unit 文件的情况下,用 systemd 调度一次性任务?
使用带定时器选项的 systemd-run,例如 sudo systemd-run --on-calendar='Mon 14:00' /usr/bin/cleanup.sh。这会创建一对临时的(transient)定时器和 service。只有定时器会立即启动,service 则在定时器到期时运行。--on-active= 和 --on-boot= 等选项会创建单调型临时定时器。临时 unit 在重启后不会保留,因此周期性任务仍应写成 unit 文件。
定时器文件必须与其触发的 service 同名吗?
不是必须的,但这是默认行为。定时器会激活去掉后缀后同名的 service,因此 backup.timer 会启动 backup.service。如需触发其他名称的 unit,请在 [Timer] 段中设置 Unit=,例如 Unit=nightly-backup.service。该值不能是另一个 .timer unit。尽管如此,systemd.timer(5) 仍建议让定时器与其启动的 unit 使用相同的基本名称。