# SSH 命令成功检查：证明远程操作已经完成

退出状态为 0 的 SSH 命令，只完成了一个很有限的任务：远程程序告诉自己的 shell，它执行成功了。这是有用的证据，却不能证明部署已经切换到正确版本、服务一直健康，或配置变更确实生效。

我见过不少自动化流程因为 `ssh host command` 返回 0 就宣布成功，之后才发现命令写入了错误目录、排队的任务后来失败，或者重启后的服务立即崩溃。解决办法不是增加一些更乐观的日志，而是把远程变更视为一个需要验证的过程。只有当三个事实同时成立时，才算完成：SSH 已经到达远程程序，程序返回了预期结果，独立的读取操作确认了你想创建的状态。

## SSH 退出码只报告操作中的一层

退出状态说明的是进程是否完成，而不是整个运维结果。远程命令依赖一整套前提：DNS 和网络连接、主机身份、身份验证、shell 行为、命令解析、依赖项、远程权限，以及你希望改变的状态。

OpenSSH 的 `ssh(1)` 手册说明，`ssh` 会返回远程命令的状态；如果发生错误，则返回 255。这个区别很重要。状态为 255 通常意味着 SSH 客户端无法按要求建立或维持会话。状态为 1、2 或其他非零值，通常来自远程命令。状态为 0 也同样来自远程命令。

这段说明并没有说 0 代表“生产变更正确”。SSH 不可能知道 `/srv/app/current` 是否指向了你想要的版本，也不知道 `systemctl restart` 之后守护进程是否还能接受请求，更不知道数据库迁移是否提交了应用所需的记录。

POSIX 将命令的零退出状态定义为成功完成。这个定义有用的地方就在于它很有限，只描述了命令自己的契约。如果契约只是“运行这行 shell 命令”，那么 0 几乎不能证明什么。应当把契约写得更具体，再在命令之外检查最终状态。

可以把失败分成三层：

- SSH 失败：客户端无法连接、完成身份验证、验证主机或完成会话。
- 命令失败：远程进程发现错误并返回非零状态。
- 结果失败：进程返回 0，但预期的远程状态不存在、错误、不完整，或后来被逆转。

团队经常把后两层混为一谈，结果是事故报告含糊不清，重试也变得危险。明确失败发生在哪一层，你才能知道应该检查凭据和连接、修复命令，还是恢复远程状态。

## 只有定义准确时，成功文本才是证据

预期输出检查能发现退出状态看不到的问题，但前提是输出有明确契约。在面向人的日志中搜索 `success`、`complete` 或 `deployed` 这样的词，证据很弱。许多工具会在后续命令失败前打印这些词，包装脚本也可能在任务刚提交、尚未完成时就打印成功。

应让远程命令输出一条包含预期身份和状态的记录。JSON 通常很方便，如果你能控制字段值，固定格式的分隔行也可以。关键在于，脚本必须先完成它声称已经完成的工作，再打印这条记录。

例如，假设发布脚本要把符号链接切换到某个版本目录。下面的远程脚本输出最终目标，而不是含糊的进度消息：

```sh
#!/bin/sh
set -eu

release="$1"
base=/srv/example/releases
link=/srv/example/current

[ -d "$base/$release" ]
ln -sfn "$base/$release" "$link"

actual=$(readlink "$link")
[ "$actual" = "$base/$release" ]
printf 'RELEASE_TARGET=%s\n' "$actual"
```

调用方可以要求输出中出现完全匹配的记录：

```sh
expected="RELEASE_TARGET=/srv/example/releases/2025.06.14"
output=$(ssh deploy@web-01 '/usr/local/sbin/activate-release 2025.06.14' 2>&1)
status=$?

if [ "$status" -ne 0 ]; then
  printf 'remote command failed, status=%s\n%s\n' "$status" "$output" >&2
  exit "$status"
fi

if ! printf '%s\n' "$output" | grep -Fxq "$expected"; then
  printf 'remote command returned unexpected output:\n%s\n' "$output" >&2
  exit 1
fi
```

这里的 `grep -Fxq` 很重要。它检查的是一整行固定文本。像 `grep deployed` 这样的宽松匹配会接受垃圾内容、部分匹配和误导性的进度日志。如果输出包含动态字段，应使用真正的解析器读取结构化文档，而不是试图让正则表达式理解嵌套数据。

不要把输出匹配写成整条命令的第二份副本。它只需要回答一个小问题：远程程序是否说自己已经到达指定状态？后续读取则负责确认这个说法在真正重要的地方是否仍然成立。

## 远程写入需要由状态所有者执行读取确认

后续读取之所以最有说服力，是因为它询问了真正拥有变更状态的组件。具体读取方式取决于变更类型，而且通常与执行变更的命令不同。

切换符号链接时，用 `readlink` 检查文件系统。发布软件包时，查询已安装的软件包版本。重启服务时，先向服务管理器查询 active 状态，再对服务发起请求。修改数据库时，读取相关记录，或使用应用支持的状态接口。提交队列任务时，持续查询任务记录，直到它进入终态。

下面是常见的错误模式：

```sh
ssh deploy@web-01 'deploy-release 2025.06.14 && systemctl restart example'
```

它可能返回 0，但服务仍指向旧版本，因为部署脚本写入了另一个路径。即使服务管理器接受了重启请求，进程随后立刻退出，它也可能返回 0。shell 看到两个程序都返回成功，但用户得到的仍然是坏掉的服务。

应使用能让目标条件变得可观察的读取操作。部署检查可以这样写：

```sh
ssh deploy@web-01 '
  test "$(readlink /srv/example/current)" = /srv/example/releases/2025.06.14 &&
  systemctl is-active --quiet example &&
  curl --fail --silent --show-error http://127.0.0.1:8080/healthz
'
```

这比第一个命令好，因为它检查了三个原命令只是默认成立的条件。它仍然不能证明每个外部用户都能访问服务。如果变更影响公共接口，应从用户实际所在的网络位置执行合适的检查。本机回环地址的健康检查能发现进程失败，却发现不了防火墙或负载均衡器配置错误。

读后写检查应尽量使用独立路径。在同一个部署脚本中调用第二个函数当然比什么都不检查好，但它可能继承同一个错误变量、错误主机或模拟依赖。单独查询文件系统、服务管理器、API 或数据库的命令，能减少这种共同故障模式。

## 命令必须说明自己是否同步执行

许多远程命令返回 0，是因为它们接受了任务，而不是因为任务已经完成。对于提交队列任务、后台作业、服务重载请求或编排 API 来说，这个结果本身没有问题。但如果调用方把“已接受”当成“已完成”，就会产生错误。

应在名称和输出中区分这两种契约。`submit-backup` 可以在服务器接受请求后返回任务 ID 和 0。`wait-backup` 则只有在该任务报告完成后才返回 0。不要把两个动作都叫作 `backup`，再指望操作人员记住哪个版本运行在哪台主机上。

启动后台任务的远程脚本尤其需要小心。下面这行 shell 命令在进程启动后就返回成功，即使该进程马上失败：

```sh
long-task >/var/log/long-task.log 2>&1 &
printf 'started\n'
```

任务之后的退出码不会传回 SSH 调用方。应把它写入持久位置并在之后查询，或者保持会话打开，直到任务达到有意义的状态。如果必须分离任务，就把操作 ID 写入文件或数据库并返回这个 ID。调用方随后轮询或等待对应的操作记录。

有用的完成记录需要包含足够的信息，以便协调任务：

```text
operation=4f2c1a status=accepted release=2025.06.14
```

调用方不能把这条记录当成部署已完成。它应查询 `operation=4f2c1a`，要求终态为 `completed`，并检查最终产生的状态。对于一个简单脚本来说，这似乎有些繁琐。但相比在超时后猜测是否可以安全重跑脚本，这样做要可靠得多。

超时也要区分开来。本地超时只说明调用方停止等待，并不说明远程命令停止了。网络可能在远程主机提交变更后才中断。重试前，应检查远程状态或查询操作 ID。重复执行发布切换，如果操作具备幂等性，可能没有问题；但重复付款、轮换密钥或发送邮件，可能会扩大损失。

## 如果不专门处理，shell 组合会隐藏失败

远程 shell 语法可能把真实失败转换成成功的最终退出状态。这是 SSH 运行看起来正常、机器却已经受损的常见原因。

看下面这条命令：

```sh
ssh ops@db-01 'backup-db; upload-backup; prune-old-backups'
```

远程 shell 返回最后一条命令 `prune-old-backups` 的状态。如果备份失败但清理成功，整个 SSH 命令仍返回 0。日志顶部可能有错误，而只读取退出状态的流水线却会把这次运行标记为成功。

在可控脚本中使用 `set -e`，或者在简短命令仍然清晰时，用 `&&` 连接依赖命令：

```sh
ssh ops@db-01 'backup-db && upload-backup && prune-old-backups'
```

`set -e` 不是万能的。条件判断、命令替换和某些复合结构周围存在 shell 特例。不要写一条很长的远程单行命令，然后以为一个选项就能显示所有失败。应把复杂工作放进远程脚本，为每个操作定义清晰的成功条件，并在失败场景下测试行为。

管道也有相同的陷阱。在许多 POSIX shell 中，只要最后一个程序成功，即使前面的生产者失败，下面的命令仍可能成功：

```sh
collect-metrics | format-report > /var/tmp/report.txt
```

有些 shell 提供 `set -o pipefail`，但 `/bin/sh` 可能不支持。如果远程环境需要 POSIX 兼容性，就不要把管道作为唯一的错误边界。可以把中间数据写入临时文件，检查生产者状态后再读取；或者明确要求使用支持 `pipefail` 的 shell 来执行脚本。

也不要让诊断输出成为失败命令之后的最后一条命令：

```sh
apply-config
printf 'finished\n'
```

没有 `set -e` 或明确检查时，`printf` 就会成为退出状态。手动处理事故时，这种错误很常见，因为有人想打印一句友好的结束消息。应当只在条件检查通过后打印消息，或让失败命令终止脚本。

## 引号错误可能让你验证错误的机器

本地 shell 会在 SSH 发送命令之前展开未加引号的变量。这可能让命令针对错误版本运行、比较本地输出而不是远程输出，或把值暴露在本地进程列表和日志中。

如果你希望远程主机计算 `$release`，下面的写法就是错误的：

```sh
ssh deploy@web-01 "test \"$(readlink /srv/example/current)\" = \"$release\""
```

本地 shell 会在启动 SSH 前执行 `$(readlink ...)`。此时你比较的是工作站上的 `/srv/example/current`，如果它存在的话，以及本地变量，然后把比较结果发送给远程 shell。退出状态可能仍为 0，但检查并没有读取目标主机。

远程程序包含必须在远端运行的 shell 语法时，应把它放在单引号中。不要拼接 shell 文本来传递不可信或动态值，而应将这些值作为位置参数传入。例如：

```sh
release='2025.06.14'
ssh deploy@web-01 'sh -s -- "$1"' sh "$release" <<'REMOTE'
set -eu
release=$1
target=$(readlink /srv/example/current)
[ "$target" = "/srv/example/releases/$release" ]
printf 'verified=%s\n' "$target"
REMOTE
```

带引号的 heredoc 分隔符可以防止本地 shell 展开脚本正文。release 值作为 shell 参数传递，远程 shell 可以正确引用它。但这本身不会让任意输入变成安全的版本名称。在把用户控制的值用于路径、命令或数据库查询前，应验证允许的字符和预期格式。

在自动化中，优先使用带参数的远程脚本，不要不断增加嵌套引号的字符串。引号错误在代码审查中很难察觉，因为命令乍看起来合理。日志记录确切的远程脚本版本、安全的参数值，以及验证命令自身的输出后，问题会容易诊断得多。

## 有用的 SSH 契约应包含三个独立结果

把每个有实际影响的 SSH 操作都当成一个小型协议，分别记录传输结果、命令结果和观察到的状态。调用方需要这三个结果，才能决定继续、重试还是请求人工帮助。

下面的 Bash 函数展示了这种结构。它把 stderr 与 stdout 合并，确保失败操作留下诊断证据，同时单独报告 SSH 传输失败和意外的成功响应。

```bash
run_remote_check() {
  local host=$1
  local expected=$2
  shift 2

  local output status
  output=$(ssh "$host" "$@" 2>&1)
  status=$?

  if [ "$status" -eq 255 ]; then
    printf 'ssh_transport=failed host=%s\n%s\n' "$host" "$output" >&2
    return 255
  fi

  if [ "$status" -ne 0 ]; then
    printf 'remote_command=failed host=%s status=%s\n%s\n' \
      "$host" "$status" "$output" >&2
    return "$status"
  fi

  if ! printf '%s\n' "$output" | grep -Fxq "$expected"; then
    printf 'remote_result=unexpected host=%s expected=%s\n%s\n' \
      "$host" "$expected" "$output" >&2
    return 1
  fi

  printf 'remote_result=confirmed host=%s\n' "$host"
}
```

用只在内部检查完成后输出契约记录的命令调用它：

```bash
run_remote_check \
  deploy@web-01 \
  'RELEASE_TARGET=/srv/example/releases/2025.06.14' \
  '/usr/local/sbin/activate-release 2025.06.14'
```

然后把后续读取作为独立操作执行。在日志和状态报告中也保持区分。如果激活成功但服务检查失败，操作人员应能看到准确的边界。一个含糊的“部署失败”会迫使他们重新运行命令，只为弄清已经发生的事情。

对于返回结构化数据的命令，应返回小型 JSON 对象并使用 JSON 解析器。除非输出特意设计成单行哨兵值，且你不需要解读字段，否则不要对 JSON 使用 `grep`。当空白、字段顺序或转义内容发生变化时，对任意 JSON 做字符串匹配就会失效。

契约还应标明目标对象。单独的 `status=ok` 无法区分 `2025.06.14` 和昨天的版本。应加入部署标识、相关主机名、对象版本或操作 ID。这个小细节能避免一种常见的误报：检查确认某个健康对象存在，却没有确认它就是本次运行刚刚修改的对象。

## 可观测性必须保留验证结果

只记录远程命令和退出状态的日志，仍然没有回答最重要的问题：调用方是否独立观察到了预期状态？应将验证操作及其结果与变更记录放在一起。

部署记录可以包含目标主机、版本标识、SSH 状态、命令状态、精确的契约记录、后续命令状态，以及健康检查或状态读取的简短结果。不要因为完整授权头或私有命令参数有助于调试，就把密钥和这些敏感内容写入日志。应设计一个能够安全保留有用证据的命令接口。

Sallyport 为单个操作保留 Activity journal，也为代理运行保留 Sessions journal。这两者都从加密且带哈希链的审计日志生成。这样，操作人员可以区分“SSH 操作已经运行”和“验证操作确认了结果”，而不是把一次成功调用当成完整结论。

防篡改证据有助于判断记录中的操作是否在事后被修改，但它不会把薄弱的命令契约变成良好结果的证明。`sp audit verify` 可以在离线状态下对密文验证日志链，但操作设计仍然需要明确的读回检查。

验证应与写入操作保持足够接近，避免其他参与者在间隙中悄悄替换目标状态。共享系统上的竞争条件不一定能完全消除，但可以通过不可变版本 ID、操作 ID、版本检查和支持条件更新的 API 来降低风险。如果状态还可能再次变化，应记录观察到的版本或时间戳，并让后续自动化在执行前检查它。

## 重试前先协调状态

超时、连接中断或 CI 任务被打断后，结果会变得未知。远程主机可能已经完成变更，可能仍在执行，也可能执行到一半失败。调用方停止收到输出，并不能说明是哪一种情况。

不要用盲目重试来解决不确定性。先运行只读的协调命令，对远程状态进行分类。发布激活应检查当前符号链接和服务健康状态。迁移应查询迁移表。创建资源时，应使用调用方生成的操作 ID 查询。轮换密钥时，应先确认消费者实际使用的是哪个版本，再决定是否生成新的版本。

好的协调命令应返回少量明确结果之一：

- `completed`：目标状态与请求的操作一致。
- `running`：操作仍然持有任务，调用方应继续等待。
- `absent`：没有发现该操作的证据，可以考虑重试。
- `conflict`：存在不同状态，需要人工或更高层控制器决定。

不要把所有结果都强行归为成功或失败。`running` 和 `conflict` 都是有用的结果。把它们称为失败的系统，往往会重试本应保持不动的任务。

幂等性可以降低合理重试的成本，但这个词经常被滥用。一个命令具备幂等性，是指在第一次成功应用后，重复相同请求不会改变目标状态。对特定链接目标来说，`ln -sfn` 可以是幂等的。“创建一份带当前时间的新备份”不是幂等操作。“发送邮件”也不是，除非下游系统使用稳定的消息 ID 去重。

在增加重试之前，先建立读回检查。如果你无法说明如何识别一个已经完成的操作，就无法在结果未知时安全地自动重复执行。这正是 SSH 自动化从 shell 便利工具变成需要操作模型的地方。

## 第一步是审查那些显示绿色的运行

从会修改生产状态、目前却只报告绿色退出码的远程命令开始。对每个命令写清楚：必须改变的远程对象是什么，什么精确状态才算成功，哪个组件可以读取该状态，以及超时会留下哪些未知信息。

然后修改命令接口，让它在完成内部检查后输出精确的结果记录。增加独立的读回操作，并将两者的结果保存在运行记录中。你会发现，有些命令从来不是同步执行的，有些脚本的最后一个 `printf` 隐藏了前面的失败，还有些部署检查查询的是发起命令的机器，而不是接收命令的机器。

零退出状态仍然有用。它是第一道关卡，不是最终结论。这样看待它，自动化流程才能在那个看似可靠的绿色信号被证明错误时，解释究竟发生了什么。
