一、PATH 出问题的样子,往往不是「命令找不到」
所有 Linux 教程都会提一句 PATH,但真正踩坑时,PATH 的问题极少以「command not found」这种干净的方式出现。更常见的形态是这些:
- cron 里的脚本死活跑不通,手动执行完全正常——因为 cron 的 PATH 只有
/usr/bin:/bin,找不到/usr/local/bin里的命令; sudo somecommand用的是 root 的 PATH,而sudo -i又换成了 login shell 的 PATH,同一条命令两种结果;- CI 或 systemd 单元里执行的脚本加载了错误的 Python / Node 版本,因为 PATH 里排在前面的目录先命中了另一份二进制;
- 某次升级之后,系统里出现了两个同名工具,一个能用一个新,你调用的永远是旧的那个。
这些问题的共同点是:它们都能在本地复现「正常」,在线上表现为「诡异」。搞清楚 PATH 的组装顺序,比记住任何一条具体命令都有用。
二、PATH 到底是怎么拼出来的:五个来源按序生效
PATH 不是「一个全局设置」,而是每个进程各自持有的一份环境变量。一个交互式登录 shell 的 PATH 大致由下面五处按顺序叠加而来:
/etc/environment:PAM 读取,不经过 shell,格式是纯粹的KEY=value,不能写export、不能用变量展开、不能写$PATH。写错这个文件最直接的后果是登录失败或 PATH 被整体覆盖。/etc/profile:登录 shell 读取,里面通常已经有一段循环遍历/etc/profile.d/*.sh的逻辑。发行版之间的 PATH 差异大多在这里。/etc/profile.d/*.sh:放系统级的第三方 PATH 追加,按文件名字典序执行。顺序会影响结果:10-a.sh里加的目录排在20-b.sh前面。~/.bash_profile或~/.profile:用户级登录配置。很多发行版默认的.bash_profile会 source.bashrc,也有不少没有,这就是「在 SSH 登录后能用的命令,在bash -l -c里却找不到」的根源。~/.bashrc:非登录交互 shell 读取。如果这里又export PATH="$PATH:...",那么一次登录可能在 PATH 里塞进多达三四份重复目录。
关键结论有两条。第一,只有登录 shell 才会读 /etc/profile 和 profile.d,非交互的脚本、cron、systemd 都不读,它们只继承调用者传下来的环境。第二,PATH 的解析是「从左到右,先命中先用」,所以「追加到末尾」和「插到最前面」的语义完全不同。
三、四个必会的确认动作
排查任何 PATH 相关的问题,先做这四步,不要凭感觉改配置:
# 1) 当前 shell 的原始 PATH,按冒号拆成一行一个,直观看顺序
tr ':' '\n' <<<"$PATH"
# 2) 某个命令到底命中了哪个文件(-a 列出所有同名候选)
type -a php
which -a python3
# 3) 加上重复检测:PATH 里出现两次以上的目录
tr ':' '\n' <<<"$PATH" | sort | uniq -d
# 4) 看一个干净登录 shell 里的 PATH 是什么(排除当前终端的历史污染)
env -i bash -lc 'echo $PATH'第 4 步最容易被忽略。你在一个开了三天的 tmux 会话里 echo $PATH,看到的是「三天前加上无数次 source 之后的累积结果」,跟新登录的用户拿到的完全不是一回事。用 env -i 清空环境重新起一个 login shell,才是真实的初始状态。
还有一把更锋利的刀:strace -f -e trace=execve -o /tmp/trace.log yourcommand,日志里会逐条显示内核尝试搜索的每一个路径。ENOENT 的那些就是找不到的,最后成功的那一条就是真实命中。cron 与交互环境的差异排查,这个比读配置文件快得多。
四、cron 与环境变量:最大的差异来源
cron 执行任务时,环境是从零构建的:它继承的只有 crond 进程自身的环境,加上 /etc/environment 的内容,此外什么都没有。/etc/profile、profile.d、.bashrc 一律不生效。它的默认 PATH 通常是 /usr/bin:/bin。
这解释了大量「手动能跑、cron 不跑」的故障。正确做法有三层,从稳到不稳依次是:
第一层:不依赖 PATH,写绝对路径。这是最可靠的。脚本内部调用其他程序时用 /usr/local/bin/php 而不是 php。Debian 系的 php 有 update-alternatives 管理,路径可能随版本切换,所以更稳的写法是把要用的二进制路径在脚本顶部定义为变量,一处修改全局生效。
第二层:在 crontab 顶部显式声明。crontab 支持直接写环境变量赋值:
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin
SHELL=/bin/bash
LANG=C.UTF-8
*/5 * * * * /home/site/run.sh >> /var/log/run.log 2>&1注意这里的 PATH 是「整体赋值」而不是追加,所以必须把系统常用目录写全,否则 ls 都可能找不到。另外要显式设置 LANG:不设的话,脚本里处理中文文件名、日期格式化都可能出问题,而报错信息本身也会变成一堆问号。
第三层:在脚本里自举。如果脚本要跨环境使用(cron、手动、CI 都要跑),在 #!/bin/bash 之后加一行保险:export PATH=/usr/local/bin:/usr/bin:/bin:$PATH。注意是追加 $PATH 而不是完全覆盖,这样手动执行时用户自己的 PATH 仍然有效。
还有一个细节:cron 的 HOME 通常就是执行用户的 home,但工作目录是 root 的 home 或 /,不是脚本所在目录。脚本里大量使用的相对路径在 cron 下全部失效,所以在脚本开头 cd 到固定目录是必修动作。
五、sudo 的 secure_path:root 的 PATH 和你不一样
Debian/Ubuntu 的 sudo 默认开启 secure_path,在 /etc/sudoers 里写着:
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"它的含义是:通过 sudo 执行命令时,不用调用者的 PATH,改用这里写死的 PATH。这是安全设计,防止普通用户把自己 home 下的恶意同名程序通过 PATH 注入到 root 的执行环境里。但它是无数「sudo 找不到命令」的来源。
如何确认并查看:
sudo -V | grep -i "secure_path"
sudo env | grep ^PATH
sudo visudo -c # 改完 sudoers 一定要做语法检查如果确实需要让 sudo 保留用户的 PATH(例如 CI 机器、容器构建),把 secure_path 里的目录补全,或者在 sudoers 里对特定命令用 SETENV: 标记。永远不要直接 Defaults !secure_path —— 那会为提权攻击打开一条顺手的路。
另一个常被混淆的点是 sudo bash 和 sudo -i 的区别:前者执行的是非登录 shell,不读 /etc/profile;后者是登录 shell,会读。所以同一台机器上 sudo -i 之后 PATH 里会多出 profile.d 追加的那几段。如果你的服务脚本用 sudo -i bash script.sh 启动,而它依赖的命令只写在 sudoers 的 secure_path 里(没进 profile.d),那就会时灵时不灵。
六、systemd 服务:Environment 不是继承来的
systemd 启动的服务,环境来自单元文件,而不是启动它的那个 shell。这意味着你在 shell 里 export 的东西对服务完全无效。有三个层次可以设:
[Service]
# 方式一:单条变量
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
Environment="LANG=C.UTF-8"
# 方式二:从一个文件读(每行 KEY=value,适合有多个变量)
EnvironmentFile=-/etc/default/myservice
# 方式三:让 systemd 从父进程环境里补(慎用,不同版本行为有差异)
PassEnvironment=PATHEnvironmentFile 前面的减号表示「文件不存在也不报错」,这在「默认配置 + 可选覆盖」的场景里很实用。文件格式是严格的一行一个 KEY=value,不要写 export,不要用引号包住整个值(引号会被当成值的一部分,这在排查时极其隐蔽)。
改完单元文件后必须 systemctl daemon-reload,否则改动不生效;然后 systemctl restart myservice,再用这条命令确认服务进程真实拿到的 PATH:
systemctl show myservice -p MainPID --value | xargs -r -I{} tr '\0' '\n' < /proc/{}/environ | grep ^PATH如果这一行输出是空的,说明 PATH 根本没设,服务用的是 systemd 编译进来的默认值(通常包含 /usr/local/sbin),那就不要指望 /opt/xxx/bin 里的命令能被找到。
七、追加还是前置:一个决定带来完全不同的结果
PATH 从左到右匹配,所以:
export PATH="$PATH:/opt/new/bin":追加。系统原有命令优先,只有原本找不到的才用新目录。升级一个新版本工具时这会导致「装了新版但执行的是旧版」。export PATH="/opt/new/bin:$PATH":前置。新目录优先,可以覆盖系统同名命令。风险是它可能悄悄劫持了你以为在用的python3、openssl。
实用的判断规则:自己写的新工具、专用版本解释器(如某个项目的 venv)用前置;给系统补充一个额外的命令目录用追加。如果拿不准,先 type -a 命令名 看清有几个候选,再决定。
另一种更干净的替代方案是根本不动 PATH:用绝对路径调用、用符号链接把命令挂到 /usr/local/bin、或者干脆用 update-alternatives 管理多版本。这三种方式都避免了「全局 PATH 被改坏之后影响所有程序」的连锁反应。
八、常见错误的处置清单
错误一:/etc/environment 里写了 PATH=$PATH:/opt/bin。该文件由 PAM 解析,不做变量展开,写进去的结果就是字面上的 $PATH,于是整个 PATH 被破坏,登录后所有命令都找不到。症状是 SSH 能连上、能输密码,进去之后任何命令都 command not found。修复方法是用绝对路径的 /bin/vi /etc/environment 改回来。这个文件的 PATH 必须写全,不能用变量。
错误二:在 .bashrc 里反复追加。每开一个子 shell 就追加一次,PATH 越滚越长——极端情况下超过系统限制后,execve 会因为 env 太大而失败,报「Argument list too long」。用第三节的 uniq -d 检查是否有重复目录,改成幂等写法:
case ":$PATH:" in
*":/opt/tools/bin:"*) ;;
*) export PATH="/opt/tools/bin:$PATH" ;;
esac错误三:profile.d 里的脚本互相覆盖。A 脚本写 PATH=...(覆盖),B 脚本写 PATH=$PATH:...(追加),执行顺序决定了最终结果,而且容易在发行版升级后被打乱。规则是:profile.d 里永远只用追加,永远带 $PATH。
错误四:改完没重新登录就以为生效。环境变量只在新进程里生效,当前 shell 需要 source /etc/profile 或 exec bash -l。而正在运行的服务进程(Nginx、php-fpm、常驻的 Node 进程)必须重启,否则它们的内存里还是旧环境。「改了配置文件但进程没重启」是这类问题最常见的假性失败。
九、小结:PATH 是显式的,不是隐式的
PATH 的所有问题都来自同一个认知偏差:以为它是「系统的一个设置」。实际上它是每个进程各自一份的变量,由启动它的那个父进程(shell、cron、systemd、sudo)组装后传下去。所以排查的入手点永远是「这个进程是谁启动的、它读了哪些配置」,而不是「系统 PATH 是什么」。
三条可以直接用的纪律:生产脚本里凡是调用外部程序,一律写绝对路径或在脚本顶部自举 PATH;任何环境下都显式声明 LANG 与 PATH,不依赖继承;改动之后一定用 env -i 或 /proc/PID/environ 验证真实值,而不是看你当前终端里的 echo $PATH。
