上周凌晨三点,我在 NAS 上跑的一个数据库备份脚本悄悄罢工了。没有报错邮件,没有日志,cron 表达式写得漂漂亮亮,crontab -l 一看——任务明明还在。但它就是没跑。
排查一圈下来,发现是老朋友 PATH 的锅:脚本里用了 pg_dump 的绝对路径以外的另一个命令,cron 的环境变量只有可怜的几个,手动测试一切正常,进了 cron 就抓瞎。
这种事 cron 党基本都经历过。所以这次我干脆把这个备份任务迁到了 systemd timer,顺手把踩的五个坑记录下来。结论先放这儿:如果你的定时任务超过一行,systemd timer 值得迁。

为什么是 systemd timer
先说迁移的理由,不是为了追新:
- 日志原生接入 journald。
journalctl -u xxx.service直接看每次执行的全量输出,不用自己重定向日志再写 logrotate - 环境可控。
Environment=想塞什么塞什么,不用跟 cron 的最小环境猜谜 - 补跑机制。机器凌晨关机错过的任务,开机后可以补跑一次(这个有坑,后面说)
- 依赖管理。可以声明”等网络就绪再跑”、”等 MySQL 起来再跑”,cron 做不到

操作步骤
一共两个文件:一个 .service 定义做什么,一个 .timer 定义什么时候做。
第一步:写 service 文件
/etc/systemd/system/db-backup.service:
[Unit]
Description=PostgreSQL 每日备份
[Service]
Type=oneshot
User=ubuntu
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=PGPASSWORD_FILE=/home/ubuntu/.pgpass
ExecStart=/home/ubuntu/scripts/db_backup.sh
Type=oneshot 是关键——这是一次性任务,不是常驻服务。
第二步:写 timer 文件
/etc/systemd/system/db-backup.timer:
[Unit]
Description=每天凌晨 2:30 触发备份
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
OnCalendar 的语法比 cron 表达式啰嗦,但可读性好。RandomizedDelaySec=300 让任务在 2:30 到 2:35 之间随机触发,避免整点扎堆——备份服务器最恨整点风暴。
第三步:启用
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer
systemctl list-timers --all | grep db-backup
注意第三条命令:list-timers 能看到 NEXT 和 LAST 两列,说明任务已注册且知道下次什么时候跑。
第四步:手动触发验证
sudo systemctl start db-backup.service
journalctl -u db-backup.service -n 50 --no-pager
别等半夜 2:30 才第一次验证。手动 start 跑一遍,输出全在 journal 里,比 cron 时代舒服太多。

踩坑记录(重点来了)
坑一:Persistent=true 不是”补跑所有漏掉的”。
我以为机器宕机三天,起来会补跑三次。实际上它只补最近一次错过的触发,而且只在 timer 被加载时检查。对每日备份来说够用,但如果你幻想它是个消息队列,会失望。另外它需要 /var/lib/systemd/timestamps/ 里有时间戳文件,目录被清过就当没这功能。
坑二:User= 不等于登录 shell。
坑我最狠的一个。User=ubuntu 只是以 ubuntu 的身份执行,不会加载 .bashrc、.profile,PATH 依然是精简版。脚本里裸写 docker、psql 这类非 /usr/bin 的命令照样找不到。解法要么在 service 里 Environment=PATH=... 写全,要么脚本里全部用绝对路径。我两个都做了。
坑三:ExecStart 里的 % 和 $ 有特殊含义。
systemd 会解析 unit 文件里的 % 系列 specifier(比如 %h 是 home 目录)和 $ 环境变量引用。我脚本参数里带个 %s 格式化占位符,直接被 systemd 吃掉报错。要传字面量,% 写成 %%,$ 写成 $$。
坑四:OnCalendar 写错不会立刻报错。OnCalendar=02:30(少了日期部分)这种写法,有的版本容忍、有的版本直接静默不触发。写完必跑一条验证:
systemd-analyze calendar "*-*-* 02:30:00"
它会告诉你下一次触发时间。对不上预期就别 enable,先改。
坑五:别 enable service,只 enable timer。enable --now db-backup.service 是常见手滑——oneshot 的 service enable 了也没意义,还可能在开机时被意外拉起一次。规则记住一条:enable 和 start 永远对着 timer,service 只用于手动验证。
迁移完的感受
备份脚本从 cron 迁到 systemd timer 之后,最大的变化其实是心理上的:journalctl -u db-backup -f 一挂,每次执行的成功失败、输出、耗时全在那里,再也不用怀疑”它到底跑没跑”。
cron 不是不好,它简单直接跑了四十年。但它的简单是建立在”出错你自己扛”上的。任务一复杂、一关键,systemd timer 的可观测性就值回票价了。
如果你的服务器上还有那种”跑了三年、没人知道输出去了哪”的 cron 任务,周末花半小时迁一个试试。