exit=0 之后,我为什么还要再验一遍

ops-agent 建造笔记第 5 篇:命令退出码为 0 不代表业务真的健康,变更场景如何把命令执行、服务后验与文件回滚分开。

exit=0 之后,我为什么还要再验一遍

《ops-agent 建造笔记》第 5 篇 代码基线:v0.1.0-rc.1,提交 d897d2c95e70(2026-10-25 发布的候选版) 主要代码:scenario.py、scenarios/*.json、agent/tools.py


0. 本地演练里,命令成功了,服务却返回 503

我给自己准备的失败注入很简单:在临时目录放一份 service.conf,本地 HTTP 服务按文件内容回答健康检查。批准的命令把内容从 healthy 改成 unhealthy,命令本身退出 0,随后 /health 返回 503。scripts/w8_acceptance.py 里的 exit_zero_unhealthy_application 检查要求场景必须得到 verify_failed。

这是回环地址上的测试服务,不是真实 nginx 事故。nginx 配置语法正确、reload 正常返回,却没达到业务预期,是我想防的同一类问题;内置 nginx 场景目前也还没有完整的应用级验证。

这类事的共同点是:命令的执行结果和变更的业务结果是两回事。opsx exec 会给出执行状态、退出码与执行记录,但它不能凭这些证明变更之后服务真的健康。W5 把执行结果从"退出码"扩展成状态机之后,W6 要填的就是剩下这块:把执行后的确定性验证变成必经步骤。

定下来的链路是预检 → 快照 → 执行 → 后验 → 回滚建议。具体到行为上就一句:命令 exit=0 但后验不健康时,报告的不是"成功",是"验证失败",并给出回滚建议。


1. 三个内置场景

场景是 JSON 声明(scenarios/*.json,自定义放 ~/.ops-agent/scenarios/ 同名覆盖),内置三个:

场景 预检 执行 后验
systemd-restart 单元存在(LoadState=loaded) systemctl restart ActiveState=active 且 SubState≠failed
docker-restart 容器 running docker restart running 且健康(有 healthcheck 时)
nginx-reload nginx -t 语法 systemctl reload(含配置快照) active + 语法仍 ok

每个场景的细节都对应一个我想过的失败。

预检的作用是把失败提前。systemd-restart 先查单元存在不存在,单元名拼错的话,在 restart 之前就停下来,而不是 restart 报错了再排查。nginx-reload 的预检是 nginx -t,坏配置在 reload 前就被拦下,这是整个场景体系里收益最大的一条规则,因为 reload 一个语法错误的配置正是经典事故剧本。

后验的口径得抠细,也得承认它现在很浅。Docker 后验只采样一次,接受 running/healthy 或没有 healthcheck 时的 running/none;starting 会验证失败,代码不会自动等它变成 healthy。

systemd 的两条条件实际是 ActiveState=active、SubState!=failed,并没有要求 running,所以 active(exited) 也可能通过。我不能把这个场景写成已经排除了 crash loop,更不能把进程状态写成业务健康。nginx 的后验也只是 active 与语法检查,不会访问真实站点。检查证明到哪里,文章就写到哪里。

预检失败不会执行变更、不建快照、不消耗审批次数,也不产生变更执行记录(exit 3)。但它仍会写场景报告、scenario-precheck-failed 事件;探针经过守门时也会留下审计。我的区别是没有发生业务变更,而不是失败尝试毫无痕迹。

还有一个容易想当然的地方:nginx-reload 保存的是 reload 前、此刻磁盘上的 {config_file}。如果我已经把好配置改坏,再来运行场景,快照里保存的也会是改过的内容。它不会凭空找回编辑前的好版本。想保留那一版,就得在编辑前另建快照或依靠版本管理;systemd 的命令快照也只是状态证据,不会撤销已经发生的重启。docker-restart 没有声明快照,不会凭场景名称就多出一个可回滚的容器副本。


2. 场景不复制安全逻辑

实现上我给自己划了一条线:场景是编排层,不重新实现守门。

执行命令复用 core.exec_change 或 remote.exec_change 的审批、目标写锁、快照和执行记录,场景只是按 JSON 声明把这些步骤串起来。远程场景还用 _fixed_target 固定这一轮的端点,避免预检在 A 机通过、实际命令却因注册表变化发到 B 机。

固定端点和整套流程互斥还有一段距离。目标写锁由 exec_change 持有,预检与后验在这把锁之外;整个场景没有被包成隔离事务。预检通过到实际变更之间、执行结束到后验之间,仍可能发生其他状态变化,后验只能描述那次采样看到的事实。

探针的 JSON 形状在加载时检查,参数替换后的完整命令则在 _probe 执行前重新分级,必须是 R0,还要经过敏感读取守门。我把“定义能加载”和“命令能执行”分开:合法 JSON 不等于获准执行。

agent 侧用 scenario_run 工具:先把完整命令和全部探针展示给用户确认,批准后执行。无人值守形态(定时巡检、子代理)不可用,场景是变更,变更要有人在场,这条和第 1 篇的地基一致。

CLI 把命令、验证和门禁的结果分开:

退出码 含义
0 执行且后验通过
1 命令执行失败
2 需审批 / 守门拒绝
3 预检未过
4 快照未建成
7 后验不通过(或自定义场景未配置后验)
120 结果未知,或有未核实的 unknown 挡着
121 目标被另一次变更占用

全都能直接进脚本。7 这条要注意:自定义场景没配后验也按 7 处理。没有验证步骤的"场景"退化成裸 exec,我不允许它顶着场景的名字报成功。


3. 回滚之后的后验,只当证据

场景的回滚有个微妙的逻辑问题,我改了一版才想清楚。

场景回滚做的事:恢复快照,然后重跑后验。问题是,后验跑完报"失败",意味着什么?

设想一下:变更把 nginx 配置从好改成坏,变更后验失败(正确地报了 verify_failed)。现在回滚,恢复成旧配置,再跑后验——后验检查的是"服务健康吗",此时服务是健康的,后验应该过。但反过来,如果变更后验的内容是"新配置生效了吗"这类验证,回滚之后它失败才是对的:配置已经回成旧版,"新版生效"当然不成立,后验失败恰恰是回滚生效的表现。

所以回滚后的验证只作证据,不作判定:报告里给出后验输出,但不把它的通过/失败当成回滚成败的结论。回滚本身的成败看恢复执行记录。文件恢复也不会自动 reload 或 restart;如果服务要重新加载旧配置,还得单独批准相应命令,再验证业务响应。另外,恢复本身失败时不运行后验、也不写"已回滚"事件——没恢复成就是没恢复成,不能给一个"回滚了但恢复失败、后验未知"的含糊态。CLI 对失败/未知分别退出 1/120。

多文件回滚中途失败,错误里会写明"已恢复 N/M 个文件"并带上回滚前副本的 id,不会只说"失败"让人以为文件一个都没动(那是 W5 二次复审修的)。


4. 为什么不做通用流水线引擎

W6 设计的时候我认真考虑过:要不要做一个通用的阶段编排,任意多步、每步任意命令、条件分支、DAG。

否了,理由有三个。

第一,用户只有我一个。我需要的场景一只手数得过来,通用引擎的复杂度(依赖解析、条件语义、部分失败的恢复策略)全是为不存在的需求付的。路线图里写明了本期不做复杂 DAG。

第二,声明式 JSON 已经够用,而且能审。scenarios/systemd-restart.json 全文几十行,预检/执行/后验一目了然。表达能力弱在这里恰恰是安全特性,能做的坏事少。

第三,真需要复杂流程时,外面有 shell。场景解决不了的事,人可以分步执行,每步都过守门。把复杂度挡在场景层外面,比把它请进来再设法关住要容易。


5. 现状

W6 交付时的自检记录是 300/300。到本篇固定的候选版,整个项目是 309 项自检、223 项独立回归,发布流水线的 Ubuntu/macOS × Python 3.9/3.12 四组检查通过;这些是本地与 CI 的证据,不能写成 nginx 或真实 SSH 已验收。

现在回头看,我给场景体系定的价值是把验证从“记得做”变成“不做就不算完”。这是一条设计约束,不是我已经靠它挡住过多少次生产事故的统计。

还没做的:内置 HTTP/TLS 探针场景和完整的应用级后验。自定义 probe 也必须满足 R0 与敏感读取约束,不能因为想验证业务就绕过守门。本地演练的 HTTP 探针是在隔离策略里精确放行的测试命令,不是默认支持任意 HTTP 命令。本版 W8 的真实 SSH/LLM/通知验收及修复后的连续 7 天观察仍未完成。下一篇讲定时巡检的另一面:任务会失败、daemon 会被 kill、机器会关机,运行记录和心跳是怎么让"没跑"和"跑了但结果不好"分开的。


这一版的代码落点


系列总览 · 目录 · 上一篇:SSH 断了,那条命令到底跑没跑 · 下一篇:巡检没跑的那天,我怎么知道