编程 systemctl start 没报错但定时任务不跑:systemd timer 替掉 crontab 的迁移与排查

2026-10-23 21:31:59

用 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,这一步漏掉的表现是“改了什么都没生效”。

复制全文 生成海报 systemd Linux运维 cron 定时任务 部署

推荐文章

程序员茄子在线接单