阅读需 8 分钟

验证 SSH 断开后的未知远程状态

通过持久化运行 ID、远程状态记录、后置条件检查和安全的重试决策,处理 SSH 断开后的未知远程状态。

验证 SSH 断开后的未知远程状态

SSH 断开并不意味着远程命令失败了。它意味着客户端失去了判断发生了什么所需的证据。运行 uname 时,这种区别很让人烦恼;运行数据库迁移、发布、凭据轮换,或调用外部 API 的命令时,代价就可能很高。

解决办法不是把超时时间调大,而是在远程端建立一套验证流程,包含持久化的运行身份、明确的状态转换,以及针对具体效果的完成证明。这样,重新连接后要回答的问题就从“要不要再运行一次?”变成了“这次运行记录写了什么?它改变了什么?”

连接断开后,只有三种诚实的答案

当 SSH 客户端报告连接重置、超时、管道断裂或意外 EOF 时,命令只有三种状态:从未开始、已经开始且仍在进行,或已经完成。客户端的退出代码无法可靠地区分这些状态。

从 shell 到远程程序之间存在几个边界:

  • 本地 shell 启动 ssh
  • 客户端发送 SSH 通道请求和命令字节。
  • 服务器接受请求,并启动远程 shell 或程序。
  • 该程序执行实际工作。
  • 程序退出,sshd 将输出和退出状态发回客户端。

这些边界中的任何一个之后发生网络中断,都可能在本地产生错误。如果中断发生在远程程序启动之前,什么都没有发生。如果中断发生在程序提交更改之后、退出状态返回之前,更改已经发生,但客户端仍然会报告失败。

所以,本地看到的 Connection reset by peer 只是传输证据,不是业务证据。它说明客户端无法完成 SSH 对话,却没有说明远程操作是否执行。

OpenSSH 的配置手册也指出了一个相关事实:关闭会话,并不保证关联的 shell 进程已经停止。因此,通道超时不是任务控制机制。

真正容易造成损失的习惯,是把模糊结果当成操作失败。这个习惯很容易理解,因为大多数命令行工具都让我们把非零退出状态理解为“再做一次”。SSH 传输失败会打破这个捷径。

命令送达和命令完成是两种不同的判断

一个远程命令至少有四个值得证明的判断:提交、开始、完成和效果。团队经常记录其中一个,就以为四个都有了。

提交表示客户端尝试发送命令。本地终端知道这一点,但这是最弱的判断。开始表示远程包装器在执行工作前创建了持久化证据。完成表示包装器记录了最终结果。效果表示预期的远程状态或外部状态已经符合请求。

进程列表能证明的事情比很多人想象的少。看到一个 PID,可能只说明现在存在一个名称相似的进程。它不能证明该进程属于你的请求,也不能证明重要部分尚未提交,更不能证明稍后的重试一定安全。进程退出后,PID 还可能被重新使用,过期记录的价值会进一步降低。

退出代码也有类似的限制。POSIX 将 wait 定义为 shell 获取已知子进程状态的方式。这种关系存在于远程 shell 内部。SSH 连接消失后,本地 shell 失去了获取该状态的路径。之后的新 SSH 会话无法通过 wait 重新建立这条关系,只能读取第一次运行持久化保存的记录。

在运行手册和自动化输出中,把下面几句话分开:

  1. “客户端无法确认完成。”
  2. “运行 r-20260722-1842-a91f 已在远程主机上开始。”
  3. “该运行记录的退出状态为 0。”
  4. “部署标记报告的发布版本为 2026.07.22.3。”

第四句话可能才真正回答了运维问题。文件复制命令需要校验和,或需要检查最终路径上的内容是否符合预期。迁移需要查询架构版本或迁移记录。向支付或工单 API 发出的请求,需要该 API 中的幂等记录,而不只是本地日志行。

在工作开始前,把运行身份放到远程主机上

持久化的运行 ID 能把模糊的重新连接工作变成一次查询。调用 SSH 前生成它,将它传给远程包装器,并让所有工件都放在由该 ID 派生的路径下。

不要只使用时间戳。两个代理可能在同一秒启动,时钟也可能漂移,而且时间戳不适合作为不透明标识符。可以将时间戳和随机数据组合起来,或使用环境中可用的 UUID 生成器。验证时必须再次提供这个 ID,并让它出现在每条有意义的日志记录中。

下面这段 shell 代码会创建运行目录,写入请求的操作,记录开始标记,并保留标准输出和标准错误。它要求在 -- 后传入命令。应将包装器放在受控位置,例如 /usr/local/sbin/run-recorded,而不是临时复制到每个命令字符串中。

#!/bin/sh
set -eu

run_id=$1
shift
[ "$1" = "--" ]
shift

base=/var/lib/recorded-runs
run_dir="$base/$run_id"

case "$run_id" in
  *[!A-Za-z0-9._-]*|'')
    printf '%s\n' "invalid run id" >&2
    exit 64
    ;;
esac

if ! mkdir "$run_dir" 2>/dev/null; then
  printf '%s\n' "run already exists: $run_id" >&2
  exit 75
fi

umask 077
printf '%s\n' "$*" > "$run_dir/request"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/started_at"
printf '%s\n' "started" > "$run_dir/state"
printf '%s\n' "$$" > "$run_dir/pid"

set +e
"$@" >"$run_dir/stdout" 2>"$run_dir/stderr"
status=$?
set -e

printf '%s\n' "$status" > "$run_dir/exit_status"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/finished_at"
printf '%s\n' "finished" > "$run_dir/state"
exit "$status"

mkdir 调用的作用不只是整理文件。如果运行 ID 已经存在,目录创建就会失败,因此它相当于一次简单的“只创建一次”声明。这能防止两个使用同一 ID 的调用悄悄执行两次工作。但它无法解决使用不同 ID 的并发工作,那需要单独的锁或应用层约束。

顺序很重要。包装器会在执行负载前写入 started_atstatepid,并在把状态切换为 finished 前记录 exit_status。如果验证器看到 finished 却找不到退出状态,应将记录视为损坏,而不是成功。如果看到运行目录,却没有 started_at,应将其视为设置不完整的失败。

不要通过 shell trap 写入 finished,然后就认为任务完成了。突然的主机故障、存储故障、强制终止或文件系统问题,都可能让 trap 无法运行。最终标记存在时是一种证据,但不能据此认为标记缺失就证明负载没有完成。

按照不会误导你的顺序验证运行状态

首先使用只读状态命令重新连接。不要通过再次使用相同参数启动负载来重新连接,并希望这样就能看清结果。

有用的验证器应该把记录归类为 absentrunningfinisheddamaged。下面的示例使用上面的目录格式,并输出供人或代理判断的事实。

#!/bin/sh
set -eu

run_id=$1
run_dir="/var/lib/recorded-runs/$run_id"

if [ ! -d "$run_dir" ]; then
  printf '%s\n' 'state=absent'
  exit 0
fi

if [ ! -f "$run_dir/started_at" ]; then
  printf '%s\n' 'state=damaged reason=missing-start-marker'
  exit 2
fi

if [ -f "$run_dir/finished_at" ] && [ -f "$run_dir/exit_status" ]; then
  printf '%s\n' 'state=finished'
  printf 'exit_status=%s\n' "$(cat "$run_dir/exit_status")"
  printf 'started_at=%s\n' "$(cat "$run_dir/started_at")"
  printf 'finished_at=%s\n' "$(cat "$run_dir/finished_at")"
  exit 0
fi

if [ -f "$run_dir/pid" ]; then
  pid=$(cat "$run_dir/pid")
  if kill -0 "$pid" 2>/dev/null; then
    printf 'state=running pid=%s\n' "$pid"
    exit 0
  fi
fi

printf '%s\n' 'state=damaged reason=no-finish-record-and-pid-not-live'
exit 2

把它作为新的 SSH 命令运行:

ssh ops@host /usr/local/sbin/check-recorded-run r-20260722-1842-a91f

输出应当是下面几种形式之一:

state=absent
state=running pid=48192
state=finished
exit_status=0
started_at=2026-07-22T18:42:19Z
finished_at=2026-07-22T18:47:03Z

协议中必须包含有些尴尬的 damaged 状态。省略它,就会迫使验证器把缺失证据变成乐观猜测。如果命令运行期间主机重启,kill -0 会失败,最终标记也不会存在。正确的做法是检查预期效果和应用日志,再决定是否需要协调或修复。

不要让验证器使用 ps | grep。它会匹配无关进程,命令名称也会变化,输出格式还可能不同。kill -0 只能根据记录的 PID 提供存活提示,不能证明完成。这也是验证器先检查最终工件、再查询 PID 的原因。

已记录的退出状态,仍可能无法证明预期效果

避免再增加一层策略
用三项固定控制管理代理操作,无需编写 SSH 策略或维护规则引擎。

包装器的最终记录证明的是包装器观察到的情况,不一定是外部世界接受的结果。对于发送请求的命令,这一点尤其明显。

假设一个远程脚本通过 API 创建 DNS 记录,然后写入 exit_status=0。脚本可能在解析器看到新记录之前,就已经从 API 收到了成功响应。部署脚本可能在提交发布后成功退出,但发布随后又未通过健康检查。数据库工具可能报告连接成功,但多步骤过程中某条语句已经提交,后面的语句却失败了。

每项操作都需要与其效果相匹配的后置条件。这个后置条件应当可以安全地重复读取,而且要足够具体,能排除旧结果或无关结果。

对于发布,可以把运行 ID 写入发布清单,然后从服务查询当前版本。对于数据库变更,可以查询迁移表中的迁移标识和校验和。对于生成的工件,可以在文件写入最终路径后,比较预先计算的 SHA-256 摘要。对于 API 请求,如果服务提供幂等令牌,就使用该令牌,然后通过令牌或已保存的请求 ID 查询生成的资源。

最糟糕的设计,是脚本发送请求后输出“完成”,再把这个词当成证据。stdout 文件只能告诉你某个进程打印了什么,后置条件才能告诉你系统现在包含什么。

这个区别还可以帮助你判断哪些操作不能只靠 SSH 安全自动化。如果远程命令调用的第三方服务没有幂等控制,也无法查询之前的请求,那么中断的调用可能根本无法分类。应在它周围加入人工审批或补偿流程。更多重试不会凭空产生缺失的证据。

幂等性胜过看似恢复的表演

验证协议可以降低不确定性。幂等的命令设计可以降低不确定性的代价。两者都需要。

幂等操作在使用同一请求再次应用时,会达到相同的目标状态。mkdir -p /srv/app/cache 基本符合这个模型。useradd deploy 则不符合,除非脚本先验证现有账户具有预期属性。curl -X POST /orders 也不幂等,除非服务理解幂等令牌,并把重复令牌视为同一个请求。

不要把“第二次运行大概什么也不会做”当成幂等性。部署命令可能两次都以相同方式覆盖文件,却触发两次重启。迁移工具可能能识别自己的历史记录,却仍会在检查之前执行危险的初始化。应阅读命令行为,并测试中断场景。

围绕稳定的操作标识符构建请求。将同一个 ID 传给远程包装器,并在可能的情况下传给目标系统。远程发布包装器可以在活动发布端点报告所请求版本后,才创建 /var/lib/recorded-runs/$run_id/effect。配置 API 调用可以使用 run_id 作为幂等值。这样,SSH 重试就能让两个系统查询同一个工作单元。

这里有一条实用规则:读取操作可以自由重试;创建操作只有在具备持久化唯一性约束时才可重试;多阶段变更必须等后置条件判断出上一次运行的结果后再重试。这比盲目重新提交命令慢一些,却远快于清理重复的基础设施。

将命令放到后台,只是把问题移到了别处

让代理无法接触 SSH 密钥
通过 Sallyport 转发代理发起的 SSH 命令,同时将 SSH 密钥保存在加密保险库中。

nohup&disowntmuxscreen 和服务管理器,各自解决不同的一部分问题。它们都不能把不确定的远程请求变成已验证的结果。

在常见的 shell 配置中,nohup 有助于让进程在收到挂断信号后继续存活。普通的 nohup task & 仍然只会给你输出文件和 PID,除非你另外加入结构化的完成记录。它还引入了新的不确定性:远程 shell 是已经启动了 nohup,还是连接在此之前就断了?

tmuxscreen 可以保留交互式环境。操作人员需要重新连接并手动检查长时间运行的命令时,它们很合适。但作为自动化契约,它们并不理想,因为会话名称可能冲突,滚动输出不是结果模式,分离的终端也无法告诉另一个系统预期效果是否发生。

当工作确实属于服务或队列任务时,服务管理器更强。例如,远程命令可以提交一个命名单元,之后再查询该单元的生命周期和日志。如果主机已经有负责管理任务的运维体系,应使用这种模型。不要为了避免写一个小型运行记录,就给一个五秒钟的管理命令接入服务管理器。

划分方式很简单。交互式修复工作放在终端复用器中,定时或长期运行的负载交给服务管理器,需要在重新连接后获得可靠答案的命令则使用记录型包装器。

保活可以缩短等待,却无法关闭模糊窗口

OpenSSH 客户端保活能更快发现失效的连接,但不能保证命令没有在网络路径失效之前被接受。

对于不希望客户端长时间卡住的主机,下面这样的客户端配置是合理的:

Host production-*
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive yes

当没有数据到达时,ServerAliveInterval 会通过加密 SSH 通道发送应用层消息。如果客户端收不到足够的响应,就会退出,而不是无限等待。OpenSSH 将它与 TCP 保活分开说明,后者运行在传输层。

用这个设置限制调用方在开始验证前最多等待多久。不要把它描述成命令送达保证。远程主机接受命令之后、客户端收到结果之前,连接仍然可能断开。

多路复用也要同样谨慎。ControlMasterControlPersist 可以让多个 SSH 命令复用已有网络连接,减少建立连接的成本,但损坏的主连接也可能同时影响多个调用。OpenSSH 手册说明,持久化的主连接会在原始客户端退出后留在后台。这在运维上很有用,却不会为通过它发送的命令增加完成证据。

对于自动化,应设置明确的连接超时,设置符合环境的存活边界,并让验证路径独立于原始 SSH 会话。快速失败只有在下一步是状态查询,而不是盲目重试时才有价值。

在凌晨两点之前,先把故障测试做好

审批每条敏感命令
将敏感 SSH 密钥设置为每次使用都需要审批,包括一键确认或 Touch ID 确认。

从未被中断过的协议只是设计草图。应使用安全但足够缓慢的操作,在不同阶段切断连接来测试它。

先准备一个负载,让它写入带编号的进度文件,在各阶段之间休眠,并写入最终效果标记。通过包装器启动它。远程出现 started_at 标记后,终止本地 SSH 客户端,然后重新连接并运行验证器。接着在远程负载写入 finished_at 之前终止它。最后,如果环境允许,再模拟一次主机重启。

预期分类应明确:

  • 包装器声明运行 ID 之前,验证结果为 absent
  • 负载执行期间,验证结果为 running
  • 正常完成后,结果为带有记录状态的 finished
  • 强制中断或主机丢失后,结果为 damaged,随后执行后置条件检查。

也要测试重复提交。让两个调用几乎同时使用同一个运行 ID。只有一个调用可以成功取得目录声明,另一个必须返回明确的重复结果,而且不能运行负载。然后再尝试使用两个不同 ID 操作同一资源。如果这样会产生竞争,包装器需要资源级锁,或者目标系统需要唯一性规则。

运行记录应保留足够长的时间,覆盖你的运维重试窗口。如果任务可能在一天后重试,而主机一小时后就删除记录,那么你其实是在不确定性中加入了一个计时器。还要保护记录,避免被随意修改。验证运行的账户不应能编辑 exit_status 或替换负载日志。在共享系统上,只要运维模式允许,就应分开提交者、运行者和读取者的权限。

当 AI 代理调用 SSH 时,凭据隔离和命令验证需要一起工作。Sallyport 将 SSH 凭据保存在保险库中,并能记录代理请求的操作;远程包装器则为任务本身提供持久化答案。操作记录可以告诉你哪个进程请求了命令,远程运行 ID 则告诉你连接变得不确定后发生了什么。

安全的重试决定有四种结果

连接断开后,先分类,再行动。这里有四种有用的结果,其中只有一种适合自动重试。

如果运行状态为 absent,说明远程包装器从未创建记录。如果你信任包装器的只创建一次行为,可以使用同一个运行 ID 提交相同操作。如果命令可能绕过包装器在其他地方执行过,仍应先检查目标,因为包装器无法证明绕过它的操作发生了什么。

如果运行状态为 running,应等待,或通过该操作专用的控制路径取消。不要启动另一个副本。超时策略应放在负载或任务管理器中,而不是放在会与第一次调用竞争的第二个 SSH 调用中。

如果运行状态为 finished,先评估退出状态;对于具有重要外部效果的操作,还要检查后置条件。退出状态为零但后置条件失败,仍然表示操作失败。应以后置条件检查为准。

如果运行状态为 damaged,就不要再把它称为重试问题。这是协调和修复工作。检查日志、日志条目、目标状态以及任何幂等记录,然后决定是修复部分效果、标记操作完成,还是提交一次明确处理当前状态的新运行。由于缺失记录移除了自动化依赖的证明,这个决定可能需要人工参与。

真正值得做的改变很小:任何重复执行可能造成损害的命令,都应拥有运行 ID、远程开始记录、远程最终记录,以及目标状态检查。SSH 仍然会断开,但你的自动化不必再假装自己知道发生了什么。

常见问题

SSH 断开后,远程状态未知是什么意思?

这表示 SSH 客户端可能已经发送请求,却在收到可信的完成结果之前失去了连接。远程主机可能从未启动命令,也可能仍在运行,或者已经完成。除非远程证据排除了这些可能,否则都应视为可能发生。

如果 SSH 断开,我的远程命令运行了吗?

不能确定。一次成功的本地写入,只能证明客户端把字节交给了本地网络栈。此后的超时或连接重置无法告诉你 sshd 是否接受了通道请求,也无法告诉你远程 shell 是否启动了命令。

SSH 断开后,如何检查命令是否仍在运行?

重新连接后,检查持久化的运行记录,不要只看进程表。查找运行目录、启动标记、结果文件、预期输出,以及与效果相关的检查,例如数据库记录、发布版本或对象校验和。

SSH 超时后,重新运行命令安全吗?

通常不安全。重试带有外部效果的命令,可能创建重复记录、覆盖更新的数据、发送重复请求,或启动第二次迁移。只有在操作经过幂等设计,或者已经确定第一次运行根本没有开始时,才应重试。

nohup 能解决 SSH 断开问题吗?

nohup 只会改变进程处理挂断信号的方式,并在常见用法中重定向输出。它不会创建持久化运行身份、记录可信的最终状态,也不会让重复运行变得安全。

长时间运行 SSH 命令时,应该使用 tmux 或 screen 吗?

tmuxscreen 可以在重新连接后保留交互式 shell,这对人工维护很有用。但它们不能证明外部效果是否已经发生,也不适合作为自动化工作的审计记录。

SSH 保活能避免远程状态未知吗?

可以使用客户端保活更快发现失效的网络路径,但不要把它当成交付或完成保证。OpenSSH 的 ServerAliveIntervalServerAliveCountMax 能帮助客户端停止等待失效连接,却无法解决命令请求已经处于未知状态的问题。

为什么 PID 不能证明远程任务已经完成?

PID 只表示某个时间点上的一个进程,进程退出后还可能被重新分配。应将 PID 作为诊断证据保存,并同时记录运行 ID、开始时间、命令版本、结果文件和与效果相关的完成记录。

什么时候应该构建远程命令包装器?

当命令会修改数据、部署软件、轮换凭据、控制基础设施或产生费用时,应使用远程包装器。无害的读取操作可以直接执行,但只要重试可能造成损害,包装器就值得增加这点复杂性。

AI 代理能安全运行可能断开的 SSH 命令吗?

Sallyport 可以在不向代理暴露 SSH 凭据的情况下执行 SSH 操作,并记录单独的操作,但远程程序仍需要完成协议。凭据控制和远程状态未知是两个不同的问题,把一个当成另一个的替代品会制造虚假的安全感。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov