2024 年我写了个数据备份脚本,在自己的终端里跑得稳稳当当,验证了好几遍,信心十足地把它挂进了 crontab。结果第二天一看,备份一份都没成,日志里冷冰冰一行 command not found。我百思不得其解:同一个脚本、同一台机器、同一个文件,我手敲就好好的,cron 一跑就报命令找不到——脚本明明一个字都没改。这件事最后查清,根子在一个我从来没认真想过的东西上:环境变量,尤其是那个决定"命令到哪儿去找"的 PATH。这次排查逼着我把 Linux 环境变量这套东西彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个 backup.sh 备份脚本
事故现象:
- 手动执行 ./backup.sh —— 完全正常,备份成功
- 挂进 crontab 定时跑 —— 失败,日志报 command not found
- 脚本、机器、文件,一个字都没动
现场排查:
# 1. 看脚本里报错的是哪个命令
$ cat backup.sh
...
aws s3 cp backup.tar.gz s3://mybucket/ # 用了 aws cli
...
# 2. 手动跑时,aws 命令在哪
$ which aws
/usr/local/bin/aws
# —— aws 装在 /usr/local/bin 下
# 3. 看手动跑时的 PATH
$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin
# —— 含 /usr/local/bin,所以手动跑找得到 aws
# 4. 看 cron 跑时的 PATH(在脚本里加一行 echo $PATH 到日志)
cron 日志里: PATH=/usr/bin:/bin
# ★ 真相:cron 给的 PATH 极简,只有 /usr/bin:/bin
# 根本不包含 /usr/local/bin -> 找不到 aws
根因(后来定位到的):
脚本里用了 aws 这个【裸命令名】,能不能找到它,
完全取决于当前环境的 PATH。
我手动跑时,shell 的 PATH 是被 /etc/profile、
~/.bashrc 一层层加料过的,包含 /usr/local/bin;
而 cron 执行任务时,给的是一个【极简环境】,
PATH 只有 /usr/bin:/bin。同一个 aws,
换了个环境就"消失"了 —— 不是脚本的错,是环境的差异。
修复 1:环境变量与 PATH 到底是什么
# === 环境变量:每个进程都带着的一组"键值对" ===
# 它们影响程序的行为。进程启动时,会从父进程
# 【继承】一份环境变量的副本。
# === 看当前所有环境变量 ===
$ env
$ printenv
# 看单个:
$ echo $PATH
$ printenv PATH
# === PATH:决定"敲一个命令名,去哪些目录里找它" ===
$ echo $PATH
/usr/local/bin:/usr/bin:/bin
# 它是一串目录,用冒号分隔。
# 当你敲 aws,shell 会【按顺序】在这些目录里找
# 名叫 aws 的可执行文件,找到第一个就用。
# 都没找到 -> command not found。
# === 验证:一个命令到底是从哪儿来的 ===
$ which aws # 在 PATH 里找,告诉你用的是哪个
/usr/local/bin/aws
$ type aws # 比 which 更全(还能认出别名、函数)
$ command -v aws # 脚本里判断"命令存在吗"最规范的写法
# === 几个最常打交道的环境变量 ===
# PATH 命令搜索路径
# HOME 当前用户的家目录
# USER 当前用户名
# SHELL 默认 shell
# LANG 语言/编码(影响排序、中文显示、报错语言)
# PWD 当前目录
# TZ 时区
# === 关键认知:同样一个命令,在 A 环境能跑、B 环境
# 报 not found,几乎总是因为两个环境的 PATH 不同 ===
# 排查这类问题,第一件事就是【对比两个环境的 PATH】。
修复 2:登录 shell 与非登录 shell——环境从哪来
# === 你的 PATH 那一长串,是被一层层文件"加料"出来的 ===
# 但读哪些文件,取决于 shell 是怎么被启动的。
# === 两个关键区分 ===
# 1. 登录 shell vs 非登录 shell
# 2. 交互式 vs 非交互式
# === 登录 shell(比如 ssh 登录、tty 登录)读这些 ===
# /etc/profile -> /etc/profile.d/*.sh
# 然后读用户的(找到第一个就停):
# ~/.bash_profile -> ~/.bash_login -> ~/.profile
# === 非登录的交互式 shell(比如登录后再开个终端/子 shell)===
# /etc/bashrc(或 /etc/bash.bashrc)
# ~/.bashrc
# === 非交互式 shell(★ 脚本、cron、很多自动化场景)===
# 默认【这些文件基本都不读】!
# 这就是为什么 cron 里环境那么"干净" ——
# 它根本没经过 /etc/profile、~/.bashrc 的加料。
# === 一个常见实践:让 .bash_profile 把 .bashrc 也加载 ===
$ cat ~/.bash_profile
if [ -f ~/.bashrc ]; then . ~/.bashrc; fi
# 很多系统默认就这么写,目的是让登录 shell 也能用上
# .bashrc 里定义的东西。理解这条,就懂了为什么
# 你写在 .bashrc 里的 PATH 修改,ssh 登录后也生效。
# === 实测:看当前 shell 是不是登录 shell ===
$ shopt -q login_shell && echo "登录 shell" || echo "非登录 shell"
$ echo $0 # -bash 开头(带横杠)通常是登录 shell
# === 自己加 PATH,该写哪个文件 ===
# 给【交互用】:写 ~/.bashrc(开终端就生效)
$ echo 'export PATH=$PATH:/usr/local/bin' >> ~/.bashrc
# 给【所有用户、所有登录】:写 /etc/profile.d/xxx.sh
# ★ 但记住:cron / 脚本根本不读这些,见修复 3。
修复 3:为什么 cron 和 systemd 里环境"变了"
# === cron 的环境,是出了名的"极简" ===
# cron 执行任务时,不是一个登录 shell,也不是
# 交互式 shell —— 它【不读】/etc/profile、~/.bashrc。
# 它给的环境通常只有可怜的几个变量:
# PATH=/usr/bin:/bin (就这么短!)
# HOME, LOGNAME, SHELL=/bin/sh
# 你在 .bashrc 里精心配的一切,cron 一概不知道。
# === 所以,这些在 cron 里都会出问题 ===
# - 用了装在 /usr/local/bin 等非标准目录的命令
# - 依赖某个自定义环境变量(JAVA_HOME、自定义配置路径)
# - 脚本里用相对路径(cron 的工作目录通常是 $HOME)
# === cron 里修正环境的几种办法 ===
# 办法 A:直接在 crontab 文件顶部定义变量
$ crontab -e
PATH=/usr/local/bin:/usr/bin:/bin
JAVA_HOME=/usr/local/jdk
0 2 * * * /opt/scripts/backup.sh
# crontab 支持在任务行之前写 VAR=value。
# 办法 B(更稳):让 cron 任务先 source 环境再跑
0 2 * * * . /etc/profile; /opt/scripts/backup.sh
# 或 source 一个专门的环境文件:
0 2 * * * . /opt/scripts/env.sh && /opt/scripts/backup.sh
# 办法 C(最稳):脚本【自己】把环境补齐(见修复 5)
# === systemd 服务里同理,环境也是极简的 ===
# systemd 启动的服务,不继承你登录 shell 的环境。
# 要在 unit 文件里显式给:
[Service]
Environment=JAVA_HOME=/usr/local/jdk
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/opt/myapp/env.conf # 或从文件读
# ★ 记住:cron 和 systemd 都【不读】你的 .bashrc。
# "手动跑好的、自动跑挂",十有八九就是这个原因。
修复 4:export 与变量的继承
# === 设变量有两种,差别在"子进程能不能看见" ===
# === 不带 export:只在【当前 shell】有效 ===
$ MYVAR=hello
$ echo $MYVAR
hello
$ bash -c 'echo $MYVAR' # 起个子 shell 看
# —— 空的!子进程看不见
# === 带 export:会被【子进程继承】 ===
$ export MYVAR=hello
$ bash -c 'echo $MYVAR'
hello # 子进程能看见了
# === 为什么这事重要 ===
# 你的脚本运行时,是一个子进程;脚本里再调别的程序,
# 又是子进程。变量要想一路传下去,必须 export。
# 没 export 的变量,出了当前这层 shell 就"消失"。
# === 查一个进程实际带着什么环境变量 ===
$ cat /proc/3721/environ | tr '\0' '\n'
# /proc/PID/environ 是进程【启动那一刻】的环境快照,
# 排查"这个服务到底拿到了什么环境"的终极手段。
# 它和你登录 shell 里 env 的输出可能差很多 ——
# 那个差异,往往就是问题所在。
# === set / env / export 的区别 ===
$ env # 只列【环境变量】(export 过的)
$ export -p # 列出所有 export 过的变量
$ set # 列出【所有】变量(含没 export 的)+ 函数
# === 临时给某个命令单独设环境(不污染当前 shell)===
$ LANG=C ls -l # 只给这一条命令用 LANG=C
$ PATH=/opt/bin:$PATH myscript.sh
# 这种"VAR=value 命令"的写法,变量只对这条命令生效。
# === 取消一个变量 ===
$ unset MYVAR
修复 5:在脚本里写出"环境无关"的健壮代码
# === 治本:别让脚本依赖"恰好对的环境",自己把环境定死 ===
# === 1. 脚本开头,显式设定 PATH ===
#!/bin/bash
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin
# 不管谁来调这个脚本(cron / systemd / 别的用户),
# PATH 都是确定的,不再"看天吃饭"。
# === 2. 关键命令,干脆用绝对路径 ===
/usr/local/bin/aws s3 cp ...
/usr/bin/mysqldump ...
# 或在脚本头部把路径存成变量:
AWS=/usr/local/bin/aws
"$AWS" s3 cp ...
# 绝对路径 = 完全不依赖 PATH,最稳。
# === 3. 用到的环境变量,脚本自己负责给默认值 ===
: "${JAVA_HOME:=/usr/local/jdk}"
: "${BACKUP_DIR:=/data/backup}"
# 语法 ${VAR:=默认值}:VAR 没设就用默认值。
# 这样脚本不依赖外部恰好 export 了它。
# === 4. 别用相对路径,先把工作目录定死 ===
cd "$(dirname "$0")" || exit 1
# dirname $0 = 脚本自己所在的目录。
# 先 cd 过去,后面的相对路径才有确定的基准。
# (cron 的工作目录默认是 $HOME,极易踩坑)
# === 5. 开头就检查依赖的命令在不在 ===
for cmd in aws mysqldump tar; do
command -v "$cmd" >/dev/null 2>&1 || {
echo "缺少命令: $cmd" >&2; exit 1
}
done
# 与其跑到一半报 command not found,
# 不如一开始就明确报"缺什么",排查快得多。
# === 6. 脚本开头加这几行,出错更好查 ===
set -e # 命令一失败就退出,别带病往下跑
set -u # 用到未定义变量就报错
set -o pipefail # 管道里任一环失败,整条算失败
# 三件套能让脚本"早失败、响亮地失败"。
修复 6:环境变量管理的纪律
# === 这次事故也暴露了环境管理上的随意,定几条纪律 ===
# === 1. "手动跑好的、自动跑挂",先查环境差异 ===
# 这是排查口诀:同一份代码两种结果,八成是环境不同。
# 在两种环境里各打印一次 env,diff 一下,
# 差异里就藏着真凶。
$ env | sort > /tmp/env_manual.txt # 手动环境
# (在脚本里) env | sort > /tmp/env_cron.txt # 自动环境
$ diff /tmp/env_manual.txt /tmp/env_cron.txt
# === 2. 脚本要"自带环境",不靠运气 ===
# 修复 5 的几条 —— 头部设 PATH、关键命令用绝对路径、
# 变量给默认值 —— 让脚本在任何环境下行为一致。
# === 3. 分清楚改哪个文件 ===
# 个人交互用 -> ~/.bashrc
# 全局所有用户 -> /etc/profile.d/xxx.sh
# 某个服务专用 -> systemd unit 的 Environment / EnvironmentFile
# cron 专用 -> crontab 顶部 或 任务里 source
# ★ 别指望"我在 .bashrc 里加了"就能让 cron/systemd 生效。
# === 4. 改了 profile / bashrc,要让它生效 ===
$ source ~/.bashrc # 在当前 shell 重新加载
# 或重新登录。光改文件不 source,当前会话不会变。
# === 5. 别把敏感信息(密码/密钥)塞进环境变量 ===
# /proc/PID/environ 对同用户可读,环境变量会被子进程
# 继承、也可能被打进日志。密钥应放专门的、权限收紧的
# 文件,而不是 export 出来满世界传。
# === 6. 别无脑往 PATH 里塞一堆目录 ===
# PATH 越长,命令查找越慢,还可能因为顺序问题
# "命中了错误目录里的同名命令"。只加真正需要的。
# 也别把 . (当前目录)放进 PATH —— 安全隐患。
# === 排查环境问题的固定动作 ===
# ① which / type 看命令实际在哪、用的哪个
# ② echo $PATH 对比两个环境
# ③ /proc/PID/environ 看进程真实拿到的环境
# ④ 在脚本里 source /etc/profile 或自带环境补齐
命令速查
需求 命令
=============================================================
看所有环境变量 env / printenv
看单个变量 echo $PATH / printenv PATH
看命令实际在哪 which 命令 / type 命令
脚本里判断命令是否存在 command -v 命令
设变量(子进程可见) export VAR=value
设变量(仅当前 shell) VAR=value
取消变量 unset VAR
看进程真实拿到的环境 cat /proc/PID/environ | tr '\0' '\n'
只给一条命令设环境 VAR=value 命令
重新加载配置 source ~/.bashrc
对比两个环境 env|sort 各存一份再 diff
口诀:同代码两结果 -> 必是环境不同 -> 对比 PATH
-> cron/systemd 不读 .bashrc -> 脚本自带环境
避坑清单
- 同一份代码在 A 环境跑正常 B 环境报错,几乎总是两个环境的变量不同
- PATH 决定裸命令名去哪些目录找,报 command not found 先查 PATH
- 登录 shell 读 /etc/profile 与 .bash_profile,非交互脚本基本都不读
- cron 执行任务环境极简,PATH 通常只有 /usr/bin:/bin,不读你的 .bashrc
- systemd 服务同样不继承登录环境,要在 unit 用 Environment 显式给
- 不带 export 的变量子进程看不见,要跨进程传递必须 export
- /proc/PID/environ 是进程启动时的环境快照,排查进程环境的终极手段
- 脚本要环境无关:头部 export PATH、关键命令用绝对路径、变量给默认值
- cron 工作目录默认是家目录,脚本里别用相对路径,先 cd 到脚本目录
- 别把密码密钥放进环境变量,会被子进程继承、可能进日志,应放权限文件
总结
这次环境变量的排查,纠正了我一个埋藏得很深、自己却浑然不觉的错误假设:我一直默认,一个脚本、一段代码,只要它本身的逻辑是对的,那它在哪儿跑、被谁调起来,结果就应该是一样的。这次的故障像一记响亮的耳光,告诉我这个假设错得有多离谱——同一个 backup.sh,同一台机器,同一个文件,我在自己的终端里手敲它,它从容地完成备份;我把它原封不动地挂进 crontab,它却在 command not found 上栽了跟头。脚本一个字都没改,变的是什么?变的是它运行时所处的那个看不见、摸不着,却时时刻刻笼罩着每一个进程的"环境"。这次排查最大的收获,就是让我真正看见了这个一直被我忽略的环境,并理解了它的运作逻辑。一个进程,从它被创建的那一刻起,就从它的父进程那里继承了一整套环境变量,这套变量像空气一样包裹着它、影响着它的行为。而在这套变量里,对"命令能不能被找到"起决定作用的,就是 PATH。当我在 shell 里敲下 aws 这个裸命令名时,shell 并不会满磁盘地去搜寻它,它只会老老实实地、按顺序地,在 PATH 这个变量所列出的那几个目录里逐一查找,找到第一个名叫 aws 的可执行文件就拿来用,如果把所有目录都翻遍了还没有,它就报 command not found。理解了这一层,这次故障的真相就豁然开朗了:我们的 aws 命令装在 /usr/local/bin 这个目录下,而我手动操作时所处的那个 shell,它的 PATH 是经过 /etc/profile、~/.bashrc 这些配置文件一层一层"加料"之后的丰满版本,里面理所当然地包含了 /usr/local/bin;可是 cron 在执行定时任务时,给脚本的却是一个极其简陋、几乎一丝不挂的环境,它的 PATH 短得可怜,通常只有 /usr/bin 和 /bin,根本就不包含 /usr/local/bin。于是同一个 aws,在丰满的环境里活得好好的,一进了简陋的环境就凭空"消失"了。这根本不是脚本的错,是两个环境的差异在作祟。而这个差异的根源,在于另一个我过去从来没分清楚过的概念——shell 是怎么被启动的,决定了它会去读哪些配置文件。我通过 ssh 登录得到的是一个"登录 shell",它会依次去读 /etc/profile 和我的 ~/.bash_profile,而后者又通常会顺手把 ~/.bashrc 也加载进来,正是这一连串的加载,把我的 PATH 喂得饱饱的;但 cron 执行任务时启动的,是一个"非交互式"的 shell,它对上面这些配置文件统统视而不见、一概不读。所以那句我曾经深信不疑的"我在 .bashrc 里加了这个目录",在 cron 和 systemd 面前,完全是一句空话——它们根本不读你的 .bashrc。这个认知一旦建立,这类问题的排查口诀也就自然浮现了:每当遇到"同一份代码,手动跑好好的,自动跑就出错",我的第一反应不该再是反复去检查那段代码的逻辑,而应该是立刻去对比这两种环境的差异,最直接的办法,就是在两种环境里各自打印一次完整的 env,然后 diff 一下,那份差异清单里,几乎必定躺着真正的元凶。除了学会排查,这次更重要的收获是学会了治本——我意识到,一个真正健壮的脚本,绝不应该把自己的正确运行,寄托在"它恰好被丢进了一个环境变量都对的环境里"这种运气上。健壮的脚本应该是"环境无关"的,它要自己对自己的运行环境负责:在脚本的开头,就用一行 export 把 PATH 显式地、完整地定死;对那些关键的、外部的命令,干脆直接写绝对路径,彻底摆脱对 PATH 的依赖;对它要用到的每一个环境变量,都用 ${VAR:=默认值} 这样的语法给一个兜底的默认值;在动手干活之前,先 cd 到脚本自己所在的目录,把工作目录这个基准也定死,免得在 cron 那个默认工作目录是家目录的环境里踩坑。这次从一个莫名其妙的 command not found 出发,我最大的收获,是终于把"环境"这个一直隐形的东西,变成了一个我看得见、查得清、并且能在脚本里主动掌控的维度——我不再天真地以为代码对了一切就对了,我学会了去追问那个更根本的问题:这段代码,此刻究竟是在一个什么样的环境里运行。
—— 别看了 · 2026