GPU 节点因 CPU/内存丰富会被默认调度器滥用,导致关键时无法腾退。用 nvidia.com/gpu=present:NoSchedule 可防止非 GPU 负载占用昂贵节点。
GPU 节点是集群中成本最高的节点,对调度器来说也是最具吸引力的节点:它通常拥有最多的空闲 CPU 和内存。如果没有污点,它会悄然被日志 sidecar 和批处理任务填满。
默认调度器会将 Pod 分散到有空间的节点上。GPU 实例类型通常搭配大量的 CPU 和内存,因此它呈现为集群中空缺最多的节点,对任何不挑地方的 Pod 都能在评分中胜出。这些 Pod 不消耗 nvidia.com/gpu,所以 GPU 调度仍然有效——直到节点需要排空或缩容的时刻,此时它无法做到了,因为上面运行着一些与之无关的任务。
这才是真正的代价,而且主要不是性能问题。一个因为 CPU 工作负载占据其上而无法移除的 GPU 节点,是你拥有的最昂贵的闲置资源。自动扩缩容器会遵守 PodDisruptionBudget 和不可驱逐的 Pod,所以一个散落的 Pod 就能将一个加速实例无限期锁定。
业界约定是 nvidia.com/gpu=present:NoSchedule,这值得采用——即使在你更喜欢使用自己 key 的集群上,因为工具链认识它。Google 文档中记载,GKE 在你向已有非 GPU 资源池的集群添加 GPU 节点池时,会自动应用完全相同的污点。
kubectl taint nodes gpu-node-1 nvidia.com/gpu=present:NoSchedule
对于任何持久化的东西,不要用这种方式做。后续由自动扩缩容器创建节点的污点,不会携带通过 kubectl apply 的污点,所以污点必须归属于节点池定义:
在托管节点组上,污点是组身上的一个字段,会被重新应用到它启动的每个节点上。
在 Karpenter 上,是 NodePool 上的 spec.template.spec.taints,参见 Karpenter 将 GPU 节点缩容至零。
在 kubelet 直接配置上,是 --register-with-taints,它在节点注册时生效,因此覆盖了任何控制器调和之前的窗口。
三种效果在对已在运行的 Pod 的处理上有所不同。NoSchedule 阻止新的调度,但让正在运行的 Pod 保持不动。PreferNoSchedule 是评分惩罚而非规则,对于 GPU 资源池意味着在压力下最终会被违反。NoExecute 会驱逐不容忍它的 Pod,向一个正在运行的资源池添加它会驱逐你的监控代理。先从 NoSchedule 开始。
容忍度按 key、operator、value 和 effect 进行匹配。对于 value 不携带信息的污点,Exists 是应使用的形式——这样即使有人把 present 改成 true 也能继续工作:
apiVersion: apps/v1
kind: Deployment
metadata:
name: inference
spec:
replicas: 2
selector:
matchLabels: { app: inference }
template:
metadata:
labels: { app: inference }
spec:
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
nodeSelector:
nvidia.com/gpu.present: "true"
containers:
- name: server
image: registry.example/inference:1.4.0
resources:
limits:
nvidia.com/gpu: 1
你可能根本不需要写这个容忍度。Kubernetes 附带了一个准入插件 ExtendedResourceToleration,它会为任何请求扩展资源的 Pod 自动添加容忍度,以资源名称为 key。Google 文档记载 GKE 在运行它,这就是为什么 GPU Pod 无需在清单中写容忍度就能调度到 GKE 自动污点化的资源池上。在未启用该插件的集群上,相同的清单会处于 Pending 状态。显式写容忍度要多六行,但在两种情况下都能工作。
这是污点文档明确声明但所有人都会忘记的一点。污点是排斥,容忍度是消除排斥。两者都不表达偏好。具有 GPU 容忍度且没有其他约束的 Pod 可以自由运行在集群的任何位置,包括 CPU 节点上。
对于请求 nvidia.com/gpu 的 Pod 来说这无害,因为资源请求本身就是迫使 Pod 落到 GPU 节点上的约束。真正会出问题的是模型服务器周围的 Pod——sidecar、指标抓取器、数据加载器——它们被给了容忍度"以便和模型一起运行",现在却可以自由漫游。如果一个 Pod 必须位于 GPU 节点上,它还需要 nodeSelector 或 nodeAffinity;容忍度只是让这种调度变得合法。参见按 GPU 类型进行节点亲和性的调度选择那一半。
对一个已经满载的节点施加污点不会清理它。使用 NoSchedule,现有的 Pod 会完全保持原位,永远如此,因为效果在绑定时评估且不会再重新评估。通常的惊讶是:施加污点本想修复拥挤的 GPU 资源池,结果一小时后发现资源池还是一样拥挤。
真正移除它们的两种方式在影响范围上有所不同。kubectl drain 会封锁节点并通过驱逐 API 驱逐其 Pod,这会遵守 PodDisruptionBudget 并给予每个容器其终止宽限期——这是正确的工具,也是应该在 GPU 节点上使用的,因为正在运行的任务应该被允许完成。改用 NoExecute 污点则会立即驱逐所有不容忍它的东西,不考虑预算,这意味着在混合节点上你的监控代理也会被赶走。封锁本身也是一种污点:node.kubernetes.io/unschedulable 配合 NoSchedule 效果,这就是为什么被封锁的节点表现得和被污点化的节点完全一样。
NoExecute 污点的容忍度也可以携带 tolerationSeconds,意思是"容忍一段时间,然后离开"。Kubernetes 默认将其用于节点条件污点,所以位于不可达节点上的 Pod 会在一段延迟后被驱逐而非立即驱逐。对于 GPU 工作负载,值得延长这个延迟:移动一个模型服务器需要数分钟来重新加载,而一个短暂不可达的节点通常在替代节点完成加载之前就恢复了。
污点也会反馈给自动扩缩容器,这也是保持声明式配置的一个理由。集群自动扩缩容器和 Karpenter 都会模拟 Pending 的 Pod 是否能在给定资源池的节点上运行,而这种模拟使用的是资源池声明的污点。手动应用到运行中节点但资源池定义中缺失的污点,会让模拟过于乐观:为 Pod 调配了新节点,而这些节点随后会排斥它们。集群自动扩缩容器页面给出了为处于零节点的节点组声明污点的标签。
对 GPU 资源池施加污点也会排除集群基础设施,而失败的后果比看起来更严重:
NVIDIA device plugin。如果它无法部署到被污点化的节点上,该节点永远不会通告 nvidia.com/gpu,因此没有任何 GPU Pod 可以在任何地方调度。NVIDIA 的清单携带了对标准 key 的容忍度;自定义污点 key 需要添加到 DaemonSet 或 chart values 中。
CNI 和 kube-proxy DaemonSet。通常随附全局容忍度,但在使用非标准网络插件的集群上值得确认——没有 CNI 的节点会处于 NotReady 状态,看起来像硬件故障。
监控和日志代理。没有任何 node-exporter 和 DCGM exporter 的 GPU 节点,在成本最高的地方完全不可见。GPU 监控覆盖了这些代理应该收集的内容。
从节点层面验证而非从清单验证。施加污点后,kubectl get pods --all-namespaces --field-selector spec.nodeName=gpu-node-1 应该列出你的 GPU 工作负载加上你预期的 DaemonSet,且没有其他。清单中任何缺失的东西,都是你停止监控或停止网络化的对象。
Scheduling GPU Pods on Kubernetes
Fixing "0/N Nodes Are Available" for a GPU Pod
Node Affinity for Pinning Inference Pods to a GPU Type