在 GPU 集群运维中,最大的痛点是“显卡明明出了故障,Kubernetes 还在继续往这个节点调度任务”。遇到这种情况时,传统做法是让值班同学先 ssh 到节点,手动封节点、删 Pod、拉群通知,一套流程走下来至少 10 到 20 分钟,而且很容易出现“同一故障被多个告警重复触发,脚本重复执行”的问题。本文就围绕这个场景,分享一套用 Golang 实现的 K8s GPU 故障自愈处理方案,重点讲解如何检测 GPU 故障、如何自动对故障节点做隔离、如何删除异常 GPU Pod,以及如何从机制上彻底避免重复处理。适合正在做 K8s 运维平台、资源调度系统,或者想把手动运维脚本升级成自动化控制器的同学。
1. 背景与核心概念
1.1 什么是 GPU 故障自愈
GPU 故障自愈,简单来说就是:当集群里的 GPU 节点出现硬件故障、驱动异常、算力丢失等状况时,系统能够自动完成“发现故障 -> 上报故障 -> 隔离节点 -> 迁移工作负载”这一整条链路,不需要运维人员手动介入。
Kubernetes 调度器本身并不关心 GPU 卡是否真的可用,它只看到节点上有一个nvidia.com/gpu资源对象。只要节点还能上报心跳,调度器就认为这个节点可以分配 GPU 资源。但实际情况是,显卡可能已经出现 Xid 错误、显卡掉线、显存损坏等故障,Pod 照样会被调度上去,然后陷入 CrashLoopBackOff。
因此,我们需要在 Kubernetes 之上额外构建一个“自愈控制器”,让它承担以下职责:
- 检测 GPU 是否存在异常;
- 将异常节点从调度池中隔离出去;
- 删除异常节点上的 GPU Pod,让 Controller 或 Deployment 把 Pod 调度到其他健康节点;
- 故障恢复后自动清理隔离标记,让节点重新回到调度池。
1.2 为什么必须做“防重复处理”
做过告警平台的同学应该都有体会:同一块 GPU 出故障后,监控系统几乎每 30 秒到 1 分钟就会产生一条新告警。如果自愈脚本只是简单监听告警,那么同一个故障可能触发 10 次甚至 100 次“封节点 + 删 Pod”操作。
重复处理带来的问题非常明显:
- Pod 被反复删除,工作负载不断重启,造成无意义的资源抖动;
- 节点 Taint 被反复写入,虽然 Taint 本身是幂等的,但会导致事件刷屏;
- 控制器日志和告警消息爆炸,真正的处理结果反而被淹没;
- 如果脚本里还有“调用外部接口”这种动作,重复执行甚至可能引发线上事故。
所以,一个合格的故障自愈系统,必须做到**“同一故障 ID 只处理一次”**。这是本文的重点。
1.3 防重复机制适用场景
防重复机制不仅适用于 GPU 故障,也适用于节点磁盘故障、内存故障、自定义硬件异常等场景。核心思路是一致的:
- 每条故障有一个唯一 ID;
- 处理结果需要持久化保存;
- 处理动作必须幂等;
- 需要一个去重窗口或去重标记,避免短时间内的重复触发。
2. 自愈系统整体架构
2.1 工作流程
整个系统的运行流程可以拆成下面几条链路。
故障检测链路:
- 每个 GPU 节点上运行一个检测 Agent;
- Agent 周期性调用
nvidia-smi或其他 GPU 诊断工具; - 如果发现 GPU 异常,Agent 将故障信息写入当前节点的 Annotation;
- 故障恢复后,Agent 清除或覆盖故障信息。
自愈处理链路:
- 控制器周期性扫描集群中所有 Node;
- 发现节点 Annotation 中带有故障标记时,提取故障 ID;
- 检查该故障 ID 是否已经处理过;
- 未处理则执行“打 Taint + 删 Pod + 记录结果”;
- 已处理则跳过,避免重复动作。
故障恢复链路:
- 检测 Agent 发现 GPU 已恢复正常;
- Agent 清除故障 Annotation;
- 控制器再次扫描时发现故障标记消失;
- 控制器清理节点上的故障 Taint,让节点重新参与调度。
2.2 组件划分
整个项目分为两个可独立部署的程序:
| 组件 | 运行方式 | 职责 |
|---|---|---|
gpu-fault-checker | DaemonSet | 检测本机 GPU 状态,上报故障 Annotation |
gpu-self-healing-controller | Deployment | 扫描故障标记,执行自愈动作,防重复 |
把“检测”和“处理”拆开,是很有必要的。这样检测策略和处置策略可以分别迭代,不会互相影响。例如,检测端可以从nvidia-smi换成 DCGM 指标,而处理端完全不需要改动。
2.3 状态定义与流转
我们使用 Node Annotation 作为状态存储载体,字段定义如下:
gpu-fault.example.com/status: faulty 或空 gpu-fault.example.com/fault-id: 故障唯一ID gpu-fault.example.com/fault-time: 故障发生时间 gpu-fault.example.com/handled-fault-id: 控制器已处理过的故障ID gpu-fault.example.com/handled-at: 控制器处理时间状态流转规则:
- 检测端发现故障,设置
status=faulty并写入fault-id; - 控制器发现未处理的
fault-id,执行处理; - 处理成功后将
handled-fault-id设为当前故障 ID; - 检测端确认 GPU 恢复,清空
status、fault-id,控制器清理 Taint。
通过 Annotation 做状态持久化,好处是控制器重启后依然能感知历史处理结果,不会因为内存数据丢失导致重复处理。
3. 环境准备与项目结构
3.1 环境要求
本项目需要以下基础环境:
- Kubernetes 集群,版本建议 1.20 以上;
- 集群中存在 GPU 节点,并安装 NVIDIA 驱动,测试时可以用任意 Node 模拟;
- 开发机安装 Go 语言环境;
- 能够访问 Kubernetes 集群,具备创建 ServiceAccount、ClusterRole 的权限。
关于 Go 版本和client-go版本,需要根据你的集群 Kubernetes API 版本选择对应版本。本文代码以常见的client-go使用方式为例,重点演示代码思路,请按实际环境调整依赖版本。
3.2 Go 项目结构
建议项目结构如下:
gpu-self-healing/ ├── cmd/ │ ├── checker/ │ │ └── main.go │ └── controller/ │ └── main.go ├── internal/ │ ├── dedupe/ │ │ └── cache.go │ └── controller/ │ └── reconcile.go ├── deploy/ │ ├── rbac.yaml │ ├── checker.yaml │ └── controller.yaml └── go.mod检测端和控制器分开两个main.go,这样镜像构建时各打各的包,部署也更灵活。
3.3 依赖引入
初始化项目后,需要引入 Kubernetes 相关依赖:
go mod init gpu-self-healing go get k8s.io/client-go@latest go get k8s.io/api@latest go get k8s.io/apimachinery@latest go get k8s.io/klog/v2@latest如果你的集群版本比较旧,则需要安装对应版本的client-go,例如 Kubernetes 1.24 集群一般使用client-go v0.24.x或相近版本。版本不匹配时会遇到 API 数据结构不一致的问题,这一点在 Kubernetes 开发中特别常见。
4. GPU 故障检测端实现
4.1 检测核心逻辑
检测端最核心的逻辑就是执行nvidia-smi命令,判断 GPU 是否可以正常枚举。
先来看一个最小可用的检测函数:
// 文件路径:cmd/checker/main.go func detectGPUFault() (bool, string) { cmd := exec.Command("nvidia-smi", "--query-gpu=index,uuid", "--format=csv,noheader") output, err := cmd.CombinedOutput() if err != nil { return true, "nvidia-smi exec failed: " + err.Error() } text := strings.TrimSpace(string(output)) if text == "" { return true, "nvidia-smi returns empty" } lines := strings.Split(text, "\n") for _, line := range lines { // 如果输出里出现 N/A 或 Unable to determine,通常意味着 GPU 状态异常 if strings.Contains(line, "N/A") || strings.Contains(line, "Unable to determine") { return true, line } } return false, "" }这段代码的逻辑并不复杂:
- 执行
nvidia-smi查询 GPU 的 index 和 UUID; - 命令执行失败,说明驱动或显卡设备已经不稳定;
- 输出为空,说明系统没有发现任何 GPU;
- 输出中出现异常关键字,说明至少有某一块卡存在异常。
生产环境中,更推荐使用 DCGM(NVIDIA Data Center GPU Manager)采集 GPU 健康指标,例如温度、功耗、Xid 错误次数,综合判断后再上报故障。本文先以nvidia-smi为例,更复杂的诊断可以在此基础上扩展。
4.2 上报故障到 Node Annotation
检测端需要把故障信息写入当前节点的 Annotation。这里有一个关键细节:故障 ID 不能每次检测都生成新值。否则控制器每次扫描都会发现一个新故障,防重复机制就失效了。
正确做法是:
- 如果节点已经处于故障状态且已经存在
fault-id,则保留原 ID; - 如果节点是从健康状态转为故障状态,才生成一个新的故障 ID。
下面是一个上报函数示例:
// 文件路径:cmd/checker/main.go func markNodeFault(ctx context.Context, client *kubernetes.Clientset, nodeName, faultID string) error { for attempt := 0; attempt < 3; attempt++ { node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) if err != nil { return err } oldFaultID := node.Annotations[AnnotationPrefix+"/fault-id"] if oldFaultID != "" { faultID = oldFaultID } if node.Annotations == nil { node.Annotations = map[string]string{} } node.Annotations[AnnotationPrefix+"/status"] = "faulty" node.Annotations[AnnotationPrefix+"/fault-id"] = faultID node.Annotations[AnnotationPrefix+"/fault-time"] = time.Now().Format(time.RFC3339) _, err = client.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{}) if err == nil { return nil } if !apierrors.IsConflict(err) { return err } // 如果是 conflict,重新 Get 再更新 } return fmt.Errorf("update node annotation conflict") }main 函数中,检测端只需要周期执行检测,并调用上报逻辑:
func main() { config, err := rest.InClusterConfig() if err != nil { klog.FatalS(err, "build in cluster config failed") } client, err := kubernetes.NewForConfig(config) if err != nil { klog.FatalS(err, "build k8s client failed") } nodeName := os.Getenv("NODE_NAME") if nodeName == "" { klog.Fatal("NODE_NAME env is required") } ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) faulty, detail := detectGPUFault() if faulty { faultID := fmt.Sprintf("%s-%s", nodeName, time.Now().Format("20060102-150405")) if err := markNodeFault(ctx, client, nodeName, faultID); err != nil { klog.ErrorS(err, "mark node fault failed", "node", nodeName) } } else { if err := clearNodeFault(ctx, client, nodeName); err != nil { klog.ErrorS(err, "clear node fault failed", "node", nodeName) } } cancel() } }clearNodeFault的实现思路是把status、fault-id、fault-time三个 Annotation 删除或置空,同时保留handled-*历史字段,方便排查。这一部分代码和markNodeFault结构类似,批量修改后执行 Node Update 即可。
4.3 DaemonSet 部署
检测端通常以 DaemonSet 方式部署:
# 文件路径:deploy/checker.yaml apiVersion: apps/v1 kind: DaemonSet metadata: name: gpu-fault-checker namespace: gpu-self-healing spec: selector: matchLabels: app: gpu-fault-checker template: metadata: labels: app: gpu-fault-checker spec: serviceAccountName: gpu-fault-checker nodeSelector: gpu.example.com/enabled: "true" containers: - name: checker image: registry.example.com/gpu-fault-checker:v0.1.0 command: ["/checker"] env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName这里的nodeSelector需要按你集群中 GPU 节点的实际标签调整。例如,如果你的 GPU 节点带有nvidia.com/gpu=true标签,就写对应的标签。主要目标是让检测 Agent 只跑在 GPU 节点上,避免无谓消耗。
5. 自愈控制器实现
5.1 控制器主循环
控制器是整个自愈系统的核心。为了便于理解,先使用“定时扫描”的方式实现,也就是每 30 秒调谐一次集群中的 Node。
// 文件路径:cmd/controller/main.go func main() { config, err := rest.InClusterConfig() if err != nil { klog.FatalS(err, "build in cluster config failed") } client, err := kubernetes.NewForConfig(config) if err != nil { klog.FatalS(err, "build k8s client failed") } dedupe := dedupe.NewCache(15 * time.Minute) ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) if err := controller.ReconcileAll(ctx, client, dedupe); err != nil { klog.ErrorS(err, "reconcile failed") } cancel() } }ReconcileAll负责遍历节点并分发处理逻辑。
5.2 防重复判断
防重复有三个层级:
第一层:持久化 Annotation 防重复。控制器读取handled-fault-id,如果与当前fault-id一致,直接跳过。
第二层:内存缓存防重复。利用带 TTL 的缓存记录最近处理过的故障 ID,防止在控制器重启或 Annotation 回写异常时重复处理。
第三层:动作幂等。即使前两层都被绕过,打 Taint 和删除 Pod 的操作也必须可以安全重复执行。
内存缓存实现如下:
// 文件路径:internal/dedupe/cache.go type Cache struct { mu sync.Mutex items map[string]time.Time ttl time.Duration } func NewCache(ttl time.Duration) *Cache { return &Cache{ items: make(map[string]time.Time), ttl: ttl, } } func (c *Cache) TryAcquire(key string) bool { c.mu.Lock() defer c.mu.Unlock() expireAt, ok := c.items[key] if ok && time.Now().Before(expireAt) { return false } c.items[key] = time.Now().Add(c.ttl) return true }这里的 key 建议使用nodeName + "/" + faultID,避免不同节点的故障互相干扰。
5.3 打 Taint 封锁节点
处理故障的第一步,是给异常节点打上一个 NoExecute 效果的 Taint,让新的 Pod 不再调度上来,并驱赶掉节点上已有的普通 Pod。
func addFaultTaint(node *corev1.Node) bool { for _, t := range node.Spec.Taints { if t.Key == FaultTaintKey { return false } } node.Spec.Taints = append(node.Spec.Taints, corev1.Taint{ Key: FaultTaintKey, Value: node.Annotations[AnnotationPrefix+"/fault-id"], Effect: corev1.TaintEffectNoExecute, }) return true }FaultTaintKey可以定义为:
const FaultTaintKey = "gpu-fault.example.com/gpu-fault"需要说明的是,NoExecute Taint 会驱逐不满足容忍条件的 Pod,但驱逐动作是异步的。如果节点本身状态已经异常,驱逐可能不够及时,所以还需要主动删除 GPU Pod。
5.4 删除 GPU Pod
控制器需要找出该节点上所有申请了 GPU 资源的 Pod,然后主动删除。
判断 GPU Pod 的方法如下:
func isGPUPod(pod *corev1.Pod) bool { containers := append(pod.Spec.InitContainers, pod.Spec.Containers...) for _, c := range containers { for name, quantity := range c.Resources.Requests { if name == corev1.ResourceName("nvidia.com/gpu") && quantity.Value() > 0 { return true } } } return false }删除 Pod 时,需要指定命名空间和 Pod 名称:
func deleteGPUPodsOnNode(ctx context.Context, client *kubernetes.Clientset, nodeName string) error { pods, err := client.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 } err := client.CoreV1().Pods(pod.Namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{}) if err != nil && !apierrors.IsNotFound(err) { klog.ErrorS(err, "delete gpu pod failed", "pod", pod.Name, "namespace", pod.Namespace) continue } klog.Infof("delete gpu pod %s/%s on node %s", pod.Namespace, pod.Name, nodeName) } return nil }这里用到 FieldSelectorspec.nodeName,可以快速筛选出指定节点上的 Pod。
5.5 处理结果回写与故障恢复
在成功完成“打 Taint + 删除 GPU Pod”之后,控制器才写入handled-fault-id。这个顺序很重要:如果先写处理结果再删 Pod,中途删 Pod 失败,后续扫描就会因为“已处理”而跳过,造成故障节点上没有 Pod 被清理的遗漏。
func markHandled(ctx context.Context, client *kubernetes.Clientset, nodeName, faultID string) error { for attempt := 0; attempt < 3; attempt++ { node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) if err != nil { return err } if node.Annotations == nil { node.Annotations = map[string]string{} } node.Annotations[AnnotationPrefix+"/handled-fault-id"] = faultID node.Annotations[AnnotationPrefix+"/handled-at"] = time.Now().Format(time.RFC3339) _, err = client.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{}) if err == nil { return nil } if !apierrors.IsConflict(err) { return err } } return fmt.Errorf("mark handled failed") }对应的ReconcileAll逻辑可以写成:
func ReconcileAll(ctx context.Context, client *kubernetes.Clientset, dedupe *dedupe.Cache) error { nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) if err != nil { return err } for i := range nodes.Items { node := &nodes.Items[i] faultID := node.Annotations[AnnotationPrefix+"/fault-id"] if faultID == "" { // 故障已恢复,清理 Taint if err := recoverNode(ctx, client, node.Name); err != nil { klog.ErrorS(err, "recover node failed", "node", node.Name) } continue } handledID := node.Annotations[AnnotationPrefix+"/handled-fault-id"] if handledID == faultID { continue } if !dedupe.TryAcquire(node.Name + "/" + faultID) { continue } // 打 Taint if err := taintNode(ctx, client, node.Name, faultID); err != nil { klog.ErrorS(err, "taint node failed", "node", node.Name) continue } // 删除 GPU Pod if err := deleteGPUPodsOnNode(ctx, client, node.Name); err != nil { klog.ErrorS(err, "delete gpu pods failed", "node", node.Name) continue } // 标记已处理 if err := markHandled(ctx, client, node.Name, faultID); err != nil { klog.ErrorS(err, "mark handled failed", "node", node.Name) } } return nil }需要注意,这里taintNode需要自己内部实现 Get -> Update 的重试逻辑,避免与检测端同时修改 Node 导致 conflict。建议参考前面markNodeFault的写法。
6. 部署到 K8s 集群与验证
6.1 RBAC 配置
控制器需要访问 Node 和 Pod 资源,因此需要相应的 RBAC 权限。
# 文件路径:deploy/rbac.yaml apiVersion: v1 kind: ServiceAccount metadata: name: gpu-self-healing-controller namespace: gpu-self-healing --- apiVersion: v1 kind: ServiceAccount metadata: name: gpu-fault-checker namespace: gpu-self-healing --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: gpu-self-healing-controller rules: - apiGroups: [""] resources: ["nodes"] verbs: ["get", "list", "watch", "update", "patch"] - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: gpu-self-healing-controller roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: gpu-self-healing-controller subjects: - kind: ServiceAccount name: gpu-self-healing-controller namespace: gpu-self-healing --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: gpu-fault-checker rules: - apiGroups: [""] resources: ["nodes"] verbs: ["get", "list", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: gpu-fault-checker roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: gpu-fault-checker subjects: - kind: ServiceAccount name: gpu-fault-checker namespace: gpu-self-healing部署前先创建命名空间:
kubectl create namespace gpu-self-healing kubectl apply -f deploy/rbac.yaml6.2 控制器 Deployment
控制器部署为单副本 Deployment,最小配置如下:
# 文件路径:deploy/controller.yaml apiVersion: apps/v1 kind: Deployment metadata: name: gpu-self-healing-controller namespace: gpu-self-healing spec: replicas: 1 selector: matchLabels: app: gpu-self-healing-controller template: metadata: labels: app: gpu-self-healing-controller spec: serviceAccountName: gpu-self-healing-controller containers: - name: controller image: registry.example.com/gpu-self-healing-controller:v0.1.0 command: ["/controller"] env: - name: DEDUPE_TTL value: "15m"这里建议 replicas 保持 1。因为内存去重缓存是单机内存的,多副本会导致各个副本之间的去重状态不同。如果要保证高可用,应该引入 Leader Election,只让其中一个副本真正执行自愈逻辑。
6.3 手动模拟故障验证
没有真实 GPU 故障时,我们可以通过手动写入 Annotation 来模拟检测端的上报行为。
选择一台测试节点,然后执行:
kubectl annotate node <node-name> \ gpu-fault.example.com/status=faulty \ gpu-fault.example.com/fault-id=manual-test-001 \ gpu-fault.example.com/fault-time="2025-01-01T00:00:00+08:00"接着观察控制器日志:
kubectl -n gpu-self-healing logs -l app=gpu-self-healing-controller -f正常情况下,控制器会依次输出类似下面的处理过程:
I0101 00:00:10.123456 123456 reconcile.go:88] taint node <node-name> with fault manual-test-001 I0101 00:00:10.234567 123456 reconcile.go:95] delete gpu pod default/resnet-train-xxxx on node <node-name> I0101 00:00:10.345678 123456 reconcile.go:105] mark node <node-name> handled, fault=manual-test-001验证结束后,清理模拟故障标记:
kubectl annotate node <node-name> \ gpu-fault.example.com/status- \ gpu-fault.example.com/fault-id- \ gpu-fault.example.com/fault-time-控制器扫描到故障标记消失后,会清理节点上的故障 Taint,节点重新恢复正常调度。
7. 常见问题与排查思路
在实际落地过程中,容易遇到下面几类问题,我整理了一份排查对照表。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 控制器更新节点一直报 conflict | 检测端和控制器同时修改同一个 Node | 更新前重新 Get,冲突后重试,或者改用 Patch 只修改指定字段 |
| 节点打了 Taint 但 GPU Pod 没有被驱逐 | NoExecute 是异步驱逐,或者 Pod 设置了容忍 | 主动删除 GPU Pod 作为兜底,不要依赖 Taint 驱逐 |
| 同一个故障被处理多次 | 故障 ID 每次都在变化,或 handled-fault-id 未正确写入 | 检查检测端是否保留旧 fault-id;确认处理完成后再写入 handled 标记 |
| 故障恢复后节点仍处于封禁状态 | 检测端没有清空 fault-id,或控制器没有执行恢复逻辑 | 检查 Node Annotation,手动清理故障标记,观察控制器日志 |
| 控制器多副本环境下重复处理 | 内存去重缓存不共享 | 控制器固定单副本,或增加 Leader Election |
| 检测端执行 nvidia-smi 失败 | 容器内没有对应二进制,或设备目录未挂载 | 在检测镜像中预装 nvidia-smi,或使用 HostPath 挂载;生产环境建议使用 DCGM Exporter |
| 删除 GPU Pod 后Pod 又被调度回故障节点 | 故障 Taint 尚未生效,或删除速度太快 | 先确保 Taint 更新成功,再删除 Pod;删除后检查 Pod 调度结果 |
建议在测试环境把以上场景完整走一遍,尤其是“故障恢复后的 Taint 清理”这个环节,这个环节很容易被开发者遗漏。
8. 最佳实践与工程建议
8.1 检测与处理解耦
检测端只负责发现故障、上报故障、清除故障标记;处理端只负责隔离节点、清理 Pod。两者通过 Node Annotation 通信,不要在处理端里面去执行nvidia-smi,也不要在检测端里面去删 Pod。这样职责清晰,后续无论是换成 DCGM 指标,还是增加新的硬件故障类型,改动范围都能控制在单侧。
8.2 动作必须幂等
自愈控制器即使做了防重复,也要假设自己会被重复调用。打 Taint 前先检查是否已存在,删除 Pod 时忽略 NotFound 错误,这些都是幂等设计。这样即使去重逻辑出现漏洞,也不会造成严重的二次破坏。
8.3 高可用方案
如果要求控制器本身高可用,可以使用 Kubernetes 的 Leader Election 机制。多个副本启动后,只有 Leader 执行 Reconcile,其他副本作为备用。Leader 失联后自动切换,同时保证去重缓存仍然只在单副本内记忆。具体可以使用k8s.io/client-go/tools/leaderelection实现。
8.4 故障确认次数
不建议检测端第一次发现 GPU 异常就直接上报故障。显卡驱动偶尔会因为光照、电压波动出现瞬时错误。更稳妥的做法是:连续 3 次检测都发现故障,才将节点标记为 faulty。可以在检测端增加一个本地失败计数状态,存入文件或内存中。
8.5 日志与审计
每个自愈动作都要记录结构化日志,至少包含:
- 节点名称;
- 故障 ID;
- 处理动作;
- 处理结果;
- 耗时。
这样后续出问题时,可以通过日志快速复盘整个处理链路。
8.6 告警联动
自愈动作执行完后,最好通过 Webhook 或 Alertmanager 发送一条通知,告诉值班同学“哪个节点已经自动隔离、哪些 Pod 已被删除”。自动化处理不等于无人值守,及时的人工确认仍然是生产环境的安全底线。
9. 总结与学习路线
本文从 GPU 故障自愈的实际需求出发,完成了一条完整的闭环方案:检测端通过nvidia-smi检测 GPU 异常,将故障 ID 写入 Node Annotation;控制器扫描故障标记,通过 Taint 隔离节点、删除 GPU Pod,并通过持久化 Annotation 和内存缓存双重机制防止重复处理。
整套方案的核心就是三个关键词:故障标识、幂等动作、状态持久化。这三个点不仅适用于 GPU 故障,也适用于其他任何需要在 Kubernetes 集群中实现“自愈 + 防重复”的场景。
如果你打算继续深入,可以按下面的路线学习:
- 先掌握
client-go的 Informer 机制,把定时扫描改成事件驱动; - 再学习
controller-runtime和 Kubebuilder,用标准 CRD 控制器模式重构本方案; - 然后引入 NVIDIA DCGM,基于指标判断 GPU 故障,而不是只依赖命令行;
- 最后研究 Leader Election 和分布式协调,把控制器做成真正的高可用组件。
自愈系统最忌讳过度处理。宁可让故障节点多停留几分钟等待人工确认,也不要让脚本在 30 秒内把整个集群的节点全部驱逐。把防重复机制做好,把动作做幂等,再配合完善的告警和审计,这套系统才能真正在 GPU 集群运维中帮上忙。