2024 年,我给一个项目加了个每天凌晨的数据库备份。事情简单:写一个备份脚本,丢进 crontab 让它每天 3 点跑一次。我把脚本写好,在终端里手动执行了一遍——跑得干净利落,备份文件乖乖地出现在了目标目录,大小也对。我很满意,把它配进 crontab,设成每天 03:00,然后就没再管它。直到一周后,有人问我要某天的备份,我去目标目录一看——空的。一个备份文件都没有。这一周,这个定时任务就没成功过一次。我心里直犯嘀咕:脚本我手动跑过、明明是好的呀。我又在终端里手动执行了一次,还是完美成功;我反复确认 crontab -l 里那行配置,时间、路径、写法,一个字都没错。手动跑必成功,定时跑必失败——同一个脚本、同一台机器,仅仅因为"是我亲手敲的"还是"cron 替我跑的",结果就天差地别。我盯着这个诡异的分裂想了很久,最后才意识到:cron 替我跑这个脚本时,它所处的"环境",和我自己在终端里敲命令时的环境,根本就不是同一个。这件事逼着我把 cron 的运行环境、PATH、登录 shell 与非交互 shell 的区别这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,给一个备份脚本配了每天 03:00 的 crontab
事故现象:
- ★ 在终端里手动执行备份脚本,每次都成功
- ★ 同一个脚本配进 crontab,到点从不产出备份文件
- crontab -l 看配置,时间、路径、写法都没错
现场排查:
# 1. crontab 配置确实在
$ crontab -l
0 3 * * * /home/app/backup.sh # ★ 配置没问题
# 2. ★ 看 cron 到底有没有"触发"这个任务
$ grep CROND /var/log/cron | grep backup
... CROND[12011]: (app) CMD (/home/app/backup.sh)
# ★ 有这行 —— 说明 cron 准时触发了,任务【跑了】
# 只是跑完【失败了】,而失败的输出我没接住
# 3. ★ 关键一招:给任务加上输出重定向,把错误接住
$ crontab -e
0 3 * * * /home/app/backup.sh >> /home/app/backup.log 2>&1
# ^^^^^^^^^^^^^^^^^^^^^^^^^^
# ★ 把标准输出和标准错误都写进日志文件
# 4. ★ 第二天看那个日志,真相出现了
$ cat /home/app/backup.log
/home/app/backup.sh: line 6: mysqldump: command not found
# ^^^^^^^^^^^^^^^^^^^
# ★ mysqldump 命令"找不到" —— 可我手动跑明明能用
# 5. 对比两个环境的 PATH
$ echo $PATH # 我的终端里
/usr/local/bin:/usr/bin:/usr/local/mysql/bin:... # ★ 很长
# crontab 里加一行 echo $PATH 看 cron 的:
/usr/bin:/bin # ★ cron 的 PATH 短得可怜
根因(后来想清楚的):
1. ★ cron 执行一个任务时,给它的"环境",和我自己
登录终端时的环境,是【两个完全不同的世界】。
2. 我登录终端,shell 会去读 /etc/profile、~/.bashrc
这些文件,把一大串环境变量(尤其是 PATH)都
配置好 —— 我那个长长的 PATH 就是这么来的。
3. ★ cron 跑任务,【不会】去读这些文件。它给任务的
PATH 是一个极简的默认值,通常只有 /usr/bin:/bin。
4. 我的 mysqldump 装在 /usr/local/mysql/bin/ 下,
这个目录在我终端的 PATH 里、却【不在】cron 那个
极简 PATH 里。于是脚本里那句不带路径的 mysqldump,
cron 环境下就 "command not found"。
5. ★ 脚本失败了,但它的错误输出,默认是被 cron
"邮件"给本地用户、根本没人看 —— 对我来说就表现为
"悄无声息地什么都没发生"。
"手动能跑、cron 不能跑" = 两者的运行环境(尤其 PATH)不一样。
修复 1:cron"没跑"还是"跑了但失败"——先分清
# === ★ 第一步:确认 cron 到底有没有触发这个任务 ===
# === 两种情况,排查方向完全不同 ===
# "定时任务没效果",其实是两件事,要先分清:
# A. ★ cron 根本【没触发】这个任务 -> 查 crontab 配置
# 本身(格式、时间、cron 服务有没有开)。
# B. ★ cron 【触发了】,任务跑了,但任务自己【失败了】
# -> 查脚本在 cron 环境下为什么失败(本文主线)。
# === ★ 怎么区分:看 cron 自己的日志 ===
# cron 每触发一个任务,都会在系统日志里留一行记录:
$ grep CROND /var/log/cron # CentOS / RHEL
$ grep CRON /var/log/syslog # Ubuntu / Debian
# 找你那个任务:
$ grep backup /var/log/cron
Apr 10 03:00:01 host CROND[12011]: (app) CMD (/home/app/backup.sh)
# ★ 这一行的含义:
# - 有这行 -> cron 准时【触发了】任务。是情况 B,
# 任务跑了但可能失败 —— 去查脚本。
# - 没有这行 -> cron 【根本没触发】。是情况 A,
# 问题在 crontab 配置或 cron 服务(见修复 4)。
# === ★ 第二步(情况 B 必做):把任务的输出接住 ===
# cron 触发了任务,但任务的输出去哪了?★ 默认情况下,
# 任务打印的东西(尤其是错误),cron 会试图通过
# 【本地邮件】发给你 —— 而几乎没人会去看本地邮件。
# 所以任务失败,对你来说就是"悄无声息"。
# ★ 解决:在 crontab 里给任务加输出重定向,亲手把
# 它的输出(连同错误)写进一个日志文件:
0 3 * * * /home/app/backup.sh >> /home/app/backup.log 2>&1
# ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^
# >> 文件 :标准输出追加进日志
# 2>&1 :★ 把标准错误也【并到】标准输出里一起写
# ★ 没有 2>&1,你接住的只是正常输出,恰恰漏掉了
# 最关键的【报错】。这两段必须都加。
# === 认知 ===
# ★ 排查定时任务,第一动作永远是这两步:
# ① 看 /var/log/cron 确认它【触发没触发】;
# ② 给任务加 >> log 2>&1,把它的输出和错误【接住】。
# 绝大多数"定时任务玄学",在你能看到它的报错那一刻
# 就不玄学了。
修复 2:cron 的环境,和你的登录终端是两个世界
# === ★ 核心认知:cron 任务跑在一个"极简环境"里 ===
# === 你登录终端时,环境是怎么来的 ===
# 你 SSH 登录、打开一个终端,启动的是一个【登录
# 交互式 shell】。它启动时会【依次读取】一串文件:
# /etc/profile -> /etc/profile.d/* -> ~/.bash_profile
# -> ~/.bashrc ...
# ★ 你的 PATH、各种环境变量、别名、函数,都是这些
# 文件一点点【喂】给你的。你那个长长的、什么命令
# 都能直接敲的 PATH,就是这么攒出来的。
# === ★ cron 跑任务时,根本不读那些文件 ===
# cron 触发一个任务,它启动的是一个【非交互、非登录】
# 的 shell。★ 这种 shell【不会】去读 /etc/profile、
# 不会读 ~/.bashrc —— 那一整套环境配置,它一概不沾。
# 结果就是:cron 给你的任务的环境,极其"干净"、极其
# 贫瘠。它的 PATH 通常只有:
PATH=/usr/bin:/bin
# ★ 你终端里习以为常的一切 —— 自定义 PATH、JAVA_HOME、
# 各种 export 的变量、别名 —— 在 cron 环境里【统统
# 不存在】。
# === ★ 亲眼对比一下两个环境 ===
# 在你的终端里:
$ echo "$PATH"
/usr/local/bin:/usr/bin:/usr/local/mysql/bin:/opt/.../bin # 长
# 在 crontab 里临时加一行,把 cron 的环境打出来:
* * * * * env > /tmp/cron_env.txt
# 等一分钟,然后:
$ cat /tmp/cron_env.txt
PATH=/usr/bin:/bin # ★ 短得可怜
HOME=/home/app
# ★ 一对比就明白:cron 环境是另一个世界。
# === ★ 这就是"手动能跑、cron 不能跑"的根源 ===
# 你手动跑脚本,脚本里写 mysqldump,shell 顺着你那个
# 长 PATH,在 /usr/local/mysql/bin 里找到了它 —— 成功。
# cron 跑同一个脚本,同样那句 mysqldump,shell 顺着
# /usr/bin:/bin 找了一圈 —— 没有 —— command not found。
# ★ 脚本一个字没变,变的是它脚下的 PATH。
# === 认知 ===
# ★ "在我的终端里能跑",从来不等于"在任何环境里都能跑"。
# 你的终端环境是被精心配置过的"温室";cron 是
# "野外"。脚本要能在 cron 里活,就不能依赖温室里
# 那些它以为理所当然、其实是别人喂给它的东西。
修复 3:怎么写一个在 cron 里能稳跑的脚本
# === ★ 让脚本"不依赖温室"——三条原则 ===
# === ★ 原则 1:命令用绝对路径,或在脚本里自己设 PATH ===
# 既然 cron 的 PATH 不全,有两种解法:
# 解法 A:脚本里所有命令,都写【绝对路径】。
# 先查出命令在哪:
$ which mysqldump
/usr/local/mysql/bin/mysqldump
# 脚本里就这么写:
/usr/local/mysql/bin/mysqldump -u... > backup.sql
# 解法 B(更省事):★ 在脚本【开头】自己把 PATH 设全。
#!/bin/bash
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/mysql/bin
# ★ 这一行加在脚本最前面,后面就能照常用命令名了。
# 还可以直接在 crontab 文件顶部设 PATH(对该文件所有
# 任务生效):
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/mysql/bin
0 3 * * * /home/app/backup.sh
# === ★ 原则 2:不要依赖"当前工作目录" ===
# cron 跑任务时,工作目录是用户的家目录(HOME),
# ★ 不是你的脚本所在的目录。
# 所以脚本里任何【相对路径】都很危险:
cd logs # ✗ 相对哪里?cron 下多半错
./mytool # ✗ 同样危险
# ★ 正确:脚本里用到的路径,全部写绝对路径;或者在
# 脚本开头,先把工作目录切到一个确定的地方:
cd /home/app/project || exit 1 # ★ 开头就锚定目录,失败就退
# === ★ 原则 3:别依赖那些"登录时才有"的环境变量 ===
# JAVA_HOME、各种应用自己 export 的变量 —— 它们都是
# ~/.bashrc 之类喂的,cron 环境里没有。
# 脚本如果需要,就在脚本里【自己显式设】:
export JAVA_HOME=/usr/local/jdk
export LANG=en_US.UTF-8 # ★ 编码也常需要显式设
# ★ 不要假设"环境里本来就该有"——在 cron 里,什么
# 都不该假设。
# === ★ 一个好习惯:让脚本"自带环境" ===
# 把脚本写成"不管谁来跑、在什么环境跑,都能跑"的样子:
#!/bin/bash
set -euo pipefail # 出错即停,别带病往下跑
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/mysql/bin
export LANG=en_US.UTF-8
cd /home/app/project || exit 1
# ... 下面才是正经业务,且尽量用绝对路径 ...
# ★ 脚本开头这几行,就是给它【自己搭一个温室】。
# === ★ 验证脚本能不能在"干净环境"下跑 ===
# 别只用你的终端测!用 env -i 模拟一个空环境来测:
$ env -i /bin/bash -c '/home/app/backup.sh'
# ★ env -i 清空所有环境变量。这一关能过,cron 基本
# 也能过 —— 这是检验"脚本依不依赖温室"的试金石。
修复 4:cron"根本没触发"的常见原因
# === ★ 如果 /var/log/cron 里压根没有你的任务 ===
# 那就是"情况 A":cron 没触发。逐个排查:
# === 原因 1:cron 服务没在跑 ===
$ systemctl status crond # CentOS:服务名是 crond
$ systemctl status cron # Ubuntu:服务名是 cron
# ★ 没 running 就 start + enable。
# === ★ 原因 2:crontab 文件最后一行没有换行 ===
# 一个经典坑:crontab 文件【最后一行末尾必须有换行符】。
# 最后一行如果没换行,这一行任务【会被忽略】。
# ★ 用 crontab -e 编辑,保存时确保末尾留一个空行。
# === ★ 原因 3:时间字段写错 / 理解错 ===
# crontab 五个字段:分 时 日 月 周
0 3 * * * # 每天 3:00
*/10 * * * * # 每 10 分钟
0 3 * * 1 # 每周一 3:00(周:0 和 7 都是周日)
# ★ 常见错:把"每 10 分钟"写成 10 * * * *(那是
# "每小时的第 10 分"跑一次)。拿不准就上在线工具
# 校验,或先用 * * * * * 测它到底跑不跑。
# === ★ 原因 4:百分号 % 没转义 ===
# 在 crontab 里,% 是特殊字符(会被当成换行)。命令里
# 要用 %(最常见是 date +%Y%m%d),必须【转义成 \%】:
0 3 * * * /home/app/backup.sh $(date +\%Y\%m\%d)
# ^ ^ ^ ★ 每个 % 都要 \
# ★ 不转义,任务多半直接不执行,且非常难看出问题。
# === 原因 5:脚本没有执行权限 ===
$ ls -l /home/app/backup.sh
$ chmod +x /home/app/backup.sh # ★ 给它可执行权限
# 或在 crontab 里显式用解释器调,绕开权限问题:
0 3 * * * /bin/bash /home/app/backup.sh
# === ★ 原因 6:用户 crontab vs /etc/crontab 搞混 ===
# - crontab -e 编辑的是【当前用户】的 crontab,
# 它的任务【以这个用户身份】运行,五个字段后【直接
# 跟命令】。
# - /etc/crontab 和 /etc/cron.d/ 里的,★ 在五个时间
# 字段后,【多一列"用户名"】,再跟命令:
# 0 3 * * * root /home/app/backup.sh
# ^^^^ ★ 这一列别漏,漏了整行格式就错
# ★ 把"用户 crontab 的写法"抄进 /etc/cron.d/ 却漏了
# 用户名列 —— 是个高频错误。
# === 认知 ===
# ★ "没触发"几乎都是 crontab 文件本身的问题:服务、
# 格式、换行、转义、权限、用户列。逐个对一遍。
修复 5:正确解法——让定时任务稳定且可观测
# === ★ 解法:既要它能跑,也要它跑挂了你能知道 ===
# === ★ 解法 1:脚本自带环境(根治本文这个坑)===
# 见修复 3。脚本开头显式 export PATH、设好该有的
# 变量、cd 到确定目录。让脚本不依赖任何"温室"。
# === ★ 解法 2:任务永远带输出重定向 ===
# crontab 里每条任务,都把输出和错误接住:
0 3 * * * /home/app/backup.sh >> /var/log/backup.log 2>&1
# ★ 这是底线 —— 没有日志,任务挂了你永远不知道。
# === ★ 解法 3:脚本里加"成功/失败"的明确标记 ===
#!/bin/bash
set -euo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/mysql/bin
echo "=== backup start $(date '+%F %T') ==="
# ... 备份逻辑 ...
echo "=== backup done $(date '+%F %T') 退出码 $? ==="
# ★ 日志里有清清楚楚的起止时间和退出码,一眼看出
# 它跑没跑、成没成。
# === ★ 解法 4:失败要"主动喊一声",别等人来发现 ===
# 我这次的教训:任务静默失败了一周才被发现。给关键
# 任务加上失败告警:
/home/app/backup.sh >> /var/log/backup.log 2>&1 || \
curl -s "告警接口/webhook" -d "backup.sh 失败了"
# ★ || 后面的命令,只在前面失败(退出码非 0)时执行。
# 把失败【推】给你,而不是等你某天想起来去看。
# === ★ 解法 5:任务要"幂等",别假设上次一定成功 ===
# 定时任务可能漏跑、可能重叠跑。脚本最好做到:重复
# 跑、跑多次,都不会出乱子(比如备份文件名带日期、
# 开头清理半成品)。
# === ★ 解法 6:防止任务"跑重叠" ===
# 如果一次任务可能跑很久,下一次又到点了,两个一起跑
# 会出问题。用 flock 加锁,跑不完就跳过这次:
0 3 * * * /usr/bin/flock -n /tmp/backup.lock /home/app/backup.sh >> /var/log/backup.log 2>&1
# ★ -n:拿不到锁就直接退出,不等待。
# === 验证 ===
# 别傻等到凌晨 3 点。把时间临时改成"一两分钟后":
$ crontab -e # 改成比如 * * * * *,等它跑一次
$ tail -f /var/log/backup.log # 实时看它的输出
$ env -i /bin/bash -c '/home/app/backup.sh' # 干净环境再测一遍
# ★ 确认能跑、日志正常,再把时间改回 0 3 * * *。
修复 6:定时任务排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ "手动能跑"不等于"cron 能跑",两者环境不同 ===
# === 2. ★ 先看 /var/log/cron,分清"没触发"还是"触发了但失败" ===
$ grep 任务名 /var/log/cron
# === 3. ★ cron 任务永远带 >> log 2>&1,把输出和错误都接住 ===
# === 4. ★ cron 环境的 PATH 极简,不读 .bashrc / profile ===
# === 5. 脚本命令用绝对路径,或脚本开头自己 export PATH ===
# === 6. ★ cron 任务的工作目录是 HOME,别依赖相对路径,开头先 cd ===
# === 7. crontab 末尾要有换行,命令里的 % 要转义成 \% ===
# === 8. ★ /etc/crontab 和 /etc/cron.d 的写法比用户 crontab 多一列用户名 ===
# === 9. 用 env -i 模拟干净环境测脚本,这关过了 cron 基本就过 ===
# === 10. 排查"定时任务不执行"的步骤链 ===
$ grep 任务 /var/log/cron # ① 触发了没
$ 没触发 -> 查 crontab 格式/服务/换行/转义/权限 # ② 情况 A
$ 触发了 -> 加 >> log 2>&1 看报错 # ③ 情况 B
$ 报 command not found -> 设 PATH / 用绝对路径 # ④ 对症
$ env -i 测脚本 + 加失败告警 # ⑤ 验证并防复发
# 按这个顺序,定时任务的"玄学"基本能定位、能根治。
命令速查
需求 命令
=============================================================
看当前用户的 crontab crontab -l
编辑当前用户的 crontab crontab -e
看 cron 触发日志 grep CROND /var/log/cron (Ubuntu: /var/log/syslog)
看 cron 服务状态 systemctl status crond (Ubuntu: cron)
任务带输出重定向 cmd >> /var/log/x.log 2>&1
打印 cron 环境(临时任务) * * * * * env > /tmp/cron_env.txt
查命令的绝对路径 which 命令名
干净环境测脚本 env -i /bin/bash -c '/path/script.sh'
脚本开头设全 PATH export PATH=/usr/local/bin:/usr/bin:/bin:...
给任务加锁防重叠 flock -n /tmp/x.lock /path/script.sh
crontab 里转义百分号 date +\%Y\%m\%d
口诀:手动能跑不等于 cron 能跑,cron 环境极简不读 bashrc PATH 只有 /usr/bin:/bin
先看 /var/log/cron 分清没触发还是触发了失败,任务永远带 >> log 2>&1 接住报错
避坑清单
- 手动能跑不等于 cron 能跑,cron 给任务的运行环境和你登录终端的环境是两个不同的世界
- 排查定时任务先看 /var/log/cron,有 CMD 记录是触发了任务失败,没记录是根本没触发
- cron 任务的输出默认发本地邮件没人看,务必加 >> log 2>&1 把标准输出和错误都接住
- 没有 2>&1 只接住正常输出会漏掉最关键的报错,标准输出和标准错误两段都要重定向
- 登录终端的 PATH 是 profile 和 bashrc 喂的,cron 不读这些文件 PATH 只有 /usr/bin:/bin
- 脚本里命令找不到就是 PATH 问题,解法是命令写绝对路径或脚本开头自己 export 全 PATH
- cron 任务的工作目录是用户家目录不是脚本所在目录,别依赖相对路径开头先 cd 锚定
- crontab 文件最后一行必须有换行符,命令里的百分号是特殊字符必须转义成反斜杠百分号
- etc/crontab 和 etc/cron.d 里的写法比用户 crontab 在五个时间字段后多一列用户名别漏
- 用 env -i 清空环境变量来测脚本,这个干净环境能跑通 cron 基本就能跑通,是试金石
总结
这次"手动跑必成功、定时跑必失败"的事故,纠正了我一个关于"脚本"的、藏得很深的错觉。在我的脑子里,一个脚本写好之后,它就是一个独立、自足、确定的东西:它里面写了什么命令,它就会做什么事;它在我手里能跑成功,它就是一个"好脚本"。脚本的行为,在我看来完全由脚本自己的内容决定——同样的脚本,在同一台机器上,无论谁来跑、什么时候跑,结果都该是一样的。正因为这个"脚本即一切"的观念,所以当定时任务失败时,我的怀疑对象从来没有离开过两样东西:要么是脚本写错了,要么是 crontab 那行配置写错了。我反复手动执行脚本,它每次都成功,这本该是个巨大的线索,可在我的逻辑里,它反而成了一个让我更困惑的"证据"——既然脚本是好的、配置也是对的,那它凭什么不跑?我陷在这个死循环里,因为我那套"脚本即一切"的模型,根本容不下"同一个脚本会有不同结果"这种可能。复盘到根上,我才明白,一个脚本能不能成功,从来不只取决于脚本自己。它还取决于另一样我从来没有正眼看过的东西——它运行时所处的那个"环境"。一个脚本,就像一颗种子;而它能不能发芽、长成,不光看种子本身,还要看它被种在了什么样的土壤里。我在终端里手动跑脚本时,这颗种子是被种在一片我自己都没意识到的、极其肥沃的"温室"土壤里的:我每次 SSH 登录、打开终端,系统都会默默地、自动地为我读取一连串配置文件,把一个长长的、什么命令都能直接找到的 PATH,把各种环境变量,一点一点地、不声不响地喂给我的 shell。我在这片温室里待久了,早已忘了这片土壤是别人精心为我铺好的,我以为这就是大地本来的样子。而 cron 替我跑这个脚本时,它把同一颗种子,种到了一片我从未踏足过的"野地"里——cron 不会去读那些配置文件,它给脚本的环境极其贫瘠,PATH 短得只剩 /usr/bin:/bin。我的 mysqldump 命令,装在一个温室里有、野地里没有的目录下。于是同一句不带路径的 mysqldump,在温室里,shell 顺着肥沃的长 PATH 一找就找到了;在野地里,shell 顺着贫瘠的短 PATH 找了一圈,两手空空。脚本一个字都没改,改变的,只是它脚下那片我从不曾留意的土壤。这次最大的收获,是我意识到,我对"我的脚本能跑"这件事的自信,其实一直建立在一个我毫无察觉的依赖之上——它依赖着我那个被精心配置过的、舒适的终端环境,而我把这份依赖,错当成了脚本自身的能力。"它在我这儿能跑",这句话里其实藏着一个巨大的、未被言明的前提:"……在我这套环境下"。一旦换了环境,这个前提失效了,那份自信也就跟着塌了。真正健壮的脚本,不应该是一颗只能在温室里发芽的娇贵种子;它应该把自己需要的土壤,自己带在身上——在脚本的开头,就亲手把 PATH 设好,把该有的环境变量显式地声明清楚,把工作目录明确地锚定下来。它不该对脚下的土壤做任何乐观的假设,因为它不知道下一次,是谁、会在什么样的荒地上,把它种下去。所以下一次,当某样东西"在我这儿明明是好的、换个地方却坏了"的时候,我不会再只盯着那样东西本身翻来覆去地查了。我会把目光移开,移到它周围那片我习惯性视而不见的环境上,问一句:这个东西能正常工作,是它自己真的足够独立、足够自足,还是仅仅因为,我恰好把它放在了一个一直在默默替它兜底的、舒适的地方?——很多东西的"好用",是环境的功劳,不是它自己的本事;而一旦离开那个环境,真相就露出来了。
—— 别看了 · 2026