ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

source 并非全局生效:深入理解 /etc/profile 与环境变量传播

source 并非全局生效:深入理解 /etc/profile 与环境变量传播 改完/etc/profile随手source一下当前终端里echo $MY_VAR输出正常以为万事大吉。切到旁边那个早就开着的窗口敲同样的命令一片空白。再开一个终端还是空白。最后重启机器好了。这套流程我见过太多次很多人的结论是profile 有延迟或者必须重启才能生效——这个结论是错的而且会让你在真正需要排查的时候找错方向。source从来就没有全局生效这个能力它只能改当前这一个 shell 进程自己的环境。所谓全局需要你先定义清楚你是想让所有新登录的用户拿到这个变量还是想让已经跑着的进程也拿到还是想让图形界面里点开的程序也拿到这三个问题的答案、成本和做法完全不同。下面我按先定位、再理解、后动手的顺序把这件事拆开讲清楚适合被这个问题反复绊住的运维和开发同学。1. 先分清不生效是哪一种不生效1.1 四种典型症状根因完全不一样很多人一上来就说profile 不生效其实这句话信息量几乎为零。我习惯先让提问的人做一个动作把你期望生效的目标按它是什么进程、由谁拉起来的、什么时候启动的描述一遍。因为同样是变量不见了背后的原因能差出十万八千里。症状真正的原因一句话验证当前终端echo $VAR有值别的已打开终端没有环境变量不可横向传播只对新起的子进程有效在旧终端执行cat /proc/$$/environ对比新开的终端也没有新终端不是登录 shell压根不读/etc/profileshopt login_shell或看echo $0图形桌面里点开的程序没有桌面会话走的 PAM不经过/etc/profile看会话进程的/proc/PID/environ服务、定时任务里没有systemd/cron 有独立的环境配置入口systemctl show 服务名 -p Environment这张表是我这些年反复用的一张分诊表。你会发现它有一个共同特征问题从来不在命令敲得对不对而在于这个进程有没有机会读到那个文件。source之所以让人产生幻觉是因为它在你眼皮子底下生效了——当前终端立刻就能echo出来反馈太即时人就默认写进去了。可实际上你改的只是这一个进程内存里的环境块。1.2source到底动了什么东西先把source以及 POSIX 写法.的行为说透。它做的事情是在当前 shell 进程里逐行读取并执行指定文件的命令。注意关键词——当前进程。它不是启动一个新 shell 去跑这个文件所以文件里的cd、set、变量赋值都会留在当前 shell 身上也正因为如此文件里如果写了exit会直接把你当前这个终端关掉这是个经典的翻车点后面还会讲。那么环境变量在 Linux 里是什么它是进程的一块内存区域在execve调用时由父进程传给子进程。进程一旦被创建它的环境块就定型了父进程之后再怎么改自己的环境也不可能回头去改已经存在的子进程。反过来子进程修改自己的环境更不可能影响父进程。所以这里的逻辑链是这样的你在终端 A 里source /etc/profile终端 A 这个 shell 进程的环境块被改了终端 A 之后启动的子进程比如你在里面敲python、make会继承到新变量终端 B 是另一个独立进程它和终端 A 之间没有父子关系它们的父进程通常是同一个会话管理器比如systemd --user或sshd所以终端 B 完全看不到终端 B 里已经跑着的那些子进程同样看不到。提示判断能不能看到某个变量永远问一句它是不是在变量被设置之后从那个被设置的进程 fork 出来的。这比背任何配置文件路径都管用。1.3 为什么大家会产生全局的错觉因为PATH这类变量给我们的经验是改了到处都在用。但那是错觉——那些程序之所以能用上新 PATH是因为它们都是在新的登录会话里启动的。真正的全局在 Linux 里其实不存在只有某个进程树范围内的传播。我见过最典型的误解是把/etc/profile当成 Windows 的系统环境变量设置界面。两者的心智模型完全不同。Windows 那个界面写注册表新启动的进程由系统在创建时统一注入Linux 这边没有这样一个中央注入点环境变量的传递是纯粹的进程继承。理解了这个差异后面所有的为什么都会顺理成章。2. 环境变量的传播路径为什么全局是个伪概念2.1 父子继承是唯一可靠的通道Linux 中环境变量的传播只有一条正道forkexecve时的继承。父进程把自己环境块的一份副本交给子进程子进程在此基础上可以增删改但这个修改只对它自己以及它之后 fork 出来的后代可见。这条规则推导出几个非常实用的判断想影响新登录的用户改/etc/profile、/etc/profile.d/*.sh因为新会话的登录 shell 会读它想影响当前这个 shell 的所有后续操作source是有效的因为当前 shell 是后续命令的父进程想影响一个已经在运行的守护进程没有任何优雅办法只能重启它或者走它自己的 reload 机制想影响一个和后端服务同级的兄弟进程做不到除非它们的共同父进程重新拉起它们。我经常用一句话总结给新人环境变量只会往下流不会往上冒也不会横着走。上下游关系和进程树是绑死的跟你是不是 root 没关系。哪怕你sudo su -变成 root也只是新开了一个进程那些旧世界的进程依然故我。2.2 登录 shell 与非登录 shell 的分岔口这是/etc/profile问题里最关键的一个机制。bash 启动时会根据是不是登录 shell决定读哪些启动文件shell 类型判定方式会读的启动文件登录交互 shelllogin_shell为 on或argv[0]以-开头如-bash/etc/profile→/etc/profile.d/*.sh→~/.bash_profile/~/.bash_login/~/.profile只读第一个存在的非登录交互 shell普通打开终端、bash直接启动~/.bashrcDebian 系还会读/etc/bash.bashrc非交互 shell脚本执行、bash -c通常什么都不读除非设置了BASH_ENV网络登录sshd的PermitUserEnvironment同样走登录 shell 路径现在解释一下你遇到的现象。很多发行版在图形界面下点开终端启动的是非登录交互 shell它压根不读/etc/profile只读~/.bashrc。而 Debian/Ubuntu 的~/.bashrc里通常有一段# If not running interactively, dont do anything case $- in *i*) ;; *) return;; esac # 会去 source /etc/bash.bashrc但这段只处理交互判断并不会反向去读/etc/profile。所以你的变量在图形终端里就是不出现。而你用 SSH 连上去、或者su -切换用户时那是一个登录 shell会读/etc/profile变量就出现了。这就是为什么同一个变量SSH 上有、桌面终端没有很多人误以为跟用户权限有关其实只是登录方式不同。想立刻验证可以这样# 看当前 shell 是不是登录 shell shopt login_shell # 看 argv[0] 前面有没有减号 echo $0 # 强制以登录 shell 方式重新启动一个测试变量是否出现 bash -l -c echo $MY_VARbash -l -c这个组合我在排查时用得非常多它能一次性告诉你如果它是登录 shell变量到底能不能读到从而把文件写错了和进程没读文件这两种可能区分开。2.3 绕开 profile 的三条暗河即使你的 shell 是登录 shell也还有三类进程根本不经过/etc/profile。这三条暗河是很多奇怪现象的根源。第一条systemd 系统服务。系统服务由 PID 1 拉起PID 1 自己的环境是内核在引导早期给的极其干净。服务如果想拿到变量得在 unit 文件里显式声明[Service] EnvironmentMY_VARhello # 或者从文件读 EnvironmentFile-/etc/sysconfig/myappEnvironmentFile前面的减号表示文件不存在也不报错这个小技巧在写可选配置时很有用。第二条计划任务。cron 执行任务时用的是极简环境SHELL一般是/bin/shPATH是编译进去的默认值其它变量基本没有。所以你在/etc/profile里定义的变量在 crontab 任务里是看不到的。正确的做法是在 crontab 顶部直接写SHELL/bin/bash PATH/usr/local/bin:/usr/bin:/bin MY_VARhello或者在任务脚本的第一行手动. /etc/profile。后者有个前提那个脚本得是 bash 执行而且/etc/profile内部不能有对非交互环境不友好的语句。第三条图形会话。GDM、LightDM、SDDM 这些显示管理器通过 PAM 建立会话进程树是显示管理器 → 会话 → 桌面环境 → 你点开的程序。这条链路里没有任何一环会去读/etc/profile。想影响它得用 PAM 那一套入口详见第 4 节。2.4 换个 shell 就等于换了一套启动文件还有一个高频翻车场景用户改的是/etc/profile但他的登录 shell 是 zsh。zsh 的启动文件体系和 bash 不一样/etc/zshenv、~/.zshenv所有zsh 实例都会读包括非交互、非登录/etc/zprofile、~/.zprofile登录 shell 读/etc/zshrc、~/.zshrc交互 shell 读。也就是说如果你把变量写在/etc/profile而用户的 shell 是 zsh那么只有在登录 shell这一个场景下才会生效而且还得是 zsh 编译时开启了读取/etc/profile的兼容选项很多发行版默认不开。这就是为什么同一台机器上运维账号能看到变量开发账号看不到——shell 不一样。排查时永远先确认echo $SHELL和/etc/passwd里该用户的登录 shell再看对应 shell 的文档。这一步花十秒能省下半小时。3. 排查链路从 /proc 反推环境变量的来源3.1 先看进程真实环境而不是 echoecho $VAR只反映当前 shell 的认知它有三个局限看不到别的进程、看不到已被unset的历史、看不到变量是否被 export 过。真正权威的是内核为你保存的那份环境快照。# 查看当前 shell 进程的真实环境注意用 \0 分隔 tr \0 \n /proc/$$/environ | sort # 查看任意进程的环境 tr \0 \n /proc/PID/environ | grep -i my_var # 更直观的写法 xargs -0 -L1 -a /proc/$$/environ这里有个非常重要的细节/proc/PID/environ反映的是进程启动那一刻更准确地说是最近一次 execve 时的环境。如果进程启动之后自己putenv修改过这个文件不会更新。所以它有时会和进程内部实际看到的不一致——通常表现为文件里有程序里没有这多半是程序内部做了清理或者文件被后续逻辑覆盖了。/proc/PID/environ还是只读的。我见过有人想通过写这个文件来给运行中的进程加变量写不进去还会得到Permission denied。这不是权限问题是内核设计如此。3.2 判断当前 shell 是不是登录 shell这一步是整个排查的枢纽。我通常按顺序做四个检查# 1. 是不是登录 shell shopt login_shell # 2. 看 argv[0]登录 shell 通常前面有减号 echo $0 # 3. 看 shell 选项里有没有 i交互 echo $- # 4. 看当前用户的登录 shell 是哪个 getent passwd $USER | cut -d: -f7这四个输出组合起来基本能定性。比如shopt login_shell是off$-里有i那就明确是非登录交互 shell不读/etc/profile是符合预期的不是 bug。如果想进一步确认到底读了哪些文件可以在 bash 启动时加-x跟踪bash -l -x -c true 21 | head -50这个输出会把登录 shell 读取的每一个文件、每一行命令都打出来。想找/etc/profile到底执行到哪、在哪一步提前退出了这是最直接的手段。3.3 检查 profile 内部的条件分支与执行顺序即使 shell 是登录 shell/etc/profile内部也可能因为条件判断而跳过你的配置。常见的几类一是交互性判断。很多发行版的/etc/profile开头会有类似if [ ${-#*i} ! $- ]; then # 只有交互 shell 才执行 fi或者更简单粗暴的if [ -n $PS1 ]; then ... fi这些写法会让非交互的登录 shell比如bash -l -c xxx跳过后续内容。如果你正好把变量写在这段里面那通过 SSH 交互登录能看到通过脚本调用就看不到。二是 profile.d 的加载方式。CentOS/RHEL 系的/etc/profile末尾通常是for i in /etc/profile.d/*.sh /etc/profile.d/sh.local; do if [ -r $i ]; then if [ ${-#*i} ! $- ]; then . $i else . $i /dev/null fi fi done unset i这里有几个坑只匹配*.sh文件名不以.sh结尾的文件不会被加载循环变量i用完会unset所以你在自己的脚本里千万不要依赖外部传入的i如果某个脚本里有语法错误后面的脚本可能受影响。三是执行顺序导致的覆盖。/etc/profile之后还有~/.bash_profile、~/.bash_login、~/.profile按顺序取第一个存在的以及用户自己~/.bashrc。如果你的变量在/etc/profile里被设成 A用户家目录里有段逻辑把它改成 B那你看到的永远是 B。这种明明改了系统文件却不生效的情况九成是被用户级配置覆盖了。3.4 一条完整的排查记录讲个真实的例子。某台服务器上JAVA_HOME通过/etc/profile.d/java.sh设置运维张三在 SSH 里登录后echo $JAVA_HOME正常但同一台机器上另一个同事李四的账号死活拿不到。我们按下面的顺序走了一遍# 第一步确认症状范围 su - lisi -c echo $JAVA_HOME # 空 su - zhangsan -c echo $JAVA_HOME # 有值同一条命令同样的登录方式结果不同说明问题不在系统文件而在用户维度。# 第二步看登录 shell 差异 getent passwd lisi | cut -d: -f7 # /bin/zsh getent passwd zhangsan | cut -d: -f7 # /bin/bash找到了。李四的 shell 是 zsh而/etc/profile.d/里的脚本是给 bash 登录 shell 用的。zsh 作为登录 shell 不会自动读/etc/profileDebian 系的 zsh 有时会在/etc/zprofile里 source/etc/profile这台机器上没有。# 第三步验证 zsh -l -c echo $JAVA_HOME # 空符合预期 bash -l -c echo $JAVA_HOME # 有值符合预期结论清楚解决方案有两个要么把变量挪到一个所有 shell 都能读的位置比如/etc/environment要么在/etc/zprofile里补一段加载逻辑。我们选了后者改动最小。这个例子的价值在于排查的方向不是变量怎么丢的而是谁的启动路径和别人的不一样。一旦你有了按进程启动路径反推的思维这类问题的定位速度会快一个数量级。4. 对症下药四种场景的落地改法4.1 只想让新开的终端生效profile.d 拆分加幂等写法如果目标只是所有新登录的 bash 用户体验到新变量最干净的做法不是直接改/etc/profile而是在/etc/profile.d/下新建一个独立文件。# /etc/profile.d/myapp.sh export MYAPP_HOME/opt/myapp export PATH${MYAPP_HOME}/bin:${PATH}为什么推荐拆文件而不是改主文件升级安全发行版升级有时会覆盖/etc/profile独立文件不在覆盖范围内职责清晰每个应用一个文件出问题容易定位是哪一段方便启用禁用改后缀或加if判断就能临时关掉团队协作友好配置文件进版本管理时diff 干净。写这个文件时有几个细节值得注意。第一是必须用export否则变量只是当前 shell 的局部变量子进程拿不到。第二是PATH 拼接要保留原值写成PATH/opt/myapp/bin会把系统默认路径冲掉那时连ls都可能找不到。第三是幂等性如果同一个脚本可能被 source 多次最好加个守卫# /etc/profile.d/myapp.sh if [ -z ${MYAPP_HOME} ]; then export MYAPP_HOME/opt/myapp export PATH${MYAPP_HOME}/bin:${PATH} fi第四是权限文件建议644、属主root:root。因为/etc/profile的执行上下文可能包含 root 会话谁都能写的脚本等于一个提权入口这是实打实的安全问题不是洁癖。最后是尾缀必须是.sh。前面说过 RHEL 系的加载循环只匹配*.sh你写个myapp.conf放进去功能性上完全没用还不容易发现。4.2 图形桌面里启动的程序怎么拿到变量桌面会话走 PAM所以入口在 PAM 环境配置这一侧。最常用的是/etc/environment# /etc/environment MYAPP_HOME/opt/myapp这个文件有两个重要限制不支持变量展开你写PATH$PATH:/opt/myapp/bin不会被解析$PATH会被当成字面量不支持 shell 语法不能写if、不能写注释以外的东西实际上连注释支持都很有限。它只做最简单的KEYvalue逐行解析。复杂场景可以走/etc/security/pam_env.conf语法更灵活# /etc/security/pam_env.conf MYAPP_HOME DEFAULT/opt/myapp OVERRIDE/opt/myapp注意DEFAULT只在变量未设置时生效OVERRIDE强制覆盖这个区别很实用。对于 systemd 管理的用户会话还有~/.config/environment.d/*.conf这套机制需要较新版本的 systemd# ~/.config/environment.d/myapp.conf MYAPP_HOME/opt/myapp改完之后必须重新登录桌面会话注销再登录不是重启程序就行。因为 PAM 环境是在会话建立那一刻注入的前面说的父子继承规则在这里同样适用。4.3 systemd 服务与计划任务的环境配置服务这块最忌讳的做法是在服务脚本里手写. /etc/profile。原因很实在/etc/profile里通常包含大量针对交互 shell 的逻辑提示符设置、别名、/etc/profile.d全量加载在服务上下文里执行既慢又容易报错还会把一堆无关变量塞进服务环境。正确做法是让 unit 文件自己声明[Unit] DescriptionMy App [Service] Typesimple EnvironmentMYAPP_HOME/opt/myapp EnvironmentLANGen_US.UTF-8 EnvironmentFile-/etc/sysconfig/myapp ExecStart/opt/myapp/bin/start.sh改完之后执行systemctl daemon-reload systemctl restart myapp systemctl show myapp -p Environmentdaemon-reload这步经常被忘改了 unit 文件不 reload服务还是按旧配置跑然后你会怀疑自己没改对。有一点要特别提醒Environment里不做 shell 展开。写EnvironmentPATH$PATH:/opt/myapp/bin无效$PATH是字面量。想扩展现有 PATH得把完整路径写死或者干脆在ExecStart里用绝对路径调用。计划任务这边更简单直接在 crontab 顶部声明# 系统级 /etc/cron.d/myjob SHELL/bin/bash PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MYAPP_HOME/opt/myapp */5 * * * * root /opt/myapp/bin/task.sh用户级 crontab 也可以同样写。注意/etc/cron.d/里的文件格式多一列用户名格式写错会导致整个文件被忽略这是个很隐蔽的坑。4.4 已经跑起来的进程还有救吗直说没有干净的办法。/proc/PID/environ只读没有接口能让运行中的进程重新读一遍环境变量。有三个方向可以尝试但都有代价方向一进程自带的 reload 机制。有些服务支持SIGHUP或systemctl reload重新加载配置如果它的配置里包含环境相关逻辑可能生效。但这取决于具体实现不能想当然。方向二重启进程。最稳的做法。对于桌面程序就是关掉再打开对于服务就是systemctl restart。重启的成本要评估但比起折腾各种奇技淫巧通常是最省时间的。方向三调试注入。用gdbattach 到目标进程调用putenv()。这个做法我只在调试场景用过生产环境绝对不建议会影响进程稳定性可能触发崩溃而且某些语言的运行时比如 Go、Java根本不通过 libc 的getenv读取环境注入了也读不到。# 仅供调试参考生产不要用 gdb -p PID -batch \ -ex call (int)putenv(MY_VARtest) \ -ex detach我踩过的坑是注入之后在/proc/PID/environ里看不到这个变量因为那个文件反映的是 execve 时的快照不会因为putenv更新。所以注入没生效这个判断得靠进程自己的行为来验证不能靠读/proc。这一点很容易误导人第一次踩的时候我盯着environ看了半小时。5. 几个容易翻车的细节5.1export漏写是最常见的低级错误这个错误极其常见因为表现形式很迷惑# 错误写法 MY_VARhello # 正确写法 export MY_VARhello不加export变量只在当前 shell 里可见任何子进程都拿不到。症状是在终端里echo $MY_VAR有值但脚本里echo $MY_VAR是空。很多人第一反应是脚本没执行到设变量的地方实际上变量就在那里只是没导出。已经定义过的变量可以事后补一次导出MY_VARhello echo $MY_VAR # hello bash -c echo $MY_VAR # 空因为没导出 export MY_VAR bash -c echo $MY_VAR # hello判断一个变量有没有被导出可以用export -p看导出的列表或者用declare -p MY_VAR输出里带-x标记的就是导出的。5.2 变量被后加载的脚本悄悄覆盖Linux 的启动文件是按顺序执行的后面的会覆盖前面的。典型顺序bash 登录 shell/etc/profile └── /etc/profile.d/*.sh /etc/bash.bashrc (部分发行版) ~/.bash_profile (或 ~/.bash_login或 ~/.profile取第一个存在的) ~/.bashrc (如果被上面的文件 source 了)如果你的变量在/etc/profile.d/里设为 A而用户家目录的~/.bash_profile里又设了一次 B最终看到的就是 B。排查方法是在用户家目录里搜一遍grep -rn MY_VAR /home/用户名/.[a-z]* 2/dev/null还有一个更隐蔽的情况同一个变量在不同文件里用不同方式定义。比如/etc/profile.d/a.sh里是export PATH$PATH:/opt/a/bin~/.bashrc里是PATH/usr/bin:/bin后者会把前者的路径直接抹掉。这类问题只能靠完整梳理一遍加载链来发现没什么捷径。5.3source里的exit和set -e会关掉你的终端前面提过source是在当前 shell 执行脚本所以脚本里的exit会退出当前 shell。为什么这个值得单独说因为/etc/profile里如果有一句条件判断失败后直接exit你source /etc/profile时就会莫名关掉终端。还有set -e遇错即退也一样危险。如果某个 profile.d 脚本里有这么一段set -e some_command_that_might_fail set e中间那句一旦返回非零整个 shell 就退出了。而你在交互终端里来源执行它效果就是终端一闪就关。注意写/etc/profile.d/里的脚本尽量不要用set -e如果确实需要务必在条件中允许失败例如cmd || true。顺带说一个相关的行为差异source是 bash 的内建命令.是 POSIX 的定义功能等价。在某些 shell比如 dash里只有.source会报not found。写脚本要用.在交互终端里图方便用source没问题。5.4 权限、换行符与编码三个看起来小、实际很烦的坑。权限问题。/etc/profile.d/下的文件被加载时加载逻辑里通常有if [ -r $i ]判断所以至少需要可读权限。但更需要注意的是不要给它写权限。因为这段脚本会在管理员会话里执行任何能改写它的用户都等于能执行任意代码。换行符问题。从 Windows 传过来的脚本常带 CRLF 换行bash 执行时会报各种莫名其妙的错比如bash: $\r: command not found。检查方法file /etc/profile.d/myapp.sh # 如果输出里有 with CRLF line terminators就是这个问题 # 转换 sed -i s/\r$// /etc/profile.d/myapp.sh编码与引号。变量值里如果包含空格、中文、特殊字符一定要加引号。尤其在 PATH 拼接时写export PATH$PATH:/opt/my app/bin会因为空格断成两段后半段还可能被 shell 当成命令。规范写法export PATH${PATH}:/opt/my app/bin另外中文字符串在某些LANG没设置好的环境里可能乱码如果变量值要参与路径拼接建议全 ASCII。6. 一份可以直接贴在工位上的自查清单排查这类问题我总结了一套固定的动作顺序按这个顺序走基本不会漏第一步明确目标进程。到底是新登录的用户、当前终端、图形程序还是系统服务不同的目标对应完全不同的写入位置这一步没想清楚后面全是白费。第二步看进程真实环境。tr \0 \n /proc/目标PID/environ | sort第三步判断 shell 类型。getent passwd $USER | cut -d: -f7 # 登录 shell shopt login_shell # 是否登录 shell echo $- # 是否交互第四步梳理加载链。bash 登录 shell 的完整路径是/etc/profile→/etc/profile.d/*.sh→~/.bash_profile。用bash -l -x -c true 21 | grep -i profile可以直接看到它实际读了哪些文件。第五步验证变量是否导出。declare -p VAR看有没有-x标记。第六步针对场景选入口。终端用户 →/etc/profile.d/*.sh图形会话 →/etc/environment或 PAM 配置服务 → unit 文件的Environment定时任务 → crontab 顶部。第七步重启对应的父进程。shell 类重新打开终端或重新登录服务类daemon-reloadrestart图形会话注销重登。这张清单的价值在于它把我以为转换成了我验证过。/etc/profile和source的坑本质上都是人的直觉和进程模型的直觉不一致导致的。我个人的体会是环境变量这个领域没有真正意义上的全局只有谁从谁继承。每次遇到变量不生效不要急着问文件改对了吗先问这个进程是从哪儿来的、它的父进程是谁、它在什么时候启动的。这三个问题的答案往往在你打开编辑器之前就已经把问题解决了一大半。剩下那一小半交给上面那张清单就够了。
返回列表