PATH环境变量踩坑:cron、sudo与systemd

一、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 大致由下面五处按顺序叠加而来:

  1. /etc/environment:PAM 读取,不经过 shell,格式是纯粹的 KEY=value,不能写 export、不能用变量展开、不能写 $PATH。写错这个文件最直接的后果是登录失败或 PATH 被整体覆盖。
  2. /etc/profile:登录 shell 读取,里面通常已经有一段循环遍历 /etc/profile.d/*.sh 的逻辑。发行版之间的 PATH 差异大多在这里。
  3. /etc/profile.d/*.sh:放系统级的第三方 PATH 追加,按文件名字典序执行。顺序会影响结果:10-a.sh 里加的目录排在 20-b.sh 前面。
  4. ~/.bash_profile 或 ~/.profile:用户级登录配置。很多发行版默认的 .bash_profile 会 source .bashrc,也有不少没有,这就是「在 SSH 登录后能用的命令,在 bash -l -c 里却找不到」的根源。
  5. ~/.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=PATH

EnvironmentFile 前面的减号表示「文件不存在也不报错」,这在「默认配置 + 可选覆盖」的场景里很实用。文件格式是严格的一行一个 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。

Last modification:October 30th, 2026 at 12:26 pm

Leave a Comment