09:41 现象

在一台跳板机上反复登同一台后端。为了省掉每次的密钥认证往返,~/.ssh/config 里开了连接复用:

Host backend
    HostName 10.0.20.31
    User deploy
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 30m
    SendEnv BUILD_TAG

改了本地的 BUILD_TAG,再 ssh backend 'echo $BUILD_TAG',对面回的还是上一次的值。第一次登是对的,后面就不对了。

09:47 先确认是不是复用生效了

复用有没有真的生效,不用猜,socket 文件在那儿摆着:

ls -l ~/.ssh/cm-* 2>/dev/null
ssh -O check backend

ssh -O check 会直接告诉你 master 进程的 PID。看到 Master running (pid=NNNNN) 就说明后面这些 ssh 都是挂在同一条 TCP 连接上的从属客户端。

到这一步现象就清楚了:不是变量没改,是这条命令根本没走一次完整的连接建立。

10:02 分清"连接级"和"会话级"

OpenSSH 的连接复用里,master 进程持有那条已经认证完成的 TCP 连接,后续的 ssh 通过 ControlPath 指定的 unix socket 找 master,请它在这条连接上再开一个会话通道。

关键在于:哪些东西是在 TCP 连接建立、用户认证那一刻定下来的,哪些是每个会话通道单独协商的。

连接级、之后不再变的东西包括:认证身份、密钥/口令协商结果、传输层加密参数,以及 master 客户端进程自己的运行环境。

会话级的东西包括:远端起的那个 shell、它读的启动文件、分配的 PTY。

我的第一反应是"SendEnv 应该算会话级",但没敢直接下结论。

10:15 验证 master 进程的环境是不是冻结的

这一点可以在本地直接验证,不需要连远端。一个进程的 /proc/<pid>/environ 是 exec 那一刻的快照,之后不会再变——这是内核的行为,不是 ssh 的行为。

pid=$(ssh -O check backend 2>&1 | grep -o 'pid=[0-9]*' | cut -d= -f2)
echo "master pid: $pid"
tr '\0' '\n' < /proc/$pid/environ | grep '^BUILD_TAG=' || echo "(master 环境里没有 BUILD_TAG)"
echo "当前 shell: BUILD_TAG=$BUILD_TAG"

master 进程是在我最早那次 ssh backend 时 fork 出来的,它的环境里存的是当时的 BUILD_TAG。我在当前 shell 里改了多少次,都不会写进那个已经存在的进程的 /proc/<pid>/environ。

到这一步,问题的形状出来了:变量确实传过去了或者没传过去,取决于从属客户端是把自己的环境交给 master 去发,还是 master 用自己冻结的那份去发。

10:31 分情况

这里我得诚实一点:OpenSSH 的 mux 协议里,从属客户端通过 socket 向 master 发会话请求时会不会把新增的环境变量一起带过去,不同版本的行为我没能从手册页里读到明确说法,而手册页对 ControlMaster 的描述集中在"复用连接"这个层面,没有展开到环境变量的传递细节。我不打算凭印象给一个版本号分界。

能确定的是另外几件事,它们同样是"复用之后不刷新"的常见来源:

远端的 $SSH_CONNECTION、$SSH_CLIENT 描述的是 master 那条连接。 每个会话通道看到的都是同一组 IP 和端口,因为底层就只有一条 TCP。用这两个变量做审计或者做来源判断,在开了复用的机器上会失真。

-L、-R、-D 这类转发参数在从属客户端上会被忽略。 转发是连接级的,master 建的时候没开的转发,后面再 ssh -L 8080:localhost:80 backend 也不会补上去。这个坑比环境变量更常见,因为它静默失败——命令返回 0,端口就是不通。

远端的组权限、PAM 会话结果不刷新。 如果这段时间里有人在服务器上把你加进了一个新组,复用连接里的会话拿不到,因为身份是 master 认证时确定的。

远端 shell 的启动文件是会重读的。 每个会话通道都是新起的 shell,~/.bashrc、~/.profile 改了立刻生效。所以"改了远端 .bashrc 没生效"这种抱怨,通常不是复用导致的,得往别处找。

10:52 处理

针对当时的问题,最直接的办法是让 master 重来一次:

ssh -O exit backend
BUILD_TAG=new-value ssh backend 'echo $BUILD_TAG'

ssh -O exit 会关掉 master 进程和那条 TCP 连接,下一次 ssh 重新完整建立,环境按当前 shell 的状态走。

日常我不想每次都手动 exit,所以做了两个调整。一是把真正需要随每次调用变化的东西从环境变量挪到命令行参数里,ssh backend "./deploy.sh --tag $BUILD_TAG",参数是会话级的,一定跟着当前这次调用走。二是把 ControlPersist 从 30m 调短,让 master 自然过期。

我倾向的做法是:变量只在会话内有效、且每次都可能不同时,就不要依赖 SendEnv。SendEnv 的设计意图是传 locale、传终端类型这类相对稳定的东西,不是传构建参数。

11:20 怎么快速判断是不是复用的锅

留一条通用排查路径,遇到"ssh 过去行为和我预期不一样"时按顺序走:

ssh -O check backend          # 有没有 master 在跑
ssh -O stop backend           # 暂停接受新的复用会话(master 保留)
ssh -O exit backend           # 彻底关掉 master
ssh -o ControlMaster=no -o ControlPath=none backend 'echo $BUILD_TAG'

最后那条是决定性的一步:显式关掉复用再跑一次。如果结果正常,问题就在复用;如果还是不对,那是另一码事,别再往这个方向查。

ControlPath=none 必须和 ControlMaster=no 一起写。只写前者,某些配置下客户端仍会去尝试连接已有的 socket。

至于哪些环境变量的传递在复用下会失真、失真的边界在哪,我上面给的只是我能验证到的部分。如果你手上有确定的版本行为分界,值得记下来——这个坑的麻烦之处就在于它不报错。