cron与systemd timer混用:站点定时任务清点与收口

一、先把"谁在跑"搞清楚

我见过太多站长的服务器上有三套定时任务在同时跑:系统自带的 crontab、面板自己维护的 crontab、还有从别处抄来的 systemd timer。它们互不知道对方存在,于是备份一天跑三次、日志切割在午夜和凌晨两点各来一遍、证书续期两条链路抢同一个目录。等到某天数据库被两个备份进程同时压住,或者 /var/log 被两个 logrotate 轮流搬家导致日志断档,才发现问题。

排查这类问题的第一步不是改配置,而是把全机的定时任务做一次清点。这一步做扎实,后面所有判断才有依据。

四类入口,一个都不能漏

Linux 上的"定时任务"至少有四个入口,只看其中一个必然漏:

# 1. 系统级 crontab
cat /etc/crontab
ls -l /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ /etc/cron.weekly/ /etc/cron.monthly/

# 2. 用户级 crontab(root 与 Web 用户都要看)
crontab -l
crontab -u www-data -l

# 3. systemd timer(系统级)
systemctl list-timers --all --no-pager
ls /etc/systemd/system/*.timer /lib/systemd/system/*.timer

# 4. 应用自带的调度(面板、宝塔、Typecho 插件、Spring 的 @Scheduled)
ps -eo pid,lstart,cmd | grep -E 'cron|timer|scheduler' | grep -v grep

第四项最容易被忽略。ps 那一行的 lstart 会打出进程启动时刻,如果你看到一个进程每天固定时间被拉起、但三个 crontab 和 timer 里都找不到它的名字,那它就是应用层自己调度的。

把清点结果落成一张表

清点不是看一眼就完事。我自己的习惯是把它落成一张四列表格,存进站点运维的笔记里:

  • 名称:任务干什么(备份/切割/续期/清理缓存),不是"某某脚本"
  • 触发方式:cron、timer、还是应用内调度
  • 频率与时间点:写清楚时区,尤其是机器用 UTC 而人脑想的是北京时间的时候
  • 日志落点:任务失败时,日志在哪一行能看出来

填这张表时,只要有一列填不出来,就说明这个任务将来出问题时你无从下手。第四列空着的任务,优先给它补上日志重定向。

二、混用最常见的三种冲突

清点完通常会发现,冲突不外乎三种形态。认清形态,处置方式就定了。

冲突一:重复执行(同一个活干两次)

最典型的是备份。老的 /etc/cron.daily/backup 没删,新的 systemd timer 又加了一个 backup.timer,两者都在凌晨三点跑。表面看只是浪费磁盘,实际危害在别处:

  • 两个 mysqldump 同时抓 InnoDB,一致性快照会互相拖慢,长事务告警跟着来
  • 备份文件的命名如果只精确到"日",后一个会覆盖前一个,你还以为有一份,其实只有半份
  • 对象存储的请求费和流量费翻倍,账单上不容易看出来

判定方法很直接:看目标文件的时间戳和大小是否成对出现,或者直接看进程。

# 看备份目录里同一时刻是否出现两个文件
ls -l --time-style=full-iso /backup/mysql/ | tail -20

# 在任务执行时间窗内抓快照
while true; do date +%T; ps -eo pid,etimes,cmd | grep mysqldump | grep -v grep; sleep 5; done

冲突二:时间点挤在一起

第二种冲突更隐蔽:任务没有重复,但触发时间撞了。比如 logrotate 在 00:00、数据库全备在 00:00、证书续期检查在 00:00、CDN 日志回捞脚本也在 00:00。午夜那一刻 IO 和 CPU 同时被点燃,正常请求的响应时间在监控图上冒出一个尖刺,用户投诉"夜里访问慢"。

这种问题不需要大改,错峰就够:

# 系统级 crontab 里把时间点分散开
# 日志切割 00:10,全备 01:30,证书检查 03:20,日志回捞 04:40
10 0 * * *  /usr/sbin/logrotate /etc/logrotate.conf
30 1 * * *  /opt/scripts/mysql_full_backup.sh
20 3 * * *  /opt/scripts/cert_check.sh
40 4 * * *  /opt/scripts/cdn_log_fetch.sh

用 systemd timer 时更省事,加一行 RandomizedDelaySec= 就能让同日触发的一批任务自动散开,不用人工排时间:

[Unit]
Description=Nightly cache cleanup

[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=25min
Persistent=true

[Install]
WantedBy=timers.target

注意 RandomizedDelaySec 只影响本次触发的偏移量,不改变"每天一次"的语义,很适合清理类任务。但备份类任务如果你要求"必须每天这个时刻前完成",散开之后最晚可能延后 25 分钟,要自己算好余量。

冲突三:互相打架(一个在清理,一个在写入)

第三种最难查。比如清理脚本在扫描 /tmp 下超过 7 天的文件,而一个业务进程正在往 /tmp 里写临时文件;或者日志切割刚把文件 rename 走,应用还握着旧的文件句柄在写,于是磁盘空间不释放。

这类问题的根因是缺少互斥。不管用 cron 还是 timer,都可以在脚本层加一把锁来统一:

#!/bin/bash
set -uo pipefail
LOCK=/var/lock/cache-cleanup.lock
exec 9>"$LOCK"
if ! flock -n 9; then
  echo "$(date '+%F %T') skip: another instance is running"
  exit 0
fi
# 下面是真正的清理逻辑
find /home/wwwroot/*/usr/cache -type f -mtime +7 -delete

这一段的价值在于"跳过而不是排队":flock -n 拿不到锁立刻退出,避免定时任务越堆越多。如果是必须串行执行的活(比如数据导出),把 -n 去掉,让后来的等前一个结束。

三、cron 与 systemd timer 的真实差异

网上讲 timer 比 cron 好的文章很多,但落到单人维护的站点上,真正影响选择的只有几条,而且大多数条都不是"哪个更好"而是"哪个的失败更看得见"。

日志:cron 静默,timer 进 journal

cron 默认把任务输出扔给 MAILTO,如果没有 MTA,输出就消失了。很多人以为"没报错就是成功",实际上脚本第一天就挂了,只是没人告诉过他。systemd 的 service 输出默认进 journal,journalctl -u backup.service 随时能翻。

如果继续用 cron,至少把输出接住:

# 每个任务单独落日志,并在结尾记一行退出码
30 1 * * *  /opt/scripts/mysql_full_backup.sh >> /var/log/backup.log 2>&1; echo "rc=$?" >> /var/log/backup.log

注意 >> 后面的 echo 用的是分号,它不受重定向影响,会写进同一个文件的下一行。这样日志里能清楚看到每次执行是 rc=0 还是失败。

失败重试:cron 不做,timer 也不做

很多人以为换成 timer 就自动有了重试,这是误解。systemd 的 Restart= 只管"服务进程崩了要不要重启",不管"退出码非零要不要再来一次"。真正的重试要靠 Restart=on-failure 配合 Type=oneshot 的特定组合,或者干脆在脚本里写重试循环。

#!/bin/bash
set -uo pipefail
for i in 1 2 3; do
  if /opt/scripts/upload_remote.sh; then
    echo "attempt $i ok"
    exit 0
  fi
  echo "attempt $i failed" >&2
  sleep 60
done
echo "all attempts failed" >&2
exit 1

站点运维里,"失败后重试三次、间隔一分钟"能覆盖绝大多数网络抖动。别让重试无限循环,那会把一个小故障放大成持续一整天的资源占用。

环境变量:两者都是"你看见的和它看见的不一样"

cron 的 PATH 通常是 /usr/bin:/bin,systemd 的 service 默认也有自己的一套。你在 SSH 里 which php 得到 /www/server/php/74/bin/php,在 cron 里跑就是 command not found。这是新手最容易撞的墙。

正确做法不是到处写绝对路径(虽然这是最终结果),而是先确认清楚:

# 在 SSH 里看到的
echo "$PATH"
which php mysql rsync

# 看 cron 实际拿到的(临时加一行任务)
* * * * * /usr/bin/env > /tmp/cron_env.txt 2>&1

拿到 /tmp/cron_env.txt 后对比一下,差多少心里就有数。之后所有脚本一律用绝对路径,需要时在脚本开头显式设置:

export PATH=/usr/local/bin:/usr/bin:/bin:/www/server/mysql/bin
export LANG=C.UTF-8

LANG 那一行也不能省。默认 C locale 下,date 输出的月份是英文缩写,中文文件名排序也会出错,脚本里如果有 date +%b 或按名字排序的逻辑,行为就和你手工执行时不同。

四、一个站点上的实际改造过程

说一个我实际做过的改造。那台机器跑着 Nginx + PHP-FPM + MySQL,外加一个 Typecho 站点,Ubuntu 20.04,接手时定时任务散落在五个地方:

  • /etc/cron.daily/logrotate(系统自带)
  • /etc/cron.d/backup(前一个人留下的,从 2019 年就在跑)
  • root 的 crontab 里有三条:证书检查、清除缓存、拉取 CDN 日志
  • 一个 certbot.timer(apt 安装 certbot 时自动加的)
  • 应用层还有一个每天凌晨执行的插件(在后台上配的,不写在任何系统文件里)

改造分四步,顺序不能颠倒。

第一步:先只观测,不改动

把上面四类入口全列一遍,写成表格。同时把 /var/log/syslog 里 CRON 相关的行捞出来,看过去七天实际执行了什么。这一步大概花半小时,但能省掉后面所有的猜测。

# 过去七天 cron 的实际执行记录(Debian/Ubuntu)
grep -h 'CRON' /var/log/syslog* | awk '{print $1,$2,$3,$6,$NF}' | sort | uniq -c | sort -rn | head -30

# systemd timer 的实际执行历史
journalctl --since "-7 days" -u '*.timer' --no-pager | grep -E 'Started|Failed' | tail -50

这一步如果发现某个任务"配置里有、日志里从来没执行过",那多半是被 cron.allow、文件权限(可执行位缺失)、或者脚本首行 shebang 路径错误挡住了。三种原因都很好查:

ls -l /etc/cron.allow /etc/cron.deny 2>/dev/null
ls -l /etc/cron.daily/ | grep -v '^-rwx'
head -1 /opt/scripts/*.sh

第二步:合并同类项

清理类任务合成一个脚本,备份类合成一个,挨个跑一遍确认结果一致。这一步的判据很简单:合并后的脚本单独手工执行,产出与原来两套各自执行的结果一致(或者你有明确理由说明为什么不一致)。

同时把时间点重新排开。我的排法是:清理在 02:10(业务低峰且早于备份,避免备份到一半被清掉缓存影响读取),备份在 03:00,证书检查在 04:20,CDN 日志回捞在 05:40。中间各留 40 分钟以上的间隔,任何一步超时都不会挤压下一步。

第三步:统一加锁和日志

所有脚本开头统一加 flock 和日志重定向,格式统一成一行时间戳加退出码。这一步做完,将来任何一个任务出问题,都能在两分钟内定位到是哪个脚本、哪一步、什么错误码失败。

#!/bin/bash
set -uo pipefail
LOG=/var/log/site-jobs/cert_check.log
mkdir -p "$(dirname "$LOG")"
exec 9>/var/lock/cert_check.lock
flock -n 9 || { echo "$(date '+%F %T') skip: locked" >> "$LOG"; exit 0; }
{
  echo "--- begin $(date '+%F %T')"
  /opt/scripts/cert_check_inner.sh
  echo "--- end rc=$? $(date '+%F %T')"
} >> "$LOG" 2>&1

注意 rc=$? 在 echo 里取的是上一条命令的状态,这里它取到的是 cert_check_inner.sh 的退出码,正是我们要的。但如果中间还夹了别的命令,取到的就变了,所以"rc=$?"必须紧跟目标命令。

第四步:只留一条路径,其余停掉并记录

改造的最后一步是把旧的入口真正停掉,而不是留着"以防万一"。留着就是下次冲突的种子。停掉之后写一行记录到运维笔记:日期、停了哪个、为什么停、替代者是谁。半年后你自己回头看,这行记录能救你一次。

五、验证"只有一个在跑"的三个办法

改造完成不等于验收通过。定时任务有一个讨厌的特性:它大部分时间什么都不做,所以你无法通过"现在看起来正常"来判断。必须主动验证。

办法一:盯一个执行窗口

挑一个任务,在它的触发时间前后守着,用 ps 每 5 秒抓一次,看进程数量。如果同一个脚本出现两个 PID,说明还有第二个入口。

while true; do
  n=$(pgrep -fc 'mysql_full_backup.sh' || true)
  [ "$n" -gt 0 ] && echo "$(date +%T) running=$n"
  sleep 5
done

pgrep -fc 返回匹配数而不是列出来,便于判断;如果脚本自身名字也会被匹配到,就把 -f 换成更精确的模式,或者干脆匹配脚本的绝对路径。

办法二:让任务留下执行痕迹

每个任务在结尾追加一行到统一的执行记录文件,一天后看这个文件就知道跑了多少次、分别在几点、退出码是什么。这比看进程可靠,因为它记录的是历史而不是瞬间。

# 统一的执行记录,一行一条,便于 awk 统计
echo "$(date '+%F %T') cert_check rc=$?" >> /var/log/site-jobs/exec.log

# 次日检查每个任务当天跑了几次(每个应该只有 1 次)
awk '{print $4}' /var/log/site-jobs/exec.log | sort | uniq -c

办法三:把"没跑"也做成告警

最常见的故障不是"跑了两次",而是"某天起就不跑了"——机器重启后 timer 没 enable、cron 服务被停掉、脚本被误删了执行位。这类问题的判据是"最后执行时间距今超过预期周期"。

#!/bin/bash
# 放在另一个不会一起挂掉的入口里,或者由外部监控调用
LOG=/var/log/site-jobs/exec.log
last=$(grep cert_check "$LOG" | tail -1 | cut -d' ' -f1,2)
if [ -z "$last" ]; then echo "no record at all"; exit 2; fi
age=$(( ( $(date +%s) - $(date -d "$last" +%s) ) / 3600 ))
[ "$age" -gt 30 ] && echo "cert_check 已 $age 小时未执行" | mail -s "job stale" me@example.com

这条检查本身必须放在不会和它一起失效的地方:如果它写在同一个 crontab 里,而 crontab 服务挂了,告警也不会发出来。我通常把它放在另一台机器或者外部监控服务上,哪怕只是每小时 curl 一个简单的健康检查地址。

六、几种不该混用的情况

最后说清楚边界。上面讲的是"已经混用了怎么收拾",但有些情况下混用本身就是错误选择,值得提前避开。

  • 需要精确到秒级的任务(监控探针、秒级缓存刷新):cron 的粒度是分钟,做不到;systemd timer 可以做到秒级,但要求你也接受它"skew"和"accuracy"的语义,AccuracySec 默认是 1 分钟,想精确必须显式改成 1us 或 1s。这类任务其实更适合常驻进程加内部循环,而不是定时拉起。
  • 需要卡死超时限制的任务:cron 没有超时机制,脚本卡住就一直占着;systemd timer 的 service 可以配 TimeoutStartSec=,超时会被杀掉并记入 journal。凡是"可能因为网络挂住"的任务,这是我的首选理由。
  • 依赖前一个任务结果的任务链:不要用"几点几分"的固定时间来串联,那样一次超时就全乱。用 After=、Requires= 描述依赖,或者干脆写在一个脚本里顺序执行。
  • 应用自带调度已经足够的场景:WordPress 的 wp-cron、Typecho 的插件计划任务,这些在低流量站点上会因为"没人访问就不触发"而失灵,但如果你的站点流量稳定,为了"统一管理"去把它们搬到系统 cron,反而会丢掉应用层上下文(比如插件更新后的钩子)。判断标准是:这个任务是否需要应用层的数据结构和 API,需要就别搬。

一句话总结选型:能用 cron 一行解决的,不要因为它"看起来不现代"就换成 timer;需要超时、需要日志、需要依赖关系的,才换成 timer。混用的根本问题不是"用了两种工具",而是"没人知道一共有几种"。

七、小结:一张清单管住所有定时任务

把这批活干完之后,我给自己定了一份固定清单,每次上服务器都照着过一遍,成本不到五分钟,但能防住上面所有问题:

  • 清点:四个入口(/etc/crontab 与 /etc/cron.d、用户 crontab、systemd timer、应用内调度)全列一遍
  • 成表:名称、触发方式、时间点时区、日志落点,四列缺一不可
  • 错峰:所有任务时间点至少间隔 30 分钟,或统一上 RandomizedDelaySec
  • 加锁:所有脚本开头 flock -n,拿不到锁就跳过而不是排队
  • 留痕:统一执行日志,一行一条带时间戳和退出码
  • 反查:用"最后执行时间距今多久"做反向告警,防"不跑了"
  • 收口:旧入口停掉,停用记录写进笔记

定时任务这玩意最像水管:装的时候都知道在哪,日子久了接口越接越多,漏水的地方永远在最不显眼的那个接头上。定期清点一遍,比出事后半夜爬起来翻日志划算得多。

Last modification:November 3rd, 2026 at 08:26 pm

Leave a Comment