在 GPU 训练集群里,最怕的不是没有告警,而是告警之后一切照旧。节点上报 Xid 错误、ECC 显存报错、驱动异常,监控大屏已经变红,但节点依然保持 Ready,新任务照样调度上去,失败、重试、再失败。真正让运维头疼的往往不是“发现故障”,而是“处理完一次之后,下一次又被同一个故障重复折腾”。这篇文章想和你聊的,就是用 Golang 写一个 Kubernetes 上的 GPU 故障自愈控制器:自动隔离故障节点、驱逐 GPU Pod,同时把“防重复处理”这个最容易翻车的点讲清楚。
很多人一听到“K8s 自愈”,第一反应是写个脚本调 kubectl cordon、kubectl drain。但脚本在真实集群里会暴露两个问题:一是没有状态记录,重启脚本后同一个故障会被反复执行;二是没有和 K8s 的 API 机制整合,驱逐、PDB、污点、调度器等逻辑都要自己重新发明一遍。用 Go 写控制器,本质上是在 K8s 的声明式框架里做自愈,让“当前状态”和“期望状态”不断对齐,而不是靠一个一次性任务去“碰运气”。
读完这篇文章,你会得到一套可以落地的思路和代码骨架:故障如何上报、CRD 如何定义状态、Controller 如何隔离节点并驱逐 GPU Pod、以及四种防重复手段如何配合。没有 GPU 环境也能用模拟数据跑通整个链路。
1. GPU 故障自愈:为什么不能只靠告警
GPU 故障在训练集群里非常常见,而且比普通 CPU 节点更“隐蔽”。常见故障包括:
- Xid 错误:NVIDIA 驱动上报的错误码,例如 Xid 31 表示 uncorrectable ECC error,Xid 79 表示 GPU 已掉线。
- 显存 ECC 错误:显存出现可恢复或不可恢复的纠错错误,长期运行容易出现。
- 驱动或 CUDA 环境异常:GPU 设备无法初始化,导致 Pod 反复 CrashLoopBackOff。
- 温度过高或供电异常:可能是风扇、电源或硬件老化导致。
这些故障的共同特点是:节点本身未必 Crash,Kubernetes 可能仍然认为它 Ready。如果只看节点状态,GPU Pod 会被不断调度到故障节点上,然后不断失败。手动处理的问题更加直接:
- 定位慢:需要人去看监控、看日志、确认是哪块 GPU 出问题。
- 操作不可审计:一条 kubectl drain 命令敲下去,谁执行的、什么时候执行的、处理到了哪一步,事后很难追溯。
- 容易误操作:如果节点上有非 GPU 的重要服务,直接 drain 可能导致业务中断。
- 缺少恢复流程:故障节点隔离后,什么时候恢复、由谁恢复,如果没有机制,节点可能被“永久拉黑”。
所以,自愈的目标不是“用程序代替人”,而是把整个处理流程变成有状态、可审计、可收敛的闭环:
检测 -> 上报 -> 判定 -> 隔离 -> 驱逐 -> 验证 -> 恢复
这套闭环里,Go 非常适合。Kubernetes 的 client-go 和 controller-runtime 都是 Go 生态,写控制器几乎就是“官方路径”。而且 Go 的并发模型、静态编译和跨平台部署,也让它很适合做常驻集群里的自愈组件。
2. 核心概念:K8s 自愈机制与防重复关键
在写代码前,需要先把几个 K8s 概念对齐。因为自愈系统本质上就是这些机制的组合。
2.1 节点隔离与驱逐动作
- cordon:把节点标记为不可调度,新 Pod 不会调度上来,但已经在节点上的 Pod 不会自动离开。
- drain:先 cordon,再驱逐节点上的 Pod,是“隔离 + 清理”的组合操作。
- Eviction API:Kubernetes 提供的优雅驱逐接口,会遵守 PodDisruptionBudget(PDB),比直接 delete Pod 更安全。
- Taint / Toleration:给节点打污点,阻止普通 Pod 调度,只有带对应容忍度的 Pod 才能调度上去。
这里需要特别注意一个常见误区:自愈系统处理 GPU 故障节点,默认动作应该是 cordon + 驱逐 GPU Pod,而不是直接删除节点。删除节点是最后手段,GPU 硬件故障往往还需要保留现场做诊断,删除节点或重置节点反而会丢失日志和监控数据。
2.2 自定义资源与状态存储
自愈系统必须回答一个问题:某个故障到底处理过没有?如果只靠“执行一次脚本”,这个问题无解。所以要用自定义资源(CR)来记录故障和处理状态。
CRD 在代码里就是一个 Go struct,包含 Spec 和 Status:
Spec:描述故障的输入,例如节点名、GPU 编号、Xid 错误码、故障时间、故障指纹。Status:描述处理结果,例如当前阶段、处理时间、错误信息。
Controller 的 Reconcile 逻辑,本质上就是“读 CR -> 根据 Status 决定动作 -> 执行 -> 更新 Status”。
2.3 控制器的工作方式
Kubernetes Controller 使用 Informer 监听资源变化,事件进入 Workqueue,然后由 Reconcile 循环处理。同一个 key 在 Workqueue 里会被去重,这是最基础的防重复机制。但要注意:Workqueue 去重只能避免同一时刻并发处理同一个 key,不能保证“只成功执行一次”。因为 Reconcile 的底层操作(cordon、evict)可能失败重试,Controller 也可能在任意时刻重启。
这也是“防重复”真正的难点:不是“消息队列消费完就结束”,而是要做到“重复触发也无害”,也就是幂等。
3. 状态机设计:把“防重复”变成系统约束
一个 GPU 故障从上报到处理完成,应该是一个清晰的状态机。如果状态没有收敛,自愈逻辑很容易变成“每次重启都重复处理”。
3.1 故障生命周期
设计如下状态:
| 状态 | 含义 | Controller 是否处理 |
|---|---|---|
| Pending | 故障已上报,等待处理 | 是 |
| Processing | 已开始隔离和驱逐 | 是,但需要配合幂等动作 |
| Handled | 已完成隔离和驱逐 | 否 |
| Failed | 处理失败,等待人工介入 | 不建议无限重试 |
一个典型流程:
- Agent 检测到 GPU Xid 错误,创建 GpuFault CR,状态默认为空。
- Controller 收到事件后,将状态更新为 Processing。
- Controller 执行 cordon 节点。
- Controller 驱逐节点上的 GPU Pod。
- 全部成功后,将状态更新为 Handled。
- 人工或自动化流程确认 GPU 硬件恢复后,删除 GpuFault CR,允许下一次故障重新上报。
这里的“恢复后删除 CR”非常关键。如果 CR 永远保留,Agent 即使发现故障已经恢复,也不会重新上报,因为同指纹的 CR 已经存在。倒过来,如果故障还没恢复就删除 CR,Agent 会立即重新创建,Controller 又会被触发。所以CR 的生命周期应该等于故障的生命周期。
3.2 防重复的四道防线
只靠一个 Status 字段是不够的,我建议至少叠加以下四层:
- Agent 侧去重:上报前先查同一个“节点 + GPU + Xid”指纹是否已经存在 CR,存在就不重复创建。
- Controller 状态门禁:CR 状态为 Handled 时直接跳过。
- 动作幂等:cordon 一个已经不可调度的节点不会产生副作用;驱逐一个已经不存在的 Pod 会返回 NotFound,忽略即可。
- 多副本选主:如果 Controller 部署了多个副本,必须开启 Leader Election,否则多个 Reconcile 会同时处理同一个 CR。
真正理解这四个层次后,你会发现“防重复”不是一个魔法开关,而是一套约束。每个环节都可能重复触发,但每个环节重复触发的代价都很低。
4. 环境准备与项目结构
在动手写代码前,先确认环境。
4.1 运行环境
- Go 1.21 或更高版本,具体以你项目实际使用的版本为准。
- 一个 Kubernetes 集群,可以是 kind、kubeadm、云厂商集群,也可以是没有 GPU 的测试集群。
- kubectl 已配置好 kubeconfig。
- 推荐提前安装 Kubebuilder 或 Operator SDK,方便生成 CRD 骨架和 RBAC。本文的重点是核心逻辑,所以不逐个展示脚手架生成过程。
4.2 依赖库
核心依赖是:
k8s.io/apik8s.io/apimachineryk8s.io/client-gosigs.k8s.io/controller-runtime
这些是 Kubernetes 控制器开发的标准依赖。如果是从零搭建,建议用 Kubebuilder 初始化项目,它会帮你生成 go.mod、Makefile、Dockerfile 等文件。
4.3 建议目录结构
your-project/ ├── api/ │ └── v1/ │ ├── gpufault_types.go │ └── groupversion_info.go ├── controllers/ │ └── gpufault_controller.go ├── agent/ │ └── main.go ├── config/ │ ├── crd/ │ ├── rbac/ │ └── samples/ └── cmd/ └── main.go这里把 Agent 和 Controller 分成两个模块。Agent 是 DaemonSet,跑在每一个节点上,负责检测 GPU 并上报;Controller 只负责处理 CR。
5. 定义 CRD 与实现故障采集 Agent
5.1 定义 GpuFault 类型
先定义 CRD 的 Go 类型。这里给出核心类型定义,完整项目里还需要controller-gen生成 DeepCopy 和 CRD YAML。
// 文件路径:api/v1/gpufault_types.go package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // GpuFaultSpec 定义一次 GPU 故障的输入 type GpuFaultSpec struct { // NodeName 是故障节点名称 NodeName string `json:"nodeName"` // GPUIndex 是节点内的 GPU 编号 GPUIndex int `json:"gpuIndex"` // Xid 是 NVIDIA Xid 错误码,0 表示无 Xid Xid int `json:"xid,omitempty"` // Message 是故障描述 Message string `json:"message,omitempty"` // FaultTime 是故障发生时间 FaultTime string `json:"faultTime"` // Fingerprint 用于去重,格式建议:nodeName-gpuIndex-xid Fingerprint string `json:"fingerprint"` } // GpuFaultStatus 定义故障处理状态 type GpuFaultStatus struct { // Phase 是当前处理阶段:Pending、Processing、Handled、Failed Phase string `json:"phase,omitempty"` // Message 是处理过程信息或错误信息 Message string `json:"message,omitempty"` // HandleTime 是处理完成时间 HandleTime string `json:"handleTime,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status type GpuFault struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec GpuFaultSpec `json:"spec,omitempty"` Status GpuFaultStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true type GpuFaultList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []GpuFault `json:"items"` } func init() { SchemeBuilder.Register(&GpuFault{}, &GpuFaultList{}) }把Fingerprint作为去重主键是第一步。这里用“节点名 + GPU 编号 + Xid 错误码”组合,足以覆盖同一块 GPU 反复报同一个错误的情况。如果同一次故障有多个 Xid,Agent 可以生成多条 CR,也可以聚合后生成一条,按实际场景决定。
5.2 Agent 上报逻辑
Agent 跑在每个节点上,负责检测 GPU 状态并创建 GpuFault CR。真实环境可以用nvidia-smi -x -q、dcgm-exporter或 NVIDIA Device Plugin 的检测数据。这里为了在无 GPU 集群上演示,使用 Mock 数据。
// 文件路径:agent/main.go package main import ( "context" "fmt" "os" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/uuid" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/config" gpufaultv1 "yourproject/api/v1" ) type GpuFaultInfo struct { GPUIndex int Xid int Message string } // detectGpuFault 真实环境请解析 nvidia-smi、dcgm 或 device plugin 的输出。 func detectGpuFault() []GpuFaultInfo { if os.Getenv("MOCK_GPU_FAULT") != "true" { return nil } return []GpuFaultInfo{ {GPUIndex: 0, Xid: 31, Message: "uncorrectable ECC error"}, } } func namespaceOrDefault() string { if ns := os.Getenv("WATCH_NAMESPACE"); ns != "" { return ns } return "default" } func main() { c, err := client.New(config.GetConfigOrDie(), client.Options{}) if err != nil { panic(err) } nodeName := os.Getenv("NODE_NAME") if nodeName == "" { panic("NODE_NAME is required") } ctx := context.Background() ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { faults := detectGpuFault() if len(faults) == 0 { continue } for _, f := range faults { fingerprint := fmt.Sprintf("%s-gpu%d-xid%d", nodeName, f.GPUIndex, f.Xid) // 第一道防重复:查询同指纹的 CR 是否已存在,存在则不重复创建。 existing := &gpufaultv1.GpuFaultList{} if err := c.List(ctx, existing, client.InNamespace(namespaceOrDefault())); err == nil { duplicated := false for i := range existing.Items { if existing.Items[i].Spec.Fingerprint == fingerprint { duplicated = true fmt.Printf("fingerprint %s already exists, skip\n", fingerprint) break } } if duplicated { continue } } cr := &gpufaultv1.GpuFault{ ObjectMeta: metav1.ObjectMeta{ Name: "gpufault-" + string(uuid.NewUUID()), Namespace: namespaceOrDefault(), }, Spec: gpufaultv1.GpuFaultSpec{ NodeName: nodeName, GPUIndex: f.GPUIndex, Xid: f.Xid, Message: f.Message, FaultTime: time.Now().Format(time.RFC3339), Fingerprint: fingerprint, }, } if err := c.Create(ctx, cr); err != nil { fmt.Printf("create gpufault cr failed: %v\n", err) } else { fmt.Printf("create gpufault cr %s/%s success\n", cr.Namespace, cr.Name) } } } }这段代码的关键是Fingerprint去重。Agent 不是每 30 秒创建一条 CR,而是先查同名指纹是否存在。只要 CR 没有被删除,同一块 GPU 的同一个 Xid 错误就不会反复上报。这也意味着,故障恢复后必须删除对应的 CR,否则后续新的故障会因指纹相同而被吞掉。
6. Controller 核心实现:隔离节点、驱逐 Pod、防重复
Controller 是自愈系统的执行器。它监听 GpuFault CR,一旦发现 Pending 或 Processing 状态就执行处理,处理完成更新为 Handled。
6.1 Reconcile 主流程
// 文件路径:controllers/gpufault_controller.go package controllers import ( "context" "time" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" gpufaultv1 "yourproject/api/v1" ) // GpuFaultReconciler 就是我们的自愈控制器 type GpuFaultReconciler struct { client.Client K8s *kubernetes.Clientset Log log.LogrLogger } func (r *GpuFaultReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := r.Log.WithValues("gpufault", req.NamespacedName.String()) var cr gpufaultv1.GpuFault if err := r.Get(ctx, req.NamespacedName, &cr); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil } return ctrl.Result{}, err } // 第二道防重复:已经处理完成,直接跳过。 if cr.Status.Phase == "Handled" { logger.Info("gpufault already handled, skip") return ctrl.Result{}, nil } // 首次进入时先把状态改为 Processing,避免 controller 重启后完全不知道处理进度。 if cr.Status.Phase != "Processing" { cr.Status.Phase = "Processing" cr.Status.Message = "controller starts handling" if err := r.Status().Update(ctx, &cr); err != nil { return ctrl.Result{}, err } } // 动作1:cordon 节点,确保新 Pod 不再调度上去。 if err := r.cordonNode(ctx, cr.Spec.NodeName); err != nil { cr.Status.Phase = "Failed" cr.Status.Message = "cordon node failed: " + err.Error() _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } // 动作2:驱逐节点上的 GPU Pod。 if err := r.evictGPUPods(ctx, cr.Spec.NodeName); err != nil { cr.Status.Phase = "Failed" cr.Status.Message = "evict gpu pods failed: " + err.Error() _ = r.Status().Update(ctx, &cr) return ctrl.Result{}, err } // 全部成功后写入 Handled 状态,后续 Reconcile 不会再处理。 cr.Status.Phase = "Handled" cr.Status.Message = "node isolated and gpu pods evicted" cr.Status.HandleTime = time.Now().Format(time.RFC3339) if err := r.Status().Update(ctx, &cr); err != nil { return ctrl.Result{}, err } logger.Info("gpufault handled", "node", cr.Spec.NodeName) return ctrl.Result{}, nil }这段代码看起来简单,但已经把“防重复”的内核表达出来了:
Handled状态是终止条件。cordonNode是幂等的,节点已经是 Unschedulable 时不会做任何事。evictGPUPods对已经不存在的 Pod 会忽略 NotFound 错误。Status().Update会把结果写回 etcd,Controller 重启后能读到新的状态。
6.2 幂等处理动作
接下来是cordonNode和evictGPUPods的具体实现。
// cordonNode 将节点标记为不可调度。幂等:重复执行不会产生额外影响。 func (r *GpuFaultReconciler) cordonNode(ctx context.Context, nodeName string) error { node := &corev1.Node{} if err := r.Get(ctx, types.NamespacedName{Name: nodeName}, node); err != nil { return err } if node.Spec.Unschedulable { return nil } patch := []byte(`{"spec":{"unschedulable":true}}`) return r.Patch(ctx, node, client.RawPatch(types.MergePatchType, patch)) } // evictGPUPods 驱逐指定节点上的 GPU Pod。 // 这里使用 Eviction API,而不是直接 delete Pod,以便遵守 PDB。 func (r *GpuFaultReconciler) evictGPUPods(ctx context.Context, nodeName string) error { pods, err := r.K8s.CoreV1().Pods("").List(ctx, metav1.ListOptions{ FieldSelector: "spec.nodeName=" + nodeName, }) if err != nil { return err } for i := range pods.Items { pod := &pods.Items[i] if !isGPUPod(pod) { continue } if pod.DeletionTimestamp != nil { continue } eviction := &policyv1.Eviction{ ObjectMeta: metav1.ObjectMeta{ Name: pod.Name, Namespace: pod.Namespace, }, } err := r.K8s.PolicyV1().Evictions(pod.Namespace).Evict(ctx, eviction) if err != nil { if apierrors.IsNotFound(err) { continue } // 驱逐失败时不要直接返回 error,否则会无限重试。 // 这里应该记录详细告警,并考虑是否进入 Failed 状态。 r.Log.Error(err, "evict gpu pod failed", "pod", pod.Name, "namespace", pod.Namespace) continue } } return nil } // isGPUPod 判断 Pod 是否申请了 GPU 资源。 func isGPUPod(pod *corev1.Pod) bool { for _, container := range pod.Spec.Containers { for resourceName := range container.Resources.Requests { if strings.Contains(strings.ToLower(string(resourceName)), "gpu") { return true } } for resourceName := range container.Resources.Limits { if strings.Contains(strings.ToLower(string(resourceName)), "gpu") { return true } } } return false }这里有个很重要的工程判断:驱逐 Pod 失败时,不要直接 return error。因为一旦某个 Pod 有 PDB 保护或属于关键业务,Controller 会陷入无限重试。更合理的做法是:记录失败事件,把 CR 标记为 Failed 并发告警,让值班人员介入。生产环境中,自愈系统必须把“处理不了”的情况暴露出来,而不是默默重试一百次。
另外要注意,evictGPUPods在驱逐失败时continue会导致后续 Pod 继续处理,最后Reconcile会认为“已经全部成功”,把 CR 标记为 Handled。这会掩盖问题。更严谨的写法是:统计失败数量,只要有一个 Pod 驱逐失败,就不要标记 Handled,而是标记为 Failed 或进入有限次重试。为了让示例保持可读性,我在代码里用注释标出了这个分支,实际项目一定要改成“失败数量 > 0 则不标记成功”。
6.3 启动 Controller
Controller 需要在 main.go 中注册 Scheme、初始化 Manager、注册 Reconcile 对象。同时需要给 Pod 建立spec.nodeName字段索引,或者直接使用 clientset 的 FieldSelector。上面的示例用了 clientset,所以 main.go 不需要额外建索引,只要把 clientset 注入 Reconciler 即可。
Controller 必须开启 Leader Election。否则一旦部署多个副本,两个 Reconcile 可能同时处理同一个 CR。
7. 部署验证:跑通一次 GPU 故障自愈闭环
下面用模拟数据在真实集群里跑一遍。没有 GPU 也没关系,Agent 会直接创建一条 GpuFault CR。
7.1 部署 Agent
Agent 以 DaemonSet 方式运行。注意通过 Downward API 把NODE_NAME注入到环境变量。
# 文件路径:config/samples/agent.yaml apiVersion: apps/v1 kind: DaemonSet metadata: name: gpu-fault-agent namespace: default spec: selector: matchLabels: app: gpu-fault-agent template: metadata: labels: app: gpu-fault-agent spec: serviceAccountName: gpu-fault-agent containers: - name: agent image: your-image/gpu-fault-agent:latest env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName - name: WATCH_NAMESPACE value: "default" - name: MOCK_GPU_FAULT value: "true"Agent 需要 RBAC 权限:读取 Node、创建和读取 GpuFault CR。生产环境要按最小权限收紧。
7.2 部署或本地运行 Controller
Controller 可以先在本地跑,方便看日志:
export KUBECONFIG=~/.kube/config go run ./cmd/main.go也可以打成镜像部署到集群。如果部署到集群,Controller 需要权限:
- 读取、更新、Patch Node。
- 读取 Pod。
- 创建 Eviction。
- 读取和更新 GpuFault CR。
- 创建 Event。
7.3 触发模拟故障并观察
部署完成后,打开一个终端观察 Controller 日志。因为 Agent 设置了MOCK_GPU_FAULT=true,它会在默认命名空间创建一条 GpuFault CR。
然后执行:
kubectl get gpufault -A -o wide大概会看到类似输出:
NAME NODE GPUINDEX XID PHASE MESSAGE gpufault-xxxx worker1 0 31 Processing controller starts handling等几秒后再查看:
kubectl get gpufault -A -o widePhase 应该变成:
PHASE MESSAGE Handled node isolated and gpu pods evicted接着检查节点:
kubectl get node worker1 -o jsonpath='{.spec.unschedulable}'如果输出true,说明 cordon 成功。如果节点上有 GPU Pod,可以用下面命令确认它们已经被驱逐:
kubectl get pods -A --field-selector spec.nodeName=worker1这里要特别说明:验证自愈系统是否“防重复”,不要只看第一次处理。你可以手动把 CR 的状态改回 Processing,观察 Controller 是否会再次执行。也可以重启 Controller 进程,确认已处理的 CR 不会因为重启而再次触发。更完整的验证是:在 CR 为 Handled 时,把 Agent 停掉再恢复,确认它不会再次创建同指纹的 CR。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Controller 启动后不处理 CR | CRD 没有安装,或 RBAC 没有权限 | 查看 Controller 日志,执行kubectl api-resources | grep gpufault | 安装 CRD,补充 RBAC 权限 |
| Agent 反复创建相同 CR | Fingerprint 去重逻辑失效,或没有查询同指纹 CR | 查看 Agent 日志,检查 GpuFaultList 查询结果 | 统一 Fingerprint 生成规则,确认 Agent 使用相同 Namespace |
| 节点永远停在 Processing | cordon 或驱逐失败后返回错误 | 查看 CR 的 Status.Message,查看 Controller 日志 | 根据错误类型修复:RBAC、节点不存在、PDB 阻止驱逐 |
| Pod 驱逐一直失败 | Pod 有 PDB,或属于 DaemonSet,或优先级较高 | 查看 Eviction API 返回的错误,检查 PDB 配置 | 按业务分级决定是否强制驱逐;不建议绕过 PDB |
| 自愈系统把重要非 GPU 服务赶走了 | isGPUPod 判断太宽泛,或错误驱逐了非 GPU Pod | 检查 Pod 资源 requests/limits | 明确 GPU 资源名白名单,避免用模糊子串匹配 |
| Controller 多副本重复处理 | 没有开启 Leader Election | 查看多个副本日志,是否同时出现相同 CR 的 Reconcile 日志 | 在 main.go 启动参数中开启 Leader Election |
| GPU 恢复后节点一直处于隔离状态 | 缺少恢复流程,CR 一直为 Handled | 查看 CR、Node annotation | 增加恢复确认流程,恢复后删除 CR 并 uncordon 节点 |
| CR 被误删后同一故障再次触发 | 故障未恢复,但 CR 被删除 | 查看 Agent 日志 | 使用 Finalizer 保护 CR,防止在故障生命周期结束前被误删 |
排错时要记住一个顺序:先看 CR 状态,再看 Controller 日志,最后看 K8s 事件。CR 状态是整个自愈系统的“事实源”,它最能反映问题卡在哪一步。
9. 最佳实践与生产落地建议
9.1 故障分级,不要一刀切
GPU 故障不是只有“自我隔离”一种处理方式。生产上建议分级:
- 可恢复故障:例如瞬时温度过高、驱动短暂异常,可以先重新初始化 GPU,而不是直接 cordon。
- 局部故障:单块 GPU ECC 报错,可以只禁止调度到这块 GPU,而不是隔离整个节点。可以考虑给节点 GPU 编号作为扩展资源标签,由调度器拒绝该资源。
- 严重故障:Xid 44、Xid 79、掉卡等,才触发节点级 cordon 和驱逐。
如果所有故障都走节点隔离,一个错误 GPU 可能会拖垮整台节点上的所有任务。
9.2 处理动作要可审计
自愈系统不是“黑盒”。每个动作都要留下痕迹:
- 给节点打 annotation,记录故障 CR 名称和处理时间。
- 在 GpuFault CR 的 Status 里写清楚处理阶段和错误信息。
- 通过 Kubernetes Event 记录每次 cordon、驱逐动作。
这样事后追溯时才不会出现“系统自动处理了,但没人知道它为什么处理”的问题。
9.3 重视恢复流程
自愈不只是“处理故障”,还要考虑“恢复服务”。我的建议是:设计一个独立的恢复控制器或人工确认按钮,由它负责:
- 确认 GPU 硬件已经恢复。
- 删除 GpuFault CR。
- 清理节点上的故障 annotation。
- 解除节点的 Unschedulable 状态。
不要把“处理”和“恢复”放在同一个 Reconcile 里,否则会导致故障没恢复又被重新拉起。
9.4 用混沌测试验证防重复
防重复逻辑最容易在真实故障中翻车。建议定期做混沌演练:
- 手动把 Handled 的 CR 改成 Processing,观察是否会重复驱逐。
- 在 Controller 处理过程中 kill 掉进程,验证重启后不会重复 cordon。
- 同时启动两个 Controller 副本(不开启选主),观察是否会出现并发处理。
这些演练能帮你提前发现“状态门禁被绕过”的问题。
9.5 生产环境的安全边界
最后必须强调的是:在真实集群里,任何自动驱逐和节点隔离都可能影响生产业务。上线前一定要确认:
- 有明确的故障确认条件,避免误判。
- 驱逐动作遵守 PDB。
- 关键业务 Pod 有保护机制。
- 所有操作都在测试集群验证过。
- 生产环境有备份、监控、审计和回滚方案。
自愈系统的目标不是“无限自动化”,而是把重复劳动变成可靠动作,同时把人工介入的边界画清楚。它要解决的核心问题,是让 GPU 故障从“告警满天飞、处理全靠人”变成“有状态、有记录、可收敛”的自动化闭环。
这篇文章写到的代码骨架,可以在你的项目里直接改造成真实实现。下一步建议先用 Kubebuilder 生成完整项目骨架,把 CRD、RBAC、Dockerfile 补齐,然后在一套测试集群里跑一轮模拟故障演练。理解了“状态机 + 幂等动作 + 生命周期管理”这套组合拳,再去处理真实 GPU 故障才不会慌。