部署清单赋予了模块一个"期望状态",状态机知道怎么把模块从"当前状态"迁移到"期望状态"。但谁来持续检查模块的实际状态是否发生了漂移?答案是 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.Status==ModuleStatusRunning)actualRunning:=(actual.State=="running")ifexpectedRunning!=actualRunning{// 状态不一致 → 推送实际状态事件,驱动状态机纠正// 比如:期望 Running,实际 NotRunning → 推送 EventNotRunning// Runner 收到后会把 currentState 设为 NotRunning// 然后从 NotRunning 沿环向 Running 迁移(Remove→Create→Start)actualStatus:=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{interval=30*time.Second// 出错时延长间隔}else{interval=5*time.Second}}}func(m*ModuleMonitor)checkOnce()error{// 第一步:获取实际运行的模块列表actualList,err:=m.nodeCore.ListAllModules()iferr!=nil{returnfmt.Errorf("获取实际模块列表失败: %w",err)}// 转成 map:moduleID → 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 Loop(ModuleMonitor)和 FSM(Runner + StepSheetWorker)是如何分工的?
| ModuleMonitor | FSM(Runner) | |
|---|---|---|
| 职责 | 发现偏差——“期望 vs 实际不一致” | 执行纠正——“从当前状态迁移到目标状态” |
| 触发 | 定时 5s 循环 | 事件驱动(收到事件后迁移) |
| 输出 | 向 Runner 推送事件 | 执行 Step(Create/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 篇。