第 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:期望有 + 实际有 → 协调状态

这是最常见的场景------模块在运行,但需要确认它的版本和运行状态是否和期望一致。

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 篇。

相关推荐
奋飛2 年前
Fiber:React 的性能保障
react.js·fiber·diffing·reconciliation·virtual dom