第 10 篇:Reconciliation Loop——持续对比期望与实际,自动修复一切偏差 部署清单赋予了模块一个期望状态状态机知道怎么把模块从当前状态迁移到期望状态。但谁来持续检查模块的实际状态是否发生了漂移答案是 Reconciliation Loop——它是控制面的免疫系统5 秒巡检一次发现异常立即修复。一、开篇场景模块为什么会漂移部署清单上写得明明白白——module-data-collector期望状态是 Running。DeployMaster 按照状态机流程把它创建、启动一切正常。但一小时后场景 1模块里的代码有 bug进程 OOM 崩溃了。现在是 NotRunning但部署清单还是 Running。场景 2运维人员 SSH 进来排查问题时手滑docker rm -f module-data-collector。现在是 Idle不存在了但部署清单还是 Running。场景 3某次升级中镜像损坏容器创建成功但一直无法启动。现在是 Created部署清单期望 Running。这三个场景的共同点是没有人在期望层面改了任何东西但实际层面已经面目全非。如果不持续检查这些偏差会一直存在——直到下次有人修改了部署清单。Kubernetes 用 Controller 的 Reconciliation Loop 解决了这个问题。我们的 ModuleMonitor 就是同一个思想在边缘场景的实现。通用原理Reconciliation Loop 来自两个基础理论。一是分布式系统里的最终一致性Eventual Consistency——CAP 定理告诉你分区容错 一致性不可兼得所以分布式系统不强求即时一致而是通过持续对账Reconciliation达成最终一致。二是控制论里的反馈循环——恒温器不断测量实际温度 vs 设定温度偏离就调节。软件架构里的期望状态 vs 实际状态就是温度的翻版。二、概念铺垫Reconciliation 的本质Reconciliation 可以翻译为一致性协调或对账。它的工作原理极其简单每隔 N 秒 1. 获取期望状态部署清单 我应该是什么样 2. 获取实际状态从 NodeCore 查询 我实际是什么样 3. 如果一致 → 什么都不做 4. 如果不一致 → 执行操作使其一致这个循环永远运行。不依赖事件触发、不依赖消息推送——它就是靠不断对账来保证最终一致性。它为什么在边缘场景下特别有效不依赖消息不需要云端推送模块 A 崩了这个事件。即使网络断了本地的 Reconciliation Loop 照样跑。自愈不管什么原因导致的状态偏离bug、人为操作、系统故障Loop 都会自动发现并修复。天然幂等修复操作 “让状态从 A 变成 B”。如果状态已经是 B操作自动跳过。三、方案设计ModuleMonitor 的三种场景3.1 整体流程ModuleMonitor.loop()—— 每 5 秒执行一次 │ ├─ 1. 从 NodeCore 获取主机上实际运行的模块列表 │ ├── Docker: docker ps -a │ └── Process: /proc 遍历 processIndex │ ├─ 2. 从内存缓存获取部署清单期望状态 │ └── 缓存由 ManifestHandler 在收到新清单时更新 │ ├─ 3. 三场景对比 │ ├── 场景A: 期望有 实际有 → 协调状态 │ ├── 场景B: 期望有 实际无 → 补充缺失 │ └── 场景C: 期望无 实际有 → 清理孤儿 │ └─ 4. 根据对比结果向对应 Runner 推送事件3.2 场景 A期望有 实际有 → 协调状态这是最常见的场景——模块在运行但需要确认它的版本和运行状态是否和期望一致。func(m*ModuleMonitor)reconcileExisting(actual*ModuleDetail,// 从 NodeCore 查到的实际情况expected*ModuleConfig,// 从部署清单来的期望){// 检查 1版本是否一致ifactual.Version!expected.Version{// 版本不一致 → 需要升级或降级// 先检查新版本镜像是否已拉取if!m.imageMgr.Exists(expected.Image){m.imageMgr.PullImage(expected)return// 镜像还在拉下次巡检再处理}// 镜像就绪触发升级流程m.pushUpgradeEvent(expected)return}// 检查 2运行状态是否与期望一致expectedRunning:(expected.StatusModuleStatusRunning)actualRunning:(actual.Staterunning)ifexpectedRunning!actualRunning{// 状态不一致 → 推送实际状态事件驱动状态机纠正// 比如期望 Running实际 NotRunning → 推送 EventNotRunning// Runner 收到后会把 currentState 设为 NotRunning// 然后从 NotRunning 沿环向 Running 迁移Remove→Create→StartactualStatus:mapActualStateToFSM(actual.State)m.pushRealityEvent(expected.ModuleID,actualStatus,expected)return}// 版本一致 状态一致 → 完美什么都不做}3.3 场景 B期望有 实际无 → 补充缺失模块在部署清单里但 NodeCore 返回的列表里没有它——说明模块丢了崩溃删除、被误删等。func(m*ModuleMonitor)recoverMissing(expected*ModuleConfig){// 向 Runner 推送 EventModuleNotFound// Runner 收到后currentState Idle, desireState expected.Status// 状态机计算路径Idle → Created → Running// 自动把模块重建并启动m.pushRealityEvent(expected.ModuleID,StateIdle,expected)}3.4 场景 C期望无 实际有 → 清理孤儿系统里运行着部署清单上没有的模块——可能是之前删除没删干净或者手动创建了不该存在的模块。func(m*ModuleMonitor)cleanOrphaned(actual*ModuleDetail){// 不属于核心系统模块的直接停掉并删除if!isSystemModule(actual.ModuleID){m.nodeCore.StopModule(actual.ModuleID)m.nodeCore.RemoveModule(actual.ModuleID)}}四、Go 核心骨架typeModuleMonitorstruct{nodeCore*ModuleManagerProxy cache*ManifestCache// 内存中的部署清单缓存runnerMgr*RunnerManager// 管理所有 RunnerimageMgr*ImageManager healthMgr*HealthCheckManager}func(m*ModuleMonitor)loop(){// 正常间隔 5 秒异常时延长到 30 秒降低系统负载interval:5*time.Secondfor{time.Sleep(interval)err:m.checkOnce()iferr!nil{interval30*time.Second// 出错时延长间隔}else{interval5*time.Second}}}func(m*ModuleMonitor)checkOnce()error{// 第一步获取实际运行的模块列表actualList,err:m.nodeCore.ListAllModules()iferr!nil{returnfmt.Errorf(获取实际模块列表失败: %w,err)}// 转成 mapmoduleID → ModuleDetailactualMap:make(map[string]*ModuleDetail)for_,mod:rangeactualList{actualMap[mod.ModuleID]mod}// 第二步获取期望清单manifest:m.cache.GetCurrentManifest()expectedMap:make(map[string]*ModuleConfig)for_,mod:rangemanifest.AllModules(){expectedMap[mod.ModuleID]mod}// 场景 A B遍历期望清单 formoduleID,expected:rangeexpectedMap{actual,exists:actualMap[moduleID]if!exists{// 场景 B期望有、实际无 → 推送丢失事件m.pushRealityEvent(moduleID,StateIdle,expected)continue}// 场景 A期望有、实际有 → 协调状态m.reconcileExisting(actual,expected)}// 场景 C遍历实际列表 formoduleID,actual:rangeactualMap{if_,inManifest:expectedMap[moduleID];!inManifest{// 场景 C期望无、实际有 → 清理孤儿m.cleanOrphaned(actual)}}returnnil}五、与状态机的协作关系Reconciliation LoopModuleMonitor和 FSMRunner StepSheetWorker是如何分工的ModuleMonitorFSMRunner职责发现偏差——“期望 vs 实际不一致”执行纠正——“从当前状态迁移到目标状态”触发定时 5s 循环事件驱动收到事件后迁移输出向 Runner 推送事件执行 StepCreate/Start/Stop/Remove类比医院里的查房护士——发现病人状态不对“主治医生”——开药并治疗一次完整的自愈流程1. ModuleMonitor 巡检 → 发现模块 A 不在运行列表里 2. ModuleMonitor 向 Runner A 推送 EventModuleNotFound 3. Runner A 收到事件 → currentState Idle 4. Runner A 的 desireState Running来自部署清单 5. getSteps(Idle, Running) → [CreateStep, StartStep] 6. 执行 CreateStep → 调 NodeCore 创建容器 7. 执行 StartStep → 调 NodeCore 启动容器 8. 模块 A 恢复运行 9. 下次 ModuleMonitor 巡检 → 版本一致、状态一致 → 无事六、边界与反模式反模式一巡检间隔太短错误做法每 100ms 巡检一次“实时性拉满”。为什么错每次巡检都要调 NodeCore 的 Docker API 和 /proc 遍历——这些都是系统调用耗时且占用资源。100ms 一次 每秒 10 次巡检在几十个模块的节点上CPU 会被巡检吃光。正确做法5s 足够及时——5s 是模块从崩溃到被检测到恢复的最长延迟。对边缘场景来说5s 的故障发现时间完全可接受。反模式二巡检时更新数据库错误做法巡检发现不一致时先更新数据库中的状态标记再推送事件。为什么错这个状态标记没人读——Runner 不读它ManifestHandler 不读它。唯一可能读它的是下次巡检——但下次巡检会重新从 NodeCore 拉最新实际状态不会看这个标记。数据库写入只是无意义的磁盘 IO。正确做法巡检只负责发现 推送事件不负责记录。反模式三把巡检当成业务恢复的唯一途径错误做法模块崩溃后只能等 ModuleMonitor 巡检最多 5 秒才能恢复。为什么 5 秒其实很快因为在边缘场景下模块崩溃后的恢复不需要毫秒级——设备数据在离线缓存里第 14-17 篇讲不会因为模块崩溃 5 秒而丢失。如果你真的需要毫秒级 HA那应该用主备切换第 12 篇讲而不是靠巡检速度。七、小结Reconciliation Loop 是控制面的持续发动机——它不依赖任何外部事件靠自己的 5 秒心跳保证系统始终在正确状态。与状态机的关系可以这样记期望清单 → 发现偏差(ModuleMonitor) → 推送事件 → 状态迁移(FSM) → 真实状态 ▲ │ └──────────────── 下次巡检再次对比 ───────────────────────────┘下一篇控制面的最后一个技术动作——优雅升级探针正式升级前先问模块你现在方便吗不方便我可以等。本文是《边缘平台架构沉思录Go 架构推演与工程决策》系列的第 10 篇。