升级是分布式系统中最容易出事故的操作。本文不只是讲怎么升级,更是讲"升级失败了怎么办"------用 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 容器 → 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 负责。
六、小结
升级回滚的核心设计可以浓缩成三条原则:
- 永远不要删除上一个可以正常工作的版本------createBody 是你最后的退路
- 不要相信"启动成功"就是"运行正常"------健康检查窗口是必须的验证环节
- 断电后要能恢复升级进度------updateBody 是升级状态的持久化锚点
下一篇,我们进入 NodeCore 的最后一个话题------而且是整个系列篇幅最长、内容最硬核的一篇:边缘安全纵深防御体系。 从 HMAC-SHA256 令牌到 AES-GCM 加密,从三层认证中间件到 TPM 硬件安全模块,从 SQLCipher 数据库加密到 PKI 证书链------边缘节点暴露在物理可触碰的环境中,安全防护不能有任何短板。
本文是《边缘平台架构沉思录:Go 架构推演与工程决策》系列的第 6 篇。