
一台跑了两年的备份机,cron 里躺着七条任务。某天磁盘满了,备份连续失败了十九天,没人发现——因为 cron 把错误写进了系统邮件,而这台机器根本没装邮件服务。
cron 最大的问题不是不能定时,是它不告诉你它没做。任务成没成功、跑了多久、上次是什么时候跑的,全靠自己另建一套监控。
下面是把它换成 systemd timer 的完整过程,环境是 Debian 12(systemd 252)和 Ubuntu 22.04(systemd 249),两台上都跑通过。三个文件,一处软链接,四行命令,之后所有执行记录都进 journal。
1. 先看清楚两者差在哪
cron 是一个「到点就 fork 一个 shell」的调度器,它对任务本身一无所知。systemd timer 则是把「什么时候跑」和「跑什么」拆成两个单元,跑的东西是一个正经的 service。拆开之后,日志、超时、依赖、失败重试全部由 systemd 接管。
| 能力 | cron | systemd timer |
|---|---|---|
| 执行日志 | 默认发系统邮件,通常没人收 | 直接进 journal,journalctl -u 可查 |
| 关机期间错过的任务 | 直接丢掉 | Persistent=true 开机补跑一次 |
| 超时控制 | 无,卡死就永久卡死 | TimeoutStartSec 到点强杀 |
| 错开并发 | 手动改时间 | RandomizedDelaySec 自动抖动 |
| 依赖顺序 | 只能靠 sleep 猜 | After= / Requires= 明确声明 |
| 环境变量 | 一份极简的 PATH,和登录 shell 不同 | Environment= 显式声明 |
表格里最值钱的一行是「关机期间错过的任务」。备份机半夜断电、白天修好,cron 那一晚的备份就是永久缺失,而且不会有任何提示。
2. 目录树:三个文件放哪里
系统级单元放 /etc/systemd/system/,脚本放 /usr/local/bin/。不要把脚本和单元混在同一个目录里,升级系统时容易一起被覆盖。
/etc/systemd/system/
├── pg-backup.service # 干什么
├── pg-backup.timer # 什么时候干
└── timers.target.wants/
└── pg-backup.timer -> ../pg-backup.timer # 开机启用
/usr/local/bin/
└── pg-backup.sh # 实际执行的脚本
注意那个 timers.target.wants 目录里的软链接。它不是必须手建的,用 systemctl enable 会自动生成,把它列出来是为了让你知道 enable 到底改了什么——很多人以为 enable 是改配置文件,其实只是建一个链接。
3. 第一份配置:service,定义「跑什么」
定时任务的服务类型必须是 Type=oneshot,因为它跑完就退出,不常驻。写成 Type=simple 会让 systemd 一直以为它还在启动中,后续的超时判断全都失效。
[Unit]
Description=PostgreSQL daily dump to /srv/backup
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=backup
Group=backup
Environment=PGPASSFILE=/home/backup/.pgpass
Environment=BACKUP_DIR=/srv/backup/pg
ExecStart=/usr/local/bin/pg-backup.sh
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=1800
Restart=on-failure
RestartSec=300
[Install]
WantedBy=multi-user.target
Restart=on-failure 配合 oneshot 是允许的,但它只在本次启动内重试,重试次数用完就放弃,不会等到下一个周期。真要「失败就等到明天再试」,应该靠 timer 的 OnFailure= 去拉起一个通知单元。
脚本本身要做三件事:先检查目标目录的可用空间,再执行导出,最后删掉超过保留期的旧文件。空间检查放在导出之前,是因为磁盘满的时候导出会写一个半截文件,比不导出更危险。
#!/bin/bash
set -euo pipefail
: "${BACKUP_DIR:?BACKUP_DIR is not set}"
KEEP_DAYS=14
printf -v STAMP '%(%Y%m%d-%H%M)T' -1
mkdir -p "$BACKUP_DIR"
# 剩余空间低于 2GB 时 awk 以非零码退出,pipefail 会让整个脚本停下
df -Pk "$BACKUP_DIR" | awk 'NR==2 && $4 < 2097152 {print "abort: less than 2GB free"; exit 1}'
pg_dumpall --clean --if-exists | gzip > "$BACKUP_DIR/all-$STAMP.sql.gz"
find "$BACKUP_DIR" -maxdepth 1 -name 'all-*.sql.gz' -mtime +"$KEEP_DAYS" -exec rm -f {} +
echo "done: all-$STAMP.sql.gz"
4. 第二份配置:timer,定义「什么时候」
[Unit]
Description=Run pg-backup daily at 03:30
Requires=pg-backup.service
[Timer]
Unit=pg-backup.service
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=600
AccuracySec=1min
[Install]
WantedBy=timers.target
OnCalendar=*-*-* 03:30:00 的含义是「每年每月每日的 03:30:00」,等价的旧写法是 03:30。写成 03:30 更短,但容易和「每 30 小时」的间隔语法混淆,建议一律写全。
RandomizedDelaySec=600 让实际触发时间在 03:30 之后十分钟内随机浮动。如果你的机器不止一台,这个参数是必须的:十台机器同时向同一个对象存储上传,比单台失败更难查。
5. 启用与验收:四条命令
sudo systemctl daemon-reload—— 改完单元文件必须执行,否则 systemd 用的还是旧内容。sudo systemctl enable --now pg-backup.timer—— 建软链接并立刻启动计时器。systemctl list-timers pg-backup.timer—— 看 NEXT 和 LAST 两列,NEXT 有值就说明排上了。sudo systemctl start pg-backup.service然后journalctl -u pg-backup -n 50—— 不等定时,先手动跑一次,确认脚本本身没问题。
验收的标准不是「NEXT 有值」,而是手动触发一次之后,journal 里有完整的输出,退出状态是 0,目标目录多了一个当天日期的文件。这三条缺任何一条,都别急着等第二天。
6. 四个必然踩到的坑
下面四条是我在两台机器上挨个踩过的,按出现概率排序。它们都不会报错,只会让任务安静地不执行——这比报错难受得多。
坑一:ExecStart 不经过 shell,管道和重定向全部失效。写成 ExecStart=/bin/echo a | gzip > /tmp/x,systemd 会试图执行一个叫 | 的程序。要保留 shell 语义,必须显式写出解释器:ExecStart=/bin/bash -lc '/usr/local/bin/pg-backup.sh'。单引号里的内容由 bash 解析,systemd 只负责把整串交给它。
坑二:Persistent=true 的补跑会和 RandomizedDelaySec 叠加。机器凌晨两点关机、早上九点开机,补跑不会在 03:30 发生,而是在开机后叠加一个随机延迟。如果你有一个「必须在北京时间六点前完成」的下游任务,这个组合会让它偶尔失败,且没有任何日志提示原因。
坑三:OnCalendar 用的是系统时区,容器里通常是 UTC。在 Docker 或 LXC 里配 03:30,宿主机看着是 11:30 才跑。timedatectl 一眼能看出时区,但很多人是在排查了半小时之后才想起来看。跨时区部署时,直接在 timer 里写 OnCalendar=*-*-* 03:30:00 Asia/Shanghai,systemd 249 以上支持在表达式里指定时区。
坑四:用户级 timer 需要 linger,否则用户一登出就停。把单元放在 ~/.config/systemd/user/ 下、用 systemctl --user enable 启用,看起来一切正常,但 SSH 会话一断,计时器就跟着会话消失。执行 loginctl enable-linger backup 才能让它在没有登录会话时继续运行。
7. 迁完之后,cron 什么时候还该留着
不是所有 cron 任务都值得搬。一次性、临时性的任务留在 cron 里更省事:写一行 crontab -e 就完事,不必建两个文件。
值得搬的是三类:需要知道成功与否的、错过一次就有后果的、以及需要和别的服务排先后顺序的。判断标准很简单:这个任务失败了,你会不会想知道。会,就搬。
有一个前提要先确认:journal 默认可能不持久化。如果 /var/log/journal 目录不存在,systemd 会把日志只写在内存里,机器一重启就全没了。你要查的是「上周三那次为什么失败」,而这个问题的答案可能已经被一次重启抹掉。
处理方式很简单:sudo mkdir -p /var/log/journal,然后 sudo systemctl restart systemd-journald。之后 journalctl --disk-usage 能看到实际占用,默认上限是磁盘的百分之十。这一步不做,前面所有「可查询」的说法都是空话。
搬完之后,cron 里那七条任务可以逐条注释掉,留一周观察期。观察期内两边都留着会跑两遍,所以更稳的做法是先把 cron 那条改成 # 开头,只留 timer,一周后确认 journal 里有七天的成功记录,再把整行删掉。
写在最后
这套东西真正的价值不在「更现代」,而在它把「任务有没有做」变成了一个可以查询的事实,而不是一个需要相信的假设。cron 的运行状态是一种信念,systemd 的运行状态是一条记录。
十九天没人发现备份失败,问题从来不在调度器。是那台机器上,没有任何一个地方能回答「上次成功是什么时候」这个问题。换工具解决不了没人看的问题,但至少让「看」这件事变得只要一条命令。
后来我给那台机器加了一条更土的规则:备份目录里如果最新的文件名不是今天,就在 SSH 登录时打一行提示。它比任何监控系统都更早发现问题。能被顺手看见的告警,才是有效的告警。
你机器上那几条 cron,最近一次成功是什么时候?现在能查到吗?

评论 (0)