Kubernetes 混沌工程:随机杀推理 Pod 验证自动恢复
Kubernetes 混沌工程随机杀推理 Pod 验证自动恢复一、凌晨三点的告警教会我一件事你不测试故障故障就会测试你推理服务在生产环境挂掉的模式往往不是你能预料到的。CPU 打满、OOM Kill、GPU 显存泄漏——这些问题在压测环境就能暴露。但有一种故障模式极具破坏力却最容易被忽视随机 Pod 被驱逐。可能是节点 not-ready、可能是 kubelet 心跳超时、也可能是集群自动扩缩容触发的意外缩容。当你的推理服务部署了 20 个 Pod 时丢一个 Pod 是噪声但当它丢到只剩 3 个、而流量恰好达到峰值时剩下的 Pod 会被瞬间压垮形成连锁反应。这就是混沌工程要解决的问题。它的信条不是我的系统很健壮而是我不知道系统哪里脆弱所以我要主动制造破坏来找出答案。对于推理服务而言最直接的实验就是随机杀 Pod然后观察三件事恢复速度多久重新 Ready、服务可用性是否有请求失败、下游影响调用方是否触发熔断。二、Kubernetes 混沌实验的架构与执行流程混沌实验的核心拓扑很简单控制器、目标集群、观测器三部分协作。sequenceDiagram participant Cha as 混沌控制器 participant API as K8s API Server participant Tgt as 目标 Pod participant Svc as Service/Endpoint participant Obs as 可观测性栈 Cha-API: 1. 查询目标 Namespace 下的 Pod 列表 API--Cha: 返回 Running Pod 列表 Cha-Cha: 2. 按策略选择目标随机/N% Cha-API: 3. DELETE /api/v1/namespaces/{ns}/pods/{name} API-Tgt: 4. 发送 SIGTERM Tgt-Tgt: 5. Graceful Shutdown30s Note over Tgt: PreStop Hook 执行br/健康检查摘除流量 API-Svc: 6. Endpoint 更新移除被杀 Pod Note over Svc: 流量不再路由到该 Pod Tgt--API: 7. Pod Terminated API-API: 8. ReplicaSet 检测副本数不足 API-Tgt: 9. 创建新 Pod 替换 Tgt-Tgt: 10. Init Container → 模型加载 → Ready API-Svc: 11. Endpoint 更新加入新 Pod Cha-Obs: 12. 查询 Prometheus 指标 Obs--Cha: 返回恢复时间 / 错误率 / 延迟分布整个流程中关注点不在Pod 是否被杀掉——那是必然发生的。关注点在于步骤 6 到步骤 11 之间的时间窗口在这段时间内剩余 Pod 能否承受流量调用方重试策略是否生效HPA 是否触发了扩容一个典型的发现很多团队配置了terminationGracePeriodSeconds: 30但没有配置 PreStop Hook。这意味着 Pod 被杀时kube-proxy 更新 iptables 规则和 Pod 停止处理请求之间存在一个约 5-10 秒的竞态窗口——流量仍然被路由到正在关闭的 Pod导致请求失败。三、基于 kubectl 和 Go 的混沌实验脚本生产环境的混沌实验不应该用 kubectl 手动操作。以下是一个 Go 实现的轻量级混沌实验执行器package main import ( context fmt math/rand os time metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/client-go/kubernetes k8s.io/client-go/rest ) // ChaosConfig 定义一次混沌实验的参数 type ChaosConfig struct { Namespace string // 目标命名空间 LabelSelector string // Pod 标签选择器如 appinference-service KillPercent float64 // 要杀掉的比例如 0.3 表示杀 30% MinSurvive int // 最少保留 Pod 数避免全量杀光 CooldownSecs int // 每次杀 Pod 之间的冷却时间 } // ChaosRunner 混沌实验执行器 type ChaosRunner struct { client *kubernetes.Clientset config ChaosConfig } func NewChaosRunner(config ChaosConfig) (*ChaosRunner, error) { // 优先使用 InCluster 配置回退到 kubeconfig kubeConfig, err : rest.InClusterConfig() if err ! nil { kubeConfig, err rest.InClusterConfig() // 实际上这里应该 fallback 到 kubeconfig为简洁省略 if err ! nil { return nil, fmt.Errorf(获取 K8s 配置失败: %w, err) } } client, err : kubernetes.NewForConfig(kubeConfig) if err ! nil { return nil, fmt.Errorf(创建 K8s 客户端失败: %w, err) } return ChaosRunner{client: client, config: config}, nil } // Run 执行混沌实验选择目标 Pod、逐个删除、记录结果 func (r *ChaosRunner) Run(ctx context.Context) error { // 1. 获取目标 Pod 列表 pods, err : r.client.CoreV1().Pods(r.config.Namespace).List(ctx, metav1.ListOptions{ LabelSelector: r.config.LabelSelector, FieldSelector: status.phaseRunning, }) if err ! nil { return fmt.Errorf(获取 Pod 列表失败: %w, err) } if len(pods.Items) 0 { return fmt.Errorf(未找到匹配的 Running Pod) } // 2. 计算要杀的数量下限保护 totalRunning : len(pods.Items) killCount : int(float64(totalRunning) * r.config.KillPercent) if killCount 0 { killCount 1 // 至少杀 1 个 } if totalRunning-killCount r.config.MinSurvive { killCount totalRunning - r.config.MinSurvive if killCount 0 { return fmt.Errorf(当前 Pod 数 (%d) 少于最低保留数 (%d)终止实验, totalRunning, r.config.MinSurvive) } } fmt.Printf([Chaos] 目标: %s/%s, 当前 %d Pod, 计划杀 %d 个\n, r.config.Namespace, r.config.LabelSelector, totalRunning, killCount) // 3. 随机选择并逐个删除 rng : rand.New(rand.NewSource(time.Now().UnixNano())) indices : rng.Perm(totalRunning)[:killCount] for i, idx : range indices { pod : pods.Items[idx] fmt.Printf([Chaos] [%d/%d] 删除 Pod: %s\n, i1, killCount, pod.Name) // 立即删除不等待优雅终止模拟最坏情况 deletePolicy : metav1.DeletePropagationBackground err : r.client.CoreV1().Pods(r.config.Namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{ PropagationPolicy: deletePolicy, }) if err ! nil { fmt.Printf([Chaos] 删除 %s 失败: %v\n, pod.Name, err) continue } // 冷却间隔逐步释放压力避免同时重建过多 Pod select { case -time.After(time.Duration(r.config.CooldownSecs) * time.Second): case -ctx.Done(): return ctx.Err() } } fmt.Printf([Chaos] 实验完成已删除 %d 个 Pod\n, killCount) return nil } func main() { config : ChaosConfig{ Namespace: inference-prod, LabelSelector: appllm-inference, KillPercent: 0.3, // 杀 30% MinSurvive: 3, // 至少保留 3 个 CooldownSecs: 10, // 间隔 10 秒 } runner, err : NewChaosRunner(config) if err ! nil { fmt.Fprintf(os.Stderr, 初始化失败: %v\n, err) os.Exit(1) } ctx, cancel : context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if err : runner.Run(ctx); err ! nil { fmt.Fprintf(os.Stderr, 混沌实验异常: %v\n, err) os.Exit(1) } }代码的核心考量下限保护MinSurvive确保不会把所有 Pod 杀光。在生产环境跑混沌实验这个参数是最后的安全网。冷却间隔不一次性全部杀掉而是间隔删除模拟渐进式故障。一次性全杀可能会触发 HPA 疯狂扩容这不是真实故障的模式。立即删除而非优雅终止选用DeletePropagationBackground立即删除模拟最坏情况节点突然宕机。如果服务在这种情况下还能存活优雅终止场景自然也能通过。四、混沌实验的安全边界混沌工程最大的风险不是实验本身而是实验的失控。一个 Pod 都不留。如果配置失误KillPercent: 1.0且MinSurvive: 0整个服务会被清空。上线混沌实验的最基础要求是在非生产环境验证三次确认保底逻辑生效后才能进生产。建议在 staging 环境先以KillPercent: 0.1开始逐步放大到0.5。观测盲区。杀完 Pod 不看恢复指标等于白做。必须接入 Prometheus 指标kube_pod_status_ready的恢复时长、http_requests_total{code~5..}的错误率、HPA 的kube_hpa_status_current_replicas变化曲线。如果恢复时间超过 60 秒说明镜像拉取或模型加载流程需要优化。依赖连锁。推理服务不是孤岛它依赖 Redis 缓存、模型存储、消息队列。如果混沌实验杀了 Pod 的同时恰好 Redis 也重启了——这种情况在生产环境并不罕见。建议从依赖较少的下游服务开始实验逐步扩展到核心链路。定时执行。不要只跑一次。Kubernetes 集群的状态是动态的节点增删、网络策略变更、调度器参数调整都可能改变系统的故障响应行为。建议通过 CronJob 每周自动执行一次混沌实验并将恢复指标输出到监控大盘。五、总结混沌工程的本质是用可控的破坏验证不可控的韧性。随机杀 Pod 是最简单也最有效的实验起点。落地三步第一步在 staging 环境部署本文的 ChaosRunner配置KillPercent: 0.3、MinSurvive: 2观察恢复全过程第二步接入 Prometheus 告警如果恢复时间超过阈值则阻断第三步通过 CronJob 将实验固化为每周自动化巡检。记住一条原则如果你不敢在生产环境杀 Pod说明你对系统的信心还不够。主动制造故障不是为了证明系统不会挂而是为了在用户发现之前你自己先发现。