2024 年,我给一台服务器写了个数据库备份脚本,手动跑了好几遍,每次都完美收工——备份文件准时出现在目录里,大小也对。我很放心地把它丢进了 crontab,设成每天凌晨三点跑,然后就把这件事忘了。直到一周后要找备份,我才发现:那个备份目录,从我设好定时任务那天起,就再没多过一个文件。脚本明明是好的,我手动跑一百遍都成功,可一交给 cron,它就像从来没存在过一样,不声不响,什么都没发生。最让我抓狂的是它的"沉默"——没有报错,没有日志,没有任何线索,我甚至无法确认 cron 到底有没有尝试运行过它。我盯着那行写得清清楚楚的 crontab 看了很久:时间对、路径对、脚本可执行——它凭什么不跑?后来才明白,问题根本不在"跑没跑",而在于 cron 跑脚本时,递给脚本的是一个我从未见过的、极度贫瘠的环境。这件事逼着我把 Linux 的 cron 机制、运行环境、定时任务排查这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个数据库备份 shell 脚本 /opt/backup.sh
事故现象:
- 脚本手动执行 100% 成功,备份文件正常生成
- 写进 crontab 设每天 3 点跑,一周下来一个备份都没有
- ★ 没有任何报错,cron 像没执行过一样,完全沉默
现场排查:
# 1. 手动跑 —— 完美
$ /opt/backup.sh
备份完成:/data/backup/db_20240601.sql.gz # ★ 手动一切正常
# 2. 看 crontab 配置 —— 看起来没问题
$ crontab -l
0 3 * * * /opt/backup.sh # ★ 时间、路径都对
# 3. ★ 关键一步:看 cron 到底有没有【尝试】运行它
$ grep CRON /var/log/cron | grep backup
... CROND[12强]: (root) CMD (/opt/backup.sh) # ★ cron 确实跑了!
# cron 没偷懒,它每天 3 点都老老实实执行了这条命令。
# 4. ★ 那就是脚本在 cron 环境里【跑失败了】
# 让 cron 把脚本的输出发给我 —— 加一行 MAILTO,或重定向到文件
0 3 * * * /opt/backup.sh > /tmp/backup.log 2>&1
# 第二天看 /tmp/backup.log:
$ cat /tmp/backup.log
/opt/backup.sh: line 8: mysqldump: command not found # ★ 真相!
/opt/backup.sh: line 12: /data/backup: No such file or directory
根因(后来想清楚的):
1. ★ cron 跑脚本时,给的环境和我登录终端时【完全不同】。
2. 我手动跑,继承的是登录 shell 的环境:PATH 很全、
有一堆环境变量、当前目录是我 cd 过去的地方。
3. ★ cron 跑,给的是一个【极简环境】:PATH 短得可怜
(基本只有 /usr/bin:/bin)、几乎没有环境变量、
工作目录是用户家目录。
4. mysqldump 装在 /usr/local/mysql/bin,不在 cron 的
PATH 里 -> command not found。
5. 脚本里用了相对路径 ./data/backup,cron 的工作目录
不是我以为的那个,于是路径也错了。
6. ★ 脚本没写错,是它【依赖了我登录环境才有的东西】,
而 cron 不提供这些。
cron 不执行,十有八九是"环境"问题,不是"时间"问题。
修复 1:cron 给脚本的,是一个极度贫瘠的环境
# === ★ 先纠正最核心的误解:cron 跑脚本 ≠ 你手动跑脚本 ===
# === 你手动跑脚本时,脚本继承了什么 ===
# 你登录终端 -> 系统加载 /etc/profile、~/.bashrc 等一堆文件
# -> 你的 shell 里于是有了:很全的 PATH、一堆环境变量、
# 别名、当前工作目录(你 cd 到的地方)。
# 你在这个"装备齐全"的 shell 里跑脚本,脚本把这一切
# 【全盘继承】了过去 —— 它要什么有什么。
# === ★ cron 跑脚本时,脚本继承了什么 ===
# cron 是个后台守护进程,它跑你的脚本时:
# - ★ 不会去加载 /etc/profile、~/.bashrc —— 一个都不读
# - ★ 所以那些文件里设的 PATH、环境变量,脚本一概拿不到
# - 它只给一套【自己内置的、极简的】环境
# ★ 一句话:cron 给的环境,和你登录后的环境,是两个世界。
# === 亲眼看看 cron 环境有多"穷" ===
# 临时加一条 cron,把 cron 环境打印出来:
$ crontab -e
* * * * * env > /tmp/cron_env.txt # 每分钟导出一次 cron 环境
# 等一分钟,然后:
$ cat /tmp/cron_env.txt
PATH=/usr/bin:/bin # ★ PATH 短得可怜!
SHELL=/bin/sh
HOME=/root
LOGNAME=root
# ★ 就这么几行。对比你自己终端里 env 输出的几十行 ——
# cron 环境少了一大半东西。
# === ★ 于是"cron 不执行"的真相,通常是这样 ===
# cron 其实【执行了】,只是脚本在那个贫瘠环境里跑【失败】了:
# - 找不到命令(PATH 不全)
# - 找不到文件(工作目录不对、相对路径失效)
# - 缺某个环境变量(脚本依赖的变量没了)
# ★ 排查 cron,第一信条:先分清是"没执行"还是"执行了但失败"。
修复 2:PATH 问题——cron 找不到你的命令
# === ★ cron 故障头号原因:命令找不到(PATH 太短)===
# === 现象:手动跑好好的命令,cron 里报 command not found ===
# /tmp/backup.log 里那行:
# mysqldump: command not found
# 可你手动 which 一下,它明明在:
$ which mysqldump
/usr/local/mysql/bin/mysqldump # ★ 在这,但不在 cron 的 PATH 里
# === 为什么手动跑就找得到 ===
# 因为你的 ~/.bashrc 里有一行:
# export PATH=$PATH:/usr/local/mysql/bin
# 你登录时这行生效了 -> 你的 PATH 里有这个目录。
# ★ 但 cron 不读 ~/.bashrc -> cron 的 PATH 里【没有】它。
# === 解法 A:脚本里用命令的【绝对路径】(最稳)===
# 把脚本里的:
# mysqldump -u root ...
# 改成:
# /usr/local/mysql/bin/mysqldump -u root ...
# ★ 绝对路径不依赖 PATH,放到任何环境里都能跑 —— 最保险。
# === 解法 B:在脚本【开头】自己把 PATH 设全 ===
#!/bin/bash
export PATH=/usr/local/bin:/usr/local/mysql/bin:/usr/bin:/bin:$PATH
# ★ 脚本第一件事就把 PATH 补全,后面就能直接用命令名了。
# === 解法 C:在 crontab 文件【顶部】设 PATH ===
$ crontab -e
PATH=/usr/local/bin:/usr/local/mysql/bin:/usr/bin:/bin
0 3 * * * /opt/backup.sh
# ★ crontab 文件里可以直接写 PATH=... 这样的赋值行,
# 它对这个文件里【下面所有】的任务都生效。
# === ★ 我的建议:脚本里关键命令一律用绝对路径 ===
# 给脚本里每个外部命令都 which 一下,确认绝对路径:
$ which mysqldump gzip find date
# 然后脚本里全用绝对路径 —— 这样脚本【不挑环境】,
# 手动跑、cron 跑、换台机器跑,都不会因 PATH 翻车。
修复 3:工作目录与环境变量——cron 不在你以为的地方
# === ★ cron 故障第二大原因:工作目录 / 相对路径 ===
# === cron 跑脚本时,当前工作目录是哪 ===
# ★ 是执行用户的【家目录】(比如 root 就是 /root)。
# 【不是】脚本所在的目录,更不是你手动跑时 cd 到的目录。
# 验证一下:
* * * * * pwd > /tmp/cron_pwd.txt
$ cat /tmp/cron_pwd.txt
/root # ★ cron 的工作目录 = 家目录
# === ★ 于是脚本里的相对路径会集体失效 ===
# 脚本里写:
# mkdir data/backup # 相对路径!
# cd ./logs
# 你手动跑时,当前目录是 /opt,所以 data/backup = /opt/data/backup。
# cron 跑时,当前目录是 /root,data/backup 就成了 /root/data/backup ——
# 完全是另一个地方,于是 "No such file or directory"。
# === 解法 1:脚本里【全用绝对路径】 ===
# mkdir -p /data/backup
# cd /opt/logs
# ★ 和命令一样:文件路径也别依赖"当前目录",写死绝对路径。
# === 解法 2:脚本开头先 cd 到一个确定的目录 ===
#!/bin/bash
cd "$(dirname "$0")" || exit 1 # ★ cd 到脚本自己所在的目录
# dirname "$0" = 脚本所在目录;这样后面的相对路径就有了确定基准。
# === ★ 环境变量同理:cron 里你的自定义变量都没了 ===
# 你在 ~/.bashrc 里 export 的变量(比如 JAVA_HOME、
# 各种应用的配置变量),cron 环境里【一个都没有】。
# 脚本若依赖它们,要么在脚本里自己 export,
# 要么在脚本开头显式 source 配置文件:
#!/bin/bash
source /etc/profile # ★ 主动加载,把环境补回来
source /opt/app/env.sh # 应用自己的环境文件
# ★ 注意:source /etc/profile 能解决很多环境问题,
# 但更干净的做法,还是脚本里把依赖的东西显式写全。
# === 一个排查神器:让脚本【在 cron 环境里】自报家门 ===
# 在脚本开头临时加几行,把环境信息记下来:
echo "PATH=$PATH" >> /tmp/debug.log
echo "PWD=$(pwd)" >> /tmp/debug.log
echo "USER=$(whoami)" >> /tmp/debug.log
# ★ cron 跑一次后看这个 log,cron 给的真实环境就一清二楚了。
修复 4:怎么知道 cron 到底跑没跑——别再瞎猜
# === ★ cron 最折磨人的是"沉默",但其实它留了不少线索 ===
# === 线索 1:cron 自己的日志,记录"它有没有执行" ===
# CentOS / RHEL 系:
$ tail -f /var/log/cron
... CROND[1234]: (root) CMD (/opt/backup.sh) # ★ 有这行 = cron 执行了
# Debian / Ubuntu 系:
$ grep CRON /var/log/syslog
# ★ 这个日志只证明 cron "发起了执行",不证明脚本"成功了"。
# 看到了 CMD 行 -> cron 没问题,问题在脚本本身。
# 没看到 CMD 行 -> cron 压根没执行,问题在 crontab 配置/cron 服务。
# === 线索 2:★ cron 会把脚本的输出"邮件"给你 ===
# cron 的默认行为:任务有任何 stdout/stderr 输出,
# cron 就把它【当邮件发给执行用户】。
$ cat /var/mail/root # ★ 看 root 的本地邮件
# 报错信息往往就静静躺在这里 —— 很多人根本不知道有这封信。
# === ★ 线索 3:最实用 —— 自己把输出重定向到文件 ===
0 3 * * * /opt/backup.sh > /tmp/backup.log 2>&1
# > 收 stdout,2>&1 把 stderr 也并进去 —— ★ 一个都别漏。
# ★ 这一招是排查 cron 的【万能钥匙】:
# 脚本在 cron 环境里说的每一句话(包括报错),全进这个文件。
# === 用 MAILTO 指定收件人,或干脆关掉邮件 ===
$ crontab -e
MAILTO="ops@example.com" # 把 cron 邮件发到指定邮箱
MAILTO="" # 设空 = 不发邮件
0 3 * * * /opt/backup.sh > /tmp/backup.log 2>&1
# === 线索 4:确认 cron 服务本身活着 ===
$ systemctl status crond # CentOS 叫 crond
$ systemctl status cron # Debian/Ubuntu 叫 cron
# ★ 服务没起来,所有定时任务都不会跑 —— 先确认它是 active。
# === ★ 排查口诀:先看 /var/log/cron 这一关 ===
# 有 CMD 行 -> cron 尽责了,去查脚本(看重定向的 log)
# 没 CMD 行 -> cron 没执行,去查 crontab 配置和 cron 服务
修复 5:crontab 语法的那些暗坑
# === ★ 就算环境对了,crontab 的语法本身还有几个经典坑 ===
# === 坑 1:五个时间字段,别数错位 ===
# ┌─ 分(0-59)
# │ ┌─ 时(0-23)
# │ │ ┌─ 日(1-31)
# │ │ │ ┌─ 月(1-12)
# │ │ │ │ ┌─ 周几(0-7,0 和 7 都是周日)
# │ │ │ │ │
# 0 3 * * * /opt/backup.sh # 每天 3:00
# */5 * * * * /opt/check.sh # ★ 每 5 分钟一次
# 0 2 * * 1 /opt/weekly.sh # 每周一 2:00
# ★ */5 这种"每隔几"的写法很常用,但容易和 5 写混。
# === 坑 2:★ 百分号 % 必须转义 ===
# crontab 里 % 有特殊含义(会被当成换行)。
# 想在命令里用 date 的 % 格式,必须转义成 \%:
0 3 * * * /opt/backup.sh $(date +\%Y\%m\%d) # ★ % 要写成 \%
# 不转义 -> cron 解析直接出错,任务诡异地不执行。
# === 坑 3:用户级 crontab vs 系统级 crontab ===
# 用户级:每个用户一份,crontab -e 编辑,命令【不写用户名】
$ crontab -e # 编辑当前用户的
$ crontab -l # 查看
$ crontab -u www -l # 看 www 用户的(需 root)
# 系统级:/etc/crontab 和 /etc/cron.d/ 里的文件,
# ★ 格式多【一个用户名字段】:
# 0 3 * * * root /opt/backup.sh
# ^^^^ ★ 系统级要指明用哪个用户跑,用户级没有这一列
# ★ 把系统级格式写进用户 crontab(或反过来)-> 直接失效。
# === 坑 4:脚本得有执行权限,或显式用解释器跑 ===
$ chmod +x /opt/backup.sh # 给执行权限
# 或者在 crontab 里显式指定解释器(更稳,不依赖 x 权限):
0 3 * * * /bin/bash /opt/backup.sh
# === 坑 5:crontab 最后一行要有【换行符】 ===
# ★ 老版本 cron 有个坑:文件最后一行如果没有换行,
# 那一行任务会被忽略。用 crontab -e 编辑一般没事,
# 但用脚本生成 crontab 文件时,务必在结尾留个空行。
# === 改完怎么确认生效 ===
$ crontab -l # 确认内容是你要的
$ systemctl restart crond # 一般不用重启,改完即生效
# ★ crontab -e 保存后 cron 会自动加载,通常无需重启服务。
修复 6:cron 定时任务排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ cron 不执行,先分清"没跑"还是"跑了但失败" ===
$ grep CRON /var/log/cron | grep 脚本名
# 有 CMD 行 = 跑了,查脚本;没有 = 没跑,查配置/服务
# === 2. ★ cron 环境极简,脚本别依赖登录环境 ===
# PATH 短、无自定义环境变量、工作目录是家目录。
# === 3. ★ 脚本里命令和文件路径,一律用绝对路径 ===
$ which 命令 # 先查清绝对路径,再写进脚本
# === 4. ★ 永远给 cron 任务加输出重定向 ===
0 3 * * * /opt/backup.sh > /tmp/backup.log 2>&1
# 2>&1 不能漏,否则报错(stderr)看不到
# === 5. 排查时让脚本自报环境 ===
echo "PATH=$PATH PWD=$(pwd)" >> /tmp/debug.log
# === 6. crontab 语法:% 转义、字段别数错、用户级/系统级别混 ===
$ crontab -l # 用户级
$ cat /etc/crontab /etc/cron.d/* # 系统级(多一个用户名字段)
# === 7. 排查 cron 问题的命令链 ===
$ systemctl status crond # ① cron 服务活着吗
$ crontab -l # ② 任务配置对不对
$ grep CRON /var/log/cron # ③ cron 有没有执行
$ cat /tmp/xxx.log # ④ 看脚本输出(重定向来的)
$ cat /var/mail/root # ⑤ 没重定向就看邮件
# 按这个顺序,cron 问题基本能定位。
命令速查
需求 命令
=============================================================
编辑当前用户定时任务 crontab -e
查看当前用户定时任务 crontab -l
查看指定用户定时任务 crontab -u 用户名 -l
看 cron 有没有执行 grep CRON /var/log/cron
看 cron 服务状态 systemctl status crond
看脚本在 cron 里的输出 给任务加 > /tmp/x.log 2>&1 再看
看 cron 发的邮件 cat /var/mail/用户名
导出 cron 环境排查 * * * * * env > /tmp/cron_env.txt
查命令的绝对路径 which 命令名
系统级定时任务 cat /etc/crontab /etc/cron.d/*
口诀:cron 不执行先 grep /var/log/cron,有 CMD 行就是脚本失败
脚本失败八成是 PATH 或工作目录,命令和文件全用绝对路径
避坑清单
- cron 跑脚本的环境和登录终端完全不同,PATH 极短、无自定义环境变量
- cron 不会加载 /etc/profile 和 ~/.bashrc,那里设的东西脚本一概拿不到
- cron 不执行先 grep CRON /var/log/cron,有 CMD 行说明 cron 跑了是脚本失败
- 脚本里的外部命令一律用绝对路径,which 查清楚再写,不要依赖 PATH
- cron 的工作目录是用户家目录,脚本里的相对路径会集体失效
- 给每个 cron 任务加 > log 2>&1,2>&1 不能漏否则看不到报错
- cron 默认把任务输出当邮件发给执行用户,/var/mail/用户名 能看到
- crontab 里百分号 % 有特殊含义,命令里用到必须转义成 \%
- 用户级 crontab 不写用户名,系统级 /etc/crontab 多一个用户名字段,别混
- 脚本要有执行权限或在 crontab 里显式用 /bin/bash 跑,crontab 文件结尾留空行
总结
这次"脚本手动跑一百遍都成功,交给 cron 就一次都不跑"的事故,纠正了我一个埋藏得很深、自己却从未察觉的假设——我一直默认,cron 帮我运行脚本,和我自己在终端里敲命令运行脚本,是同一件事,无非是"运行的时刻"由人换成了机器。在我的想象里,cron 就是一个尽职的闹钟,时间一到,它就替我把那条命令敲下去,而敲下去之后发生的一切,都和我亲手敲是一模一样的。正是这个"cron 跑 = 我手动跑"的等式,让我在排查时完全找错了方向:我反反复复地检查那行 crontab 的时间字段,怀疑是不是星号写错了、是不是三点写成了别的时刻——我所有的注意力,都钉在了"cron 这个闹钟准不准"上。可真正的问题,从来不在闹钟响没响,而在闹钟响了之后,它把我的脚本领进了一个什么样的房间。复盘到根上,我才真正理解,一个脚本能跑起来,靠的远不止它自己那几行代码。我手动运行脚本时,这个脚本其实是站在一大片我从未留意过的"地基"之上的:我登录系统的那一刻,系统已经默默替我加载了 /etc/profile、~/.bashrc 这一摞文件,它们给我的 shell 铺好了一条很长很全的 PATH,塞进了一大把环境变量,而我又用 cd 把自己挪到了某个特定的目录。我的脚本,是在这片我亲手布置好、却又因为太熟悉而彻底无视了的地基上跑起来的——它要的 mysqldump,PATH 里正好有;它写的相对路径,正好落在我 cd 过去的那个目录下。一切都"恰好"成立,以至于我误以为这是脚本自身的能力。而 cron 唤起这个脚本时,它根本不会去走我登录时的那套流程,它不读 /etc/profile,不读 ~/.bashrc,它递给脚本的,是一个它自己内置的、贫瘠到近乎空旷的环境:PATH 短得只剩 /usr/bin:/bin,我那些自定义的环境变量一个都不剩,工作目录也不由分说地被设成了家目录。我的脚本被领进的,就是这样一个房间——它伸手去够 mysqldump,够了个空;它顺着相对路径去找备份目录,找到的是一个根本不存在的地方。脚本一行代码都没错,错的是它一直靠着一片地基生活,而它自己从不知道那片地基的存在,更不知道换个环境,地基就没了。cron 给它的,正是一个抽走了地基的环境。这次从一个"沉默的定时任务"里走出来,我最大的收获,是不再把"一个脚本"看成一个孤立、自足的东西。一个脚本能不能跑成功,是它的代码和它所处的环境【共同】决定的。在自己温暖熟悉的登录 shell 里验证通过,只证明了它在那片特定地基上站得住,绝不证明它换个地方也站得住。从此凡是要交给 cron、交给别的用户、或是搬到别的机器上去跑的脚本,我都会逼自己问一句:它现在依赖的每一样东西——每一个命令、每一个路径、每一个变量——如果脚下这片地基被抽走,它还找得到吗?把命令和路径全部写成绝对的,把依赖的环境在脚本开头亲手补齐,本质上就是在做一件事:让脚本自己带着地基上路,而不是指望落地的地方恰好有一片。
—— 别看了 · 2026