定时任务"偶尔没跑"背后的那 30%
很多个人站长给服务器配了定时任务,用起来的感觉是"大部分时候没问题":备份在跑、日志在切、缓存每天清,好像一切正常。直到某天发现备份文件是三天前的,或者缓存根本没清,才回头去查,然后发现——脚本单独执行完全正常,写在 crontab 里就是时好时坏。
这类"偶发失效"的定时任务,根因几乎总是同一个:crontab 的运行环境和你在终端里的交互式 Shell 环境不是一回事。这个差异横跨 PATH、环境变量、工作目录、stdin、并发重叠、时区五个维度。本文逐个拆开,给你一套能直接照抄的写法。
根因一:PATH 不一样,命令"找不到"
假设你的脚本里有这么一行:
rclone sync /var/www/uploads remote:backup在终端里跑得好好的,因为 ~/.bashrc 或者 /etc/profile.d/ 里已经把 /usr/local/bin 加进了 PATH,rclone 就在那儿。
但 crontab 启动的进程不读 .bashrc,也不读 .profile。它的 PATH 通常是系统默认的那一条,非常短:
/usr/bin:/bin于是 crontab 环境里 rclone 找不到,任务静默失败——没有报错页面,没有邮件(如果你没配 MAILTO),你什么都不知道。
先看看 crontab 实际拿到什么环境:
# 临时加一条任务,把环境 dump 出来
crontab -e
# 加一行:
* * * * * env > /tmp/cron-env.txt 2>&1
# 等一分钟,然后对比
cat /tmp/cron-env.txt
echo "--- 交互式 Shell ---"
env | sort对比两者的 PATH,你大概率会发现差了 /usr/local/bin、/usr/local/sbin、/opt/xxx/bin 甚至 /snap/bin。
正确做法:脚本里自己设 PATH,而不是改 crontab
#!/bin/bash
# 关键:在脚本最前面显式声明 PATH,不依赖外部环境
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 如果还依赖某个工具,做一次硬校验,缺了立刻报警退出
command -v rclone >/dev/null 2>&1 || { echo "rclone 未找到,任务中止"; exit 1; }强烈建议不要在 crontab 里写 PATH=...。脚本可能被 systemd timer、被别的脚本、被你手动调用,把 PATH 内置在脚本里,走到哪儿都能跑。这是"可移植的定时任务"和"只能在这台机器这个用户下跑的任务"之间的分界线。
根因二:环境变量缺失,尤其是密码和 locale
crontab 环境里通常没有你 export 过的任何变量。三处最容易出问题:
1. 密钥 / 令牌类变量
# 你依赖的环境变量(在 .bashrc 里 export 过)
export AWS_ACCESS_KEY_ID=xxx
export RCLONE_CONFIG=/root/.config/rclone/rclone.conf正确做法是用 systemd 的 EnvironmentFile(推荐),或者直接在脚本里加载一个受保护的文件:
# 权限 600,只有 root 可读
install -m 600 /dev/null /etc/backup.env
cat > /etc/backup.env <<'EOF'
AWS_ACCESS_KEY_ID=xxxx
AWS_SECRET_ACCESS_KEY=xxxx
EOF
# 脚本里加载
set -a # 之后定义的所有变量自动 export
source /etc/backup.env
set +a2. locale 导致的编码差异
交互式环境下 LANG=zh_CN.UTF-8,crontab 下往往是 LANG 为空,退化成 POSIX。后果很隐蔽:
- 含中文的文件名或数据库内容被当成字节序列处理,排序、正则全部错位;
mysql客户端连接时字符集不是 utf8mb4,导出中文变问号;sort的结果顺序和你在终端里看到的不一样。
# 脚本里显式锁定 locale
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
# mysqldump 加 charset 参数,双保险
mysqldump --default-character-set=utf8mb4 --single-transaction \
-uroot -p"$DBPASS" --databases mydb | gzip > /backup/db-$(date +%F).sql.gz3. HOME 与配置文件路径
crontab 里 $HOME 通常是 /root 或对应用户家目录,但如果你用 install 或某些工具在别的机器上生成的配置放在 ~/.config,而脚本又以 sudo -u www-data 之类降权运行,HOME 就成了别人的家目录,配置读不到。所有路径要么写绝对路径,要么显式 export HOME=/root。
根因三:工作目录不是你想的那个
crontab 任务的默认工作目录是用户家目录(/root 或 /home/xxx),不是脚本所在目录,也不是网站目录。所以脚本里任何相对路径都会指向错误的位置。
# 错误写法:tar 会因为找不到相对路径而失败,或者打出 0 字节包
tar czf backup.tar.gz ./uploads
# 正确写法一:脚本里 cd 到确定目录,失败即退出
cd /var/www/mysite || exit 1
tar czf /backup/site-$(date +%F).tar.gz uploads/
# 正确写法二:全部使用绝对路径
tar czf /backup/site-$(date +%F).tar.gz -C /var/www/mysite uploads/注意 tar 的 -C 参数:打包时用 -C 比先 cd 更安全,因为一旦中途脚本出错,cd 导致的目录状态可能影响后续命令,而 -C 只作用于当次调用。
配合 set -euo pipefail 让脚本"出错就停",避免半成品文件覆盖掉好备份:
#!/bin/bash
set -euo pipefail
# -e : 任一命令失败立即退出
# -u : 使用未定义变量报错(防 $DBPASS 拼错变空)
# -o pipefail : 管道中任一环节失败也算失败(关键!否则 gzip 失败会被忽略)根因四:上一次还没跑完,下一次就来了
这是"偶发失败"里最难查的一类。*/5 的任务,如果偶尔耗时超过 5 分钟(比如备份变大、上游接口变慢),第二个实例就会和第一个叠加,两者同时写同一个文件、同时连数据库、同时抢同一把锁,结果互相踩踏,产生损坏的备份或死锁。
注意:Debian/Ubuntu 的 cron 默认是串行派发的,但那是针对同一个用户的不同任务排队,不会阻止同一任务的重叠实例——只要上一次还没退出,它就不会派发下一次(这其实是一种保护)。问题是:如果你用 systemd timer,或者用 flock 之外的机制,这个保护就不存在了。所以最稳的做法是自己加锁。
方案 A:flock 文件锁(最推荐)
# crontab 里直接包一层 flock,-n 表示拿不到锁就立刻退出(不排队堆积)
*/5 * * * * /usr/bin/flock -n /var/lock/mysync.lock /usr/local/bin/mysync.sh >> /var/log/mysync.log 2>&1三个细节:
-n(nonblock):拿不到锁直接退出,避免任务在锁上排队堆积成雪崩;- 用绝对路径
/usr/bin/flock,因为 crontab 的 PATH 里可能没有它; - 锁文件放在
/var/lock或/run/lock,不要放/tmp(可能被 tmpfiles 清理,也可能被其他用户占位)。
方案 B:脚本内部自锁(更可移植)
#!/bin/bash
set -euo pipefail
LOCKFILE=/var/lock/mysync.lock
exec 200>"$LOCKFILE"
if ! flock -n 200; then
echo "$(date '+%F %T') 上一个实例仍在运行,跳过本次" >> /var/log/mysync.log
exit 0
fi
# 从这里开始是真正的任务逻辑,fd 200 会一直持有锁直到脚本退出
...
# 更简单的一种:用 flock + 自身重入
# if ! flock -n 200; then ... ; fi 这一句就够了,
# 脚本结束(含异常)时 fd 自动关闭,锁自动释放方案 B 的好处是无论谁调用这个脚本(crontab / systemd / 手动),锁都生效,不会出现"绕过保护"的情况。
根因五:时区不对,任务在错误的时间跑
用 0 3 * * * 想凌晨三点跑备份,结果拿到的服务器时间是 UTC,实际在北京时间 11 点跑——业务高峰期做全量备份。
# 查系统时区
timedatectl
# 改成中国时区
timedatectl set-timezone Asia/Shanghai
timedatectl
# 确认 cron 使用的时间基准(默认读系统时区)
date; date -u
# 重要:如果修改了时区,重启 cron 让它重新读取
systemctl restart cron # Debian/Ubuntu
# systemctl restart crond # CentOS/RHEL更稳的写法是在 crontab 里显式声明时区(需 cron 支持,Debian 的 cron 支持 CRON_TZ):
# 顶部声明本 crontab 的时间基准
CRON_TZ=Asia/Shanghai
# 每 5 分钟
*/5 * * * * /usr/local/bin/mysync.sh
# 每天 03:00 备份
0 3 * * * /usr/local/bin/backup.sh但更推荐用 systemd timer,它对时区的处理更明确,也更容易查看下次执行时间:
cat > /etc/systemd/system/mysync.service <<'EOF'
[Unit]
Description=Site sync task
[Service]
Type=oneshot
User=root
WorkingDirectory=/root
EnvironmentFile=/etc/backup.env
ExecStart=/usr/local/bin/mysync.sh
EOF
cat > /etc/systemd/system/mysync.timer <<'EOF'
[Unit]
Description=Run mysync every 5 minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
Unit=mysync.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now mysync.timer
systemctl list-timers mysync.timer # 看下一次执行时间Persistent=true 是 systemd timer 相比 cron 的一大优势:服务器在预定时间关机了,开机后会补偿执行一次漏掉的任务。cron 完全没有这个能力,服务器半夜重启,凌晨三点的备份就永久丢了。
根因六:静默失败——你根本不知道它没跑
上面每一条根因,最终都表现为"没有输出、没有告警、没人发现"。所以给所有定时任务加监控,比修任何单点问题都重要。三层防御:
第一层:脚本内部日志与退出码
#!/bin/bash
set -euo pipefail
LOG=/var/log/mysync.log
exec >> "$LOG" 2>&1 # 标准输出和错误全部追加进日志
log() { echo "$(date '+%F %T') [$1] ${*:2}"; }
log INFO "任务开始"
# 记录开始,配合 watchdog 检测"卡死"
touch /run/mysync.running
trap 'rm -f /run/mysync.running' EXIT
... # 业务逻辑
log INFO "任务结束,退出码 0"
rm -f /run/mysync.running
trap - EXIT第二层:心跳文件 watchdog
任务成功时写一个时间戳文件;另开一个"看门狗"任务,检查这个时间戳有没有过期:
# 成功完成时
date +%s > /run/mysync.last_success
# 看门狗(每小时跑一次)
#!/bin/bash
LAST=$(cat /run/mysync.last_success 2>/dev/null || echo 0)
NOW=$(date +%s)
AGE=$(( (NOW - LAST) / 60 ))
if [ "$AGE" -gt 90 ]; then
# 超过 90 分钟没有成功记录,发告警
curl -s -X POST "https://api.telegram.org/bot$TOKEN/sendMessage" \
-d chat_id="$CHATID" \
-d text="[告警] mysync 已 ${AGE} 分钟未成功执行"
fi
# 同时检查"卡死":有 running 标记但时间过久
if [ -f /run/mysync.running ]; then
echo "[告警] mysync 可能卡死,running 标记残留"
fi第三层:可观测性——让它可见
cron 默认会把输出通过邮件发给 MAILTO,但绝大多数服务器没配 MTA,邮件发不出去就无声无息。更实用的做法是统一写结构化日志,方便统计:
# crontab 顶部统一设置
SHELL=/bin/bash
HOME=/root
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 关键:把输出重定向到统一日志,而不是依赖邮件
MAILTO=""
*/5 * * * * /usr/bin/flock -n /var/lock/mysync.lock /usr/local/bin/mysync.sh
0 3 * * * /usr/local/bin/backup.sh
15 4 * * * /usr/local/bin/watchdog.sh一份可直接照抄的脚本模板
#!/bin/bash
# 站点同步 / 备份通用模板
set -euo pipefail
# ---- 环境锁定:不依赖 crontab 环境 ----
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
export HOME=/root
# ---- 配置 ----
SITE_DIR=/var/www/mysite
BACKUP_DIR=/backup
KEEP_DAYS=14
LOG=/var/log/website-task.log
# ---- 关键依赖校验 ----
for cmd in tar gzip rsync; do
command -v "$cmd" >/dev/null 2>&1 || { echo "缺少依赖: $cmd"; exit 1; }
done
# ---- 自锁 ----
exec 200>/var/lock/website-task.lock
flock -n 200 || { echo "$(date '+%F %T') 上次未结束,跳过"; exit 0; }
# ---- 日志 ----
exec >> "$LOG" 2>&1
echo "===== $(date '+%F %T') 任务开始 ====="
trap 'echo "$(date "+%F %T") 任务异常退出,码=$?"' ERR
# ---- 业务逻辑 ----
[ -d "$SITE_DIR" ] || { echo "站点目录不存在: $SITE_DIR"; exit 1; }
mkdir -p "$BACKUP_DIR"
STAMP=$(date +%F)
tar czf "$BACKUP_DIR/site-$STAMP.tar.gz" -C "$SITE_DIR" .
# 校验产物:大小与可读性(防止 tar 中途失败产出坏包)
[ -s "$BACKUP_DIR/site-$STAMP.tar.gz" ] || { echo "备份文件为空"; exit 1; }
tar tzf "$BACKUP_DIR/site-$STAMP.tar.gz" >/dev/null || { echo "备份包损坏"; exit 1; }
# 清理过期备份
find "$BACKUP_DIR" -name 'site-*.tar.gz' -mtime +$KEEP_DAYS -delete
# 写心跳
date +%s > /run/website-task.last_success
echo "===== $(date '+%F %T') 任务完成 ====="踩坑清单
- 不要在 crontab 里写业务逻辑,最长的一行也别超过一条命令加参数,逻辑全部进脚本。
- PATH 在脚本里设,不要指望 crontab;外部命令全部校验存在性。
set -euo pipefail是底线,尤其是 pipefail——否则mysqldump | gzip里 mysqldump 失败你完全看不出来,还会生成一个几 KB 的"成功"备份。- 相对路径是定时任务的头号杀手,全部改绝对路径,或用 tar 的
-C。 - 依赖 locale 的操作必须显式锁定
LC_ALL,中文站尤其容易中招。 - 重叠执行必须用 flock 自己防,锁文件放
/var/lock不放/tmp。 - 时区改过之后要重启 cron,或者显式用
CRON_TZ/ systemd timer。 - 一定要验产物:文件非空 + 可解包 + 能恢复。只有"命令返回 0"不算成功。
- 静默失败必须靠 watchdog 兜底,不能靠人记得去看。
- 能上 systemd timer 就上,
Persistent=true补跑、systemctl list-timers可观测、日志直接进 journalctl,整体比 cron 省心。
常见问题(FAQ)
Q:为什么我的脚本手动跑没问题,cron 里跑就失败?
九成是环境差异。按本文顺序排查:先 dump 一份 cron 的 env 对比 PATH,再确认工作目录和 locale,基本上三步之内就能定位。
Q:cron 没发邮件给我,怎么知道失败了?
大多数服务器没有装 MTA,cron 的邮件根本没有出口。不要依赖 MAILTO,直接在 crontab 里把输出重定向到日志文件,再用心跳 watchdog 做告警。
Q:flock 和 set -e 会不会冲突?
不会。flock -n 200 || exit 0 这一行在拿不到锁时主动 exit 0,属于"正常跳过",不会被 set -e 当成错误。注意必须显式 || exit 0,否则 set -e 会让脚本直接退出(结果也是对的,但退出码是失败,会污染监控)。
Q:systemd timer 能完全替代 cron 吗?
功能上可以,而且更强大。唯一的摩擦是语法不熟(OnCalendar 的写法需要适应)。如果站点已经在用 systemd 管理服务,直接上 timer 是更现代的选择。
Q:任务跑得比预期慢很多,怎么定位?
在脚本关键步骤前后打时间戳,算每段耗时;或者在 crontab 行首加 /usr/bin/time -v 输出资源使用。最有效的办法还是压到 systemd timer 里,用 systemd-analyze blame 和 journalctl 看单次执行耗时。
Q:备份任务要做到什么程度才算"可靠"?
一个可交付的标准:任务失败会告警、产物经过可解包校验、过期文件自动清理、锁防重叠、时区明确、开机漏跑能补(systemd timer)、每季度真做过一次恢复演练。缺任何一条,你的备份都只是"看起来有备份"。
写在最后
定时任务从"大概能跑"到"真正可靠",差距不在脚本写得多复杂,而在这些环境细节有没有逐一钉死。把脚本写成不依赖外部环境、出错就停、有锁保护、有产物校验、有心跳告警的形态,它就能在不被人盯着的深夜里,替你守住服务器的底线。
建议这周就做一件事:挑出你服务器上最重要的那个定时任务,按本文模板重写一遍,加上 watchdog。这件事的投入产出比,比再学一个新的运维工具高得多。