第 11 篇:优雅升级探针——先问“能停吗“,再动手升级

升级一个正在处理业务数据的模块,相当于"给正在比赛的跑车换轮胎"。直接停掉升级会丢数据,但无限等待又永远升不了。本文设计一套三阶段升级探针机制------让模块在"忙完这一段"后安全交接,实现升级过程业务零中断。


一、开篇场景:数据采集模块正在收设备数据

data-collector 模块连接着 200 台 CNC 机床,每台每秒上报 10 条加工数据。这些数据经过模块的缓冲区,批量写入本地时序数据库。

此时云端下发了一个升级命令:data-collector 要从 v1.0 升级到 v2.0。

如果直接停掉 v1.0:缓冲区里还有 500 条未写入的数据,直接丢失。更严重的是------v1.0 正在处理一个三阶段的写事务,只完成了第一阶段。停掉后事务不完整,数据库进入不一致状态。

如果等它忙完:CNC 机床 24 小时不停,数据采集模块永远"忙"。等到什么时候才能升级?

需要一个聪明的方案:升级前,先问模块"你什么时候方便"。 模块自己最清楚自己什么时候处于安全状态(缓冲区为空、没有进行中的事务)。当它"不方便"时,升级流程就等着;当它说"方便了",立即升级。


二、概念铺垫:升级探针的本质

升级探针(Upgrade Probe)是一个 HTTP 回调接口。模块暴露一个探针端点,当被调用时返回自己当前是否处于"可以安全停止"的状态:

复制代码
GET /probe/upgrade-ready

响应:
  HTTP 200 → "可以停了,我现在空闲"
  HTTP 503 → "不行,我正在处理业务,过会儿再问"

但直接用这个探针就够了吗?不够。因为升级前还有一些准备工作要做(比如新版本镜像要先拉下来,但不能影响旧版本运行)。所以整个升级流程拆成三个阶段:

阶段 做什么 旧版本是否受影响
Preloading 拉取新版本镜像/包,放本地缓存
Preloaded 持续探测旧模块,直到它返回"空闲"
Upgrading 真正执行停旧→删旧→建新→启新 (这是唯一的业务中断窗口)

三个阶段中,前两个不影响旧模块运行。只有第三个阶段------Upgrading------才会中断业务,但这个阶段的耗时是可控的(停+删+建+启,通常 5~30 秒)。


三、方案设计:升级编排器 + 升级任务持久化

3.1 升级编排器(UpgradeOrchestrator)

当部署清单的 Diff 发现某个模块的版本变了,ModuleEvent 中的 UpdateModules 列表会被推送过来。升级编排器并不立即执行升级------而是:

  1. 检查是否有升级探针配置------如果没有,走快速升级路径(直接停旧启新)。没有探针的模块通常是"无状态模块",不需要优雅停止。
  2. 有探针 → 创建升级任务 → 进入三阶段流程
  3. 升级任务持久化到 SQLite。如果节点在 Preloaded 阶段断电,重启后任务从 SQLite 恢复,继续探测------不用重头拉镜像。

3.2 三阶段升级流程

go 复制代码
type UpgradeOrchestrator struct {
    db          *UpgradeTaskRepo    // 升级任务持久化
    nodeCore      *ModuleManagerProxy
    imageMgr    *ImageManager
    runningTasks map[string]*UpgradeTask
    mu          sync.Mutex
}

type UpgradeTask struct {
    TaskID      string
    ModuleID    string
    OldConfig   *ModuleConfig   // 当前版本配置
    NewConfig   *ModuleConfig   // 目标版本配置
    Phase       UpgradePhase    // 当前阶段
    CreateTime  time.Time
    UpdateTime  time.Time
}

type UpgradePhase string
const (
    PhasePreloading UpgradePhase = "preloading"
    PhasePreloaded  UpgradePhase = "preloaded"
    PhaseUpgrading  UpgradePhase = "upgrading"
    PhaseCompleted  UpgradePhase = "completed"
)

func (o *UpgradeOrchestrator) StartUpgrade(oldCfg, newCfg *ModuleConfig) error {
    // 创建升级任务
    task := &UpgradeTask{
        TaskID:    uuid.New().String(),
        ModuleID:  newCfg.ModuleID,
        OldConfig: oldCfg,
        NewConfig: newCfg,
        Phase:     PhasePreloading,
    }
    o.db.SaveTask(task)

    o.mu.Lock()
    o.runningTasks[task.TaskID] = task
    o.mu.Unlock()

    // 异步执行升级流程
    go o.executeTask(task)
    return nil
}

func (o *UpgradeOrchestrator) executeTask(task *UpgradeTask) {
    // === 阶段一:Preloading ===
    task.Phase = PhasePreloading
    o.db.UpdateTask(task)

    // 拉取新版本镜像(不影响旧模块运行)
    err := o.imageMgr.PullImage(task.NewConfig.Image)
    if err != nil {
        o.failTask(task, "拉取镜像失败: "+err.Error())
        return
    }

    // === 阶段二:Preloaded ===
    task.Phase = PhasePreloaded
    o.db.UpdateTask(task)

    // 探测旧模块,直到它返回"空闲"
    if task.NewConfig.HasUpgradeProbe() {
        maxWait := 30 * time.Minute // 最多等 30 分钟
        deadline := time.Now().Add(maxWait)

        for time.Now().Before(deadline) {
            ready, err := o.probeModule(task.ModuleID, task.NewConfig.UpgradeProbeURL)
            if err != nil {
                // 探测失败(网络/超时),可能是模块出问题了
                // 不等了,直接进入升级阶段
                break
            }
            if ready {
                break // 模块说空闲了,可以升级
            }
            time.Sleep(5 * time.Second) // 5 秒后再问
        }
        // 超过等待时间或探测失败 → 仍然进入升级
        // 不能无限等待,30 分钟已经足够模块完成任何正在进行的事务
    }

    // === 阶段三:Upgrading ===
    task.Phase = PhaseUpgrading
    o.db.UpdateTask(task)

    // 执行升级(短暂的业务中断窗口)
    o.nodeCore.StopModule(task.ModuleID)
    o.nodeCore.RemoveModule(task.ModuleID)
    o.nodeCore.CreateModule(task.NewConfig)
    o.nodeCore.StartModule(task.ModuleID)

    // 升级完成
    task.Phase = PhaseCompleted
    o.db.UpdateTask(task)

    // 清理旧版本镜像
    o.imageMgr.CleanImage(task.OldConfig.Image)
}

3.3 探针探测

go 复制代码
func (o *UpgradeOrchestrator) probeModule(moduleID, probeURL string) (bool, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", probeURL, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return false, err
    }
    defer resp.Body.Close()

    // 200 = 空闲,503 = 忙
    return resp.StatusCode == 200, nil
}

3.4 断电恢复

边缘节点可能在升级三个阶段的任意时刻断电。重启后,NodeCore 恢复 SQLite 中的模块数据(第 5 篇),同时升级编排器从 SQLite 恢复未完成的升级任务:

go 复制代码
func (o *UpgradeOrchestrator) Recover() {
    // 从数据库加载所有"未完成"的升级任务
    tasks := o.db.FindUncompletedTasks()
    for _, task := range tasks {
        // 恢复升级进度
        o.runningTasks[task.TaskID] = task

        switch task.Phase {
        case PhasePreloading:
            // 还没拉取新镜像,从头开始
            go o.executeTask(task)
        case PhasePreloaded:
            // 镜像已拉取,直接进入探测阶段
            task.Phase = PhasePreloaded
            go o.executeTask(task)
        case PhaseUpgrading:
            // 升级过程中断电------这是最危险的情况
            // 策略:回滚到旧版本(createBody),下次拉取清单时重新触发升级
            o.nodeCore.StopModule(task.ModuleID)
            o.nodeCore.RemoveModule(task.ModuleID)
            o.nodeCore.CreateModule(task.OldConfig)
            o.nodeCore.StartModule(task.ModuleID)
            task.Phase = PhaseCompleted
            o.db.UpdateTask(task)
        }
    }
}

四、Go 核心骨架:模块侧探针实现示例

作为模块开发者,你需要在你的模块里暴露一个升级探针端点。下面是一个示例:

go 复制代码
// 业务模块侧------暴露升级探针
type UpgradeProbeHandler struct {
    collector *DataCollector
}

func (h *UpgradeProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 检查三个条件:
    // 1. 数据缓冲区为空(没有待处理的消息)
    // 2. 没有正在进行的事务
    // 3. 没有正在执行的长耗时操作

    bufferEmpty := h.collector.BufferSize() == 0
    noActiveTransaction := !h.collector.HasActiveTransaction()
    noLongRunningOp := !h.collector.HasLongRunningOp()

    if bufferEmpty && noActiveTransaction && noLongRunningOp {
        w.WriteHeader(200)
        w.Write([]byte(`{"ready": true}`))
    } else {
        w.WriteHeader(503)
        w.Write([]byte(`{"ready": false, "reason": "buffer not empty or transaction in progress"}`))
    }
}

// 在主程序中注册
func main() {
    probe := &UpgradeProbeHandler{collector: myCollector}
    http.Handle("/probe/upgrade-ready", probe)
    http.ListenAndServe(":9090", nil)
}

五、边界与反模式

反模式一:探针返回 503 时无限等待

错误做法:模块永远返回 503(业务永不停),升级流程一直 Preloaded。

为什么错:这不是探针的问题------是业务逻辑的问题。如果模块真的 24/7 忙不停,那你需要设计热升级方案(双实例灰度切换),而不是依赖升级探针。

正确做法:设置最大等待时间(如 30 分钟)。超时后强制执行升级------最多丢这 30 分钟内的数据(但优雅停机会让模块尽量 flush 缓冲区)。

反模式二:升级探针里有副作用

错误做法:探针被调用时顺便更新了某个计数器、写了一条日志到业务数据库。

为什么错:探针可能在 Preloaded 阶段被调用几十次(每 5 秒一次,30 分钟就是 360 次)。如果每次调用都有副作用,会对业务系统造成不必要的干扰。

正确做法 :探针必须是只读的、无状态的------它只是一个状态查询,不改变任何东西。

反模式三:没有升级锁

错误做法:同一个模块同时有多个升级任务在进行。

正确做法:升级编排器在创建升级任务前,检查该模块是否已有进行中的任务,有则拒绝。


六、小结

优雅升级探针的设计哲学:

  1. 让被升级者决定时机------模块最清楚自己什么时候安全
  2. 前两阶段不影响业务------Preloading 和 Preloaded 都在旧版本旁运行
  3. 持久化防断电------升级任务写入 SQLite,杀了进程也能恢复进度
  4. 超时兜底------不能无限等待,30 分钟上限后强制执行

这一篇结束了卷三控制面中关于"怎么安全改变模块状态"的全部内容。下一篇,我们讨论极端场景------节点断网 30 天了怎么办?主节点挂了备节点怎么顶上去?


本文是《边缘平台架构沉思录:Go 架构推演与工程决策》系列的第 11 篇。

相关推荐
大侠锅锅1 天前
第 8 篇:部署清单驱动架构——用“期望状态“统一管理边缘节点上的一切
架构·边缘计算平台