第 5 篇:节点重启自愈——两阶段模块恢复机制 边缘节点的宿命就是随时断电。重启后如果 50 个模块要人工逐个启动那边缘计算就是伪命题。本文设计一套先打扫战场、再从权威数据源重建的两阶段恢复机制让节点重启后全自动回到正确状态。一、开篇场景凌晨三点的工厂凌晨 3 点工厂的电力维护人员拉错了电闸。整个车间断电 5 分钟包括你的边缘网关。5 分钟后电力恢复网关自动重启。你被手机告警吵醒——“所有设备离线”。你怎么做方案 A穿衣服开车去工厂SSH 进去查看哪些模块挂了逐个重启。到工厂已经 4 点了。方案 B你的 NodeCore 在启动时自动完成了模块恢复30 秒后所有模块正常运行。你翻个身继续睡。方案 B 就是本文要做的事。但设计一套可靠的启动恢复机制远没有重启时把上次跑的模块再启动一遍这么简单。真正的挑战上次断电时Docker 可能留下了状态异常的残畲容器直接启动会失败进程模块的 PID 已经无效了需要重建某些模块需要优先启动比如 DeployMaster 必须先于业务模块如果有模块正在升级过程中断电重启后是恢复旧版本还是新版本二、概念铺垫恢复的本质是声明式2.1 命令式 vs 声明式命令式systemctl restart moduleA——告诉系统把这个模块重启依赖的是操作序列。断电后操作序列丢了系统不知道上次执行到哪一步。声明式我希望模块 A 是 Running 状态——告诉系统最终状态是什么不关心中间怎么达到。系统自己去找从当前状态到目标状态的路径。2.2 恢复的数据源SQLiteNodeCore 在运行期间把所有模块的定义信息持久化在本地 SQLite 数据库里。这张表的每行记录描述了一个模块应该长什么样子列类型含义module_idTEXT PK模块唯一 IDmodule_nameTEXT模块名称容器名/进程标识typeTEXT“docker” 或 “process”imageTEXTDocker 镜像名或可执行包路径versionTEXT模块版本create_bodyTEXT(JSON)创建模块需要的完整参数update_bodyTEXT(JSON)如果正在升级保存升级目标的参数container_idTEXTDocker 容器 ID运行时变化last_start_timeINTEGER最后启动时间戳关键设计这张表不是运行时状态的快照而是模块的完整定义。即使节点坏了、换个新硬盘只要把这张表的数据恢复模块就能重建。本文涉及的 Go 包sortsyscalltimefmtencoding/jsongithub.com/docker/docker/clientgithub.com/docker/docker/api/types三、方案设计两阶段恢复3.1 阶段一Clean清理残留先别急着恢复——上次断电留下的状态大概率是脏的。Docker 可能残留容器、进程可能残留僵尸、网络配置可能不完整。Clean 阶段做什么 ├── 1. 通过 Docker API 列出所有容器 │ └── 逐个删除Forcetrue不优雅停止 ├── 2. 通过 /proc 遍历所有子进程 │ └── 杀掉属工作目录在 NodeCore 管辖范围内的进程 ├── 3. 清理 cgroup 残留配置 ├── 4. 检查 Docker bridge 网络是否存在 │ └── 网络被删就重建 └── 5. 清理临时文件、锁文件为什么用 Forcetrue 删除容器因为残留容器状态不确定可能卡在 restarting优雅停止要等它响应——它根本不会响应。Force 删除跳过优雅期直接拿掉。3.2 阶段二Recover重建清理干净后从 SQLite 读出模块定义按正确的顺序重建Recover 阶段做什么 ├── 1. 从 SQLite 读取所有模块定义 ├── 2. 按依赖排序——DeployMaster 排到最后 ├── 3. 逐个重建模块 │ ├── 优先使用 update_body如果有正在进行的升级 │ ├── 其次使用 create_body │ ├── Docker 模块先删除可能残留的同名容器再创建 启动 │ └── Process 模块直接创建 启动 └── 4. 最后一个启动 DeployMaster关键设计update_body 优先于 create_body。如果节点在升级过程中断电update_body 保存了新版本的定义。重启后应该用新版本恢复而不是倒退回旧版本。当然如果这个新版本启动后发现有问题DeployMaster 的健康检查机制会自动回滚——这个在第 6 篇讲。3.3 为什么 DeployMaster 要最后启动这是一个从血泪中总结的经验。DeployMaster 启动后会做三件事从云端拉取最新部署清单对比上次的清单生成需要创建/更新/删除的模块列表通过状态机驱动模块生命周期变更如果 DeployMaster 先启动它正在拉取清单→对比→决策而 NodeCore 还在恢复其他模块——两边同时在操作模块导致状态冲突。所以 NodeCore 先静默恢复所有模块不动用部署清单只用 SQLite最后再启动 DeployMaster 让它接管控制权。四、Go 核心骨架// NodeCore 启动入口 func(nc*NodeCore)Start()error{// 阶段一Clean iferr:nc.cleanupAll();err!nil{returnfmt.Errorf(清理阶段失败: %w,err)}// 阶段二Recover iferr:nc.recoverModules();err!nil{returnfmt.Errorf(恢复阶段失败: %w,err)}// 启动模块健康监控第 6 篇细讲gonc.monitorModules()returnnil}// 阶段一Clean func(nc*NodeCore)cleanupAll()error{// 1. 清理所有 Docker 容器containers,_:nc.dockerClient.ContainerList(ctx,types.ContainerListOptions{All:true})for_,c:rangecontainers{// Force 删除不容有丝毫犹豫nc.dockerClient.ContainerRemove(ctx,c.ID,types.ContainerRemoveOptions{Force:true})}// 2. 清理残留进程拿到 NodeCore 下所有子进程// 用 gopsutil 或直接读 /proc根据 CWD工作目录判断是否归 NodeCore 管辖childProcs:nc.findChildProcesses(nc.runtimeHome)for_,proc:rangechildProcs{// 杀整个进程组防止残留子进程syscall.Kill(-proc.PGID,syscall.SIGKILL)}// 3. 清理 cgroupnc.cgroupMgr.CleanupStaleCgroups()// 4. 确保 Docker bridge 网络存在if!nc.networkMgr.NetworkExists(iot-edge-bridge){nc.networkMgr.CreateNetwork(iot-edge-bridge,169.254.254.0/24)}returnnil}// 阶段二Recover func(nc*NodeCore)recoverModules()error{// 从加密 SQLite 读出所有模块定义modules,err:nc.db.FindAllModules()iferr!nil{returnerr}// 关键排序非 DeployMaster 的排前面DeployMaster 排最后sort.Slice(modules,func(i,jint)bool{iIsMaster:modules[i].ModuleIDdeploy-masterjIsMaster:modules[j].ModuleIDdeploy-master// 把 DeployMaster 排到最后ifiIsMaster!jIsMaster{returnjIsMaster// 当 j 是 master 时返回 true让它排后面}returnmodules[i].LastStartTimemodules[j].LastStartTime})for_,mod:rangemodules{// 选择用哪个 body有 updateBody 说明正在升级优先用它bodyJSON:mod.CreateBodyifmod.UpdateBody!{bodyJSONmod.UpdateBody}varreq ModuleDeployRequest json.Unmarshal([]byte(bodyJSON),req)// 先拉取镜像/包如果有新版本的话nc.packageMgr.PullPackage(req)ifmod.Typedocker{// Docker先删再建防止残留容器名冲突nc.proxy.RemoveModule(mod.ModuleID,docker)nc.proxy.CreateModule(req,docker)}else{// Process直接创建nc.proxy.CreateModule(req,process)}// 启动nc.proxy.StartModule(mod.ModuleID,mod.Type)// 更新启动时间戳nc.db.UpdateLastStartTime(mod.ModuleID,time.Now().UnixMilli())}returnnil}五、边界与反模式反模式一用 Clean 不干净、Recover 也不全错误做法只删除容器不清残留进程。为什么错进程模块的 PID 已经变了但残留进程还在占用端口。新进程启动时端口被占用启动失败。更隐蔽的是残留 cgroup 配置新的 cgroup 没法创建资源隔离失效。反模式二恢复时直接复用上次的容器错误做法Docker 重启后容器还在因为容器设置了--restartalways直接复用上次的容器改改状态就行。为什么错Docker 重启后容器的网络命名空间可能变了IP 地址变了挂载的卷可能状态异常。更致命的是——你怎么知道这个容器在断电瞬间是不是正在写入关键数据文件系统可能损坏。正确做法直接删掉重建。重建的成本拉镜像、创建容器跟排查残留容器为什么不工作的成本比不值一提。反模式三恢复时不检查 updateBody错误做法恢复时只看 createBody忽略 updateBody。为什么错如果节点在升级过程中断电下次启动应该用新版本。如果你用 createBody旧版本恢复这个节点就永远卡在了旧版本上。云端以为节点已经升级成功不再下发升级指令节点也就永远失去了升级机会。正确做法body updateBody ! ? updateBody : createBody确保升级不会因为断电而丢失。反模式四所有模块平等启动不排依赖错误做法从数据库读出来的模块顺序随机直接按数据库返回的顺序创建启动。为什么错DeployMaster 可能在 MessageHub 还没起来时就启动了。DeployMaster 启动后尝试 MQTT 连接云端但 MessageHub 还没起来——连接失败。DeployMaster 可能认为网络不通而进入离线模式但实际上只是 MessageHub 慢了 2 秒。正确做法明确依赖拓扑底层服务先启动控制面最后启动。补充恢复的性能考虑如果节点上有 50 个 Docker 模块每个镜像是 200MB全部重新拉取需要多长时间答案是——一般不需要重新拉取。Docker 的镜像分层存储在宿主机上断电不会丢失。ContainerCreate只是基于本地已有的镜像创建一个新容器实例耗时通常是毫秒级。只有 updateBody 引用了新版本镜像才需要重新拉取。六、小结节点恢复不是把模块启动一下——它是一套严谨的工程流程清理干净的起点→ 重建权威的数据源→ 排序正确的依赖→ 升级感知updateBody 优先两阶段恢复的设计思想可以提炼为一句话先确保世界上没有你认识的东西再从你唯一相信的地方开始重建。下一篇我们探讨一个更刺激的话题一个运行中的模块怎么安全地升级到新版本失败了怎么自动滚回去——“空中换引擎”。本文是《边缘平台架构沉思录Go 架构推演与工程决策》系列的第 5 篇。