一个部署脚本用 diff <(ssh web1 'cat /etc/nginx/default.conf') <(ssh web2 'cat /etc/nginx/default.conf') 比较两台机器的配置,本地跑了没问题,放到 CI 里"输出为空、退出码 0"——因为 CI 的默认 shell 是 sh(dash),而不是 bash,于是 <(...) 被当成"重定向语法错误",而错误被"命令替换的空输出"吃掉了。
进程替换(process substitution)是 bash 的扩展:把一个"命令的输出"伪装成一个"文件路径",交给另一个命令去读——这个"伪装"很实用:diff、while read、tar 这些"只吃文件"的命令,第一次能"直接吃命令输出"——**而"省下的中间文件"正是"交互排查里最烦的那一步".
于是"只能读文件"的命令也能"直接比较两个命令的结果"。
1. 它的机制:一个 /dev/fd/N
| 步骤 | 发生了什么 |
|---|---|
| 1 | bash 创建"管道或临时文件" |
| 2 | 把读端"绑定"到 /dev/fd/63 这类路径 |
| 3 | 把该路径"作为参数"传给命令 |
| 4 | 另一侧命令"打开该路径",读到的是"管道内容" |
第三项解释了"为什么路径看起来怪":它不是一个"真的文件",而是"内核 fd 的一个别名"这个"别名"决定了它的两个失效条件:"fd 不跨进程"(容器/sudo)、"只读一次"(管道语义)——**而这两条恰好是"脚本化"时最需要的.
——于是"关闭 fd"或"跨进程传递"就会失效.
"是不是 bash"这件事,可以在脚本开头"硬性检查":
// 三行"保护"(放在脚本最前)
[ -n "$BASH_VERSION" ] || { echo "需要 bash,请用 bash 运行" >&2; exit 1; }
set -o pipefail
// 或:把 shebang 写死
#!/usr/bin/env bash
// 判据:脚本"入口"就失败,而不是"跑到第 40 行才炸"
2. 三个坑之一:shell 不是 bash
| 环境 | 是否支持 |
|---|---|
| bash / zsh | 支持 |
dash / ash(sh) |
不支持 |
make 的默认 shell |
/bin/sh(多为 dash) |
CI 的 sh -c |
视镜像 |
第一项之外的场合都可能"直接语法错误"——**而"错误"常常表现为"命令没输出",而不是"明显报错".
"跨容器"这件事,最稳的做法是"在容器内生成、在外面比较":
// 反例:宿主机的 fd 传不进容器
docker exec web1 diff <(cat a) <(cat b) # 不可靠
// 正解:容器内落文件,再取出来比
docker exec web1 sh -c 'cat /etc/nginx/default.conf' > /tmp/web1.conf
docker exec web2 sh -c 'cat /etc/nginx/default.conf' > /tmp/web2.conf
diff /tmp/web1.conf /tmp/web2.conf
// 判据:不依赖"fd 跨进程"这件不成立的事
3. 三个坑之二:/dev/fd 不可跨进程
| 场景 | 结果 |
|---|---|
| 同 shell 内的命令读它 | 正常 |
通过 sudo 传给别的用户 |
可能"Permission denied" |
通过 docker exec 传进容器 |
不可用(fd 不共享) |
| 后台进程"稍后再读" | 空(写端已关) |
第三项最隐蔽:**"宿主机的 /dev/fd/63 在容器里不存在"**第三项的"典型"是"一条在生产上很好用的排查命令,进了容器就'文件不存在'"——**而"文件不存在"会被误读成"配置为空".
——于是"同样一条命令,本机行、容器里不行".
"空 vs 空"可以用"一次长度检查"挡住,而它只要两行:
// 两行"防假阴性"
[ -s /tmp/web1.conf ] && [ -s /tmp/web2.conf ] || { echo "取配置失败(空文件)"; exit 1; }
// 判据:非空之后才允许"比较",否则"比较"没有意义
4. 三个坑之三:错误被"吞"
| 现象 | 原因 |
|---|---|
| 输出为空、退出码 0 | 被替换的命令"失败了",而"读的一方"读到空 |
diff 返回 0 |
两边都是空 → "没有差异" |
| 真正的错误看不到 | 错误在"子 shell"的 stderr |
第二项的"后果"是"假阴性":"两台机器的配置一致"这个结论,可能建立在"两边都没读到"之上——而"一致"是最不需要复核的结论.
第二项最危险:"空 vs 空 = 相同"**——于是"两边都没取到配置"被读成"两台机器配置一致".
"校验和"比较是"只关心'是否相同'"时最省的写法:
// 一行"只比差异"(不必比内容)
diff <(sha256sum < /tmp/web1.conf) <(sha256sum < /tmp/web2.conf)
// 更省:直接比两个哈希字符串
[ "$(sha256sum < /tmp/web1.conf)" = "$(sha256sum < /tmp/web2.conf)" ] && echo same
// 判据:只需"是否一致"时,哈希比"逐行 diff"快且稳
5. 三种"稳妥"的替代
| 替代 | 说明 |
|---|---|
| 先落到临时文件再比较 | 最稳(多一步 IO) |
用 bash -c 显式指定 |
解决"shell 不对" |
| 用"校验和"比较 | 不必比内容(sha256sum) |
第一项是"跨环境"的正解:**mktemp + 两次重定向 + diff**第一项的"成本"是"多两个文件、多两次 IO":在"配置文件只有几 KB"时,这点开销可以忽略——**而在"几 GB 的导出"时,它反而是"唯一可行"的(管道会背压卡死).
——**代价是"两个临时文件",收益是"在任何 shell 里都对".
// 三行"跨环境安全"的比较(不依赖 bash 扩展)
a=$(mktemp); b=$(mktemp)
ssh web1 'cat /etc/nginx/default.conf' > "$a"
ssh web2 'cat /etc/nginx/default.conf' > "$b"
diff "$a" "$b"; rc=$?; rm -f "$a" "$b"
// 关键:加上"非空校验"(否则"两边都空"被当成相同)
[ -s "$a" ] && [ -s "$b" ] || { echo "取配置失败"; exit 1; }
"交互式"的价值不该被否定,它省掉的是"最烦的一步":
// 三种"交互排查"的用法
1 比两台机器:diff <(ssh a 'cat conf') <(ssh b 'cat conf')
2 比两个时间点:diff <(git show HEAD~1:f) f
3 边看边筛:while read -r l; do echo "$l"; done < <(cmd)
// 判据:只在"自己的终端"里用,不进脚本
6. "什么场景真该用它"
| 场景 | 适合 |
|---|---|
| 交互式比较两个命令输出 | 很适合 |
一次性的排查(diff <(a) <(b)) |
很适合 |
| 写进"要跨环境跑的脚本" | 不适合 |
| 需要"错误可诊断"的 CI | 不适合 |
7. 边界:什么时候"临时文件"更好
| 场景 | 为什么 | 做法 |
|---|---|---|
| 输出可能很大(GB) | 管道"背压"会"卡死两侧" | 临时文件 + 流式处理 |
| 需要"多次读同一份" | 管道只能读一次 | 临时文件 |
| 需要"权限隔离"(sudo/容器) | fd 不共享 | 临时文件 |
| 需要"保留证据"(排查) | 事后可看 | 临时文件 |
最后一条与"可诊断"有关的边界:"错误要能留下证据":<(...) 的错误只出现在"那个子 shell 的 stderr",而它"常常没被收集";"临时文件 + rc + 非空校验"能把"取失败"与"真没差异"分开——而这一条,比"省一个文件"重要得多.
判断口径一句话:"这条命令是在"我的交互终端里跑",还是"在别人的环境里跑""**。交互(自己排查),<(...) 最省事;进脚本、进 CI、进容器,就用"临时文件 + 非空校验"——而"非空校验"这一步,才是"两台机器配置一致"与"两边都没读到"的分界线.
8. 自测
- 进程替换的本质是什么?为什么"路径看起来怪"?
- 为什么"shell 不是 bash"时"错误不显眼"?
- 为什么
/dev/fd不能"跨进程(容器)"使用? - "输出为空、退出码 0"为什么会发生?它最危险的后果是什么?
- "临时文件"方案要多做哪一步才算"稳妥"?
- 判断"该用
<(...)还是临时文件"的那个问题是什么?
补一句"代价"的量化:<(...) 省的是"一个中间文件",而它要求"bash + 同进程 + 只读一次 + 错误可收集"四个前提同时成立——四个前提里"脚本化"通常只满足"bash"一条。所以它更像"交互式语法糖",而不是"脚本原语";而"临时文件 + 非空校验"虽然多三行,却把这四个前提全部绕开。
9. 一句话总结
进程替换是"交互式的好工具、脚本里的坏依赖"——它把"两个命令的输出"变成"两个可比较的文件",代价是"依赖 bash、依赖同进程、且错误会被静默"。而"三行临时文件 + 非空校验"能换来"在任何环境都成立"的确定性。