# SSH 管道退出状态会掩盖失败的命令吗？

远程命令可能失败，格式化工具却打印出令人信服的输出，代理仍然宣布成功。这不是什么 SSH 之谜，而是普通的 shell 语义跨越网络边界时没有携带足够证据。

解决办法不只是给每个脚本都加上 `set -o pipefail`。`pipefail` 只会改变一个汇总结果。执行关键 SSH 操作的代理需要知道管道每个阶段的状态，明确哪些非零退出状态是预期结果，并让远程最终退出代码无法被误认为成功。立即捕获状态向量，为它命名，再由包装器决定成功的含义。

## 最后一个成功命令可能掩盖第一个失败命令

默认情况下，shell 管道会报告最后一个命令的退出状态。因此，下面这行在部署、迁移、备份和修复任务中很危险：

```bash
build_manifest | sign_manifest | tee /var/tmp/manifest.json
```

假设 `build_manifest` 因为无法读取必需文件而失败。`sign_manifest` 可能收到不可用的输入并随之失败，也可能生成空结果。`tee` 仍然可以创建文件、写入零字节，并以零状态退出。于是 shell 为整个管道报告零。只检查 `$?` 的调用方会看到成功。

GNU Bash Reference Manual 对此说明得很清楚：除非启用 `pipefail`，否则管道使用最后一个命令的退出状态。Bash 会等待同步管道中的所有命令，但等待并不等于保留它们各自的结果。

人在交互式终端中有时会注意到数据缺失或错误消息。代理通常只能看到更窄的视图。它可能收到被截断的记录、格式化摘要，或者只有命令的最终结果。如果脚本返回零，即使实际操作没有完成请求的事情，代理也有理由宣布操作成功。

团队经常混淆的区别很简单：

- 管道退出状态是一个决策值。
- 管道中各命令的状态是支撑这个决策的证据。

两者都需要。决策值决定远程命令是否返回成功，证据则告诉审查者、日志或监管代理问题出在哪里。

这在第一个阶段会改变外部世界时尤其重要。比如，一个远程导出任务读取生产数据、压缩、加密，然后上传。上传客户端可能在上传空流后仍以零状态退出。记录中还可能出现「已完成」这样的让人放心的词，因为后续程序完成了自己的狭义工作。这个结果不能变成导出成功的错误声明。

## SSH 返回远程 shell 选择返回的结果

OpenSSH 不会检查远程 shell 管道中的各个命令。它返回远程命令的状态；如果 SSH 自身遇到错误，则返回 255。

这种行为正确且有用。SSH 无法知道下面的远程文本是管道、shell 函数、脚本，还是以自己的方式使用退出代码的应用程序：

```bash
ssh deploy@host 'generate | transform | tee result.txt'
```

远程登录 shell 会解析这条命令。如果它的管道语义报告最后一个 `tee` 的状态，SSH 就会把这个状态返回给本地机器。远程 shell 一旦丢弃了前面命令的结果，本地调用方就无法重新构建这些结果。

在本地 shell 中设置 `set -o pipefail`，并不能修复远程运行的管道。下面的命令只改变本地管道的状态规则：

```bash
set -o pipefail
ssh deploy@host 'generate | transform | tee result.txt'
```

`generate | transform | tee result.txt` 仍由远程 shell 负责。它需要自己的显式 shell 和自己的失败处理。

还有第二个陷阱。下面的本地命令会在 SSH 返回后再创建一个管道：

```bash
ssh deploy@host 'remote command' 2>&1 | tee session.log
```

现在存在两个不同的管道：

1. `remote command` 内部可能有一个远程 shell 管道。
2. 本地 shell 有 `ssh | tee session.log`。

成功的本地 `tee` 可以掩盖 SSH 传输失败或远程包装器的非零退出状态。你必须在远程主机上检查远程管道，在本地检查 SSH 周围的管道。把整行当作一个不透明的命令，正是错误的成功结果通过审查的原因。

## pipefail 能发现失败，但无法解释失败

`set -o pipefail` 会改变 Bash 的汇总结果。启用后，Bash 返回最右侧非零命令的状态；如果所有命令都成功，则返回零。

对许多脚本来说，这是实质性的改进：

```bash
set -o pipefail
produce_data | validate_data | publish_data
printf 'pipeline status: %s\n' "$?"
```

如果 `produce_data` 以 17 退出，而后续命令以零退出，管道返回 17。如果 `validate_data` 以 4 退出，而 `publish_data` 以零退出，管道返回 4。调用进程收到的是失败，而不是谎言。

但多个阶段同时失败时，`pipefail` 会丢失细节。假设状态为 `17 4 0`。管道结果是 4，因为 4 来自最右侧的失败命令。你知道发生了失败，却无法据此判断验证器是否导致了生产者失败、对生产者的失败作出了反应，还是独立失败。

所以，`pipefail` 是护栏，不是报告格式。希望管道作为一个整体失败时使用它；需要回答下面这些问题时使用 `PIPESTATUS`：

- 哪个阶段返回了非零代码？
- 较早阶段失败后，后面的阶段是否仍运行并成功？
- 进程是收到信号，还是返回了自己的错误？
- 对于这个特定命令，非零代码是否属于预期结果？

不要用 `|| true` 来掩盖这个缺口：

```bash
produce_data | validate_data | publish_data || true
```

这种写法很流行，因为它能让脚本继续执行，但也会抹掉调用方唯一拥有的信号。如果某个阶段确实允许非零状态，应在捕获实际状态向量后，为该阶段写明允许的状态。不要让整个管道免于失败。

## PIPESTATUS 只要多等一条命令就会消失

Bash 通过 `PIPESTATUS` 数组公开每个阶段的退出代码。这个数组本来就很脆弱：它描述最近执行的前台管道，下一条命令就可能替换它。

下面的写法看起来合理，实际上是错的：

```bash
source_data | normalize | upload
pipeline_rc=$?
printf 'pipeline result: %s\n' "$pipeline_rc"
statuses=("${PIPESTATUS[@]}")
```

Bash 执行到最后的赋值时，`pipeline_rc=$?` 和 `printf` 已经运行过了。此时 `PIPESTATUS` 不再描述 `source_data | normalize | upload`。

应先复制数组，其他事情一件也不要做：

```bash
source_data | normalize | upload
statuses=("${PIPESTATUS[@]}")
```

然后在不依赖管道汇总代码的情况下检查它：

```bash
printf 'source_data=%s normalize=%s upload=%s\n' \\
  "${statuses[0]}" "${statuses[1]}" "${statuses[2]}"
```

这也是为什么随意使用 `set -e` 可能让诊断更困难。启用 `pipefail` 后，失败的管道可能使 Bash 在下一行复制 `PIPESTATUS` 之前就退出。shell 错误处理有许多依赖上下文的例外，单靠 `set -e` 的脚本往往会在命令失败时提供更少的证据。

对于状态很重要的管道，可以在运行和快照管道所需的几行中关闭 `errexit`，然后明确作出决定。这比一个神奇的 shell 选项需要更多代码，但故障发生时，这些代码是可以读懂的。

## 使用所需的 shell 运行远程程序

`PIPESTATUS` 是 Bash 数组，不是可移植的 POSIX `sh` 语法；POSIX shell 也不要求支持 `pipefail`。通过 SSH 调用的远程命令可能运行在你没有选择的登录 shell 中。一台主机上可能是 Bash，另一台上可能是 `dash`、`zsh` 或受限 shell。

不要把 Bash 语法发送给未指定的远程 shell，然后希望机器碰巧与你的假设一致。请显式启动 Bash：

```bash
ssh deploy@host 'bash -s' <<'REMOTE_SCRIPT'
printf 'alpha\n' | grep 'beta' | tee /var/tmp/example.out
statuses=("${PIPESTATUS[@]}")
printf 'stages=%s,%s,%s\n' \\
  "${statuses[0]}" "${statuses[1]}" "${statuses[2]}" >&2
REMOTE_SCRIPT
```

带引号的 heredoc 分隔符很重要。`<<'REMOTE_SCRIPT'` 会阻止本地 shell 在发送脚本之前展开变量、命令替换和反斜杠。远程 Bash 进程收到的就是你写下的文本。

macOS 的系统 Bash 版本较旧，但支持索引数组、`PIPESTATUS` 和 `set -o pipefail`。这不代表 `/bin/sh` 是 Bash。只有直接执行带有 `#!/bin/bash` 的文件时，这个 shebang 才能发挥作用。如果把单行命令传给 `ssh host '...'`，除非显式启动 Bash，否则仍由远程登录 shell 解析。

对于长期维护的自动化流程，应把远程包装器放入版本控制脚本，并调用其绝对路径。对于短期代理任务，带引号的 heredoc 配合 `bash -s` 通常更容易审计，因为完整的远程程序会出现在本地操作请求中。

## 包装器应写明阶段，并返回真实结果

一个有用的远程包装器要完成四件事：运行管道，立即复制状态向量，输出机器可读的记录，并在必需阶段失败时以非零状态退出。

下面的例子使用三阶段数据传输。替换命令即可，但应保留控制流程。它有意不依靠 `set -e` 决定管道之后发生什么。

```bash
#!/usr/bin/env bash
set -uo pipefail

run_export() {
  local -a status
  local stage
  local -a names=("collect" "compress" "send")

  set +e
  collect_records | gzip -c | send_archive --destination daily
  status=("${PIPESTATUS[@]}")
  set -e

  if ((${#status[@]} != ${#names[@]})); then
    printf 'agent_pipeline_error pipeline=export reason=status_count expected=%s got=%s\n' \\
      "${#names[@]}" "${#status[@]}" >&2
    return 70
  fi

  for stage in "${!names[@]}"; do
    printf 'agent_pipeline_status pipeline=export stage=%s code=%s\n' \\
      "${names[$stage]}" "${status[$stage]}" >&2
  done

  for stage in "${!status[@]}"; do
    if (( status[stage] != 0 )); then
      printf 'agent_pipeline_result pipeline=export outcome=failed\n' >&2
      return "${status[$stage]}"
    fi
  done

  printf 'agent_pipeline_result pipeline=export outcome=ok\n' >&2
  return 0
}

run_export
```

采集阶段失败、压缩器和发送器成功时，包装器会产生类似这样的输出：

```text
agent_pipeline_status pipeline=export stage=collect code=23
agent_pipeline_status pipeline=export stage=compress code=0
agent_pipeline_status pipeline=export stage=send code=0
agent_pipeline_result pipeline=export outcome=failed
```

包装器退出 23。SSH 将 23 返回给本地进程。即使 `send_archive` 为一个空流打印了完成消息，代理也可以报告导出在 `collect` 阶段失败。

具体返回哪个代码没有背后的纪律重要。在这个包装器中，按管道顺序，第一个非零状态获胜。Bash 的 `pipefail` 则选择最右侧的非零状态。两种策略都可以，只要明确说明并进行测试。对于运维工作，我更倾向于第一个失败阶段，因为它通常更接近最初的故障。应把完整状态向量保存在操作记录中，这样任何人都不必从一个数字猜测发生了什么。

阶段名称不是装饰。`0=23,1=0,2=0` 会迫使人重新打开脚本；`collect=23,compress=0,send=0` 则能让监管程序路由失败、补充上下文，或判断重试是否安全。

## 本地日志可能制造第二个错误成功

运维人员需要本地记录，代理也需要。最直接的写法是：

```bash
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
```

如果 SSH 返回 23，而本地 `tee` 写入记录并返回零，那么默认情况下本地管道返回零。你修复了远程谎言，却引入了本地谎言。

同样捕获本地状态：

```bash
set +e
ssh deploy@host 'bash -s' < remote-export.sh 2>&1 | tee ssh-export.log
local_status=("${PIPESTATUS[@]}")
set -e

ssh_rc=${local_status[0]}
tee_rc=${local_status[1]}
printf 'ssh=%s tee=%s\n' "$ssh_rc" "$tee_rc" >&2

if (( ssh_rc != 0 )); then
  exit "$ssh_rc"
fi
if (( tee_rc != 0 )); then
  exit "$tee_rc"
fi
```

不要只启用本地 `pipefail` 就停下来。它能在 `ssh` 或 `tee` 任一失败时给出非零汇总结果，这比默认行为好。但它无法告诉代理究竟是远程操作失败、网络连接失败，还是本地日志记录失败。三种情况会导致不同的决定。

SSH 状态 255 需要特别处理。OpenSSH 将它保留给 SSH 客户端路径中的错误，而不是远程命令结果。包装器应将它报告为传输或 SSH 执行失败，而不是声称某个命名的远程管道阶段返回了 255。

把本地和远程结果分开还有一个实际原因。一份记录可能包含多条远程管道记录、登录 shell 的警告和 SSH 诊断信息。如果代理从自由文本中寻找最后一个数字，它迟早会选错。应使用可识别的记录，再把最终操作结果绑定到实际进程退出状态。

## SIGPIPE 需要明确的例外，而不是一概放过

`pipefail` 会暴露许多脚本过去忽略的失败：SIGPIPE。在 Bash 中，被信号编号 `N` 终止的进程会得到 `128 + N` 的状态；SIGPIPE 通常表现为 141。

一个典型的有意情况是：

```bash
generate_many_lines | head -n 10
```

`head` 读取十行后成功退出。生成器可能继续写入，因为没有读者而收到 SIGPIPE，并以 141 退出。启用 `pipefail` 后，即使已经生成了所需的十行样本，整个管道也可能看起来失败。

但这不代表 141 在所有管道中都无害。网络客户端、压缩器或数据生产者可能因为下游消费者意外崩溃或拒绝输入而收到 SIGPIPE。把所有 141 状态都标记为成功，会掩盖损坏的传输。

正确的规则应当很窄：只有当某个阶段的提前终止属于该命令明确约定的行为时，才允许由信号产生的状态。把这个例外写在该阶段旁边，不要放进全局 shell 设置。

例如，有意生成预览时，包装器可以只在 `head=0` 的情况下接受 `generate_many_lines=141`：

```bash
if (( status[0] == 141 && status[1] == 0 )); then
  printf 'agent_pipeline_result pipeline=preview outcome=ok reason=expected_sigpipe\n' >&2
  return 0
fi
```

其他所有非零结果仍然是失败。这样少量的明确规则可以防止一种常见的过度纠正：人们启用 `pipefail`，偶尔看到一次嘈杂的 141，就把它从整个自动化环境中关闭。

## 代理需要独立于命令输出的证据

代理不应通过阅读说明文字来判断成功。命令可能在失败前打印成功词语，工具会把警告和结果混在一起，远程脚本也可能在某个阶段出错后仍输出最后一行。

应定义一个包含两层的操作契约：

1. 进程退出代码决定请求的操作是否成功。
2. 结构化状态记录解释所有重要的管道阶段。

保留普通命令输出以便调试，但不要让代理从中推断控制流程。在上面的包装器中，stderr 携带以 `agent_pipeline_status` 和 `agent_pipeline_result` 开头的记录。调用程序可以保存这段流，只解析这些确切记录，同时把其余内容展示给人类。

不要因为某个标记出现在不可信的命令输出中就信任它。如果管道阶段处理另一个用户或系统提供的数据，这些数据可能包含一行看起来像状态记录的内容。更安全的方式是让包装器捕获阶段输出，并在管道完成后自行发出记录。对于风险更高的工作，可以使用权限严格的专用结果文件，再由包装器读取和验证，最后只发出一条最终记录。

代理的报告应在可用时包含远程退出代码、本地 SSH 退出代码以及带名称的远程阶段状态。它还应区分以下结果：

- 远程操作已经运行，但某个命名阶段失败；
- 远程包装器无法生成完整的状态记录；
- SSH 无法建立或维持操作通道；
- 远程操作完成后，本地记录捕获失败。

这些在运维上是不同的事实。网络中断后的重试可能重复已经完成的远程修改；验证阶段失败后的重试可能是安全的；本地 `tee` 失败后的重试可能没有意义，因为远程工作已经完成。

Sallyport 可以在执行 SSH 操作时让 SSH 凭据远离代理，但远程命令仍需要这种真实的退出和证据契约。

## 在代理遇到失败路径前先测试它们

只有经过受控失败，shell 包装器才值得信任。测试成功路径只能证明最不值得关注的分支。

创建能返回所需状态的一次性命令：

```bash
fail_23() { printf 'collector failed\n' >&2; return 23; }
pass_through() { cat; }
succeed() { cat >/dev/null; return 0; }

set +e
fail_23 | pass_through | succeed
status=("${PIPESTATUS[@]}")
set -e
printf 'observed=%s,%s,%s\n' "${status[0]}" "${status[1]}" "${status[2]}"
```

预期结果是 `23,0,0`。然后通过代理实际使用的完整 SSH 调用运行同样的模式。不要只做本地 shell 测试，因为远程 shell 的选择、heredoc 引号、本地记录管道和包装器退出行为都不在第一次检查之内。

至少测试这些情况：

- 所有阶段成功，包装器退出零；
- 早期阶段失败，而后续阶段返回零；
- 中间阶段在消耗部分输入后失败；
- SSH 无法连接或认证；
- 本地 `tee` 无法写入记录；
- 有意使用 `head` 的管道触发预期的 SIGPIPE 规则。

为每种情况记录预期退出代码和预期阶段记录。如果某项测试在早期阶段返回非零后仍报告成功，包装器就没有完成任务。

最诱人的捷径，是让代理在每次操作后检查记录，然后判断输出「看起来是否正确」。这种方式会在负载升高时失败，会在工具更改措辞时失败，也会在输出被截断时失败。退出代码是控制通道，阶段记录是证据通道。跨 SSH 保留两者，并且不要让最后一个 `tee` 决定远程操作是否发生。
