第 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 列表会被推送过来。升级编排器并不立即执行升级——而是:
- 检查是否有升级探针配置——如果没有,走快速升级路径(直接停旧启新)。没有探针的模块通常是"无状态模块",不需要优雅停止。
- 有探针 → 创建升级任务 → 进入三阶段流程。
- 升级任务持久化到 SQLite。如果节点在 Preloaded 阶段断电,重启后任务从 SQLite 恢复,继续探测——不用重头拉镜像。
3.2 三阶段升级流程
typeUpgradeOrchestratorstruct{db*UpgradeTaskRepo// 升级任务持久化nodeCore*ModuleManagerProxy imageMgr*ImageManager runningTasksmap[string]*UpgradeTask mu sync.Mutex}typeUpgradeTaskstruct{TaskIDstringModuleIDstringOldConfig*ModuleConfig// 当前版本配置NewConfig*ModuleConfig// 目标版本配置Phase UpgradePhase// 当前阶段CreateTime time.Time UpdateTime time.Time}typeUpgradePhasestringconst(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()// 异步执行升级流程goo.executeTask(task)returnnil}func(o*UpgradeOrchestrator)executeTask(task*UpgradeTask){// === 阶段一:Preloading ===task.Phase=PhasePreloading o.db.UpdateTask(task)// 拉取新版本镜像(不影响旧模块运行)err:=o.imageMgr.PullImage(task.NewConfig.Image)iferr!=nil{o.failTask(task,"拉取镜像失败: "+err.Error())return}// === 阶段二:Preloaded ===task.Phase=PhasePreloaded o.db.UpdateTask(task)// 探测旧模块,直到它返回"空闲"iftask.NewConfig.HasUpgradeProbe(){maxWait:=30*time.Minute// 最多等 30 分钟deadline:=time.Now().Add(maxWait)fortime.Now().Before(deadline){ready,err:=o.probeModule(task.ModuleID,task.NewConfig.UpgradeProbeURL)iferr!=nil{// 探测失败(网络/超时),可能是模块出问题了// 不等了,直接进入升级阶段break}ifready{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 探针探测
func(o*UpgradeOrchestrator)probeModule(moduleID,probeURLstring)(bool,error){ctx,cancel:=context.WithTimeout(context.Background(),10*time.Second)defercancel()req,_:=http.NewRequestWithContext(ctx,"GET",probeURL,nil)resp,err:=http.DefaultClient.Do(req)iferr!=nil{returnfalse,err}deferresp.Body.Close()// 200 = 空闲,503 = 忙returnresp.StatusCode==200,nil}3.4 断电恢复
边缘节点可能在升级三个阶段的任意时刻断电。重启后,NodeCore 恢复 SQLite 中的模块数据(第 5 篇),同时升级编排器从 SQLite 恢复未完成的升级任务:
func(o*UpgradeOrchestrator)Recover(){// 从数据库加载所有"未完成"的升级任务tasks:=o.db.FindUncompletedTasks()for_,task:=rangetasks{// 恢复升级进度o.runningTasks[task.TaskID]=taskswitchtask.Phase{casePhasePreloading:// 还没拉取新镜像,从头开始goo.executeTask(task)casePhasePreloaded:// 镜像已拉取,直接进入探测阶段task.Phase=PhasePreloadedgoo.executeTask(task)casePhaseUpgrading:// 升级过程中断电——这是最危险的情况// 策略:回滚到旧版本(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 核心骨架:模块侧探针实现示例
作为模块开发者,你需要在你的模块里暴露一个升级探针端点。下面是一个示例:
// 业务模块侧——暴露升级探针typeUpgradeProbeHandlerstruct{collector*DataCollector}func(h*UpgradeProbeHandler)ServeHTTP(w http.ResponseWriter,r*http.Request){// 检查三个条件:// 1. 数据缓冲区为空(没有待处理的消息)// 2. 没有正在进行的事务// 3. 没有正在执行的长耗时操作bufferEmpty:=h.collector.BufferSize()==0noActiveTransaction:=!h.collector.HasActiveTransaction()noLongRunningOp:=!h.collector.HasLongRunningOp()ifbufferEmpty&&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"}`))}}// 在主程序中注册funcmain(){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 次)。如果每次调用都有副作用,会对业务系统造成不必要的干扰。
正确做法:探针必须是只读的、无状态的——它只是一个状态查询,不改变任何东西。
反模式三:没有升级锁
错误做法:同一个模块同时有多个升级任务在进行。
正确做法:升级编排器在创建升级任务前,检查该模块是否已有进行中的任务,有则拒绝。
六、小结
优雅升级探针的设计哲学:
- 让被升级者决定时机——模块最清楚自己什么时候安全
- 前两阶段不影响业务——Preloading 和 Preloaded 都在旧版本旁运行
- 持久化防断电——升级任务写入 SQLite,杀了进程也能恢复进度
- 超时兜底——不能无限等待,30 分钟上限后强制执行
这一篇结束了卷三控制面中关于"怎么安全改变模块状态"的全部内容。下一篇,我们讨论极端场景——节点断网 30 天了怎么办?主节点挂了备节点怎么顶上去?
本文是《边缘平台架构沉思录:Go 架构推演与工程决策》系列的第 11 篇。
