2024 年,我写了一个数据库备份脚本,逻辑很简单:把库 dump 出来、打包、扔到 /backup 目录,再删掉七天前的旧包。脚本写好后我在命令行手动跑了好几遍,每次都干净利落地生成一个备份包,毫无问题。于是我把它放进 crontab,设成每天凌晨 3 点自动执行,心想这下高枕无忧了。一周后,某个库出了点问题我想回滚,去 /backup 找备份——目录是空的。一个备份包都没有。crontab 我明明写了,凌晨 3 点这个时间我也反复确认过没错,脚本手动跑更是百分百正常,可它就是一次都没成功执行过。一个"手动跑完全没问题"的脚本,放进 crontab 就集体失踪,这件事把我彻底搞懵了。后来才明白,问题不在脚本,而在我对 cron 这个东西的运行环境一无所知。这件事逼着我把 Linux 的 crontab、cron 运行环境、定时任务排查这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个数据库备份脚本 /opt/backup.sh
事故现象:
- 脚本手动执行,完全正常,稳定生成备份包
- 放进 crontab 设每天 3 点跑
- 一周后发现 /backup 目录空空如也,一次都没成功
现场排查:
# 1. 确认 crontab 确实写了,时间也没错
$ crontab -l
0 3 * * * /opt/backup.sh # ★ 看着没问题
# 2. ★ 关键命令:看 cron 的执行日志
$ grep CRON /var/log/cron | tail -5
... CROND[2891]: (root) CMD (/opt/backup.sh)
# ★ cron 确实"启动"了脚本 —— 说明定时本身没问题!
# 3. 那脚本为什么没产出?去看脚本自己的报错
# 但脚本的输出我没做任何重定向 -> 报错根本没留下来
# 4. ★ 手动模拟 cron 的极简环境跑一遍
$ env -i /bin/sh -c '/opt/backup.sh'
/opt/backup.sh: line 3: mysqldump: command not found # ★ 真相!
# 手动跑能跑,是因为我的 shell 里 PATH 很全;
# cron 给的 PATH 极简,mysqldump 不在里面 -> 命令找不到
根因(后来想清楚的):
1. ★ cron 执行任务时,给的【环境极简】—— 它不是
我的登录 shell,没有 .bashrc/.bash_profile,
PATH 通常只有 /usr/bin:/bin 这么一点点。
2. 我脚本里直接写了 mysqldump、tar 等命令,没写绝对路径。
手动跑:我的 shell PATH 很全,能找到 -> 正常。
cron 跑:PATH 极简,mysqldump 不在 -> command not found。
3. ★ 更要命的是:脚本的报错输出我没做任何重定向,
cron 把它当邮件发给本地用户,我从来不看本地邮件
-> 错误信息石沉大海,我以为"没动静=没问题"。
4. 于是:cron 准时启动脚本 -> 脚本第一条命令就失败 ->
错误被默默吞掉 -> 备份目录一直是空的。
crontab 不执行,十有八九不是定时错了,是环境和路径。
修复 1:crontab 是什么——以及 cron 能写在哪几个地方
# === ★ 先认识 cron:它是 Linux 上【按时间自动跑任务】的服务 ===
# 后台有个常驻进程 crond,它每分钟醒来一次,
# 检查"这一分钟有没有该跑的任务",有就跑。
# === 确认 crond 这个服务是活的(★ 最容易被忽略的前提)===
$ systemctl status crond
Active: active (running) # ★ 必须是 running
# crond 没起来,你写再多 crontab 都不会执行。
$ systemctl enable --now crond # 没开就开起来
# === ★ cron 任务其实能写在【好几个地方】,别只知道一个 ===
# --- 地方 1:用户 crontab(最常用)---
$ crontab -e # 编辑【当前用户】的 crontab
$ crontab -l # 查看
$ crontab -r # ★ 删除(危险!整份清空,别手滑)
$ crontab -u www -l # 看【别的用户】的 crontab
# 用户 crontab 文件实际存在 /var/spool/cron/ 下,按用户名分文件。
# --- 地方 2:系统 crontab /etc/crontab ---
$ cat /etc/crontab
# ★ 注意:它的格式比用户 crontab 【多一列"用户名"】!
# 分 时 日 月 周 用户 命令
0 3 * * * root /opt/backup.sh
# --- 地方 3:/etc/cron.d/ 目录 ---
# 往这个目录丢一个文件,格式同 /etc/crontab(也带用户名列)。
# ★ 软件包安装的定时任务,通常就丢在这里。
# --- 地方 4:/etc/cron.{hourly,daily,weekly,monthly}/ ---
# 把【可执行脚本】丢进去,就会按小时/天/周/月跑。不写时间表达式。
# === ★ 一个坑:同一个任务可能在多处定义,排查要都看一遍 ===
$ crontab -l # 当前用户的
$ cat /etc/crontab /etc/cron.d/* # 系统的
$ ls /etc/cron.{hourly,daily,weekly,monthly}/
修复 2:看懂那五个星号——时间表达式
# === ★ crontab 每行前面那五个字段,就是"什么时候跑" ===
# ┌── 分钟 (0-59)
# │ ┌── 小时 (0-23)
# │ │ ┌── 一个月的第几天 (1-31)
# │ │ │ ┌── 月份 (1-12)
# │ │ │ │ ┌── 星期几 (0-7,0 和 7 都是周日)
# │ │ │ │ │
# * * * * * 要执行的命令
# === 几个常见写法,照着抄 ===
0 3 * * * 每天凌晨 3:00
*/10 * * * * 每 10 分钟一次(★ 斜杠 = "每隔多少")
0 */2 * * * 每 2 小时(在整点)
0 9 * * 1-5 周一到周五的 9:00
30 2 1 * * 每月 1 号的 2:30
0 0 * * 0 每周日 0:00
15 14 * * 1,3,5 周一/三/五的 14:15(★ 逗号 = 列举)
# === ★ 一个经典误区:"日"和"周"两个字段同时写 ===
0 3 5 * 1 # 你以为是"5 号【且】是周一才跑"?
# ★ 错!cron 的规则是:日和周都不为 * 时,是【或】的关系 ——
# "每月 5 号" 或 "每个周一",满足任意一个就跑。
# 想要"既是几号又是周几",cron 表达式做不到,得在脚本里自己判断。
# === 拿不准表达式?先用一个高频任务验证 ===
* * * * * date >> /tmp/cron_test.log # 每分钟写一次
$ tail -f /tmp/cron_test.log # 看它是不是真的每分钟在涨
# ★ 验证完记得把这行测试任务删掉。
# === ★ 特殊写法:@reboot 和 @daily ===
@reboot /opt/init.sh # 开机后跑一次
@daily /opt/backup.sh # 等价于 0 0 * * *
@hourly /opt/check.sh # 等价于 0 * * * *
# @reboot 很有用 —— 想"开机自动跑个脚本"又不想写 systemd 单元时。
修复 3:cron 的环境极简——这次踩坑的真正核心
# === ★ 这次事故的根:cron 跑任务时的环境,和你的 shell 完全不同 ===
# === 你登录的 shell:环境很"富裕" ===
# 你 SSH 登录、敲命令时,系统会加载 /etc/profile、
# ~/.bash_profile、~/.bashrc —— 里面设了一长串 PATH、
# 各种环境变量、别名…… 你的命令能跑,靠的是这一身"装备"。
# === ★ cron 跑任务的环境:几乎是"裸奔" ===
# crond 执行你的任务时:
# - 【不会】加载 .bashrc / .bash_profile / /etc/profile
# - PATH 通常只有极简的 /usr/bin:/bin
# - 没有你 shell 里那些自定义环境变量
# - 工作目录是该用户的家目录
# ★ 所以"我手动能跑"和"cron 能跑"是两码事 ——
# 手动跑你带着全套装备,cron 跑几乎是空手。
# === 亲眼看看 cron 到底给了多穷的环境 ===
# 临时加一行任务,把 cron 环境打印出来:
* * * * * env > /tmp/cron_env.txt
# 等一分钟后:
$ cat /tmp/cron_env.txt
PATH=/usr/bin:/bin # ★ 看,就这么点
HOME=/root
SHELL=/bin/sh
# 和你 echo $PATH 看到的那一长串,天差地别。
# === ★ 解法 1:命令一律用【绝对路径】 ===
# 脚本里别写 mysqldump,写全路径:
$ which mysqldump # 先查出它在哪
/usr/local/mysql/bin/mysqldump
# 脚本里就写 /usr/local/mysql/bin/mysqldump
# === ★ 解法 2:在 crontab / 脚本顶部,自己把 PATH 设全 ===
# crontab 文件里,命令行之前可以直接定义变量:
$ crontab -e
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * /opt/backup.sh
# 或者在脚本第二行(shebang 之后)就 export PATH。
# === ★ 解法 3:脚本里主动加载需要的环境 ===
#!/bin/bash
source /etc/profile # 把系统环境捞回来
source ~/.bashrc # 需要的话也加载用户环境
# ★ 适合那些"严重依赖环境变量"的脚本(比如要用 JAVA_HOME 的)。
# === ★ 黄金法则:脚本要能在"裸环境"下跑 ===
# 写完任务脚本,务必这样自测一遍:
$ env -i /bin/sh -c '/opt/backup.sh'
# env -i = 清空所有环境变量。能在这里跑通,放进 cron 才稳。
修复 4:cron 跑了没、跑成没——怎么看日志
# === ★ 排查 cron,第一件事永远是:看它到底跑没跑 ===
# === 证据 1:/var/log/cron —— cron 自己的执行日志 ===
$ tail -20 /var/log/cron
... CROND[2891]: (root) CMD (/opt/backup.sh)
# ★ 有 CMD 这行 = cron 确实在那个时间点【启动】了你的任务。
# 注意:这只证明"启动了",不证明"跑成功了"。
$ grep backup.sh /var/log/cron # 只看某个任务
$ grep CROND /var/log/cron | grep 'May 20' # 看某天的
# === ★ 关键判断:日志里【有没有】你的任务那一行 ===
# - 有 CMD 行 -> cron 没问题,问题在脚本本身(往下查脚本)
# - 没有 CMD 行 -> cron 压根没触发,问题在 crontab/crond
# (时间表达式写错?crond 没运行?写错文件了?)
# === 证据 2:脚本自己的输出 —— ★ 必须主动重定向收集 ===
# cron 默认会把任务的 stdout/stderr 当【邮件】发给本地用户。
# 你不看本地邮件 = 所有报错石沉大海(这正是我这次的坑)。
# ★ 正确做法:在 crontab 里把输出重定向到日志文件:
0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
# ^^^^^^^^^^^^^^^^^^^^^ ^^^^
# 把标准输出追加进日志 把标准错误也并进去
# ★ 2>&1 一定要写 —— 否则报错(stderr)还是会漏掉。
# === 证据 3:实在要看 cron 发的邮件 ===
$ cat /var/spool/mail/root # 或 mail 命令
# 里面能看到任务输出/报错。但更推荐上面的重定向方案。
# === ★ 一个好习惯:任务脚本自己打带时间戳的日志 ===
#!/bin/bash
log() { echo "[$(date '+%F %T')] $*"; }
log "备份开始"
/usr/local/mysql/bin/mysqldump ... || { log "dump 失败"; exit 1; }
log "备份完成"
# 配合 >> /var/log/backup.log 2>&1,
# 每次跑没跑、第几步挂的,一目了然。
# === 还想确认 crond 进程级别有没有动静 ===
$ journalctl -u crond --since '1 hour ago'
# systemd 下,crond 的运行情况这里也看得到。
修复 5:时区、权限、换行符——其余几个常见坑
# === 坑 1:★ 脚本没有可执行权限 ===
$ ls -l /opt/backup.sh
-rw-r--r-- 1 root root ... /opt/backup.sh # ★ 没有 x
$ chmod +x /opt/backup.sh
# 没有 x,直接 "/opt/backup.sh" 会执行失败。
# ★ 规避:crontab 里显式带上解释器,就不依赖 x 权限:
0 3 * * * /bin/bash /opt/backup.sh >> /var/log/backup.log 2>&1
# === 坑 2:★ cron 跑的时间和你以为的时区不一样 ===
$ date # 看系统当前时间和时区
$ timedatectl # 看时区设置
# crond 用的是【系统时区】。如果系统是 UTC,你以为的"凌晨 3 点"
# 实际是北京时间上午 11 点。改时区:
$ timedatectl set-timezone Asia/Shanghai
$ systemctl restart crond # ★ 改完时区,重启 crond
# === 坑 3:★ 命令里的 % —— cron 把它当特殊字符 ===
# 在 crontab 里,% 有特殊含义(换行 / 给命令喂输入)。
# 这行想用 date 的 %Y%m%d,会直接出错:
0 3 * * * tar czf /backup/db-`date +%Y%m%d`.tar.gz /data # ★ 错
# ★ 正解:每个 % 前面加反斜杠转义:
0 3 * * * tar czf /backup/db-`date +\%Y\%m\%d`.tar.gz /data
# ★ 更省心的办法:把复杂命令全塞进脚本,crontab 里只调脚本。
# 脚本里的 % 是正常字符,不用转义 —— 这也是该用脚本的原因。
# === 坑 4:crontab 文件最后一行没有换行符 ===
# ★ crontab 要求【每一行都以换行结尾】,包括最后一行。
# 最后一行没回车,这行任务可能【整个不执行】。
# 用 crontab -e 编辑一般没事(编辑器会补),
# 但你若用 "crontab 某文件" 导入,务必确认文件末尾有空行。
# === 坑 5:被 cron.deny / cron.allow 限制 ===
$ cat /etc/cron.deny /etc/cron.allow 2>/dev/null
# 如果你的用户在 cron.deny 里,或存在 cron.allow 但你不在其中,
# 你的 crontab 根本不会被执行。普通用户排查时留意这个。
# === 坑 6:用 crontab 文件导入,覆盖了原有任务 ===
$ crontab mycron.txt # ★ 这是【整份覆盖】,不是追加!
# 想保留原有的,先 crontab -l > now.txt 导出、追加、再导入。
修复 6:cron 定时任务排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 任务不执行,先分清是"没触发"还是"跑了但失败" ===
$ grep 你的脚本名 /var/log/cron
# 有 CMD 行 = 触发了,查脚本;没有 = 没触发,查 crontab/crond。
# === 2. ★ cron 环境极简,命令一律用绝对路径 ===
$ which 命令 # 查出全路径,脚本里写全路径
# 或在 crontab/脚本顶部把 PATH 设全。
# === 3. ★ 任务输出一定要重定向收集,别让它石沉大海 ===
0 3 * * * /opt/x.sh >> /var/log/x.log 2>&1 # 2>&1 不能少
# === 4. ★ 脚本要能在裸环境下跑通,再放进 cron ===
$ env -i /bin/sh -c '/opt/x.sh' # 这样能跑通才算稳
# === 5. 复杂命令塞进脚本,crontab 只调脚本 ===
# 避开 % 转义、引号嵌套等一堆 crontab 语法坑。
# === 6. 改时区后重启 crond;确认 crond 服务在运行 ===
$ systemctl status crond
# === 7. 排查 cron 问题的命令链 ===
$ systemctl status crond # ① crond 服务活着没
$ crontab -l # ② 任务写对没、时间对没
$ grep CROND /var/log/cron # ③ cron 有没有触发任务
$ env -i /bin/sh -c '脚本' # ④ 裸环境下脚本能否跑通
$ cat /var/log/x.log # ⑤ 任务自己的输出日志
$ date; timedatectl # ⑥ 时间和时区对不对
# 按这个顺序,定时任务问题基本能定位。
命令速查
需求 命令
=============================================================
编辑当前用户 crontab crontab -e
查看当前用户 crontab crontab -l
查看别的用户 crontab crontab -u 用户名 -l
确认 crond 服务在跑 systemctl status crond
看 cron 执行日志 grep CROND /var/log/cron
看某任务有没有被触发 grep 脚本名 /var/log/cron
裸环境模拟 cron 跑脚本 env -i /bin/sh -c '脚本路径'
任务输出重定向到日志 命令 >> /var/log/x.log 2>&1
查命令绝对路径 which 命令名
看/改系统时区 timedatectl
口诀:任务没产出先看 /var/log/cron 有没有 CMD 行
有就查脚本(绝对路径!) 没有就查 crontab 和 crond
避坑清单
- cron 任务不执行,先看 /var/log/cron 有没有 CMD 行,分清没触发还是跑了失败
- cron 执行环境极简,不加载 .bashrc,PATH 只有 /usr/bin:/bin
- 脚本里命令一律用绝对路径,或在 crontab/脚本顶部把 PATH 设全
- 任务输出务必重定向 >> 日志 2>&1,否则报错被当邮件石沉大海
- 脚本要先用 env -i 在裸环境下跑通,再放进 cron 才稳
- crontab 时间字段日和周同时非星号时是"或"关系,不是"且"
- crontab 里 % 是特殊字符要转义,复杂命令应塞进脚本只调脚本
- 脚本要有可执行权限,或在 crontab 里显式写 /bin/bash 脚本
- crond 用系统时区,改时区后要重启 crond,否则跑的时间不对
- crontab 文件导入是整份覆盖不是追加,crontab -r 会清空整份
总结
这次"手动跑没问题的脚本,放进 crontab 集体失踪"的事故,纠正了我一个非常根本、却从来没被我意识到的错误假设。在这次之前,我对"把脚本放进 crontab 定时跑"这件事的理解,简单到只剩一句话:cron 就是一个"闹钟",时间到了,它替我把脚本"敲"一遍——就像我自己坐在终端前,在凌晨 3 点亲手把那条命令打进去、回车一样。在这个假设里,"我手动能跑通"和"cron 能跑通"是完全等价的两件事:既然脚本在我手里跑得好好的,那 cron 替我跑,自然也该一样好好的。正是这个看似天经地义的假设,让我对备份目录的空荡毫无防备。复盘到根上,我才终于看清,cron 替我执行脚本的那个"现场",和我自己登录上去敲命令的那个"现场",根本不是同一个世界。我 SSH 登录、在终端里敲命令时,我并不是"赤手空拳"的——系统在我登录的那一刻,默默替我加载了 /etc/profile、~/.bash_profile、~/.bashrc 这一整套配置,给我穿上了一身"装备":一个内容丰富、把各种命令目录都囊括进去的 PATH,一堆自定义的环境变量,各种别名。我那个脚本之所以手动跑得顺,靠的根本不只是脚本自己,还有我身上这一整套我从未留意过的"装备"。而 crond 在凌晨 3 点替我执行任务时,它给脚本的环境,几乎是"裸奔"的——它不会去加载 .bashrc,不会加载任何登录脚本,它递给脚本的 PATH,通常只有 /usr/bin:/bin 这么可怜的一点点。我的脚本里直接写了 mysqldump 这个命令,而 mysqldump 的真实位置在 /usr/local/mysql/bin 下——这个目录在我手动登录的丰富 PATH 里,所以手动跑能找到;可它根本不在 cron 那个极简的 PATH 里,所以 cron 一跑,脚本的第一条命令就撞上了 command not found。这就是真相:cron 每天都准时、忠实地启动了我的脚本,一次没落;只是脚本一进门就因为找不到命令而当场失败了。而比"找不到命令"更让我后背发凉的,是第二层认知盲区:这个失败,本来是有声音的——脚本报的那句 command not found,cron 其实老老实实地把它当成一封邮件,投递给了本地用户的邮箱。只是我从来不知道、也从来不去看 Linux 的本地邮件,于是这一周里,每天凌晨都有一封"我失败了"的求救信被准时送达,而我一封都没读到。这件事让我把 cron 任务的两条铁律刻进了脑子。第一条,cron 的运行环境是"裸"的,所以任务脚本必须能在"裸环境"下独立活下来——命令要么写绝对路径,要么在脚本或 crontab 顶部亲手把 PATH 补全;检验的办法也很硬核:用 env -i 清空所有环境变量再跑一遍,能在这种"一无所有"的条件下跑通,放进 cron 才算真的稳。第二条,cron 任务的输出绝不能放任不管——必须用 >> 日志文件 2>&1 把标准输出和标准错误一起重定向到一个我会去看的日志里,让任务的每一次成败都留下白纸黑字的痕迹,而不是变成一封我永远不会拆开的邮件。这次从一个"空荡荡的备份目录"出发,我最大的收获,是终于分清了"我跑一个脚本"和"cron 替我跑一个脚本"这两件事的本质区别——cron 不是坐在我的终端前替我敲命令的"我",它是一个环境极简、沉默寡言、把所有报错都默默写进信封的后台仆人。把一个任务托付给它,我就必须为它准备好一个不依赖任何外部装备、能独立站立的脚本,并且亲手为它接上一根能把哭声传出来的"日志管道"。否则,它会一如既往地准时上工,然后一如既往地、悄无声息地失败下去——直到某天我真的需要那个备份时,才发现一切早已落空。
—— 别看了 · 2026