模型服务自愈:连续失败 N 次后自动触发重启和通知,凌晨 3 点不用被叫起来
模型服务自愈连续失败 N 次后自动触发重启和通知凌晨 3 点不用被叫起来一、GPU 显存泄漏导致服务降级了 4 小时直到上班才有人发现某个推理服务因为 PyTorch 的显存碎片累积在凌晨 1:30 开始间歇返回 500 错误。失败率从 0.01% 逐渐攀升到 15%但因为是周六凌晨没有人看监控。等到周一上午运维打开 Grafana发现错误率曲线已经拉出了一条 4 小时的山峰。重启一下服务就恢复了——但故障敞口长达 4 小时。基础设施不需要漂亮话这种场景的核心矛盾不是监控没发现而是发现了但没人处理。Prometheus 的告警规则可以检测到错误率升高但如果没人响应告警规则配得再多也是摆设。解决方案是把人肉响应升级为自动自愈——当模型服务的健康指标连续 N 次不达标时系统自动执行预设的恢复动作同时发出通知告知发生了什么。自愈不是银弹如果检测条件设得太敏感会误重启设得太迟钝就没意义。关键在三个参数的精确平衡失败阈值 N多少次失败触发自愈、评估窗口 T多长的时间窗口计算失败率、恢复动作梯度先 restart → 不行再 cordon → 还不行就回滚。二、自愈系统的决策流水线一次完整的自愈循环包含四个阶段检测 → 判定 → 执行 → 通知flowchart TD A[Prometheus 指标采集br/错误率 / 延迟 / GPU 状态] -- B{健康检查br/连续 N 次不达标?} B --|通过| C[状态: HEALTHYbr/重置失败计数器] B --|不通过| D[failCountbr/记录事件] D -- E{failCount N?} E --|否| F[状态: DEGRADEDbr/等待下次检查] E --|是| G[状态: UNHEALTHYbr/触发自愈] G -- H[选择自愈动作] H -- H1[L1: 重启 Podbr/kubectl rollout restart] H1 -- I1{恢复成功?} I1 --|是| J[通知: 已自愈br/记录恢复耗时] I1 --|否| H2[L2: Cordon GPU 节点br/kubectl cordon] H2 -- I2{恢复成功?} I2 --|是| J I2 --|否| H3[L3: 回滚模型版本br/kubectl set image] H3 -- I3{恢复成功?} I3 --|是| J I3 --|否| K[通知: 自愈失败br/需要人工介入] F -- L{等待 T 时间} L -- A检测层Prometheus 持续采集推理服务的错误率、延迟 P99、GPU 显存利用率等指标。自愈控制器通过 Prometheus API 做即时查询判断当前值是否超过阈值。判定层不基于单次超标触发自愈。使用滑动窗口 连续失败计数——只有当最近 M 次检查中有 N 次超标时才认为服务确实异常。这个设计避免了瞬时抖动如 GC pause 导致的单次延迟飙升触发误自愈。典型参数M5N3检查间隔 30s。意味着连续 1.5 分钟内至少 3 次超标触发自愈。执行层自愈动作按梯度执行。L1 最轻量——只重启出问题的 Pod。L2 升级——将 Pod 所在 GPU 节点 cordon驱逐所有 Pod 到其他节点。L3 最重——回滚模型版本到上一个已知正常版本。每级动作执行后等待 2 分钟观察恢复效果未恢复则自动升级到下一级。这个梯度设计的目的是用最小代价解决问题大多数 GPU 显存碎片问题重启就能恢复不需要回滚。三、生产级自愈控制器实现以下 Go 代码实现了一个自愈控制器核心流程是 Prometheus 指标查询 滑动窗口判定 分级自愈动作package selfhealing import ( context fmt os/exec sync time v1 github.com/prometheus/client_golang/api/prometheus/v1 github.com/prometheus/common/model ) // HealerConfig 自愈控制器配置 type HealerConfig struct { // 检查间隔 CheckInterval time.Duration // 滑动窗口大小检查次数 WindowSize int // 触发自愈的失败次数阈值 FailThreshold int // 每级自愈动作后等待观察的时间 CooldownPeriod time.Duration // 通知回调发送钉钉 / Slack / PagerDuty 消息 Notify func(ctx context.Context, level string, msg string) } // Healer 模型服务自愈控制器 type Healer struct { config HealerConfig promAPI v1.API mu sync.Mutex // 滑动窗口记录最近 WindowSize 次检查的结果 window []bool // true healthy, false unhealthy cursor int // 窗口写入位置 } // NewHealer 创建自愈控制器 func NewHealer(promAPI v1.API, config HealerConfig) *Healer { if config.WindowSize 0 { config.WindowSize 5 } if config.FailThreshold 0 { config.FailThreshold 3 } if config.CheckInterval 0 { config.CheckInterval 30 * time.Second } if config.CooldownPeriod 0 { config.CooldownPeriod 2 * time.Minute } return Healer{ config: config, promAPI: promAPI, window: make([]bool, config.WindowSize), } } // Run 启动自愈主循环 // 由调用方管理 goroutine 生命周期 func (h *Healer) Run(ctx context.Context) error { ticker : time.NewTicker(h.config.CheckInterval) defer ticker.Stop() for { select { case -ctx.Done(): return ctx.Err() case -ticker.C: if err : h.healthCheck(ctx); err ! nil { // 检查失败不退出循环防止瞬时 API 错误中断自愈逻辑 h.config.Notify(ctx, WARN, fmt.Sprintf(健康检查执行失败: %v, err)) continue } } } } // healthCheck 执行一次健康检查并决策是否触发自愈 func (h *Healer) healthCheck(ctx context.Context) error { healthy, err : h.queryHealth(ctx) if err ! nil { return fmt.Errorf(query prometheus: %w, err) } // 写入滑动窗口 h.mu.Lock() h.window[h.cursor%h.config.WindowSize] healthy h.cursor failCount : h.countFailures() h.mu.Unlock() if !healthy { h.config.Notify(ctx, INFO, fmt.Sprintf(检测到服务异常窗口内失败次数: %d/%d, failCount, h.config.WindowSize)) } // 判定窗口内失败次数超过阈值 if failCount h.config.FailThreshold { h.config.Notify(ctx, WARN, fmt.Sprintf(触发自愈流程失败次数 %d 阈值 %d, failCount, h.config.FailThreshold)) if err : h.executeRecovery(ctx); err ! nil { h.config.Notify(ctx, ERROR, fmt.Sprintf(自愈流程失败: %v, err)) return err } // 自愈执行后重置窗口避免重复触发 h.mu.Lock() h.resetWindow() h.mu.Unlock() } return nil } // queryHealth 通过 Prometheus API 查询服务健康状态 // 同时监控错误率和 P99 延迟两个维度 func (h *Healer) queryHealth(ctx context.Context) (bool, error) { now : time.Now() // 1. 查询最近 1m 的错误率 errorRateQuery : sum(rate(inference_errors_total{model_namedefault}[1m])) / sum(rate(inference_requests_total{model_namedefault}[1m])) result, _, err : h.promAPI.Query(ctx, errorRateQuery, now) if err ! nil { return false, fmt.Errorf(error rate query: %w, err) } vec, ok : result.(model.Vector) if !ok || len(vec) 0 { // 没有数据 没有请求或指标缺失判定为不健康 return false, nil } errorRate : float64(vec[0].Value) if errorRate 0.05 { // 错误率超过 5% return false, nil } // 2. 查询 P99 延迟 p99Query : histogram_quantile(0.99, sum(rate(inference_request_duration_seconds_bucket{model_namedefault}[1m])) by (le)) result, _, err h.promAPI.Query(ctx, p99Query, now) if err ! nil { return false, fmt.Errorf(p99 query: %w, err) } vec, ok result.(model.Vector) if ok len(vec) 0 float64(vec[0].Value) 2.0 { // P99 2s return false, nil } return true, nil } // executeRecovery 按梯度执行自愈动作 func (h *Healer) executeRecovery(ctx context.Context) error { // L1: 重启 Deployment h.config.Notify(ctx, ACTION, L1 自愈重启推理服务 Deployment) if err : h.restartDeployment(ctx, ai-inference, inference-server); err ! nil { return fmt.Errorf(L1 restart: %w, err) } // 等待观察期 if err : h.sleepWithContext(ctx, h.config.CooldownPeriod); err ! nil { return err } // 检查 L1 是否恢复 if healthy, _ : h.queryHealth(ctx); healthy { h.config.Notify(ctx, RECOVERY, L1 重启成功服务已恢复) return nil } // L2: Cordon GPU 节点 h.config.Notify(ctx, ACTION, L1 未恢复L2 自愈Cordon GPU 节点) if err : h.cordonGPUNode(ctx, gpu-node-03); err ! nil { return fmt.Errorf(L2 cordon: %w, err) } if err : h.sleepWithContext(ctx, h.config.CooldownPeriod); err ! nil { return err } if healthy, _ : h.queryHealth(ctx); healthy { h.config.Notify(ctx, RECOVERY, L2 Cordon 成功服务已恢复) return nil } // L3: 回滚模型版本 h.config.Notify(ctx, ACTION, L2 未恢复L3 自愈回滚模型版本) if err : h.rollbackModel(ctx, ai-inference, inference-server); err ! nil { h.config.Notify(ctx, CRITICAL, L3 回滚失败需要人工介入) return fmt.Errorf(L3 rollback: %w, err) } if err : h.sleepWithContext(ctx, h.config.CooldownPeriod); err ! nil { return err } if healthy, _ : h.queryHealth(ctx); healthy { h.config.Notify(ctx, RECOVERY, L3 回滚成功服务已恢复) return nil } h.config.Notify(ctx, CRITICAL, 三级自愈均未恢复需要人工介入排查) return fmt.Errorf(all recovery levels exhausted) } // restartDeployment 执行 Deployment 滚动重启 func (h *Healer) restartDeployment(ctx context.Context, namespace, name string) error { cmd : exec.CommandContext(ctx, kubectl, rollout, restart, deployment/name, -n, namespace, --kubeconfig/etc/kubernetes/admin.conf, ) output, err : cmd.CombinedOutput() if err ! nil { return fmt.Errorf(kubectl restart: %w, output: %s, err, string(output)) } return nil } // cordonGPUNode Cordon 指定的 GPU 节点 func (h *Healer) cordonGPUNode(ctx context.Context, nodeName string) error { cmd : exec.CommandContext(ctx, kubectl, cordon, nodeName, --kubeconfig/etc/kubernetes/admin.conf, ) output, err : cmd.CombinedOutput() if err ! nil { return fmt.Errorf(kubectl cordon: %w, output: %s, err, string(output)) } return nil } // rollbackModel 回滚 Deployment 到上一个版本 func (h *Healer) rollbackModel(ctx context.Context, namespace, name string) error { cmd : exec.CommandContext(ctx, kubectl, rollout, undo, deployment/name, -n, namespace, --kubeconfig/etc/kubernetes/admin.conf, ) output, err : cmd.CombinedOutput() if err ! nil { return fmt.Errorf(kubectl rollback: %w, output: %s, err, string(output)) } return nil } // countFailures 统计滑动窗口中的失败次数 func (h *Healer) countFailures() int { count : 0 for _, healthy : range h.window { if !healthy { count } } return count } // resetWindow 重置滑动窗口自愈执行后调用 func (h *Healer) resetWindow() { for i : range h.window { h.window[i] true } } // sleepWithContext 支持 context 取消的 sleep func (h *Healer) sleepWithContext(ctx context.Context, d time.Duration) error { select { case -ctx.Done(): return ctx.Err() case -time.After(d): return nil } }四、自愈的边界与反模式误自愈的风险如果健康检查的阈值设得太敏感比如错误率 1% 就触发自愈那在上线新版本短暂报错的窗口内自愈系统会以为服务异常而执行回滚——等于自己把自己的发布给回滚了。应对策略自愈系统要有发布感知能力。在 Deployment 处于滚动更新期间status.updatedReplicas status.replicas自动禁用自愈逻辑等发布稳定后再恢复。或者至少把FailThreshold设得足够大让短暂发布抖动覆盖不到。自愈不等于根因修复自愈只是治标。如果内存泄漏的根本原因是一个代码 Bug重启只是推迟了下一次崩溃。自愈系统必须和根因分析流程打通——当自愈动作触发后自动收集 Heap Dump、Goroutine Profile、GPU 显存快照等诊断数据一并推送到运维群。否则运维人员看到的只是服务自动恢复了的通知但不知道为什么会出问题。级联故障的放大效应当一个模型服务的自愈流程是重启 Pod时如果同节点的其他 Pod 也在处理请求重启会导致这些请求失败。在高峰期一个服务的重启可能引发雪崩。自愈动作的选择必须考虑服务间的依赖关系——如果服务 A 重启会影响服务 B那优先级更高的是隔离问题 Podcordon drain而不是原地重启。通知的狼来了效应如果自愈系统的通知太频繁比如每分钟一条检测到异常运维人员会逐渐忽略这些消息。建议自愈通知按严重程度分层——检测到异常但不触发自愈 静默触发 L1 自愈 Info 通道触发 L2 自愈或自愈失败 告警通道。保证每一条告警级别的消息都值得被关注。五、总结模型服务自愈系统的三个关键设计滑动窗口 连续失败阈值避免瞬时抖动触发误自愈。M5, N3 是经过验证的参数组合平衡了响应速度和误判率。三级梯度自愈L1 重启 → L2 Cordon → L3 回滚每级之间留 2 分钟观察期。用最小代价解决问题避免动不动就回滚。通知分层 诊断数据收集自愈通知按严重程度发到不同通道自愈同时收集诊断数据为根因分析提供弹药。自动化不是把人从流程中踢出去而是把人从重复劳动中解放出来。凌晨 3 点该被叫醒的是自愈系统不是你。