SSH 断了,那条命令到底跑没跑

ops-agent 建造笔记第 4 篇:远程执行遇到 exit code 255 / 连接断开时,未知执行结果为什么不能直接重试,如何设计幂等与审计。

SSH 断了,那条命令到底跑没跑

《ops-agent 建造笔记》第 4 篇 代码基线:v0.1.0-rc.1,提交 d897d2c95e70(2026-10-25 发布,候选版) 主要代码:execution.py、remote.py、core.py


0. exit code 回答不了的那个问题

我在设计远程执行时先推演这一幕:opsx exec --target web1 'systemctl restart nginx' 已获批准,SSH 连上去、命令发出去,然后本地连接断了,ssh 进程退出,返回码 255。这个例子用来分析协议边界,不是已经完成的真实主机断线验收。

这条命令执行了没有?

诚实的回答是:不知道。可能 ssh 在命令送达前就断了,没执行;可能命令送达了、nginx 已经重启完了;可能 restart 进行到一半。255 只说明"本地到远程的通道死了",对通道另一端发生的事一无所知。

如果把 255 直接当成“没有执行”,就会留下一个后遗症:人或模型看见“失败”,自然想重试。而对可能已经生效的变更,重试就是重复生效。即使是 systemctl restart,第二次执行也可能打断刚刚恢复的服务;其他变更更没有普遍的幂等保证。

W5 的整个设计就是从这里长出来的:变更不再只有"退出码"这一个结果。每次执行落一条记录,状态机是这样:

running ──exit=0──────────────────────▶ succeeded
   │
   ├──明确失败(非零退出 / 本机超时且已终止)──▶ failed
   ├──执行前中止(守门 / 快照 / 审批 / 锁冲突)──▶ aborted
   └──无法确认(SSH 断线 255 / 远程超时 / 进程被杀)──▶ unknown

1. unknown:唯一需要人介入的终态

四种终态里,succeeded/failed 描述执行通道确认到的结果,不能单凭它们断言业务健康或所有副作用都已撤销。aborted 则明确表示记录所在路径在下发命令前中止,例如锁冲突、快照失败或等待后审批失效。更早的参数/守门拒绝也可能直接返回,根本没有创建执行记录;我不能硬给它编一条“执行失败”。

unknown 是反的,它表示系统不知道答案,命令可能已经生效了一部分。所以它要承担两件事。

第一件是挡住自动重放。同一目标、同一命令的新执行会被拒绝(exit 120),直到有人核实。这条是硬闸门,模型也好、脚本也好、我自己手快也好,都不能在 unknown 悬着的时候把命令再发一遍。

这里的“同一目标”不能只看别名。pending_unknown() 同时比较实际 host/port 和独立命令哈希,换成指向同一端点的另一个 target 名称,也不能躲过未核实记录。同一目标上的其他命令仍可按正常审批走,门禁没有把整台机器永久冻结。

第二件是留下核实的入口。

execution_id='替换为实际 execution_id'
./opsx executions show "$execution_id"                     # 命令、目标、快照、错误现场
# 以下根据真实观察二选一,不是依次执行:
./opsx executions resolve "$execution_id" --observed-ok     # 我核实了:已生效
./opsx executions resolve "$execution_id" --observed-failed # 我核实了:没生效

核实只记录事实,不提供任何自动动作。--observed-ok 之后系统不会替我做后续操作;--observed-failed 之后可以重新批准再执行。这个不对称是故意的:核实是人做出的判断,系统只根据"已生效/未生效"更新记录,要不要重跑得我来定。

有三个细节我印象比较深。

断线判定的边界。SSH exit 255、客户端被信号终止和远程超时都算 unknown,远端可能仍在跑;本机受控超时会终止进程组,代码记为 failed。但“已终止等待中的本机进程组”不等于“之前的修改都没发生”,本机超时也不是自动重放或自动回滚的许可。远程超时更只保证本地 SSH 已停止,目标机上的进程是否退出要另行检查。

“进程活着但锁没了”也要重新看执行阶段。_execution_alive() 核对 pid 和目标锁持有者;已经进入下发阶段、却失去对应锁的 running 记录,视图和对账会按 unknown 处理,不能把“REPL 进程还活着”当成“那次变更还在执行”。仍处于 lock/snapshot/authorize/backup 这类下发前阶段的记录,则归 aborted。这里的变更记录没有 interrupted 状态;interrupted 属于调度运行记录,留到第 6 篇讲。

未核实的 unknown 永不自动清理。执行记录的常规保留数是 500,prune() 跳过 running 和未核实 unknown,因此实际条数可能超过 500。core.prune_snapshots() 还保护这些记录引用的快照,以及关联的回滚前副本。损坏记录查询时可跳过,但新变更的防重放门禁会拒绝执行,需要人工核实后修复,不能“定时删除后自动放行”。


2. 同一时刻,一个目标只能有一个变更

unknown 之外,W5 还补了另一个并发洞。场景是:我开了两个终端,两个 agent 会话,几乎同时对 web1 做变更。

没有锁的话,两个执行的快照与写入会交错:A 建了快照,B 的变更混进来,A 以为自己恢复的是单次变更前状态,实际却可能夹着另一次修改。core.target_lock() 的注释说明了为什么锁不能只围住真正执行的那一行。

锁的粒度是目标,本机也算一个目标,不同目标的变更互不阻塞。几个细节:

  • 按注册表归一。锁名来自规范化 host/port 的哈希(core._lock_name),所以 web1 和 web1-alias 配成同一端点时共用一把锁;本机锁名用 @local,避免与名为 local 的远程锁命名冲突。巡检清单另把 local 保留给本机,这是另一层约定。
  • 默认不等待。DEFAULT_LOCK_WAIT = 0.0,抢不到立刻失败(exit 121)并记一条 aborted。两个变更互相等待最后都超期,不如一个明确失败、另一个干净地跑完。
  • 覆盖快照、执行、收尾等相关临界区,不只是执行那一瞬间。场景复用这个执行锁,但预检和后验不在一把覆盖全场景的锁里;固定 SSH 端点也不等于把其他进程的变更隔离成事务。
  • 旧名称锁有桥接,但升级期间不应该同时调整目标别名和 SSH 配置(README 迁移提示)。

在门禁分支里,120 是“有未核实的 unknown 挡着”,121 是“目标正被另一次变更占用”。这两种拒绝都表示本次新命令没有下发,而前一次命令的结果可能仍未知。但业务命令自己也可能返回相同数字,场景层要结合本次 execution 记录的状态再分流,不能裸看 120/121 就猜“没执行”或“已执行”。


3. 远程回滚:断线也不能留下半份配置

变更的另一半是回滚。远程回滚最坏的剧本是:覆盖配置文件的过程中 SSH 断了,目标机上留下半个文件,比旧配置和新配置都糟,服务直接起不来。

所以远程回滚做成了原子写(remote.py 的 _RESTORE_TEMPLATE,一段渲染后发到目标机执行的 shell):

tmp=$(mktemp "$d/.opsx-restore.XXXXXX")
cat > "$tmp"
got=$(wc -c < "$tmp" | tr -d ' ')
if [ "$got" != "@@SIZE@@" ]; then
  printf 'opsx-restore-incomplete got=%s want=@@SIZE@@\n' "$got" >&2
  exit 3
fi
...
mv -f "$tmp" "$p"

同目录 mktemp → 写入 → 校验字节数 → chown/chmod → mv -f 替换。同目录是为了让替换使用同文件系统 rename;校验字节数挡“传了一半”。对单个文件、模板按预期完成或中断的路径,目标要么保留旧内容,要么变为完整新内容;多文件回滚没有因此变成全局事务。这也不是断电持久性的承诺。目标机不需要安装 ops-agent,但需要 SSH 可执行的 shell 以及 mktemp、stat、mv 等模板依赖。

权限的口径:快照记录了权限就按快照还原;没记的话,覆盖已有文件沿用目标当前权限,重建文件用保守的 0600(FALLBACK_MODE)。覆盖已有文件保留当前属主/属组,无法保留就中止;文件此前被删了要重建时,不自动按快照恢复属主/属组,报告里写明,需要时由具备权限的人处理。

还有一条我很坚持的校验:目标指纹。快照采集目标机的 hostname,能读到 machine-id 时再附前 12 位;没有 machine-id 时只能退回 hostname。回滚时如果 targets.json 被改指向,或者已有指纹对不上,拒绝执行。它用来发现误指目标,不是密码学身份认证,SSH 认证仍是另一层。新快照还拒绝文件或父目录软链接改向;回滚用的路径要和快照时解析出的真实路径一致,模板用 opsx-restore-path-changed 报告变化。旧快照缺少真实路径记录时会说明校验限制,不能声称所有年代的快照都有同样证据。

同一次受控变更还用 _fixed_target() 固定 SSH 配置,避免注册表在流程中途改动,把快照和执行发到不同端点。它和指纹解决的是两个时点:前者固定这一次通道,后者核对后来恢复时是否还是当初的目标。

候选版又补了本机的一处对称检查:快照原本记录解析后的绝对路径,父目录后来如果换成软链接,core._local_restore_path() 会让预览、回滚前副本和实际恢复都拒绝,不能顺着新链接覆盖另一目录的同名文件。末级软链接的旧语义保留——本机原子替换会替换链接本身;远程则解析真实文件并保留链接。这两个行为不能混写。

边界同样写明:回滚原子替换会改变 inode,软链会被解析成真实文件(保留链接本身);快照只能恢复文件,不能替代数据库备份、卷快照、部署回退。


4. 一次批准只换一次执行

和 unknown 配套的是审批语义的变化。W5 之前审批在 TTL 内可以重复用,这在 unknown 场景下等于"授权自动重放"。

现在审批戳默认 uses: 1(core.approve),一次批准只对应一次执行。关键在消耗时机:次数在"确定要执行"的那一刻才消耗(consume_stamp 由真正执行的路径调用,且在拿锁、建快照之后),预检、opsx check、hook 都不消耗。这个顺序不能反,放在预检消耗的话,用户什么都没改成,授权先没了。

uses 可以显式放宽(上限 MAX_APPROVAL_USES = 100),给明确允许多次执行的场景;但 R3 操作钉死只能是 1。戳还在有效期但次数用完时,错误提示会区分“次数用尽”“执行环境已变”和“没有有效审批”(stamp_exhausted()、stamp_env_mismatch())。允许多次不是系统替我判断幂等,只是把这个决定明确交给批准者。


5. 现状

W5 这套(执行记录 + 写锁 + one-shot 审批 + 原子回滚)交付后又经过复审,执行协议绕过、记录脱敏、别名互斥和路径改向都补了回归。当前固定基线是候选版 v0.1.0-rc.1,309 项自检、223 项独立回归,四组发布 CI 已通过。

我对它的信心是不均匀的。unknown 挡住重放这条链路有密集的离线回归,原子回滚模板也有模拟和本地验证;这些证据不能合并说成“真实 SSH 恢复已经通过”。本地演练 10/10,本版 W8 的真实 SSH/LLM/通知外部验收与修复后连续 7 天观察仍未完成,观察期不能从候选版发布日期自动起算。各种并发交错仍要在专用测试环境继续核对,flock 覆盖的是已建模的临界区,不是所有可能的时序。

下一篇讲 W6:变更场景。为什么 exit=0 回答不了"服务健康吗",以及"预检→快照→执行→后验→回滚建议"这条链是怎么把验证变成必经步骤的。

这篇对应的代码


系列总览 · 目录 · 上一篇:告警发多了,我就再也不会点开 · 下一篇:exit=0 之后,我为什么还要再验一遍