2024 年我给一台服务器配了个每天凌晨的数据库备份任务,用 crontab -e 写好那一行,保存退出,看着提示 crontab: installing new crontab,我就当这事办妥了。三个月后,生产库出了问题需要回滚,我胸有成竹地去 /backup 目录里找备份——结果那个目录是空的,一个备份文件都没有。三个月,九十多个夜晚,那个备份任务一次都没有真正成功过。可我 crontab -l 一看,那行任务明明白白地躺在那里。它"在"册,却从不"执行成功",而我对此一无所知,因为我从来没给它配过一行日志。这件事逼着我把 cron 这套定时任务系统彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,用 crontab 配的每日数据库备份
事故现象:
- 三个月前配好备份任务,crontab -l 能看到它
- 需要回滚时,发现 /backup 目录里一个备份都没有
- 任务"在册"但从未成功,且我毫无察觉
现场排查:
# 1. 确认任务确实在 crontab 里
$ crontab -l
0 3 * * * /opt/scripts/db_backup.sh
# —— 任务在,时间 0 3 * * * 是每天 3:00,没问题
# 2. 看 cron 到底有没有在跑这个任务
$ grep CRON /var/log/cron | grep db_backup | tail -3
May 13 03:00:01 host CROND[12450]: (root) CMD (/opt/scripts/db_backup.sh)
May 14 03:00:01 host CROND[13980]: (root) CMD (/opt/scripts/db_backup.sh)
# ★ cron 确实每天 3:00 都执行了这个脚本 —— cron 没问题
# 3. 那为什么没产出备份?手动跑一遍脚本
$ /opt/scripts/db_backup.sh
$ ls /backup
db_20240514.sql.gz # ★ 手动跑,备份正常生成!
# 4. 关键差异:为什么手动能跑,cron 跑就不行?
# 在脚本开头临时加一行 env > /tmp/cron_env.txt,等 cron 跑一次后看:
$ cat /tmp/cron_env.txt
PATH=/usr/bin:/bin # ★ PATH 极简!
HOME=/root
# —— 我手动跑时 PATH 里有 /usr/local/bin,mysqldump 在那
# cron 跑时 PATH 只有 /usr/bin:/bin,找不到 mysqldump
根因(后来想清楚的):
1. 脚本里直接写 `mysqldump ...`,依赖 PATH 能找到它。
2. cron 执行任务时,给的是一个【极简环境】——
PATH 只有 /usr/bin:/bin,不加载 /etc/profile、不加载 .bashrc。
3. mysqldump 实际在 /usr/local/bin,不在 cron 的 PATH 里。
4. 于是脚本里那行 mysqldump 每晚都 "command not found",
备份根本没生成。
5. 而我【没给任务配任何日志重定向】,这个每晚的报错
被直接丢弃,我三个月都没看见。
任务不是没跑,是每晚都跑、每晚都失败、每晚都无声无息。
修复 1:cron 体系——crond、用户级与系统级
# === cron 是 Linux 的定时任务调度系统 ===
# 核心是一个常驻后台进程 crond,它每分钟醒来一次,
# 检查所有的任务表,把"这一分钟该跑"的任务跑掉。
# === 先确认 crond 这个服务是活的 ===
$ systemctl status crond # CentOS 叫 crond
$ systemctl status cron # Debian/Ubuntu 叫 cron
$ systemctl enable crond # 设成开机自启
# ★ 排查"任务没跑",第一步就是确认 crond 在运行 ——
# crond 没起,所有定时任务都是一纸空文。
# === cron 的任务来源有好几处,别只盯着 crontab -e ===
# 1. 用户级 crontab —— 每个用户一份
$ crontab -l # 看【当前用户】的任务
$ crontab -e # 编辑【当前用户】的任务
$ crontab -l -u www # 看指定用户 www 的任务
# 实际存在 /var/spool/cron/用户名,但别手动改这个文件,用 crontab -e
# 2. 系统级 /etc/crontab 和 /etc/cron.d/
$ cat /etc/crontab # 系统级任务表
$ ls /etc/cron.d/ # 各软件包丢进来的任务片段
# ★ 系统级任务比用户级【多一个字段】:在时间和命令之间多一个
# "用哪个用户身份运行"。这点下一节细说。
# 3. 现成的周期目录(丢脚本进去就会被定期执行)
$ ls /etc/cron.hourly/ /etc/cron.daily/
$ ls /etc/cron.weekly/ /etc/cron.monthly/
# 把可执行脚本放进 cron.daily,它就会每天被跑一次。
# === 排查任务,要把这几个来源都看一遍 ===
$ crontab -l # 当前用户
$ for u in root www; do echo "== $u =="; crontab -l -u $u; done
$ cat /etc/crontab; ls /etc/cron.d/
修复 2:读懂 crontab 的五个时间字段
# === crontab 一行任务的结构 ===
# ┌───── 分钟 (0-59)
# │ ┌─── 小时 (0-23)
# │ │ ┌─ 日 (1-31)
# │ │ │ ┌ 月 (1-12)
# │ │ │ │ ┌ 星期 (0-7,0 和 7 都是周日)
# │ │ │ │ │
# * * * * * 要执行的命令
#
# 用户级 crontab:五个时间字段 + 命令
# 系统级 /etc/crontab:五个时间字段 + 【用户】 + 命令(多一段)
# === 五个字段里能用的写法 ===
# * 每一个(每分钟/每小时/...)
# 5 具体值
# */10 每隔 10(配合分钟 = 每 10 分钟)
# 1-5 范围(配合星期 = 周一到周五)
# 1,15 列举(配合日 = 每月 1 号和 15 号)
# === 常见任务的写法对照 ===
0 3 * * * # 每天凌晨 3:00
*/5 * * * * # 每 5 分钟
0 * * * * # 每小时整点
30 2 * * 0 # 每周日 2:30
0 0 1 * * # 每月 1 号 0:00
0 9-18 * * 1-5 # 周一到周五,9 点到 18 点每个整点
0 2 * * * root /opt/x.sh # ★ 系统级写法:多了 root 这个用户字段
# === ★ 一个经典陷阱:日 和 星期 同时指定 ===
0 0 1 * 1 # 这【不是】"1 号且周一",而是"1 号【或】周一"
# 日和星期只要满足一个,任务就会跑 —— 是【或】的关系。
# 想精确控制,其中一个用 *,别两个都填具体值。
# === 几个方便的特殊写法(不是所有 cron 都支持)===
@reboot /opt/scripts/start.sh # 开机时执行一次
@daily /opt/scripts/clean.sh # 等价 0 0 * * *
@hourly /opt/scripts/sync.sh # 等价 0 * * * *
# === 写完拿不准时间对不对?在线/本地都能验 ===
# 把那行时间字段抄下来,对照上面的结构逐位核对,
# 尤其注意 "日 和 星期" 那个【或】的坑。
修复 3:cron 的环境陷阱——这次的根因
# === ★ cron 执行任务时,环境和你登录的 shell 完全不同 ===
# 你 ssh 登录后,shell 会加载 /etc/profile、~/.bash_profile、
# ~/.bashrc,于是有一个"完整"的环境:PATH 很长、各种变量都在。
#
# 但 cron 跑任务时,给的是一个【极简、干净】的环境:
# - PATH 通常只有 /usr/bin:/bin —— 非常短!
# - 【不加载】 /etc/profile,【不加载】 ~/.bashrc
# - HOME、LANG 等变量可能和你预期的不一样
# —— 这就是"手动跑得通、cron 跑不通"的头号原因。
# === 验证 cron 到底给了什么环境 ===
# 加一条临时任务,把环境 dump 出来:
* * * * * env > /tmp/cron_env.txt 2>&1
# 等一分钟,然后:
$ cat /tmp/cron_env.txt
PATH=/usr/bin:/bin # ★ 看,就这么短
# 对比你登录 shell 里的:
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 差的那段 /usr/local/bin,正是很多命令的所在地。
# === 解法一:命令一律写【绝对路径】(最稳)===
$ which mysqldump
/usr/local/bin/mysqldump
# 脚本里就别写 mysqldump,写全:
# /usr/local/bin/mysqldump ...
# ★ cron 任务里调用的每个命令,都用绝对路径,一劳永逸。
# === 解法二:在 crontab 顶部 或 脚本里显式设 PATH ===
# crontab 文件支持在任务行之前直接写变量赋值:
$ crontab -e
PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/bin
0 3 * * * /opt/scripts/db_backup.sh
# 或在脚本第二行就写死 PATH:
# #!/bin/bash
# export PATH=/usr/local/bin:/usr/bin:/bin
# === 解法三:脚本里主动加载需要的环境 ===
# 如果脚本依赖某些 profile 里的变量:
# #!/bin/bash
# source /etc/profile
# source ~/.bashrc # 按需
# ★ 但更推荐解法一/二 —— 让脚本【自带】它需要的一切,
# 不依赖"恰好被加载"的外部环境,这才是健壮的写法。
修复 4:给 cron 任务配日志——让失败"看得见"
# === 这次最致命的不是脚本错,是【错了三个月没人知道】===
# cron 任务默认:它的标准输出和标准错误,会被【发邮件】给用户。
# 但绝大多数服务器根本没配本地邮件 —— 于是这些输出
# 直接进了黑洞,报错信息一条都看不到。
# === ★ 铁律:每条 cron 任务都要重定向输出到日志文件 ===
$ crontab -e
0 3 * * * /opt/scripts/db_backup.sh >> /var/log/db_backup.log 2>&1
# 拆开看这串符号:
# >> /var/log/db_backup.log 把标准输出【追加】写进日志文件
# 2>&1 把标准错误也【并】到标准输出去
# 两个加起来 = 正常输出和报错,全都进同一个日志文件。
# ★ 2>&1 必须写在 >> 文件【之后】,顺序反了不生效。
# === 如果当初配了这一行,会发生什么 ===
$ cat /var/log/db_backup.log
/opt/scripts/db_backup.sh: line 8: mysqldump: command not found
# —— 第一晚就会留下这行铁证,我第二天一看日志就发现了。
# 三个月的事故,本可以在第一天就被一行 >> 日志挡下。
# === 让脚本【自己】写带时间戳的日志(更专业)===
#!/bin/bash
LOG=/var/log/db_backup.log
echo "===== backup start $(date '+%F %T') =====" >> $LOG
/usr/local/bin/mysqldump -u root mydb 2>> $LOG | gzip > /backup/db_$(date +%F).sql.gz
if [ $? -eq 0 ]; then
echo "backup OK $(date '+%F %T')" >> $LOG
else
echo "backup FAILED $(date '+%F %T')" >> $LOG
fi
# === 进阶:任务失败时主动告警 ===
# 在脚本里判断退出码,失败就发个钉钉/企业微信 webhook,
# 或者写一个监控,定期检查 /backup 里有没有当天的新文件。
# ★ "任务跑了"和"任务成功了"是两件事 ——
# 只确认前者,就是这次事故的全部教训。
修复 5:调试一个"不工作"的 cron 任务
# === 一套从外到内的排查顺序 ===
# === 1. crond 服务在跑吗 ===
$ systemctl status crond
# 没在跑 -> systemctl start crond && systemctl enable crond
# === 2. 任务确实在 crontab 里吗(别看错用户)===
$ crontab -l # 当前用户
$ crontab -l -u root # 你以为配在 root,其实配在了别的用户?
# ★ 很常见:sudo crontab -e 和 crontab -e 编辑的是【不同】用户的表。
# === 3. cron 有没有"试图执行"这个任务 ===
$ grep CRON /var/log/cron | tail -20 # CentOS
$ grep CRON /var/log/syslog | tail -20 # Debian/Ubuntu
# 日志里有 CMD (你的命令) -> cron 执行了,问题在【命令/脚本本身】
# 日志里【没有】 -> 问题在【时间字段写错】或 crond 没跑
# === 4. 命令/脚本本身的问题(执行了但失败)===
# 4a. 脚本有可执行权限吗
$ ls -l /opt/scripts/db_backup.sh
$ chmod +x /opt/scripts/db_backup.sh
# 4b. 脚本第一行有正确的 shebang 吗
$ head -1 /opt/scripts/db_backup.sh
#!/bin/bash # 没有这行,cron 可能用错解释器
# 4c. ★ 用 cron 那样的极简环境模拟跑一遍
$ env -i /bin/bash --noprofile --norc -c '/opt/scripts/db_backup.sh'
# env -i 清空所有环境变量,最接近 cron 的真实情况 ——
# 能在这种环境下跑通,cron 里基本也就通了。
# === 5. 看任务自己的日志(前提是修复 4 配了重定向)===
$ tail -50 /var/log/db_backup.log
# === 6. 临时把任务调密,快速验证 ===
# 别为了测一个"每天 3 点"的任务真等到半夜:
* * * * * /opt/scripts/db_backup.sh >> /tmp/test.log 2>&1
# 改成每分钟,跑通了再改回 0 3 * * *。
# === 排查口诀 ===
# cron 日志有没有 CMD -> 没有就是时间/服务问题
# -> 有就是脚本问题:权限、shebang、PATH、环境
修复 6:cron 任务管理纪律
# === 这次事故暴露了 cron 任务的"配完即不管",定几条纪律 ===
# === 1. ★ 第一纪律:每条 cron 任务都重定向日志 ===
0 3 * * * /opt/scripts/db_backup.sh >> /var/log/db_backup.log 2>&1
# 没有日志的定时任务 = 在黑暗里运行,出错你永远不知道。
# === 2. 任务里的命令一律用绝对路径 ===
# 不写 mysqldump,写 /usr/local/bin/mysqldump。
# cron 的 PATH 极简,靠 PATH 找命令迟早踩坑。
# === 3. 脚本自带环境,不依赖"恰好被加载" ===
#!/bin/bash
export PATH=/usr/local/bin:/usr/sbin:/usr/bin:/bin
set -e # 任何一步失败就停,别带病往下跑
# 让脚本健壮到"在任何干净环境下都能跑"。
# === 4. "跑了" ≠ "成功了",要监控【结果】 ===
# 备份任务,就监控 /backup 里有没有当天的新文件;
# 别只在 cron 日志里看到 CMD 就安心 —— 那只代表它被调用了。
$ find /backup -name 'db_*.sql.gz' -mtime -1 | grep -q . \
|| echo "警告:今天没有新备份!"
# === 5. 新配的定时任务,先调密验证再改回去 ===
# 先 */2 * * * * 跑两分钟看日志,确认没问题,再改成真正的周期。
# 别配完就等半年后用的时候才发现它从没成功。
# === 6. 改 crontab 前先备份 ===
$ crontab -l > ~/crontab_backup_$(date +%F).txt
# crontab -r 是"删除全部任务",和 -e 挨得很近,误删过的都懂。
# ★ 永远别用 crontab -r;要清空也先备份。
# === 7. 任务幂等 + 防重叠 ===
# 如果任务可能跑得比周期还久,加个文件锁防止两个实例叠在一起:
0 * * * * flock -n /tmp/sync.lock /opt/scripts/sync.sh >> /var/log/sync.log 2>&1
# flock -n:拿不到锁就直接退出,不会堆积。
# === 8. 定期巡检所有定时任务 ===
$ for u in $(cut -d: -f1 /etc/passwd); do \
echo "== $u =="; crontab -l -u $u 2>/dev/null; done
$ cat /etc/crontab; ls -la /etc/cron.d/
# 把"列出全机所有 cron 任务"做成巡检脚本的一部分。
命令速查
需求 命令
=============================================================
看当前用户的定时任务 crontab -l
编辑当前用户的定时任务 crontab -e
看指定用户的任务 crontab -l -u 用户名
看系统级任务 cat /etc/crontab; ls /etc/cron.d/
确认 cron 服务在运行 systemctl status crond
看 cron 执行日志 grep CRON /var/log/cron
任务带日志的标准写法 命令 >> /var/log/x.log 2>&1
模拟 cron 极简环境跑脚本 env -i bash --noprofile --norc -c 脚本
防任务重叠 flock -n /tmp/x.lock 命令
备份 crontab crontab -l > ~/crontab_bak.txt
口诀:cron 日志有 CMD 吗 -> 没有查时间/服务,有就查脚本
-> 命令用绝对路径 -> 每条任务必加 >> 日志 2>&1
避坑清单
- cron 任务在册不代表执行成功,要监控任务的产出结果而非是否被调用
- cron 执行任务时环境极简,PATH 只有 /usr/bin:/bin,不加载 profile/bashrc
- "手动跑得通 cron 跑不通"的头号原因是 PATH 找不到命令
- cron 任务里的命令一律用绝对路径,which 查出来后写全
- 每条 cron 任务都要重定向 >> 日志 2>&1,否则报错进黑洞
- 2>&1 必须写在 >> 文件之后,顺序反了不生效
- crontab 时间字段里日和星期同时指定是"或"的关系,不是"且"
- 排查先看 /var/log/cron 有没有 CMD,有则查脚本,无则查时间或服务
- 新配任务先调密为每分钟验证通过,再改回真实周期
- 永远别用 crontab -r(删全部任务),改 crontab 前先 crontab -l 备份
总结
这次"三个月备份全部落空"的事故,纠正了我对定时任务一个根深蒂固的误解:我一直以为,只要我用 crontab -e 把任务写好、保存了,crontab -l 也能看到它了,这件事就算"成了"。这次事故用三个月、九十多个失败的夜晚狠狠地告诉我——一条定时任务"在册",和它"每晚都成功执行",是两件相隔十万八千里的事情,而我从头到尾,只确认了前者。复盘到根上,我才真正搞懂了这次事故的两层原因,它们叠在一起,造成了这场"无声的失败"。第一层,是 cron 的环境陷阱。当我自己 ssh 登录上服务器、在 shell 里敲命令时,我的 shell 会替我加载一整套配置文件——/etc/profile、~/.bash_profile、~/.bashrc,加载完之后,我拥有一个"完整"的环境:PATH 变量很长,囊括了 /usr/local/bin 这样的目录,我手动跑那个备份脚本,里面的 mysqldump 自然能被找到,一切正常。可是 cron 在凌晨三点替我执行那个脚本时,它给脚本的,是一个截然不同的、极度精简的、干净的环境:它【不会】去加载那一整套 profile 配置文件,它给的 PATH 短得可怜,通常只有 /usr/bin:/bin。而我那台机器上的 mysqldump,偏偏装在 /usr/local/bin 里——这个目录,根本不在 cron 给的那个极简 PATH 之内。于是,我脚本里那行光秃秃的、依赖 PATH 来定位的 mysqldump 命令,每一个凌晨都在报 command not found,备份从来就没有被真正生成过。这就是为什么"我手动跑得通,cron 跑就不通"——不是脚本错了,是脚本所依赖的那个环境,在 cron 的世界里根本不存在。明白了这一层,对症的解法也就清楚了:cron 任务里调用的每一个命令,都应该写成绝对路径,比如直接写 /usr/local/bin/mysqldump,而不是把希望寄托在那个靠不住的 PATH 上;或者,在脚本的开头就主动地、显式地把 PATH 设置成一个完整的值——总之,要让脚本"自带"它运行所需要的一切,而不是依赖某个"恰好会被加载"的外部环境。但比环境陷阱更让我后怕的,是第二层原因——这次事故能瞒过我整整三个月,真正的元凶,是我从来没有给这条任务配过哪怕一行日志。cron 任务默认会把它的输出和报错以邮件的形式发给用户,可绝大多数服务器压根没有配本地邮件系统,这就意味着,那每晚一次的 command not found 报错,被直接送进了一个黑洞,我永远也看不到。如果我当初在那条任务的末尾,仅仅多写上 >> /var/log/db_backup.log 2>&1 这么一小段——把标准输出和标准错误,统统重定向、追加进一个日志文件——那么这场三个月的事故,本可以在它发生的第一个夜晚,就被那个日志文件里清清楚楚的报错信息当场抓住。这次事故之后,我给自己立下的最硬的一条规矩,就是:从今往后,我配置的每一条 cron 任务,后面都必须跟上 >> 日志文件 2>&1,绝无例外——一个没有日志的定时任务,等于让它在彻底的黑暗里运行,它是成功了还是失败了,你永远蒙在鼓里。而第二条规矩是:我必须去监控这个任务真正的"产出结果",而不是它"有没有被调用"。对于一个备份任务来说,值得我去检查的,是 /backup 目录里到底有没有出现今天的那个新文件,而不是 cron 日志里有没有那行表示"我调用了脚本"的 CMD 记录——前者才是"成功",后者仅仅是"尝试"。这次从一个空荡荡的备份目录出发,我最大的收获,是把"配置一个定时任务"这件事,从我心里那个"写好、保存、看到它在列表里"的简单动作,彻底重新理解成了一条必须完整闭合的链路:写好任务、用绝对路径和显式环境让它健壮、给它配上日志让它的成败可见、调密验证一次确认它真的能跑通、最后再加上一个对它产出结果的监控——只有这条链路完整地闭合了,一个定时任务,才算真正可靠地、值得托付地跑了起来。
—— 别看了 · 2026