备份脚本在 cron 里跑了三年,换机器后一次没成功:systemd timer 迁移实录(2026)

systemd timer 用「.service 定义做什么 + .timer 定义什么时候做」两个单元文件替代 crontab,好处是日志自动进 journal、失败能设重试、错过的任务能设补跑、依赖关系能声明。 迁移一个任务的实际成本是两个文件加一条 systemctl enable --now,五分钟以内。2026 年 10 月触发我迁移的是一个很典型的场景:备份脚本在老机器上跑了三年没出过事,换到新机器后连续一周没生成任何备份文件,crontab -l 里那行明明还在——因为新机器上 rclone 装在 /usr/local/bin,而 cron 的默认 PATH 只有 /usr/bin:/bin。

上面是结论。上一篇写 rsync + cron 异地备份时,我用的是 crontab;这篇讲为什么那套东西在新环境里会静默失效,以及迁到 timer 之后具体好在哪。

cron 与 systemd timer 的执行差异

迁移五步

cron 到底哪里不够用?

结论:cron 的问题不在「能不能定时」,而在「出问题时它什么都不说」——没有日志、没有退出码记录、没有补跑机制。

能力 cron systemd timer
执行环境 极简 PATH,几乎无环境变量 可显式声明 Environment=
日志 只有 MAILTO 邮件,或自己重定向 自动进 journal,journalctl -u 直查
错过补跑 没有,错过就是错过 Persistent=true 开机后补一次
失败重试 自己写 Restart= / RestartSec=
依赖先后 靠 sleep 猜 After= / Requires=
随机延迟 自己算 RandomizedDelaySec=
资源限制 无 MemoryMax= / CPUQuota=

最要命的是「静默失败」。cron 任务失败时,如果没配 MAILTO,你唯一能发现的方式是「该有的文件没出现」——而人不会每天去检查一个「本来就应该存在」的文件。

我那次的具体表现:

$ crontab -l
0 3 * * * /root/backup.sh >> /var/log/backup.log 2>&1
$ ls -l /var/log/backup.log
-rw-r--r-- 1 root root 214 Oct 12 03:00 /var/log/backup.log
$ cat /var/log/backup.log
/root/backup.sh: line 12: rclone: command not found

日志里明明白白写着 command not found,但这条错误在一周里没人看到。cron 不是没执行,是执行了然后失败了,而失败没有出口。

步骤 1:复现 cron 失败

第一步:先确认 cron 环境的真实 PATH

结论:在 cron 里跑一条 env 就能看到它的真实环境,别猜。

$ (crontab -l 2>/dev/null; echo "* * * * * env > /tmp/cron-env.txt") | crontab -
$ sleep 70
$ cat /tmp/cron-env.txt

典型输出里 PATH=/usr/bin:/bin,HOME=/root,SHELL=/bin/sh,其余几乎没有。这就是为什么「我手动跑没问题」——你手动跑的时候用的是登录 shell,/etc/profile 把 /usr/local/bin 加进了 PATH。

临时修补有三种写法,都治标:

# 写法一:脚本里写绝对路径
/usr/local/bin/rclone sync ...

# 写法二:脚本开头补 PATH
export PATH=/usr/local/bin:/usr/bin:/bin

# 写法三:crontab 顶部声明
PATH=/usr/local/bin:/usr/bin:/bin

我选的是迁移到 timer,因为在 timer 里这个问题变成了显式配置,而不是靠记忆。

清理刚才的测试任务:

$ crontab -l | grep -v "cron-env" | crontab -

步骤 2:查 cron 环境

第二步:把脚本拆成 service + timer

结论:一个任务对应两个文件,.service 描述执行什么,.timer 描述何时执行,两者文件名前缀必须一致。

/etc/systemd/system/backup.service:

[Unit]
Description=Nightly backup to object storage
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=TZ=Asia/Shanghai
ExecStart=/root/backup.sh
User=root
Nice=10
IOSchedulingClass=idle

# 失败重试:最多 3 次,每次间隔 5 分钟
Restart=on-failure
RestartSec=5min

# 资源上限,防止备份把机器拖死
MemoryMax=1G

/etc/systemd/system/backup.timer:

[Unit]
Description=Run backup daily at 03:00

[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=300
Persistent=true
Unit=backup.service

[Install]
WantedBy=timers.target

四个关键项:

  • Type=oneshot:脚本执行完就退出,这是定时任务的标准类型。用 simple 会让 systemd 误以为服务该常驻。
  • Persistent=true:机器在 03:00 时关着,开机后会补跑一次。这是 cron 完全没有的能力,笔记本和小鸡经常关机,这条价值很高。
  • RandomizedDelaySec=300:在 03:00 后随机延迟 0–300 秒。多台机器同时备份同一个存储时,这条能避免撞在一起。
  • Nice=10 + IOSchedulingClass=idle:备份是低优先级任务,不该和线上服务抢 IO。

官方文档在 systemd.timer 手册,OnCalendar 的完整语法在 systemd.time 手册。

步骤 3:编写 service 与 timer

第三步:验证语法、启用、手动触发一次

结论:systemd-analyze calendar 能在启用之前就告诉你「下次什么时候跑」,这是最容易省时间的一步。

$ systemd-analyze calendar "*-*-* 03:00:00"
  Original form: *-*-* 03:00:00
Normalized form: *-*-* 03:00:00
    Next elapse: Sun 2026-10-19 03:00:00 CST
       (in: 5h 12min)

如果时间不对,八成是 TZ 或系统时区的问题,先 timedatectl 看一眼。

启用:

$ systemctl daemon-reload
$ systemctl enable --now backup.timer
$ systemctl list-timers --all | grep backup
Sun 2026-10-19 03:04:47 CST  4h 17min  Sun 2026-10-18 03:04:47 CST  23h ago  backup.timer  backup.service

注意 enable --now backup.timer 里的 .timer 后缀不能省。 只写 systemctl enable backup 会去找 backup.service,而 service 没有 [Install] 段,结果是「启用了但永远不会自动跑」——这个坑我踩过,list-timers 里根本看不到条目。

手动触发一次验证(不等到凌晨):

$ systemctl start backup.service
$ systemctl status backup.service
$ journalctl -u backup.service -n 50 --no-pager

journalctl -u 直接看这个单元的完整输出,包括 stdout/stderr,不需要在脚本里重定向。这是迁移后最直观的改善。

步骤 4:启用并验证

第四步:把五个任务一次迁完

结论:迁移是机械劳动,每个任务照抄模板改三处——描述、执行命令、时间表达式。

我这次的五个任务:

原 cron 迁移后 timer OnCalendar
0 3 * * * 备份 backup.timer *-*-* 03:00:00
30 4 * * * 数据库 dump dbdump.timer *-*-* 04:30:00
*/10 * * * * 健康检查 healthcheck.timer *:0/10
0 5 * * 0 每周清理 weekly-clean.timer Sun *-*-* 05:00:00
@reboot 启动服务 改成 systemctl enable 不需要 timer

最后一行值得单独说:@reboot 在 cron 里是个常见用法,但在 systemd 里根本不需要——把服务写成 Type=simple 并 enable,开机自然启动。用 timer 模拟 @reboot 是绕远路。

*:0/10 表示「每小时的第 0、10、20…分钟」,等价于 */10 * * * *。这个写法可以用 systemd-analyze calendar "*:0/10" 验证。

步骤 5:批量迁移

踩过的四个坑

结论:忘写 .timer 后缀、Type 用错、OnCalendar 写成 cron 语法、日志被 journal 上限截断,这四条覆盖了大部分失败场景。

坑一:systemctl enable backup(漏了 .timer)。 命令成功返回,但 list-timers 里没有它。正确写法是 systemctl enable --now backup.timer。

坑二:Type=simple 配 oneshot 脚本。 表现是 systemctl status 一直显示 activating,因为 systemd 在等一个永远不会常驻的进程。定时任务一律 Type=oneshot。

坑三:OnCalendar 写成 cron 表达式。 OnCalendar="0 3 * * *" 会报 Failed to parse calendar specification。systemd 的语法是 星期 年-月-日 时:分:秒,跟 cron 完全是两套。

坑四:journal 日志查不到。 默认 journal 会按容量滚动删除,一周前的日志可能已经没了。要长期留存,给对应的 service 加:

StandardOutput=append:/var/log/backup.log
StandardError=append:/var/log/backup.err

append: 前缀是 systemd 236 之后支持的,比 >> 重定向更可靠(不会因为文件被轮转而写丢)。

权限与执行用户的一个细节

结论:service 默认用 root 跑,如果脚本实际只需要普通用户权限,一定要显式写 User=,否则一次脚本漏洞就是整机沦陷。

上面的示例里我写了 User=root,那是因为备份要读 /var/www 和数据库导出目录,绕不开。但多数任务不需要 root:

[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www/website
ExecStart=/var/www/website/bin/sync-assets.sh

WorkingDirectory= 这一项经常被忽略。cron 里任务的工作目录是执行者的 home,systemd 里默认是 /——脚本里所有相对路径都会跑到根目录去找文件。这就是「手动跑没问题、定时跑找不到文件」的第二大原因(第一大是 PATH)。凡是用相对路径的脚本,迁过来必须补 WorkingDirectory。

还有一个隐蔽的差别:systemd 默认不继承登录 shell 的环境,~/.bashrc 里配的那些变量一概没有。要么在 service 里用 Environment= 一条条声明,要么用 EnvironmentFile=/etc/backup.env 集中管理。后者更适合有多个变量(数据库密码、Webhook 地址)的场景,文件权限设 600 就行。

什么时候不该迁

结论:单台机器、任务简单、你已经有一套完整的 cron 日志和告警,就别折腾。

systemd timer 的学习成本真实存在:两个文件、一堆指令名、OnCalendar 要重新学。如果你只有一两个任务、跑在很稳定的环境里、并且已经用 MAILTO 或集中日志解决了「失败可见」的问题,cron 完全够用。

我迁移的真实动因不是「systemd 更先进」,是这台机器上的任务关系到数据安全,我需要失败时有人告诉我。日志进 journal + Restart=on-failure + Persistent=true 这三条,恰好补上了 cron 最薄的那一层。

常见问题

Q:systemd timer 和 cron 能共存吗?
A:能,互不干扰。迁移期间可以保留 cron 但先注释掉,确认 timer 跑通一周后再删。

Q:Persistent=true 补跑会不会重复执行?
A:只会补一次,且只在「上次触发时间点机器是关的」情况下补。如果任务本身不幂等(比如「发一条通知」),补跑可能导致重复,这类任务别开 Persistent。

Q:怎么让任务在开机后延迟几分钟再跑?
A:OnBootSec=5min,或者用 OnCalendar 配 RandomizedDelaySec。前者更适合「等网络就绪」的场景。

Q:timer 能精确到秒级吗?
A:OnCalendar=*-*-* *:*:00/30 可以做到 30 秒一次,但 systemd 的最小调度粒度在实际表现上约 1 秒,高频任务建议还是用常驻进程而不是 timer。

Q:怎么一次看所有 timer 的执行情况?
A:systemctl list-timers --all 看排期,journalctl -u '*.timer' -u '*.service' --since today 看当天执行记录。

相关阅读

上一篇 主机「活着」但很慢:Prometheus + Grafana 画出资源曲线(2026)