系统韧性的最高境界不是"永不死机",而是"挂了知道,并且能用最小的代价活下来"。本文设计两个极端场景的应对策略:节点长期断网时如何自动收缩、主节点宕机时备节点如何毫发无伤地接管。
一、开篇场景:断网 30 天与主节点宕机
场景一:长期离线。你的边缘网关部署在偏远山区的气象站。某天光纤被山体滑坡切断,维修队说至少 30 天才能修复。30 天里,网关上的 20 个业务模块还在正常运行——数据采集、本地分析、日志写入——但它连不上云端,所有数据堆在离线缓存里。30 天后,32GB 的存储空间满了,系统 OOM,连 DeployMaster 也挂了。等到光纤修复后,云端再也没收到这个节点的消息。
场景二:主节点宕机。工厂的核心产线用主备双节点保证高可用。主节点运行着 15 个控制模块,备节点热待机。某天主节点的电源模块烧了,keepalived 检测到 VRRP 心跳丢失,将 VIP 切到备节点。备节点上的 DeployMaster 需要立刻从"不启动模块"切换到"启动全部模块"——这个切换过程如果超过 30 秒,产线就会停机。
这两个场景的共同诉求:系统要在"非正常"状态下做出正确的最小化决策。
二、概念铺垫:退化 vs 切换
离线退化(Graceful Degradation):当系统环境恶化时,主动放弃非核心功能,保留核心能力。类比:手机低电量时自动关掉蓝牙和后台刷新,只保留通话和短信。
主备切换(Failover):当主节点不可用时,备节点接管其全部职责。核心指标是 RTO(Recovery Time Objective,恢复时间目标)——从主节点故障到备节点接管完成的时长。
三、方案设计:离线退化
3.1 离线退化的分级
不是所有"断网"都一样。断网 5 分钟和断网 30 天,系统的应对策略应该不同。
| 断网时长 | 系统行为 | 理由 |
|---|---|---|
| < 30 分钟 | 正常运作,离线缓存堆积 | 大概率是临时网络抖动,不值得收缩 |
| 30 分钟 ~ 30 天 | 正常运作,离线缓存接近上限时通知模块减速(第 17 篇) | 仍是正常范围,缓存能撑 |
| > 30 天 | 停掉所有业务模块,只保留 DeployMaster 和 MessageHub | 数据传不上去,跑了白跑;而且缓存可能已满,持续写入会导致 OOM |
| 云端返回"节点不存在" | 立即全停 | 节点已被注销,再跑毫无意义 |
3.2 CloudOfflineMonitor——离线追踪器
typeCloudOfflineMonitorstruct{consecutiveFailuresintmaxFailuresBeforeDegradeint// 默认值 = 30天对应的失败次数// 每次心跳间隔 5 分钟// 30天 = 8640 次连续失败cloudClient*CloudClient moduleMgr*ModuleMonitor isDegradedbool}// 每次和云端通信失败时,调用此方法func(m*CloudOfflineMonitor)RecordFailure(){m.consecutiveFailures++if!m.isDegraded&&m.consecutiveFailures>=m.maxFailuresBeforeDegrade{m.degrade()// 触发离线退化}}// 每一次成功通信,计数器归零func(m*CloudOfflineMonitor)RecordSuccess(){ifm.consecutiveFailures>0{m.consecutiveFailures=0}ifm.isDegraded{m.recover()// 网络恢复,重建模块}}func(m*CloudOfflineMonitor)degrade(){m.isDegraded=true// 停掉所有业务模块manifest:=m.moduleMgr.GetCurrentManifest()for_,module:=rangemanifest.Modules{// 只保留系统核心模块(DeployMaster、MessageHub)if!isCoreSystemModule(module.ModuleID){m.moduleMgr.StopAndRemoveModule(module.ModuleID)}}}func(m*CloudOfflineMonitor)recover(){m.isDegraded=false// 通知 DeployMaster 重新拉取部署清单// ManifestHandler 会按新版清单恢复所有模块m.moduleMgr.RefreshManifest()}3.3 “节点不存在”——云端主动告诉边缘"别跑了"
这是一种更极端的场景。云端管理员删除了这个节点(项目下线、设备报废)。DeployMaster 下一次心跳时,云端返回 HTTP 410 Gone 或特定错误码。这不是"网络不通",而是"你不应该存在了"。
func(m*CloudOfflineMonitor)handleCloudResponse(errerror){ifisNodeNotFoundError(err){// 云端说这个节点不存在了// 立即停掉所有模块,NodeCore 进入休眠模式m.moduleMgr.StopAllModules()// 保留 DeployMaster 进程,以防管理员反悔重新注册// 定期以低频(1 小时一次)尝试连接云端m.enterDormant()}}四、方案设计:主备切换
4.1 工作原理
┌──────────┐ ┌──────────┐ │ 主节点 │ VRRP │ 备节点 │ │ │◀══════▶ │ │ │ keepalived│ 心跳 │ keepalived│ │ VIP=持有 │ │ VIP=不持有│ │ │ │ │ │ 所有模块 │ │ 模块仅 │ │ 正常运行 │ │ Created │ │ │ │ 不启动 │ └──────────┘ └──────────┘VRRP(虚拟路由冗余协议):主备之间通过 keepalived 维持心跳。主节点持有 VIP(虚拟 IP),所有流量走 VIP。当主节点宕机,keepalived 检测到心跳丢失,备节点接管 VIP。
4.2 关键设计:备节点上的模块"只创建不启动"
这是避免"双主"问题的核心设计。如果备节点也启动了相同的模块(连接相同的设备和数据库),就会发生冲突。
// 备节点收到部署清单后的处理func(h*ManifestHandler)HandleForStandby(manifest*DeployManifest){// 备节点的策略:将所有模块的期望状态标记为 "Created"(不启动)// 这样 NodeCore 会拉镜像、创建容器,但不会 startfori:=rangemanifest.Modules{manifest.Modules[i].Status=ModuleStatusCreated}// 正常走清单处理流程// diff 后会生成 Create 事件,Runner 创建容器但不启动h.Handle(manifest)}4.3 切换时的行为
keepalived 通过执行 notify 脚本通知 DeployMaster 角色变更。DeployMaster 通过 Unix Domain Socket 监听这个通知:
// DeployMaster 监听 keepalived 的角色变更通知func(dm*DeployMaster)OnHARoleChange(newRole HARole){switchnewRole{caseRoleMaster:// 变为"主"——需要启动所有模块// 第一步:从云端拉取最新部署清单(可能已经变化了)dm.shadowPuller.ForcePull()// 第二步:将备节点模式的所有模块从 Created→Running// 清单处理器 diff 后会生成 Update 事件,状态机自动执行 Startmanifest:=dm.cache.GetCurrentManifest()fori:=rangemanifest.Modules{manifest.Modules[i].Status=ModuleStatusRunning}dm.manifestHandler.Handle(manifest)caseRoleBackup:// 变为"备"——停止所有模块manifest:=dm.cache.GetCurrentManifest()fori:=rangemanifest.Modules{manifest.Modules[i].Status=ModuleStatusCreated}dm.manifestHandler.Handle(manifest)}}4.4 切换时间的优化
RTO 目标 < 30 秒。主要耗时在哪些地方?
| 步骤 | 耗时 | 优化手段 |
|---|---|---|
| keepalived 检测到主节点宕机 | 3~5s(VRRP 心跳间隔) | 已是最小 |
| keepalived 通知 DeployMaster | < 1s(本地 UDS) | 无需优化 |
| DeployMaster 拉取最新部署清单 | 2~5s(HTTPS 请求) | 预先缓存,切换时优先使用缓存 |
| 模块启动 | 3~10s(Docker start) | 镜像已在备节点本地,容器已创建 |
关键优化:备节点上的容器是"已创建但已停止"的状态(Created),切换时只需docker start——耗时 < 1 秒,远低于"拉镜像 + 创建 + 启动"的数十秒。
五、Go 核心骨架:离线退化状态机
typeDegradeStateintconst(StateNormal DegradeState=iota// 正常运行StateDegraded// 已退化(停业务模块)StateDormant// 已休眠(节点被注销))typeResilienceManagerstruct{state DegradeState offlineMonitor*CloudOfflineMonitor haHandler*HAHandler}func(r*ResilienceManager)loop(){// 每 5 分钟检查一次和云端的连通性ticker:=time.NewTicker(5*time.Minute)forrangeticker.C{err:=r.cloudClient.Ping()iferr!=nil{// 检查是否是"节点不存在"错误ifisNodeNotFoundError(err){r.transitionTo(StateDormant)continue}r.offlineMonitor.RecordFailure()}else{r.offlineMonitor.RecordSuccess()}}}func(r*ResilienceManager)transitionTo(target DegradeState){switchtarget{caseStateDegraded:r.stopAllBusinessModules()caseStateDormant:r.stopAllBusinessModules()r.stopMessageHub()// 只保留 DeployMaster,每小时一次尝试连接r.startDormantPing()caseStateNormal:r.restoreAllModules()}r.state=target}六、边界与反模式
反模式一:离线退化阈值设得太小
错误做法:断网 1 小时就退化,停掉所有业务模块。
为什么错:1 小时的网络中断在边缘场景很常见(运营商基站维护、光纤临时故障)。频繁启停模块反而引入不必要的波动。
反模式二:备节点完全空闲不准备
错误做法:备节点上什么都不做,切换时才拉镜像、创建容器、启动。
为什么错:拉镜像可能需要数分钟(取决于镜像大小和网速)。切换时间远超业务可接受的 RTO。
正确做法:让备节点"静默准备"——拉取镜像、创建容器,但 Startup=Created 不启动。
反模式三:双主检测机制缺失
错误做法:keepalived 裂脑(脑裂)导致两个节点都认为自己是主节点。
正确做法:多个检测路径——keepalived VRRP 主检测 + DeployMaster 定期检查 VIP 归属 + 云端心跳确认。任何路径检测到异常,立即告警并强制降级其中一个节点。
七、小结
极端场景的韧性设计,核心思想是做减法:
- 离线退化:断网时间越长,跑的业务越少——从"全跑"降到"只保留核心"
- 主备切换:备节点提前准备但不启动——用最小的切换代价换取最快的接管速度
这一篇结束了卷三控制面的全部内容。下一篇开始数据面的核心——消息路由引擎:用 DSL 让每条数据流向正确的地方。
本文是《边缘平台架构沉思录:Go 架构推演与工程决策》系列的第 12 篇。