用 systemd timer 替掉 crontab:certbot、备份和 WordPress 任务的迁移记录
同一件事现在写成两个文件,不再挤进 crontab 的一行:.timer 负责“什么时候跑”,.service 负责“跑什么”。
$ systemctl list-timers --all
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-08-11 03:00:00 CST 1h 23min left Mon 2026-08-10 03:00:00 CST 22h ago certbot-renew.timer certbot-renew.service
Tue 2026-08-11 06:00:00 CST 4h 23min left Mon 2026-08-10 06:00:00 CST 19h ago wp-backup.timer wp-backup.service
Wed 2026-08-12 00:00:00 CST 22h left Tue 2026-08-11 00:00:00 CST 1h ago logrotate.timer logrotate.service
每个 timer 的状态、下一次触发时间、上次运行时间都能直接查。cron 那边只有 crontab 文件和 syslog:前者可能被清空,后者只能靠 grep 翻。
问题一:crontab 静默消失,systemd 用文件隔离
$ crontab -l
no crontab for root # ← 什么时候没的?不知道
$ grep CRON /var/log/syslog | tail -20
Aug 10 03:00:01 vps CRON[384712]: (root) CMD (certbot renew)
Aug 11 03:00:01 vps CRON[391203]: (root) CMD (certbot renew)
Aug 12 03:00:01 vps CRON[397851]: (root) CMD (certbot renew)
Aug 13 03:00:01 — 没了
crontab 的编辑完全依赖编辑器正常退出。vim 崩溃、SSH 断连、:q! 退出,都可能让整个文件变空,而系统不会提示你任务丢了。
timer 的做法是把配置拆成两个独立文件,写坏一个不影响另一个:
# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot Renew
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot-renew twice daily
Requires=certbot-renew.service
[Timer]
OnCalendar=03:00
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
RandomizedDelaySec=300 用于错开 ACME 服务端的请求高峰;只要证书还有余量,证书续期晚跑几分钟没有影响。
问题二:Persistent=true 的实际行为
凌晨 3 点因 OOM 重启,3 点到 4 点之间的 cron 任务会被直接跳过,要等第二天。certbot renew 跳一天问题不大,证书有效期通常还有 30 天;数据库备份跳一天就是明显的漏洞。
[Timer]
OnCalendar=03:00
Persistent=true
Persistent=true 只在“触发时间点系统处于关机状态”时生效:下次开机补跑一次,不会把错过的多个时间点逐个补齐。它也仅对 OnCalendar 有意义,OnUnitActiveSec、OnBootSec 这类相对时间不会补。cron 没有对应概念,anacron 勉强算类似,但只支持 daily/weekly/monthly 粒度。
排查:配置正常但 timer 不触发
systemctl start 没报错,任务就是不跑,先看 status:
$ systemctl status my-backup.timer
● my-backup.timer - Run daily backup
Loaded: loaded (/etc/systemd/system/my-backup.timer; disabled; preset: disabled)
Active: inactive (dead)
Trigger: n/a
Triggers: ● my-backup.service
文件加载了(Loaded),但 Active: inactive (dead) 表示没有启动。直接 start 试试:
$ systemctl start my-backup.timer
# 没报错但没效果
$ systemctl enable my-backup.timer # ← 关键
Created symlink /etc/systemd/system/timers.target.wants/my-backup.timer → /etc/systemd/system/my-backup.timer.
$ systemctl start my-backup.timer
$ systemctl status my-backup.timer
● my-backup.timer
Active: active (waiting) since Tue 2026-08-11 10:23:05 CST
Trigger: Wed 2026-08-12 03:00:00 CST
enable 创建 timers.target.wants 下的符号链接,start 只是立刻激活当前进程;只 start 不 enable 时,timer 会被当成一个临时实例,下次 daemon-reload 后就丢了。这是“配置检查全对但不触发”最常见的根因,遇到它先看 Loaded 行尾是 disabled 还是 enabled。
从 crontab 迁移
拿一条备份任务举例,原来是这样:
# 1. crontab 原文
0 3 * * * /opt/scripts/backup-db.sh >> /var/log/backup.log 2>&1
拆成两个文件,日志交给 journald,不再自己重定向:
# 2. backup-db.service
[Unit]
Description=Database Backup
After=network.target mysqld.service
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup-db.sh
StandardOutput=journal # 替代 >> log 文件
StandardError=journal
User=root
# 3. backup-db.timer
[Unit]
Description=Daily database backup
[Timer]
OnCalendar=03:00
Persistent=true
[Install]
WantedBy=timers.target
# 4. 部署与验证
$ systemctl daemon-reload
$ systemctl enable backup-db.timer
$ systemctl start backup-db.timer
$ systemctl list-timers --all | grep backup-db
$ journalctl -u backup-db.service -f
字段对应关系:0 3 * * * 是每天 03:00,对应 OnCalendar=03:00;cron 里的 >> log 2>&1 换成 StandardOutput=journal / StandardError=journal,以后用 journalctl -u 查,不用再自己切日志、写 logrotate。
环境变量:PATH 和时区
手动执行成功、timer 执行失败,多数情况是两边的环境不同。systemd 的环境比登录 shell 干净得多,PATH 通常只有:
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
脚本里调用的命令不在这个范围,就会得到 127 command not found。要么在脚本里写绝对路径,要么在 service 里用 Environment= 显式声明。
另一个容易出错的地方是时区:OnCalendar 用系统时区。UTC 的服务器上写 03:00,实际对应北京时间 11 点,所以迁移前先 timedatectl 确认一下。
实战:WordPress 定时任务
wp-cron.php 默认每次页面请求都会触发 wp-cron,高流量站点上就是额外的性能负担。先在配置里关掉:
# wp-config.php
define('DISABLE_WP_CRON', true);
再改用 timer 每 5 分钟发一次请求:
# /etc/systemd/system/wp-cron.service
[Unit]
Description=WordPress Cron
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -s -o /dev/null https://yoursite.com/wp-cron.php?doing_wp_cron
User=www
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/wp-cron.timer
[Unit]
Description=WordPress Cron Timer
[Timer]
OnCalendar=*:*:0/300 # 每 5 分钟
RandomizedDelaySec=30 # 随机偏移 30 秒,避免惊群
Persistent=false # wp-cron 跳过几次无所谓
[Install]
WantedBy=timers.target
$ systemctl daemon-reload
$ systemctl enable --now wp-cron.timer
$ journalctl -u wp-cron.service -f
这里 Persistent=false 是有意为之:wp-cron 补跑一次没有意义,反而可能在开机瞬间和其他服务争资源。
这些场景还是继续用 cron
- 容器环境:容器里通常没有 systemd,要启用就得特权模式或特殊镜像,cron 或应用内调度器更轻。
- 秒级定时:
OnUnitActiveSec=5s理论上能写,但 systemd 的调度精度大约 1 秒,再细就应该用专门的调度器。 - 一次性临时任务:
echo "rm -rf /tmp/*" | at now + 2 hours比建两个 unit 文件快得多。 - 老系统:CentOS 6 / RHEL 6 没有 systemd,只能 cron。
- 用户级 timer:默认只在登录会话期间运行,想在无登录时跑需要
loginctl enable-linger。
迁移后的检查清单
systemctl is-enabled xxx.timer 确认已 enable;list-timers 看 NEXT 列是否落在预期时间;用 systemd-analyze calendar "Mon..Fri *-*-* 09:00:00" 验证表达式;失败原因看 status 和 journalctl -u xxx.service;需要前置条件时用 ConditionPathExists 挡掉;触发时如果上一次实例还在跑,要留意 Type=oneshot 的重叠行为。改过任何 unit 文件,都要 systemctl daemon-reload,这一步漏掉的表现是“改了什么都没生效”。