部署清单赋予了模块一个"期望状态",状态机知道怎么把模块从"当前状态"迁移到"期望状态"。但谁来持续检查模块的实际状态是否发生了漂移?答案是 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:期望有 + 实际有 → 协调状态
这是最常见的场景------模块在运行,但需要确认它的版本和运行状态是否和期望一致。
go
func (m *ModuleMonitor) reconcileExisting(
actual *ModuleDetail, // 从 NodeCore 查到的实际情况
expected *ModuleConfig, // 从部署清单来的期望
) {
// 检查 1:版本是否一致
if actual.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")
if expectedRunning != 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 返回的列表里没有它------说明模块丢了(崩溃删除、被误删等)。
go
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:期望无 + 实际有 → 清理孤儿
系统里运行着部署清单上没有的模块------可能是之前删除没删干净,或者手动创建了不该存在的模块。
go
func (m *ModuleMonitor) cleanOrphaned(actual *ModuleDetail) {
// 不属于核心系统模块的,直接停掉并删除
if !isSystemModule(actual.ModuleID) {
m.nodeCore.StopModule(actual.ModuleID)
m.nodeCore.RemoveModule(actual.ModuleID)
}
}
四、Go 核心骨架
go
type ModuleMonitor struct {
nodeCore *ModuleManagerProxy
cache *ManifestCache // 内存中的部署清单缓存
runnerMgr *RunnerManager // 管理所有 Runner
imageMgr *ImageManager
healthMgr *HealthCheckManager
}
func (m *ModuleMonitor) loop() {
// 正常间隔 5 秒,异常时延长到 30 秒(降低系统负载)
interval := 5 * time.Second
for {
time.Sleep(interval)
err := m.checkOnce()
if err != nil {
interval = 30 * time.Second // 出错时延长间隔
} else {
interval = 5 * time.Second
}
}
}
func (m *ModuleMonitor) checkOnce() error {
// 第一步:获取实际运行的模块列表
actualList, err := m.nodeCore.ListAllModules()
if err != nil {
return fmt.Errorf("获取实际模块列表失败: %w", err)
}
// 转成 map:moduleID → ModuleDetail
actualMap := make(map[string]*ModuleDetail)
for _, mod := range actualList {
actualMap[mod.ModuleID] = mod
}
// 第二步:获取期望清单
manifest := m.cache.GetCurrentManifest()
expectedMap := make(map[string]*ModuleConfig)
for _, mod := range manifest.AllModules() {
expectedMap[mod.ModuleID] = &mod
}
// ===== 场景 A + B:遍历期望清单 =====
for moduleID, expected := range expectedMap {
actual, exists := actualMap[moduleID]
if !exists {
// 场景 B:期望有、实际无 → 推送丢失事件
m.pushRealityEvent(moduleID, StateIdle, expected)
continue
}
// 场景 A:期望有、实际有 → 协调状态
m.reconcileExisting(actual, expected)
}
// ===== 场景 C:遍历实际列表 =====
for moduleID, actual := range actualMap {
if _, inManifest := expectedMap[moduleID]; !inManifest {
// 场景 C:期望无、实际有 → 清理孤儿
m.cleanOrphaned(actual)
}
}
return nil
}
五、与状态机的协作关系
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 篇。