Node Problem Detector:GPU 节点异常要在调度层感知到,别等 Pod 卡死才排查

发布时间:2026/7/11 11:33:20
Node Problem Detector:GPU 节点异常要在调度层感知到,别等 Pod 卡死才排查 Node Problem DetectorGPU 节点异常要在调度层感知到别等 Pod 卡死才排查一、GPU 节点的 ECC 错误累积了 3 天调度器还在往上发 Pod线上遇到过这样一个场景某台 A100 节点的 GPU 内存开始出现可纠正的 ECC 错误Single-Bit Error数量从每小时几十逐渐增长到每秒上百。但 Kubernetes 调度器完全不知道这件事依然持续往这个节点调度推理 Pod。直到某天 GDR 内存的一根 VRAM 彻底烧坏变成不可纠正错误Double-Bit Error节点上所有 Pod 集体挂掉业务才发出告警。基础设施不需要漂亮话Kubernetes 原生的节点健康检查只有NodeReady和MemoryPressure/DiskPressure/PIDPressure这几个粗粒度条件。GPU 的 ECC 错误、NVLink 降级、PCIe 链路训练失败、GPU 温度过高——这些硬件级异常对 kubelet 来说是不可见的。调度器看到的只是一个Ready的节点根本不知道这节点已经在硬件层面带病工作了。Node Problem DetectorNPD就是用来填补这个空白的。它在每个节点上以 DaemonSet 方式运行监控内核日志、系统日志、自定义健康检查脚本然后将检测到的异常转化为 Node Condition 或 Events让调度器能基于真实硬件健康状态做调度决策。二、NPD 的检测架构与 GPU 异常映射NPD 的检测流程分为三层日志解析 → 规则匹配 → 条件上报flowchart TD subgraph 节点层 A1[kernel logbr//var/log/kern.log] -- B[log-counterbr/日志模式匹配] A2[nvidia-smi / dcgmibr/GPU 健康检查] -- C[custom-pluginbr/脚本执行] A3[systemd journalbr/服务状态] -- D[system-log-monitorbr/journal 监控] end subgraph NPD Daemon B -- E[规则引擎br/pattern → condition 映射] C -- E D -- E end subgraph Kubernetes API E -- F1[Node Conditionbr/GPUECCErrorTrue] E -- F2[Node Conditionbr/GPUTempHighTrue] E -- F3[Node Conditionbr/NVL inkDegradedTrue] E -- F4[Eventsbr/GPU ECC error detected] end subgraph 调度与自愈 F1 F2 F3 -- G{调度器 Taint 匹配} G -- H1[Taint: gpu-ecc-errorbr/NoSchedule] G -- H2[Taint: gpu-temp-highbr/NoExecute] F1 -- I[自愈系统br/自动 Cordon Drain] endNPD 的核心能力是条件映射——将底层硬件异常转化为 Kubernetes 原生的调度语义硬件异常检测方式NPD 映射GPU ECC Errordcgmi 日志正则匹配GPUMemoryECCErrorTrue→ Taint:gpu-ecc-error:NoScheduleGPU 温度过高nvidia-smi 温度查询GPUTemperatureHighTrue→ Taint:gpu-temp-high:NoExecuteNVLink 降级dcgmi nvlink 状态检查NVL inkDegradedTrue→ Taint:nvlink-degraded:NoSchedulePCIe 链路降速lspci 速率检查PCIeLaneDegradedTrue→ Taint:pcie-degraded:NoScheduleGPU 掉卡nvidia-smi 设备枚举GPUDeviceLostTrue→ Taint:gpu-lost:NoExecuteTaint 与 Toleration 的配合正常推理 Pod 不配置任何 GPU 相关的 Toleration当节点被标记了gpu-ecc-error:NoScheduleTaint 后调度器自然跳过该节点。同时已有的 Pod 不受 NoSchedule 影响只影响新调度给运维留出处理时间。对于NoExecute级别的 Taint如 GPU 掉卡已有的 Pod 也会被驱逐适配紧急场景。三、生产级 NPD GPU 监控部署3.1 NPD DaemonSet 配置# NPD DaemonSet with GPU monitoring apiVersion: apps/v1 kind: DaemonSet metadata: name: node-problem-detector namespace: kube-system spec: selector: matchLabels: app: node-problem-detector template: metadata: labels: app: node-problem-detector spec: hostNetwork: true hostPID: true serviceAccountName: node-problem-detector containers: - name: node-problem-detector image: registry.k8s.io/node-problem-detector/node-problem-detector:v0.8.17 command: - /node-problem-detector - --logtostderr - --config.system-log-monitor/config/kernel-monitor.json,/config/gpu-monitor.json - --config.custom-plugin-monitor/config/gpu-custom-plugin.json - --prometheus-address0.0.0.0 - --prometheus-port20257 securityContext: privileged: true volumeMounts: - name: log mountPath: /var/log readOnly: true - name: kmsg mountPath: /dev/kmsg readOnly: true - name: config mountPath: /config readOnly: true - name: nvidia mountPath: /usr/bin/nvidia-smi readOnly: true env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName volumes: - name: log hostPath: path: /var/log/ - name: kmsg hostPath: path: /dev/kmsg - name: config configMap: name: npd-config - name: nvidia hostPath: path: /usr/bin/nvidia-smiGPU 内核日志监控规则gpu-monitor.json{ plugin: kmsg, logPath: /dev/kmsg, lookback: 5m, bufferSize: 10, source: gpu-monitor, metricsReporting: true, conditions: [ { type: GPUMemoryECCError, reason: GPU ECC memory error detected, message: GPU has experienced ECC memory errors }, { type: GPUNVL inkError, reason: GPU NVLink error detected, message: GPU NVLink has experienced errors }, { type: GPUPCIeError, reason: GPU PCIe error detected, message: GPU PCIe link has experienced errors }, { type: GPUXidCriticalError, reason: GPU Xid critical error detected, message: GPU has experienced a critical Xid error requiring reset } ], rules: [ { type: temporary, reason: GPUDRAMECCSBE, pattern: NVRM:.*SBE.*DRAM, condition: GPUMemoryECCError }, { type: permanent, reason: GPUDRAMECCDBE, pattern: NVRM:.*DBE.*DRAM, condition: GPUMemoryECCError }, { type: permanent, reason: GPUNVL inkFailure, pattern: NVRM:.*NVLink.*error|NVRM:.*nvlink.*fail, condition: GPUNVL inkError }, { type: permanent, reason: GPUPCIeLinkDown, pattern: NVRM:.*PCIe link.*down|NVRM:.*PCIE.*error, condition: GPUPCIeError }, { type: permanent, reason: GPUXidCritical, pattern: NVRM: Xid.*: (31|43|44|45|48|61|62|63|64|74|79|92|94|95), condition: GPUXidCriticalError } ] }3.2 Go 侧 GPU 节点健康守护程序NPD 检测到的条件最终通过 Kubernetes API 可查询。以下 Go 代码实现一个 GPU 节点健康守护周期性扫描节点 Conditions 并做自动化处理package gpu import ( context fmt time github.com/pkg/errors corev1 k8s.io/api/core/v1 metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/client-go/kubernetes k8s.io/client-go/rest ) // GPUNodeGuard GPU 节点健康守护程序 type GPUNodeGuard struct { clientset *kubernetes.Clientset // 需要监控的 GPU 节点标签选择器 nodeSelector string // 检查间隔 interval time.Duration // 问题节点列表用于去重避免重复操作 actionedNodes map[string]time.Time } // NewGPUNodeGuard 创建 GPU 节点守护程序 func NewGPUNodeGuard(nodeSelector string, interval time.Duration) (*GPUNodeGuard, error) { config, err : rest.InClusterConfig() if err ! nil { return nil, errors.Wrap(err, get in-cluster config) } clientset, err : kubernetes.NewForConfig(config) if err ! nil { return nil, errors.Wrap(err, create clientset) } return GPUNodeGuard{ clientset: clientset, nodeSelector: nodeSelector, interval: interval, actionedNodes: make(map[string]time.Time), }, nil } // Run 启动守护循环 func (g *GPUNodeGuard) Run(ctx context.Context) error { ticker : time.NewTicker(g.interval) defer ticker.Stop() for { select { case -ctx.Done(): return ctx.Err() case -ticker.C: if err : g.scanNodes(ctx); err ! nil { // 单次扫描失败不退出循环 // 生产上建议在此上报 scan_error 指标 } } } } // scanNodes 扫描 GPU 节点的 Conditions 并执行对应动作 func (g *GPUNodeGuard) scanNodes(ctx context.Context) error { // 列出 GPU 节点 nodes, err : g.clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{ LabelSelector: g.nodeSelector, }) if err ! nil { return errors.Wrap(err, list nodes) } for _, node : range nodes.Items { g.processNode(ctx, node) } return nil } // processNode 处理单个节点的异常条件 func (g *GPUNodeGuard) processNode(ctx context.Context, node corev1.Node) { conditions : node.Status.Conditions for _, cond : range conditions { if cond.Status ! corev1.ConditionTrue { continue } switch cond.Type { case GPUMemoryECCError: // ECC 错误分等级处理 if cond.Reason GPUDRAMECCDBE { // 不可纠正 ECC 错误Double-Bit Error // 立即 Cordon 节点 驱逐所有 Pod g.handleCriticalNode(ctx, node.Name, GPU DBE ECC, cond.Message) } else { // 可纠正 ECC 错误Single-Bit Error // 只 Cordon不驱逐已有 Pod g.handleWarningNode(ctx, node.Name, GPU SBE ECC, cond.Message) } case GPUXidCriticalError: // Xid 31/43/45/48 等是致命错误必须重启才能恢复 g.handleCriticalNode(ctx, node.Name, GPU Xid Critical, cond.Message) case GPUTemperatureHigh: // 温度过高Cordon 等待降温后自动恢复 g.handleWarningNode(ctx, node.Name, GPU Temperature High, cond.Message) case GPUNVL inkError: // NVLink 错误Cordon业务降级为纯 PCIe 推理 g.handleWarningNode(ctx, node.Name, GPU NVLink Error, cond.Message) case GPUPCIeError: // PCIe 错误严重影响数据传输Cordon g.handleCriticalNode(ctx, node.Name, GPU PCIe Error, cond.Message) case GPUDeviceLost: // GPU 掉卡最严重 g.handleCriticalNode(ctx, node.Name, GPU Device Lost, cond.Message) } } } // handleWarningNode 处理 Warning 级别的节点异常 // 只阻止新 Pod 调度不驱逐已有 Pod func (g *GPUNodeGuard) handleWarningNode(ctx context.Context, nodeName, reason, message string) { // 去重同一节点 10 分钟内不重复操作 if lastAction, exists : g.actionedNodes[nodeName]; exists { if time.Since(lastAction) 10*time.Minute { return } } // Cordon 节点 if err : g.cordonNode(ctx, nodeName); err ! nil { // Cordon 失败记录但不中断流程 return } // 记录操作时间 g.actionedNodes[nodeName] time.Now() // 发送通知 g.notify(ctx, fmt.Sprintf([GPU Guard] 节点 %s: %s - %s (已 Cordon), nodeName, reason, message)) } // handleCriticalNode 处理 Critical 级别的节点异常 // 阻止新 Pod 调度 驱逐已有 Pod func (g *GPUNodeGuard) handleCriticalNode(ctx context.Context, nodeName, reason, message string) { if lastAction, exists : g.actionedNodes[nodeName]; exists { if time.Since(lastAction) 10*time.Minute { return } } // 1. Cordon 节点 if err : g.cordonNode(ctx, nodeName); err ! nil { return } // 2. Drain 节点驱逐所有 Pod // 注意Drain 是破坏性操作需要确认节点上无状态服务 if err : g.drainNode(ctx, nodeName); err ! nil { // Drain 失败通知运维 g.notify(ctx, fmt.Sprintf([GPU Guard DRAIN FAILED] 节点 %s: %s, nodeName, err.Error())) return } g.actionedNodes[nodeName] time.Now() g.notify(ctx, fmt.Sprintf([GPU Guard CRITICAL] 节点 %s: %s - %s (已 Cordon Drain), nodeName, reason, message)) } // cordonNode 将节点标记为不可调度 func (g *GPUNodeGuard) cordonNode(ctx context.Context, nodeName string) error { node, err : g.clientset.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) if err ! nil { return errors.Wrap(err, get node) } if node.Spec.Unschedulable { return nil // 已经 Cordon } node.Spec.Unschedulable true _, err g.clientset.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{}) return errors.Wrap(err, update node) } // drainNode 驱逐节点上的所有 Pod // 生产上建议使用 client-go 的 drain.Helper 做更精细的控制 func (g *GPUNodeGuard) drainNode(ctx context.Context, nodeName string) error { pods, err : g.clientset.CoreV1().Pods().List(ctx, metav1.ListOptions{ FieldSelector: fmt.Sprintf(spec.nodeName%s, nodeName), }) if err ! nil { return errors.Wrap(err, list pods) } var lastErr error for _, pod : range pods.Items { // 跳过 DaemonSet 管理的 Pod它们会自动重建 if isDaemonSetPod(pod) { continue } err : g.clientset.CoreV1().Pods(pod.Namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{}) if err ! nil { lastErr err } } if lastErr ! nil { return errors.Wrap(lastErr, delete pods) } return nil } // notify 发送通知对接钉钉/Slack/PagerDuty func (g *GPUNodeGuard) notify(ctx context.Context, msg string) { // 生产实现发送 Webhook 到通知通道 } // isDaemonSetPod 判断 Pod 是否由 DaemonSet 管理 func isDaemonSetPod(pod corev1.Pod) bool { for _, owner : range pod.OwnerReferences { if owner.Kind DaemonSet { return true } } return false }四、NPD 的运维代价与边界误报与阈值调优GPU ECC 单比特错误在某些卡上是正常现象A100 的 ECC SBE 设计上允许一定发生率。如果 NPD 把每个 SBE 都映射为GPUMemoryECCErrorTrue可能导致频繁的不必要 Cordon。正确做法是在 NPD 规则中区分 SBE 和 DBESBE 设为 Warning Event不改变 Node ConditionDBE 才触发 Taint。同时配合 Prometheus 的rate()做 SBE 频率趋势监控——SBE 频率持续增长时才触发排查。NPD 本身的资源占用NPD 以 DaemonSet 形式运行在每台 GPU 节点上。GPU 监控插件需要执行nvidia-smi或dcgmi命令这些命令调用 GPU 驱动的内核接口。在高负载推理节点上频繁的 GPU 状态查询可能与推理任务竞争 GPU 驱动资源。建议GPU 健康检查频率不低于 30 秒一次避免每秒轮询。Taint 的传播延迟从 NPD 检测到异常 → 更新 Node Condition → 调度器感知 Taint → 停止调度新 Pod这个过程有数秒到数十秒的延迟。在高并发调度场景下如弹性扩容期这个延迟窗口内可能会有 Pod 被调度到问题节点上。可以配合Descheduler在调度后做二次校验发现 Pod 落在问题节点上主动驱逐。与节点自动修复的协作NPD 负责检测和标记但节点修复是谁来做可以集成KuredKubernetes Reboot Daemon——Kured 监听 Node Condition当检测到GPUXidCriticalErrorTrue时自动 drain reboot 节点。但 reboot 前需要确认节点上所有 GPU 进程已退出否则 reboot 可能导致 GPU 驱动异常。建议 Kured 和 GPU Node Guard 通过 Node Annotation 通信——Guard 先 Cordon Drain完成后设置 AnnotationKured 再执行 reboot。五、总结Node Problem Detector 让 Kubernetes 调度器能看到 GPU 节点的真实硬件健康状态条件映射是核心ECC 错误、NVLink 降级、PCIe 异常 → Node Condition Taint调度器基于真实状态做决策。SBE/DBE 要分等级处理可纠正错误设 Warning 不触发 Taint不可纠正错误才执行 Cordon Drain。避免单比特错误导致的误调度干预。GPU 节点守护程序做自动化闭环NPD 检测 守护程序执行 Cordon/Drain/通知形成从检测到响应的完整闭环。配合 Descheduler Kured 做自动修复NPD 负责发现问题Descheduler 负责纠偏调度Kured 负责修复节点——三件套协同。调度器不知道节点带病比节点生病本身更危险。