
1. 一次环境变量丢失事故让我重新认识 source先讲个我自己的真实经历。有次同事在/etc/profile.d/下加了个自定义环境变量脚本改完之后忘记重新登录直接在终端里跑应用结果程序一直报找不到配置。他问我怎么回事我说你 source 一下他反问source 不就是重新执行一遍脚本吗我手动执行一遍不行吗这个问题其实很典型。凡是写过 shell 脚本的人几乎都用过source但真正能把它讲清楚的人不多。source在很多教材里就一句话读取并执行文件中的命令。但这句简单背后藏着 shell 进程模型、环境变量传递机制、点文件加载顺序、POSIX 兼容性这一大串东西。你如果只停留在source 就是执行脚本这个层面迟早会在环境变量不生效、脚本加载失败、函数库重复定义这些坑里反复打转。这篇文章我打算从历史背景、核心执行机制、基础用法、进阶技巧一直到排障步骤把source命令完整过一遍。适合三类人看刚入门 shell 脚本、还在背命令列表的新手写了几个月脚本但对环境变量传递机制一知半解的初级运维以及被奇怪加载问题折磨、想彻底搞懂底层的进阶使用者。先说结论source之所以特殊不是因为它能执行文件里的命令——任何 shell 执行命令的能力都是天生的——而是因为它在当前 shell 进程内执行而不是新开一个子进程。这个区别九十%的人第一步就没意识到。2. 历史背景为什么 shell 世界里同时存在 source 和 .2.1 点号命令的出身来自 Bourne shell在最古老的 Bourne shellsh里并没有source这个词。当时用来在当前 shell 中执行脚本的写法是点号命令. /path/to/script这个点号是个单独的命令写法就是.后面跟一个文件名。因为它是内建命令builtin所以不依赖外部程序也不需要执行权限。Bourne shell 奠定了这个语义在当前 shell 进程中读取并执行文件。后来的 POSIX 标准也沿用了这个点号形式。也就是说在严谨的 POSIX 规范里根本没有source这个命令只有.。这一点经常被人忽略。你看 Linux 上最小的 sh——dash——执行source的时候会直接报 command not found但它支持.。这就是标准写法和非标准写法最直接的差别。2.2 source 的诞生来自 C shellsource这个词来自 Bill Joy 在伯克利写的 C shellcsh。C shell 用source命令把文件内容读入当前 shell 执行语义上跟.完全一致。为什么叫 source我个人的理解是这个命令就像在源头接入水流把文件里的命令一句句引入到当前交互环境中。文件是命令的源头你从源头取东西进当前环境所以叫 source。在 csh 和 tcsh 里.反而不可用或很少用source是主流写法。后来 bash 作为 POSIX shell 的超集同时支持.和source两种形式这才让source这个词被更多人熟知。今天的开发环境里你输入source或.在 bash 和 zsh 中效果基本一致但要知道它们的血统不同。2.3 各主流 shell 的实现差异我刚说了 dash 只支持.那其他 shell 呢这里列个对照表Shell是否支持 .是否支持 source备注sh / dash支持不支持POSIX 严格实现bash支持支持source 是 . 的同义词zsh支持支持两者等价ksh支持不支持用 . 才是正统csh / tcsh通常不用支持主流写法是 sourcefish支持支持默认推荐 source这里有个细节在 bash 里source和.几乎完全等价但source在找不到文件时返回非零状态而.也一样。有资料说 bash 的source会在 PATH 中搜索文件名如果文件名不含斜杠的话。这点很多人踩过坑——你写source myfileshell 不一定只从当前目录找还可能在 PATH 里翻一轮。所以我在自己的脚本里永远写明确的路径比如source ./myfile或者source $(dirname $0)/myfile绝不依赖搜索规则。3. 核心机制为什么 source 必须在当前 Shell 里执行3.1 先理解脚本运行时的进程模型要理解 source必须先理解一个概念shell 执行外部的脚本文件时通常是新开一个子进程来跑的。你写了这样一个脚本#!/bin/bash export MY_ENVhello cd /tmp然后执行bash myscript.sh这个执行过程发生了什么当前终端里的 shell 会调用fork()创建一个子进程子进程里再用exec把bash程序加载进去运行脚本。子进程的一切都是独立的它有自己独立的环境变量副本自己的当前工作目录自己的变量空间。父进程里 export 的环境变量会复制给子进程但子进程里的任何改动——哪怕是 export——都无法回传给父进程。这就是环境变量只能向下传递不能向上回传的核心原因。所以当你运行bash myscript.sh之后回到命令行cd /tmp的副作用全部消失MY_ENV也不存在。因为那些操作发生在另一个进程里那个进程已经退出一切归零。3.2 source 的读取与执行方式那source呢它走的是另一条路。执行source myscript.sh的时候shell不会fork 一个新进程而是把文件内容读入当前 shell然后逐条执行文件里的命令就像你在终端里逐行敲进去一样。如果文件里有cd /tmp你的当前 shell 就真的会切换目录如果文件里有export MY_ENVhello当前 shell 的环境变量里就真的多出这一项。用一个生活化的类比执行普通脚本像是把一份菜谱交给另一个厨师去厨房做做完告诉你结果source 像是把这个厨师请到你的厨房当着你的面用你的锅碗瓢盆做菜。做完之后厨房的状态自然就变了。这个机制还意味着source 的文件不需要执行权限。因为 shell 是把它当作命令集合来读取的不是作为外部程序去 exec。所以哪怕文件权限是-rw-r--r--source也能正常加载。3.3 source 与 bash script 的关键差异把两者并排对比差异非常清楚行为bash script.shsource script.sh变量、函数定义仅存在子进程保留在当前 shellcd 切换目录不影响父 shell改变当前 shell 的目录exit 语句只退出子进程会退出当前 shellexport 新变量子进程退出后消失当前 shell 和后续子进程都能看到执行权限需要不需要进程 fork有无这里面最危险的差异是exit。普通脚本里写exit 1只是子进程退出但是被 source 的文件里如果有exit你的整个当前 shell 都会退出——如果你在远程 SSH 会话里执行了包含exit的 source 文件会话直接断开。我曾经见过有人写的一个配置脚本里带着exit 0别人一 source终端就关了。这个问题放在后面避坑部分再详细说。4. 基础用法加载配置、切环境、做函数库4.1 刷新点文件配置新手最常见的source场景就是改完~/.bashrc之后想让它立刻生效vim ~/.bashrc source ~/.bashrc这个操作的本质是把新的 bashrc 文件重新读一遍让新的 alias、函数、环境变量进入当前 shell。如果你不 source直接开新终端新终端里的 bash 启动时会自动加载一次 bashrc所以还勉强可以。但当前这个终端里已经存在的 shell 不会自动感知文件变化必须手动 source。这里有三个注意事项第一.bashrc通常被设计成幂等的吗答案是不一定。有些人的 bashrc 里写export PATH$PATH:/some/dir你每次 source 都会往 PATH 后面追加一次。source 十次PATH 里同一个目录重复十次。虽然一般不影响功能但会带来隐患——命令查找顺序被拉长某些场景下还会出现同名命令被后缀目录里的旧版本抢到的坑。第二source bashrc 之前最好先检查语法。你刚改完文件如果里面有语法错误source 执行到错误行时会直接把当前 shell 搞乱部分配置加载不完整你还要花时间定位。稳妥做法是先bash -n ~/.bashrc做语法检查再用source加载。第三在非交互式 shell 里bash 默认不加载.bashrc。所以你在脚本里 source ~/.bashrc 往往没效果或者把环境改得不可预期。正确做法是脚本需要共享配置时单独维护一个config.sh而不是去 source 交互式配置文件。4.2 Python 虚拟环境的 source 激活另一个高频场景是 Python 虚拟环境。venv激活方式就是 sourcesource venv/bin/activate为什么这里必须 source不能直接执行因为 activate 脚本做的事情就是设置VIRTUAL_ENV变量、修改PATH、覆盖python命令指向当前虚拟环境、定义 deactivate 函数。这些全部需要发生在当前 shell 环境里。如果你写./venv/bin/activate去执行它会在子进程里把 PATH 改了然后立刻消失当前 shell 一无所获虚拟环境自然也不会激活。这个例子是理解 source 价值的最佳标本环境切换类脚本的核心工作就是修改环境变量而环境变量只能靠 source 在当前进程里改才有意义。所以 venv、rvm、nvm、conda 的激活脚本全都要求 source 或.加载。顺带一提conda 常见的提示do you wish to update your shell profile to automatically initialize conda?也正是利用在 bashrc 里加一段 source 调用来实现的初始化逻辑必须 source 到你的交互 shell 里才会生效。4.3 用 source 管理共享函数库如果你写过一批常用的小工具函数你会想把它们集中放在一个文件里比如~/.shell_lib.sh# ~/.shell_lib.sh timestamp() { date %Y-%m-%d %H:%M:%S } log_info() { echo [$(timestamp) INFO] $* } is_root() { [ $(id -u) -eq 0 ] }然后在交互环境里加载source ~/.shell_lib.sh这样之后你在终端里随时都能用log_info hello。函数和变量一样默认只存活于当前 shell 进程。如果不用 source而是把函数库脚本用 bash 子进程执行函数定义完就丢了后面根本调不到。在脚本内部复用函数库也很常见#!/usr/bin/env bash BASE_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) source $BASE_DIR/lib/common.sh log_info 脚本开始执行注意这里用了${BASH_SOURCE[0]}来获取脚本自身路径。这是个关键细节$0在函数里可能会变成函数名在 source 场景下也可能指父 shell不可靠BASH_SOURCE[0]才是当前脚本文件的真实路径。用这个方式组织函数库项目脚本无论从哪个目录被调用都能准确找到同目录下的库文件。5. 进阶用法模块化脚本与动态加载5.1 按需加载配置片段系统级的/etc/profile.d/目录就是动态加载配置片段的最佳范例。目录下每个.sh文件会被登录 shell 通过循环 source 的方式加载# 类似 /etc/profile 里的逻辑 for f in /etc/profile.d/*.sh; do [ -r $f ] source $f done这个模式的好处是不同的软件包可以各自往目录里放一个配置片段互不冲突不用去改主配置文件。你自己维护脚本时也可以照抄这个思路。比如项目根目录下建一个conf.d/里面放着不同模块的配置# load_conf.sh CONF_DIR$(dirname ${BASH_SOURCE[0]})/conf.d for conf in $CONF_DIR/*.conf; do # 跳过不可读文件 [ -r $conf ] || continue source $conf done这样做非常灵活新增一个模块只要新增一个 conf 文件就行删掉一个模块移除文件就完事。比把所有变量堆在主文件里强得多。但要注意循环 source 的加载顺序取决于文件名排序如果配置之间存在依赖关系比如 A 模块需要读取 B 模块的变量你需要在文件名里用数字前缀控制顺序比如01_base.conf、10_network.conf、99_check.conf这样排序逻辑一眼就能看明白。5.2 用 source 实现脚本间的环境传递写脚本时我经常遇到这种需求主脚本准备好了数据库账号密码等敏感配置希望子任务脚本能读取但又不想通过命令行参数传递因为ps会暴露参数也不想写临时文件。这时候可以动用一个技巧先 source 一段公共配置再调用子脚本。假设有一个env.shexport DB_HOST10.0.0.5 export DB_USERmonitor export DB_PASS$(cat /run/secrets/db_pass)主脚本这样用source ./env.sh ./run_backup.shrun_backup.sh因为是子进程会继承主 shell 里所有 export 过的环境变量自然能看到DB_HOST、DB_USER。这就实现了环境传递。相比写临时文件环境变量的方式不需要清理文件相比命令行参数不会因为进程列表泄漏密码更安全。但有一个注意点传递的是export 过的变量普通变量不会传给子进程。所以配置脚本里要记得写export。类似地如果只是想传一个普通变量子脚本读不到很多人在这里排查半天其实只是忘了export或者用了set -a。5.3 source 与命令替换的边界还有一个容易被混淆的场景source和命令替换$(...)有什么不同两者看起来都是执行内容并把结果留到当前环境其实天差地别。命令替换在子 shell 中执行然后把标准输出当作字符串返回export APP_VERSION$(cat version.txt)这是把命令的输出赋值给变量不是把命令里的环境变更带回来。如果你在$(...)里写export X1这个 export 发生在子 shell当前进程根本感知不到。那$(source file; echo $VAR)能不能拿到变量能拿到因为 source file 在子 shell 里执行后那个子 shell 环境里有$VAR然后 echo 输出被捕获了。这确实是个取变量的技巧但要意识到source 本身的环境变更仍然没发生在当前进程只是通过输出传递了一个值。反过来说如果你只是想读取配置文件里的某一个变量没必要用命令替换去折腾。直接source config.sh echo $VAR更干脆。我见过有人在 Makefile 里用$(shell source config echo $$VAR)来拿变量本质上就是这么回事。能用 source 直接拿的不要绕到子 shell 的输出通道里。6. 高级技巧与隐蔽坑位6.1 source 在函数中的持久化效果很多人没意识到你在一个函数内部 source 一个文件变量和函数定义的作用域是当前整个 shell而不是函数。看个例子load_config() { source ./config.sh } load_config echo $SOME_VAR # 这里依然能取到这是因为函数级的作用域只对local变量生效而 source 执行过程中声明的普通变量直接落在全局作用域。这个特性可以用来做带参数的加载load_config() { local env_name$1 source ./conf/${env_name}.sh }source 文件里如果用到$1它拿到的是函数传递给 source 文件位置的参数吗这里有个微妙逻辑函数内部执行source ./file arg时./file里的$1会被 source 命令的参数替换。如果只是source ./file则$1保留函数调用的参数实际上在 bash 里source 不改变位置参数的值除非你显式传给 source。默认情况下 source 文件里看到的位置参数就是当前 shell/函数的位置参数。所以如果你想隔离参数就显式传参不想隔离就让文件沿用现有位置参数。这个小细节足够写一篇排查笔记但先记住结论函数里 source 文件前想清楚你需要哪种参数行为。6.2 set -e、set -u 与 source 的相爱相杀写健壮脚本的人通常会在开头写上set -euo pipefail但这个组合遇到 source 就会出各种幺蛾子。先看set -u。它要求所有变量必须已定义。如果你的 config.sh 里写了if [ $MODE prod ]而MODE还没定义source 这个 config 的时候脚本直接报unbound variable并退出。这类错误很难排查因为报错位置指向 config.sh但实际是因为调用时机太早。解决办法配置文件里用${MODE:-}做默认值扩展。再看set -e。source 的文件中任何一条命令返回非零整个脚本立刻退出。最常见的坑是 source 文件末尾的最后一条命令状态码非零比如一个grep没匹配到任何内容。我的习惯是敏感操作前加判断if ! source ./config.sh; then echo 加载配置失败 2 exit 1 fi另外很多万能的做法是source file || exit 1这个写法在set -e下是合法的因为它把 source 的失败状态显式处理了。set -e还有一个隐蔽问题source 一个只定义函数不调用的文件时通常没问题但如果文件里有return你打算用它提前退出 source那么set -e下 return 非零同样会炸。所以模板类的 source 文件末尾加return 0是个好习惯。6.3 重复 source 的副作用前面提过 PATH 重复追加的问题这里展开来讲。source执行多少次配置文件里的赋值语句就执行多少次。如果配置里是固定赋值重复执行无害但如果是累积追加比如PATH...:${PATH}每 source 一次就攒一份时间长了 PATH 超长命令查找变慢某些软件还会因为 PATH 顺序变化加载了错误版本的工具。更麻烦的是重复 source 同一个函数库文件时函数会被重复定义。虽然覆盖定义通常没有报错但如果你在文件里用readonly声明变量第二次 source 会直接报错readonly variable。解决思路很简单要么让配置文件具备幂等性在开头检查标记变量# config.sh if [ -n ${__CONFIG_LOADED:-} ]; then return 0 fi __CONFIG_LOADED1要么在赋值时先清理旧值。PATH 类变量尤其推荐用从基准变量重建的方式# 在 bashrc 中 BASE_PATH/usr/local/bin:/usr/bin:/bin export PATH$BASE_PATH:$MY_EXTRA_PATH而不是反复$PATH:xxx追加。6.4 调试 source 加载过程想看清楚 source 到底执行了什么最直接的办法是开 xtraceset -x source ./config.sh set x这样每一条命令执行前都会被打印到标准错误能看到变量赋值和分支走向。如果你的文件很大可以只对某一段做( set -x source ./config.sh ) 21 | tee /tmp/source_trace.log注意这里我用了一个子 shell 来包裹目的是让set -x只影响子进程不影响当前 shell 的调试状态——因为 source 的副作用依然会落到子 shell 里你拿到的只是 trace 日志不会污染当前环境。另一个有用的内建变量是BASH_LINENO和FUNCNAME。在 source 的文件里打印echo ${BASH_SOURCE[0]} : ${BASH_LINENO[0]} : ${FUNCNAME[0]}可以快速定位当前执行是在哪个文件、哪一行、哪个函数里。多层 source 嵌套时这套信息比瞎猜强太多。7. 一次完整的排障链路source 后变量没生效7.1 现象有次我维护的一台服务器上应用启动脚本里明明 source 了配置文件但应用就是读不到某个HTTP_PORT环境变量。手动在终端里 source 同一份配置再echo $HTTP_PORT值又是正常的。这就很诡异了同一个文件手动可以脚本里不行。7.2 排查过程第一步检查脚本开头有没有set -u。一看果然有。但set -u是使用未定义变量才报错配置文件里是定义变量应该不受影响。继续往下看。第二步打印脚本执行过程。我在启动脚本前面加了一行set -x source ./config.sh echo HTTP_PORT inside $HTTP_PORT set x结果发现source ./config.sh执行成功了但紧接着的echo打印出来是空。这说明配置里的赋值没有按预期落地。第三步检查 config.sh 本身。猫了一眼发现配置文件开头居然有一行#!/usr/bin/env bash这行并不影响 source因为 source 不会把第一行当作 shebang而是当作注释。排除了这个可能。第四步再看文件里变量赋值的上下文。这里有个隐藏问题——配置文件里充满了export HTTP_PORT${SOME_PREFIX}_8080这种依赖其他变量的赋值如果SOME_PREFIX在启动脚本里还没定义那么${SOME_PREFIX}展开成空字符串HTTP_PORT就变成了_8080看着像没值其实是值不对。加上脚本里有set -u按理说未定义变量会报错啊为什么没报再看一眼发现赋值的右侧用了${SOME_PREFIX:-}有默认值空字符串展开所以set -u不会拦截它。7.3 根因与修复根因找到了启动脚本里source config.sh的时机太早SOME_PREFIX尚未定义配置文件里又用了:-默认值扩展把空值静默吞掉了。修复方式有两种选择一是调整加载顺序先定义基础变量再 source 子配置SOME_PREFIXprod source ./config.sh二是让配置文件对依赖做显式校验: ${SOME_PREFIX:?请先定义 SOME_PREFIX}set -u排查此类问题特别有效如果你把配置文件里所有该有默认值的地方都写成${VAR:?message}每当顺序不对时source 会立刻报错而不是静默赋值错误。这次排障给我最大的启发是source 本身很简单但 source 一个依赖其他变量的配置时加载顺序就是整个环境构建的地基。所以我现在会严格要求自己配置脚本要么完全自包含要么在最顶部做依赖声明检查绝不隐式依赖调用方。回到开头那个同事的问题。手动执行脚本 vs source一字之差机制完全不同。source 是你和当前 shell 的一次同化把所有状态留在原地执行脚本则是一场隔离实验结束即清零。理解了背后这一点后续所有关于环境变量、配置文件加载、函数库复用的问题都会顺理成章。如果有时间建议你把自己常用的一堆脚本打开看看哪些地方用了source、哪些地方只是普通调用想象一下如果换一种写法会发生什么。这种反向推演是掌握 shell 进程模型最快的路径。等你哪一天在新开的终端里敲完source ~/.bashrc后能条件反射般意识到哦bash 把整个文件重新读了一遍我的 alias 都回来了这就算真正入门了。