让恢复动作敢按下去:dry-run、审批门、自动回滚与 mutable twin

SRE 最怕的往往不是发现故障,是把手悬在「重启 / 回滚 / 扩容」按钮上的那几秒 ------ 影响面说不清,按下去了回不来怎么办。这篇讲我的恢复动作引擎怎么把「敢按」这件事工程化。

设计目标

一个恢复动作引擎要回答四个问题:

  1. 按下去之前:会影响到谁?(预演)
  2. 谁有权按?(审批)
  3. 按下去之后:怎么知道它生效了?(验证)
  4. 搞砸了 :怎么反悔,而且不用人再审一遍?(回滚)

我把这四问做成一条管线,每个动作走完全程:

css 复制代码
pending → dry_run_ok → [awaiting_approval] → executing → succeeded
                                          ↘ verify → verify_failed?
                                                        ↓ 是 + 有回滚动作
                                                   自动反向回滚 → rolled_back

dry-run:把「爆炸半径」算成一张表

每个动作带一张传播规则表:沿哪几类边、什么方向、多深、命中什么类型的资源、影响多大。dry-run 就是拿这张表在当前拓扑上跑一遍 BFS:

yaml 复制代码
scale_deployment(target: Deployment replicas +1)
  ├→ Deployment 本体          impact: high
  ├→ ─CONTAINS→ Pod ×3        impact: medium   "滚动重启所有实例"
  └→ ─SCHEDULED_ON→ Node ×1   impact: low      "调度压力略增"
affected_count: 5 · estimated_sla_impact: medium

关键实现选择:dry-run 是纯函数 ,吃 &Topology 不碰任何 I/O。这意味着「预演」可以在任何时刻对任何拓扑快照跑 ------ 包括测试里的合成拓扑,也可以在审批门里再跑一遍确认这半小时拓扑没变。

8 个动作(scale / restart_pod / rollback_deployment / refresh_secret / drain_node / kill_query / restart_service / clear_cache)各自有规则表;drain_node 的深度最大(节点上所有 Pod + 它们的主人链),scale 最浅。

审批门:风险决定要不要人点头

规则简单到一张表:low 同步执行;medium / high 进 awaiting_approval,等人确认。

makefile 复制代码
low:     scale_deployment, kill_query, restart_service
medium:  restart_pod, refresh_secret, clear_cache
high:    rollback_deployment, drain_node

一个诚实的取舍:原设计里有审批人团队推导、24 小时 TTL 过期、多人 approve-reject 流。我把它砍成了桌面单机确认门 ------ 这是一个单机工具,没有多人协作场景,做一套多角色审批是给演示造复杂度。风险等级 → 状态的映射保留,因为那是真正影响交互安全的部分。

回滚跳过二次审批是刻意的:原始动作已经被人审过了,反向操作是「撤销已审的决定」,不是新的风险决定。再让人审一遍回滚,只会让紧急时刻多一道卡壳。

mutable twin:让「验证」和「正确地反悔」成为可能

这是整个引擎里我最喜欢的设计。问题这样开始:动作执行完,怎么验证它生效了?而且 ------ 如果要回滚,回滚必须基于「动作执行后的状态」做正确的反转,而不是拿动作前的参数瞎倒。

我的做法:执行时把目标拓扑 clone 一份孪生(mutated twin) ,handler 在孪生上写回动作生效的字段:

ini 复制代码
scale +1 在孪生上写:
  desired_replicas: 3 → 4
  available_replicas: 3 → 4

verifier 读孪生验谓词:
  new_replicas == 4 ?  ✓ passed

若 verify 失败 → 回滚读的是孪生的 post-action 状态:
  new_replicas=4 → 反向 delta −1 → 回到 3(而不是拿动作前的 3 再减一次)

verifier 每个动作一个,读孪生上 handler 写的字段验谓词。两个无可观测副作用的动作(kill_query / clear_cache------mock 世界里没有慢查询计数器)诚实地返回 not_supported(passed=true),不装模作样地「验证通过」。

自动回滚 串起来:verify_failed 且动作配了 rollback_action_id → 自动执行反向动作,带一个 marker 防递归(回滚的回滚不会无限套娃);没配 rollback 的动作只告警不自动反悔(比如 drain_node,反悔它的语义是重新调度,不是简单反向)。

孪生是 clone 出来的,不写回物化拓扑 ------ 真实世界的拓扑只由数据源同步更新,mock 的动作效果留在孪生里。这样「模拟演练」永远不会污染「真实状态」。

动作链:多步恢复只审一次

真实的恢复常常是多步:「换 Secret → 滚动重启 → 观察三分钟」。链模板把步骤声明出来,失败策略三选一:

  • Stop:停在人这边,等人看;
  • RollbackAll:反向回滚已完成的步骤(逆序);
  • Continue:记录失败,继续走完。

链级审批语义:任一步是 medium/high,整链审一次 ------ 不是每步卡一道门。这在「安全」和「紧急时刻可用」之间取了实用的一点。

一次完整的排查:real 模式第一天,verifier 全军覆没

这是整个引擎开发里最值得复盘的一战。接真实 K8s handler(WASM handler 经 http-write 真改集群)后的第一次端到端验证:scale +1 执行成功、集群副本数真的变了 ------ 然后 8 个 verifier 全部判 fail,自动回滚被误触发,把刚做的扩容又缩了回去

第一层:症状。verifier 读的是执行后节点的 attributes_json,翻执行记录发现:handler 返回的 result 里根本没有 verifier 期望的字段名(new_replicas)。那为什么 mock 模式从来没暴露过?------ mock handler 和 verifier 是我同期写的,字段名恰好互相咬合 ;而 WASM handler 是另一端独立实现的,它返回的是自己的字段名(desired_replicas)。两个独立实现要对齐到一份谁也没写下来的隐式契约上,不咬合是必然,咬合才是巧合。

**第二层:更深的雷。**修字段名时发现真正危险的问题:宿主若把 WASM handler 返回的 attrs 整体替换 到节点上,会把 connector 之前写入的字段(cluster / name / replicas_desired...)全部擦掉 ------ 图谱节点从此残缺,残缺还会被物化,下一轮 diff 把它放大成「全图变更」。修法是显式的 overlay 合并 ------ 逐字如下(engine-wasm/src/handler_executor.rs,动作字段覆盖同名键、connector 字段原样保留):

rust 复制代码
fn merge_values(base: Value, overlay: Value) -> Value {
    let mut m = match base {
        Value::Object(m) => m,
        _ => Map::new(),
    };
    if let Value::Object(o) = overlay {
        for (k, v) in o {
            m.insert(k, v);
        }
    }
    Value::Object(m)
}

**第三层:把隐式契约写显。**verifier 期望的字段名各不相同(scale 看 new_replicas,rollback 看 new_revision...),与其要求每个 handler 记住这张映射表,不如宿主按 action 统一合成 ------ 完整函数逐字如下(同文件):

rust 复制代码
fn synthesize_result_field(action_id: &str, merged_attrs: &Value) -> Option<Value> {
    let pick = |k: &str| merged_attrs.get(k).cloned();
    match action_id {
        "scale_deployment" => pick("desired_replicas").map(|v| json!({ "new_replicas": v })),
        "restart_pod" => pick("restart_count").map(|v| json!({ "new_restart_count": v })),
        "restart_service" => pick("endpoints_refresh_count").map(|v| json!({ "endpoints_refresh_count": v })),
        "refresh_secret" => pick("secret_version").map(|v| json!({ "new_version": v })),
        "rollback_deployment" => pick("current_revision").map(|v| json!({ "new_revision": v })),
        _ => None,
    }
}

修完给这两个纯函数补了单测 ------ 不启动任何 WASM 就能测,因为它们本来就与绑定层解耦。复盘三条:

  1. mock 的「全绿」不等于接通了 ------ mock 双方共享同一个心智模型,真实对接的两端不共享;
  2. 两层各自写同一个对象时,合并语义是必须显式声明的契约,不是实现细节;
  3. 排查最好的产物不是那处修复,是把隐式约定变成代码里的显式映射。

坦白说,这三个 bug 在此前的实现里一直是潜伏的 ------ real 模式那条路从未真正跑通过,直到这次带真集群的端到端验证才现形。这也是我一直坚持「验证要接真数据源」的原因:有些契约 bug,合成环境里永远绿。

小结

  • 恢复动作的信任是分层买来的:dry-run 买「预演」,审批门买「授权」,verifier 买「确认」,自动回滚买「反悔保险」------ 少一层,按钮就悬;
  • 预演和验证都做成纯函数(吃拓扑快照),才可能在测试里、在审批前反复跑;
  • mutable twin 同时解决了「验证什么」和「回滚基于什么状态反转」两个问题,且天然隔离了模拟与真实;
  • 回滚跳过二次审批、链只审一次 ------ 审批的目的是控风险,不是走流程;
  • 跨层写同一对象时,合并语义要写成显式契约,不然它会在最深的层咬你。

完整实现(engine/crates/engine-recovery/:action_defs / cascade / execution / verifiers / chains):github.com/Thneoly/sre...

系列上一篇:用 WebAssembly 给不可信插件上镣铐 | 下一篇:变更与告警的时间线


系列连载 (全栈纵切 / WASM 沙箱 / 数据契约 / 多源合并 / 变更追踪 / 巡检视图 / 观测配比),首发于本专栏,全部收录在仓库 doc/blog/。 完整代码、架构文档与 case study:github.com/Thneoly/sre... (MIT,文中代码与排查过程都可对照仓库验证)

相关推荐
程序员爱钓鱼4 小时前
Rust where详解:让泛型与Trait约束更加清晰
前端·后端·rust
ltl14 小时前
mTLS 工程实践:服务间双向认证怎么落地
kubernetes
Flynt17 小时前
就编译了一下代码,浏览器密码没了——Rust 生态遭遇史上最狠供应链攻击
安全·rust·开源
栩栩云生1 天前
别再硬记 AWS 和 k8s 命令了!一行命令把十几个云平台的 CLI 全接进 AI
kubernetes·agent·mcp
对象存储与RustFS1 天前
给 Kubernetes 找一个对象存储后端:RustFS Helm 部署 + 应用接入实录
后端·云原生·kubernetes
NutShell Wang1 天前
Rust 1.97 实战迁移:v0 符号重整、Cargo 警告治理与位运算新 API
人工智能·后端·性能优化·rust·vibe coding
进哥AI研习社1 天前
AtomCode 源码编译与二次开发入门:从 Clone 仓库到自定义 Agent 工具的完整指南
rust·二次开发·源码编译·atomcode·agent 工具·tool 扩展·ai 编码智能体
分布式存储与RustFS1 天前
给 Kubernetes 找一个对象存储后端:RustFS Helm 部署 + 应用接入实录
云原生·kubernetes·开源·对象存储·分布式存储·s3·rustfs