
升级是分布式系统中最容易出事故的操作。本文不只是讲怎么升级更是讲升级失败了怎么办——用 updateBody/createBody 双重定义 健康检查窗口 自动回滚实现即使断电也能安全完成升级。一、开篇场景一次升级引发的血案周五下午 5 点你发布了模块data-collector的新版本 v2.0。云端的部署清单自动更新边缘节点陆续收到开始拉镜像、停旧启新。5 分钟后告警电话响了3 个边缘节点上data-collector挂了。日志显示 v2.0 有一个隐藏的内存泄漏跑 2 分钟后 OOM。更要命的是这 3 个节点的 v1.0 已经被删了你的升级流程是先删旧、再建新——现在新版本起不来旧版本又没了。你呢开车去 3 个节点现场、手动拉 v1.0 镜像、手动启动。整个周末没了。这个升级流程至少犯了两个致命错误删旧和建新之间没有回头路新版本上线后没有健康检查等人工发现故障时已经晚了本文要设计的升级流程核心目标就一个任何时刻出事系统都有一条回到上一个正常工作版本的路径。二、概念铺垫updateBody 与 createBody在 SQLite 的模块表中有两个特殊的字段字段含义什么时候写入create_body模块初始创建的完整参数 JSON模块第一次被部署时写入之后不轻易修改update_body模块升级目标的完整参数 JSON升级开始前写入新版本配置升级成功后永远留在这里两者的配合逻辑——一段状态机初始状态: createBody v1.0 的配置参数 updateBody 空 模块运行 v1.0 云端下发升级命令: updateBody v2.0 的配置参数 createBody 不变仍然是 v1.0 NodeCore 执行升级停 v1.0 → 删 v1.0 容器 → 拉 v2.0 镜像 → 重建并启动 v2.0 升级成功: createBody v2.0 的配置参数覆盖为当前版本 updateBody 清空升级已完成 升级失败180s 健康检查不通过: 自动回滚停 v2.0 → 删 v2.0 容器 → createBodyv1.0 配置重建并启动 updateBody 清空放弃本次升级关键洞见createBody 永远是当前确定能工作的版本updateBody 是我们正在尝试的新版本。断电后恢复时——如果 updateBody 非空说明断电前正在升级优先用 updateBody新版本启动。这也是第 5 篇提到的恢复优先级。本文涉及的 Go 包timesyncnet/httpnetencoding/json三、方案设计升级的完整状态机3.1 升级五步流程┌─────────┐ │ 1. Stop │ 停 v1.0 容器/进程不删除 └────┬────┘ ▼ ┌──────────┐ │ 2. Remove│ 删除 v1.0 容器/进程此时已无回头路 └────┬─────┘ ▼ ┌───────────┐ │ 3. Pull │ 从镜像仓库/对象存储下载 v2.0 镜像或包 └────┬──────┘ ▼ ┌───────────┐ │ 4. Create │ 用 v2.0 的配置参数创建新容器/进程 └────┬──────┘ ▼ ┌───────────┐ │ 5. Start │ 启动 v2.0进入 180s 健康检查窗口 └───────────┘ │ ├── 检查通过 ──▶ createBody v2.0, updateBody │ └── 检查失败 ──▶ 回滚停删 v2.0 → 用 createBody(v1.0) 重建启动3.2 健康检查窗口的设计升级后的 180 秒是观察期。在这期间NodeCore 每隔 10 秒检查一次模块是否健康。检查方式适用场景实现HTTP 探针模块暴露了 HTTP 健康检查接口GET/health期待 200TCP 探针模块监听某个端口Dial TCP 端口能连通即健康Exec 探针任意自定义检查逻辑在容器/进程内执行检查脚本exit code 0 即健康连续 3 次检查成功 → 升级确认成功createBody 覆盖为新版本。连续 3 次检查失败 → 触发自动回滚。为什么是连续 3 次而不是1 次防止网络抖动误判。如果只检查 1 次HTTP 刚好超时可能是瞬时高负载就错误触发回滚导致不必要的版本切换。3.3 回滚流程回滚的本质是——回到 createBody 定义的那个版本。func(nc*NodeCore)Rollback(moduleIDstring)error{mod,err:nc.db.FindModule(moduleID)// 1. 停掉新版本nc.proxy.StopModule(moduleID,mod.Type)// 2. 删掉新版本nc.proxy.RemoveModule(moduleID,mod.Type)// 3. 用 createBody旧版本重建varreq ModuleDeployRequest json.Unmarshal([]byte(mod.CreateBody),req)nc.proxy.CreateModule(req,mod.Type)// 4. 启动旧版本nc.proxy.StartModule(moduleID,mod.Type)// 5. 清空 updateBody——放弃本次升级nc.db.ClearUpdateBody(moduleID)returnnil}四、Go 核心骨架升级与回滚的完整实现typeUpgradeHandlerstruct{proxy*ModuleManagerProxy db*ModuleRepo healthMgr*HealthCheckManager}// 升级主流程 func(h*UpgradeHandler)UpdateModule(moduleIDstring,newVersionConfig*ModuleDeployRequest)error{// 1. 从数据库读取当前模块信息mod,err:h.db.FindModule(moduleID)iferr!nil{returnerr}// 2. 保存 updateBody升级目标的完整配置updateBodyJSON,_:json.Marshal(newVersionConfig)h.db.SaveUpdateBody(moduleID,string(updateBodyJSON))// 3. 执行升级序列h.proxy.StopModule(moduleID,mod.Type)h.proxy.RemoveModule(moduleID,mod.Type)// 拉取新镜像/包h.packageMgr.PullPackage(newVersionConfig)// 创建并启动新版本h.proxy.CreateModule(newVersionConfig,mod.Type)h.proxy.StartModule(moduleID,mod.Type)// 4. 启动健康检查窗口传入回滚回调h.healthMgr.StartHealthCheck(moduleID,newVersionConfig,func(){// 连续 3 次成功 → 升级确认// 更新 createBody 为当前版本h.db.SaveCreateBody(moduleID,updateBodyJSON)// 清空 updateBodyh.db.ClearUpdateBody(moduleID)// 清理旧版本镜像释放磁盘空间h.packageMgr.CleanOldImage(mod.Image,mod.Version)},func(){// 连续 3 次失败 → 回滚h.Rollback(moduleID)})returnnil}// 健康检查循环 typeHealthCheckManagerstruct{checksmap[string]*HealthCheckTask mu sync.Mutex}typeHealthCheckTaskstruct{moduleIDstringprobe ProbeConfig// HTTP/TCP/Exec 配置successCountintfailCountintonSuccessfunc()// 升级成功回调onFailfunc()// 回滚回调maxFailBeforeRollbackint// 3maxSuccessBeforeConfirmint// 3}func(h*HealthCheckManager)loop(){ticker:time.NewTicker(10*time.Second)forrangeticker.C{h.mu.Lock()for_,task:rangeh.checks{h.checkOne(task)}h.mu.Unlock()}}func(h*HealthCheckManager)checkOne(task*HealthCheckTask){healthy:falseswitchtask.probe.Type{caseProbeHTTP:resp,err:http.Get(task.probe.HTTPURL)healthy(errnilresp.StatusCode200)caseProbeTCP:conn,err:net.DialTimeout(tcp,task.probe.TCPAddr,5*time.Second)healthy(errnil)ifconn!nil{conn.Close()}caseProbeExec:// 在容器/进程内执行脚本result,err:execInModule(task.moduleID,task.probe.ExecCmd)healthy(errnilresult.ExitCode0)}ifhealthy{task.successCounttask.failCount0iftask.successCounttask.maxSuccessBeforeConfirm{task.onSuccess()h.removeTask(task.moduleID)}}else{task.failCounttask.successCount0iftask.failCounttask.maxFailBeforeRollback{task.onFail()h.removeTask(task.moduleID)}}}// 回滚 func(h*UpgradeHandler)Rollback(moduleIDstring)error{mod,_:h.db.FindModule(moduleID)// 停删当前版本可能是失败的新版本h.proxy.StopModule(moduleID,mod.Type)h.proxy.RemoveModule(moduleID,mod.Type)// 用 createBody 重建旧版本varoldConfig ModuleDeployRequest json.Unmarshal([]byte(mod.CreateBody),oldConfig)h.proxy.CreateModule(oldConfig,mod.Type)h.proxy.StartModule(moduleID,mod.Type)// 清空 updateBody放弃升级h.db.ClearUpdateBody(moduleID)// 清理新版本镜像h.packageMgr.CleanImageByDigest(mod.UpdateDigest)returnnil}五、边界与反模式反模式一升级前不检查镜像是否存在错误做法先停旧模块再去拉新镜像。为什么错如果镜像仓库不可达网络故障旧模块已经停了新版本又拉不下来——系统就两头空。正确做法先拉新镜像不影响旧模块运行确认拉取成功后再停旧建新。反模式二回滚时用网络去拉旧镜像错误做法回滚时从云端重新下载旧版本镜像。为什么错如果升级失败的原因恰好是网络故障比如镜像仓库不可达回滚也必然会失败——系统完全不可用。正确做法回滚用的旧版本镜像必须已经在本地磁盘上。升级前不要急着删除旧镜像等到健康检查通过确认升级成功后再清理。反模式三升级锁缺失——并发升级错误做法同一模块没有升级锁两个升级请求同时到达。为什么错线程 1 正在停旧版本线程 2 也来停——两次停止冲突。线程 1 创建了新容器线程 2 又创建一个同名容器——创建失败。正确做法对每个模块加升级锁。升级开始时拿到锁升级结束成功或回滚释放锁。后续升级请求发现锁被占用直接返回模块正在升级中。反模式四健康检查用固定间隔 固定阈值错误做法健康检查每 10 秒一次连续 3 次失败触发回滚。为什么这是对的因为这里恰好不是反模式。10 秒检查 3 次失败 30 秒内完成回滚决策对于一个边缘模块来说足够快。但如果是核心数据库你可能需要更灵活的配置——允许健康检查间隔和失败阈值作为模块配置的一部分下发。一个极端场景的推演假设 v2.0 版本部署后健康检查通过了createBody 更新为 v2.0但随后因为某种原因比如新版本偶尔触发的内存泄漏模块又崩了。NodeCore 怎么处理答案是——这不是升级回滚机制的职责而是后面的文章会讲的模块状态协调器Reconciliation Loop。升级回滚只管升级操作本身的安全升级确认后的故障由 DeployMaster 的 ModuleMonitor 负责。六、小结升级回滚的核心设计可以浓缩成三条原则永远不要删除上一个可以正常工作的版本——createBody 是你最后的退路不要相信启动成功就是运行正常——健康检查窗口是必须的验证环节断电后要能恢复升级进度——updateBody 是升级状态的持久化锚点下一篇我们进入 NodeCore 的最后一个话题——而且是整个系列篇幅最长、内容最硬核的一篇边缘安全纵深防御体系。从 HMAC-SHA256 令牌到 AES-GCM 加密从三层认证中间件到 TPM 硬件安全模块从 SQLCipher 数据库加密到 PKI 证书链——边缘节点暴露在物理可触碰的环境中安全防护不能有任何短板。本文是《边缘平台架构沉思录Go 架构推演与工程决策》系列的第 6 篇。