第 6 篇:模块自升级与自动回滚——空中换引擎

升级是分布式系统中最容易出事故的操作。本文不只是讲怎么升级,更是讲"升级失败了怎么办"------用 updateBody/createBody 双重定义 + 健康检查窗口 + 自动回滚,实现即使断电也能安全完成升级。


一、开篇场景:一次升级引发的血案

周五下午 5 点,你发布了模块 data-collector 的新版本 v2.0。云端的部署清单自动更新,边缘节点陆续收到,开始拉镜像、停旧启新。

5 分钟后,告警电话响了:3 个边缘节点上 data-collector 挂了。日志显示 v2.0 有一个隐藏的内存泄漏,跑 2 分钟后 OOM。更要命的是,这 3 个节点的 v1.0 已经被删了,你的升级流程是"先删旧、再建新"------现在新版本起不来,旧版本又没了。

你呢?开车去 3 个节点现场、手动拉 v1.0 镜像、手动启动。整个周末没了。

这个升级流程至少犯了两个致命错误:

  1. 删旧和建新之间没有"回头路"
  2. 新版本上线后没有健康检查,等人工发现故障时已经晚了

本文要设计的升级流程,核心目标就一个:任何时刻出事,系统都有一条回到上一个正常工作版本的路径。


二、概念铺垫: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 容器 → createBody(v1.0 配置)重建并启动
  updateBody = ""(清空,放弃本次升级)

关键洞见:createBody 永远是"当前确定能工作的版本",updateBody 是"我们正在尝试的新版本"。断电后恢复时------如果 updateBody 非空,说明断电前正在升级,优先用 updateBody(新版本)启动。这也是第 5 篇提到的恢复优先级。


本文涉及的 Go 包"time" "sync" "net/http" "net" "encoding/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,期待 200
TCP 探针 模块监听某个端口 Dial TCP 端口,能连通即健康
Exec 探针 任意自定义检查逻辑 在容器/进程内执行检查脚本,exit code 0 即健康

连续 3 次检查成功 → 升级确认成功,createBody 覆盖为新版本。

连续 3 次检查失败 → 触发自动回滚。

为什么是"连续 3 次"而不是"1 次"? 防止网络抖动误判。如果只检查 1 次,HTTP 刚好超时(可能是瞬时高负载),就错误触发回滚,导致不必要的版本切换。

3.3 回滚流程

回滚的本质是------回到 createBody 定义的那个版本

go 复制代码
func (nc *NodeCore) Rollback(moduleID string) error {
    mod, err := nc.db.FindModule(moduleID)

    // 1. 停掉新版本
    nc.proxy.StopModule(moduleID, mod.Type)
    // 2. 删掉新版本
    nc.proxy.RemoveModule(moduleID, mod.Type)
    // 3. 用 createBody(旧版本)重建
    var req 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)

    return nil
}

四、Go 核心骨架:升级与回滚的完整实现

go 复制代码
type UpgradeHandler struct {
    proxy     *ModuleManagerProxy
    db        *ModuleRepo
    healthMgr *HealthCheckManager
}

// ========== 升级主流程 ==========

func (h *UpgradeHandler) UpdateModule(moduleID string, newVersionConfig *ModuleDeployRequest) error {
    // 1. 从数据库读取当前模块信息
    mod, err := h.db.FindModule(moduleID)
    if err != nil {
        return err
    }

    // 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)
        // 清空 updateBody
        h.db.ClearUpdateBody(moduleID)
        // 清理旧版本镜像,释放磁盘空间
        h.packageMgr.CleanOldImage(mod.Image, mod.Version)

    }, func() {
        // 连续 3 次失败 → 回滚
        h.Rollback(moduleID)
    })

    return nil
}


// ========== 健康检查循环 ==========

type HealthCheckManager struct {
    checks map[string]*HealthCheckTask
    mu     sync.Mutex
}

type HealthCheckTask struct {
    moduleID       string
    probe          ProbeConfig      // HTTP/TCP/Exec 配置
    successCount   int
    failCount      int
    onSuccess      func()   // 升级成功回调
    onFail         func()   // 回滚回调
    maxFailBeforeRollback int // = 3
    maxSuccessBeforeConfirm int // = 3
}

func (h *HealthCheckManager) loop() {
    ticker := time.NewTicker(10 * time.Second)
    for range ticker.C {
        h.mu.Lock()
        for _, task := range h.checks {
            h.checkOne(task)
        }
        h.mu.Unlock()
    }
}

func (h *HealthCheckManager) checkOne(task *HealthCheckTask) {
    healthy := false
    switch task.probe.Type {
    case ProbeHTTP:
        resp, err := http.Get(task.probe.HTTPURL)
        healthy = (err == nil && resp.StatusCode == 200)
    case ProbeTCP:
        conn, err := net.DialTimeout("tcp", task.probe.TCPAddr, 5*time.Second)
        healthy = (err == nil)
        if conn != nil { conn.Close() }
    case ProbeExec:
        // 在容器/进程内执行脚本
        result, err := execInModule(task.moduleID, task.probe.ExecCmd)
        healthy = (err == nil && result.ExitCode == 0)
    }

    if healthy {
        task.successCount++
        task.failCount = 0
        if task.successCount >= task.maxSuccessBeforeConfirm {
            task.onSuccess()
            h.removeTask(task.moduleID)
        }
    } else {
        task.failCount++
        task.successCount = 0
        if task.failCount >= task.maxFailBeforeRollback {
            task.onFail()
            h.removeTask(task.moduleID)
        }
    }
}


// ========== 回滚 ==========

func (h *UpgradeHandler) Rollback(moduleID string) error {
    mod, _ := h.db.FindModule(moduleID)

    // 停+删当前版本(可能是失败的新版本)
    h.proxy.StopModule(moduleID, mod.Type)
    h.proxy.RemoveModule(moduleID, mod.Type)

    // 用 createBody 重建旧版本
    var oldConfig 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)

    return nil
}

五、边界与反模式

反模式一:升级前不检查镜像是否存在

错误做法:先停旧模块,再去拉新镜像。

为什么错:如果镜像仓库不可达(网络故障),旧模块已经停了,新版本又拉不下来------系统就"两头空"。

正确做法:先拉新镜像(不影响旧模块运行),确认拉取成功后再停旧建新。

反模式二:回滚时用网络去拉旧镜像

错误做法:回滚时从云端重新下载旧版本镜像。

为什么错 :如果升级失败的原因恰好是网络故障(比如镜像仓库不可达),回滚也必然会失败------系统完全不可用。

正确做法:回滚用的旧版本镜像必须已经在本地磁盘上。升级前不要急着删除旧镜像,等到健康检查通过(确认升级成功)后再清理。

反模式三:升级锁缺失------并发升级

错误做法:同一模块没有升级锁,两个升级请求同时到达。

为什么错:线程 1 正在停旧版本,线程 2 也来停------两次停止冲突。线程 1 创建了新容器,线程 2 又创建一个同名容器------创建失败。

正确做法:对每个模块加升级锁。升级开始时拿到锁,升级结束(成功或回滚)释放锁。后续升级请求发现锁被占用,直接返回"模块正在升级中"。

反模式四:健康检查用固定间隔 + 固定阈值

错误做法:健康检查每 10 秒一次,连续 3 次失败触发回滚。

为什么这是对的? 因为这里恰好不是反模式。10 秒检查 + 3 次失败 = 30 秒内完成回滚决策,对于一个边缘模块来说足够快。但如果是核心数据库,你可能需要更灵活的配置------允许健康检查间隔和失败阈值作为模块配置的一部分下发。

一个极端场景的推演

假设 v2.0 版本部署后,健康检查通过了(createBody 更新为 v2.0),但随后因为某种原因(比如新版本偶尔触发的内存泄漏)模块又崩了。NodeCore 怎么处理?

答案是------这不是升级回滚机制的职责,而是后面的文章会讲的模块状态协调器(Reconciliation Loop)。 升级回滚只管"升级操作本身的安全",升级确认后的故障由 DeployMaster 的 ModuleMonitor 负责。


六、小结

升级回滚的核心设计可以浓缩成三条原则:

  1. 永远不要删除上一个可以正常工作的版本------createBody 是你最后的退路
  2. 不要相信"启动成功"就是"运行正常"------健康检查窗口是必须的验证环节
  3. 断电后要能恢复升级进度------updateBody 是升级状态的持久化锚点

下一篇,我们进入 NodeCore 的最后一个话题------而且是整个系列篇幅最长、内容最硬核的一篇:边缘安全纵深防御体系。 从 HMAC-SHA256 令牌到 AES-GCM 加密,从三层认证中间件到 TPM 硬件安全模块,从 SQLCipher 数据库加密到 PKI 证书链------边缘节点暴露在物理可触碰的环境中,安全防护不能有任何短板。


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

相关推荐
陶辉2 小时前
大模型时代的流量调度与架构演进:解构基于语义的路由策略
架构
梦帮科技4 小时前
GRAVIS v5.5:向硬核桌面端进化与云端多节点容灾部署实践
windows·架构·rust·自动化·区块链·智能合约·数字货币
●VON4 小时前
HarmonyKit | HarmonyOS Kit 模块化体系与零依赖架构实践
ui·华为·架构·harmonyos·鸿蒙·新特性
Geek-Chow4 小时前
微服务认证与授权:06 — Keycloak / IdP
微服务·信息可视化·架构
BreezeJiang4 小时前
为什么你调的 LLM 接口不值钱?——从零理解 Agent 开发的正确姿势
人工智能·架构
大侠锅锅5 小时前
第 5 篇:节点重启自愈——两阶段模块恢复机制
物联网·边缘计算·节点自愈
李燚5 小时前
五个适配器:DeepFlux 如何把 Eino 接进 DDD 架构
架构·ddd·eino·deepflux
我叫张小白。5 小时前
一个微服务电商+社区项目(瓷韵app)的技术深度复盘
docker·微服务·云原生·架构