如何在香港服务器的 Debian 集群里用「节点亲和性」把 GPU 虚拟化任务调顺

凌晨 3:10,我站在香港数据中心那个熟悉的机柜前,冷风从地板缝里往上钻。两台 A100 的风扇正对着我叫;而 Slack 里,模型推理队列又堆到 900+。同事说“GPU 明明空了,为啥 Pod 不上?”,我打开 kubectl get pods -A -o wide,一眼望去,全是 Pending,理由“node(s) didn't match node affinity/selector”。
那晚我把从 Debian 裸机到 Kubernetes 调度链路又撸了一遍,最后靠节点亲和性(Node Affinity)+ MIG/时间片共享把 GPU 虚拟化任务调顺了。第二天早上 6:40,落地窗外维港开始发白,队列清空,整整齐齐。
本文就是那晚(以及后续一周复盘)沉淀下来的完整实操教程。我会从硬件、系统、驱动、K8s、GPU Operator 到调度策略,一步步把坑和解法摆在你面前;既保证新手能复刻,也能让老手挑到细节。
现场环境与目标
机房与硬件(实配)
| 节点名 | 机架/可用区 | CPU & 内存 | GPU | MIG | 系统盘/数据盘 | NIC | 备注 |
|---|---|---|---|---|---|---|---|
hk-gpu-01 |
hk1a / R42 | 2×Xeon Gold 6348, 512GB | 2×A100 40GB PCIe | 支持 | 2×1.92TB NVMe(RAID1) | 2×25GbE | 机柜顶端,进风好 |
hk-gpu-02 |
hk1a / R43 | 2×Xeon Gold 6348, 512GB | 2×A100 40GB PCIe | 支持 | 2×1.92TB NVMe(RAID1) | 2×25GbE | 与 01 同交换机 |
hk-gpu-03 |
hk1b / R05 | 2×AMD EPYC 7643, 1TB | 4×A10 24GB | 不支持 MIG | 2×3.84TB NVMe(RAID1) | 2×25GbE | 专做时间片共享 |
OS 统一:Debian 12 (bookworm),内核 6.1;容器运行时 containerd 1.7;K8s v1.29(双控平面);CNI 用 Cilium;集群里安装了 NVIDIA GPU Operator(负责驱动、GFD、DCGM、Device Plugin)。
我们的目标
- 在 Debian 上稳定启用 A100 的 MIG(如 1g.5gb / 2g.10gb / 3g.20gb / 4g.20gb / 7g.40gb),实现“硬切片”多租并发推理。
- 在 A10 节点上启用 时间片共享(time-slicing) 做“软切片”。
- 用 Node Affinity/Anti-Affinity + 自定义标签 把不同 SLA/任务类型调度到合适的节点/机架/可用区。
- 用 Taints/Tolerations 保证 GPU 节点不被非 GPU 任务污染。
- 用 度量对比 验证优化效果。
基础安装:Debian + containerd + NVIDIA 组件
如果你也用 GPU Operator(强烈推荐),以下两条路二选一:Operator 自动装驱动 或 节点预装驱动。我实践下来,生产更偏向 Operator 托管,版本一致性更好。
1)containerd 与基础内核参数
# 基础组件
apt-get update && apt-get install -y curl gnupg2 software-properties-common jq
# containerd
apt-get install -y containerd
# systemd cgroup(很关键,否则 nvidia-container-runtime/cgroups 会出幺蛾子)
mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
systemctl enable --now containerd
# 内核参数(NUMA/大页按需):以下示例保持保守
echo 'vm.max_map_count=262144' > /etc/sysctl.d/99-sysctl.conf
sysctl --system
2)安装 Kubernetes 组件(略写关键步)
# 关闭 swap(kubelet 要求)
swapoff -a && sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
# kubelet/kubeadm/kubectl(按你集群管理方式走)
apt-get install -y kubelet kubeadm kubectl
systemctl enable kubelet
3)安装 NVIDIA GPU Operator(Helm)
helm repo add nvidia https://nvidia.github.io/gpu-operator
helm repo update
# hk-gpu 命名空间
kubectl create ns gpu-operator
# 基础安装(驱动、GFD、DCGM、Device Plugin 都交给它)
helm install gpu-operator nvidia/gpu-operator -n gpu-operator \
--set driver.enabled=true \
--set toolkit.enabled=true \
--set devicePlugin.enabled=true \
--set migManager.enabled=true \
--set node-feature-discovery.enabled=true
坑 1:驱动版本与内核头文件
Debian 12 下如果你自己装 DEB 驱动包,linux-headers 要与 running kernel 完全匹配,否则 DKMS 会失败。Operator 模式把这事打包好了,建议生产用它。
让 GPU“会说话”:MIG 与时间片共享
A100 开启 MIG(硬切片)
在 A100 节点(hk-gpu-01/02)上:
# 开启 MIG 模式(每块卡)
nvidia-smi -i 0 -mig 1
nvidia-smi -i 1 -mig 1
# 生效通常需要重置 GPU(会短暂中断,生产请维护窗操作)
nvidia-smi -i 0 -rgc
nvidia-smi -i 1 -rgc
# 例:在每块卡上创建 7×1g.5gb(小切片满铺)
nvidia-smi mig -i 0 -cgi 19,19,19,19,19,19,19 -C
nvidia-smi mig -i 1 -cgi 19,19,19,19,19,19,19 -C
# 19 是 A100 40GB 上 1g.5gb 的 profile ID(不同卡/容量 ID 不同,请以节点上 `nvidia-smi mig -lgip` 为准)
GPU Operator 的 MIG Manager + GFD(GPU Feature Discovery) 会把切片后的资源暴露成 K8s 资源与标签,例如:
资源名:nvidia.com/mig-1g.5gb、nvidia.com/mig-2g.10gb 等
标签:nvidia.com/mig.capable=true、nvidia.com/gpu.product=NVIDIA-A100-40GB-PCIe 等
坑 2:MIG 改动后 Pod 看不见新资源
经验:做完 MIG 分区后,等 GFD 重新发现(或手动滚动 GFD/Device Plugin DaemonSet)。极端情况下需要重启 kubelet。
A10 开启时间片共享(软切片)
在 A10 节点(hk-gpu-03)上,我们用 Device Plugin 的 time-slicing:
创建/更新 ConfigMap(Operator 会挂载给 Device Plugin):
apiVersion: v1
kind: ConfigMap
metadata:
name: nvidia-device-plugin-config
namespace: gpu-operator
data:
config.yaml: |
version: v1
flags:
migStrategy: none
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 4 # 每块卡切 4 份时间片
让 Device Plugin 使用它(Helm values):
helm upgrade gpu-operator nvidia/gpu-operator -n gpu-operator \
--set devicePlugin.config.name=nvidia-device-plugin-config \
--set devicePlugin.config.default=config.yaml
时间片心法:Pod 仍然 requests: nvidia.com/gpu: 1,只是 Device Plugin 让“同一物理卡并发跑多个 Pod”。吞吐拉高,但单个推理延迟会升高——适合批量/吞吐型。
给节点贴上“会说话的标签”:区域/机架/型号/SLA
我采用了一套自定义标签,再配合 Operator/GFD 自动的硬件标签:
# 可用区与机架
kubectl label node hk-gpu-01 topology.kubernetes.io/zone=hk1a rack=r42
kubectl label node hk-gpu-02 topology.kubernetes.io/zone=hk1a rack=r43
kubectl label node hk-gpu-03 topology.kubernetes.io/zone=hk1b rack=r05
# 角色与 GPU 能力
kubectl label node hk-gpu-01 gpu.role=online gpu.mig=enabled
kubectl label node hk-gpu-02 gpu.role=online gpu.mig=enabled
kubectl label node hk-gpu-03 gpu.role=batch gpu.timeslicing=enabled
# Taint 防止被通用工作负载占用
kubectl taint nodes hk-gpu-01 hk-gpu-02 hk-gpu-03 gpu-only=true:NoSchedule
坑 3:标签漂移
手工标签容易丢。我后面用 DaemonSet + init 脚本在节点启动时校正标签,或者把标签写进 kubelet --node-labels(自动化/不可变)。
调度策略:Node Affinity / Anti-Affinity / Taints / 拓扑
1)在线推理(低延迟)→ A100 + MIG-1g.5gb,固定 hk1a
apiVersion: apps/v1
kind: Deployment
metadata:
name: infer-online-a100-1g
namespace: ai
spec:
replicas: 12
selector:
matchLabels: { app: infer-online }
template:
metadata:
labels:
app: infer-online
tier: online
spec:
tolerations:
- key: "gpu-only"
operator: "Equal"
value: "true"
effect: "NoSchedule"
nodeSelector: # 最硬的筛选先走 here(简单场景够用)
nvidia.com/mig.capable: "true"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["hk1a"]
- key: gpu.mig
operator: In
values: ["enabled"]
- key: nvidia.com/gpu.product
operator: In
values: ["NVIDIA-A100-40GB-PCIe"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: rack
operator: In
values: ["r42","r43"] # 同交换机,跨机架少
- weight: 20
preference:
matchExpressions:
- key: gpu.role
operator: In
values: ["online"]
containers:
- name: infer
image: registry.local/serving:cuda12.2
resources:
limits:
nvidia.com/mig-1g.5gb: 1
cpu: "2"
memory: "6Gi"
ports:
- containerPort: 8080
要点
- limits: nvidia.com/mig-1g.5gb: 1:请求一个 1g.5gb 实体 MIG 切片。
- requiredDuringScheduling… 把“必须条件”写实:区域、MIG 可用、GPU 产品型号。
- preferred… 把“更优”表达出来:同交换机机架优先,online 角色优先。
2)离线批处理(吞吐优先)→ A10 时间片,尽量不与 online 混
apiVersion: batch/v1
kind: Job
metadata:
name: batch-emb-a10
namespace: ai
spec:
backoffLimit: 1
parallelism: 16
template:
metadata:
labels: { app: emb-build, tier: batch }
spec:
tolerations:
- key: "gpu-only"
operator: "Equal"
value: "true"
effect: "NoSchedule"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: gpu.timeslicing
operator: In
values: ["enabled"]
- key: topology.kubernetes.io/zone
operator: In
values: ["hk1b"]
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
podAffinityTerm:
labelSelector:
matchExpressions:
- key: tier
operator: In
values: ["online"]
topologyKey: "kubernetes.io/hostname"
containers:
- name: emb
image: registry.local/offline:cuda12.2
resources:
limits:
nvidia.com/gpu: 1 # time-slicing 下仍是 1,设备插件会“并发”
cpu: "4"
memory: "12Gi"
要点
- gpu.timeslicing=enabled 把任务锁进支持时间片的节点;
- Anti-Affinity 尽量避免与 tier=online 同宿主机;
- parallelism 结合 time-slicing 的 replicas 调大吞吐。
3)统一的 RuntimeClass(避免容器运行时踩坑)
创建 NVIDIA runtimeclass:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: nvidia
handler: nvidia
在 Pod 里引用:
spec:
runtimeClassName: nvidia
坑 4:could not select device driver "nvidia"
99% 是 nvidia-container-toolkit 没注入或 containerd 没开 SystemdCgroup=true。Operator 模式下一般由 toolkit 组件搞定,自己装时注意 /etc/containerd/config.toml 的 runtimes.nvidia 配置是否存在。
验证与回归:资源发现、拓扑与监控
1)节点与标签快检
kubectl get nodes -L topology.kubernetes.io/zone,rack,gpu.mig,gpu.timeslicing,nvidia.com/gpu.product
2)资源是否被 K8s 识别
# MIG 资源
kubectl describe node hk-gpu-01 | grep -A2 "Capacity" | grep mig
# 时间片配置(看 Device Plugin 日志)
kubectl -n gpu-operator logs ds/nvidia-device-plugin-daemonset | grep time-slicing
3)DCGM + Prometheus 指标(核心几个)
- DCGM_FI_DEV_GPU_UTIL(GPU 利用率)
- DCGM_FI_DEV_FB_USED(显存使用)
自定义:队列长度、P95 推理延迟、作业等待时长(从队列/网关采)
优化前后对照(真实数据一夜复盘)
| 指标 | 优化前(散落调度) | 优化后(亲和性 + MIG/时间片) |
|---|---|---|
| 在线推理 P95 延迟 | 120ms | 78ms |
| 在线 Pod Pending 平均时长 | 4m12s | 28s |
| A100 平均 GPU 利用率 | 41% | 71% |
| A10 节点吞吐(req/s) | 1.0× | 2.7× |
| 同机架跨交换机流量 | 高 | 降 36% |
| 事故:误上非 GPU 节点 | 偶发 | 0(taint 生效) |
真实踩坑与解决清单
MIG Profile ID 对不上
先跑 nvidia-smi mig -lgip 看本卡支持的具体 ID 和 profile 名,再下命令。不同驱动/卡/容量 ID 可能不同。
GFD/GPU Device Plugin 没感知新切片
滚动这两个 DaemonSet 最快;极端再滚 kubelet。
Debian 12 + cgroup v2
containerd SystemdCgroup=true 必开;RuntimeClass 用 nvidia;否则偶发 cgroup permission。
镜像 CUDA/驱动不匹配
统一用一个 CUDA 基础镜像族(如 cuda:12.2),Operator 装的驱动与它匹配;不混搭。
标签失真/丢失
用 DaemonSet 初始化脚本或 --node-labels 固化;不要全靠手贴。
队列优先级
给在线/离线两个 PriorityClass,搭配 preemptionPolicy,确保 SLA。
跨可用区回源慢
preferredDuringScheduling 倾向就近(同 zone、同 rack),把远端作为次选,必要时加 topologySpreadConstraints。
额外的“老鸟手感”建议
- 把亲和性策略做成“模板”:在线、离线、训练三类各一套,CI/CD 里按应用类型自动注入。
- 不要一次把 MIG 切满:先从 1g.5gb/2g.10gb 做 AB 测出最优切片,再铺满。迁移切片要维护窗。
- 把 GPU 节点当“小集群”管理:独立 namespace、独立网段(必要时)、单独 taint。
- 配合 Descheduler:避免“初调好后来又漂”,比如启用 RemoveDuplicates、LowNodeUtilization 等策略(按需)。
结尾:凌晨的风停了
那天早上,我在机房门口把工单状态改成“已恢复”。手机屏幕上,在线延迟曲线像是被人按住了脑袋,稳稳地贴在 80ms 附近;A10 的吞吐飙上去,队列也逐步清空。
其实我们做的并不神秘:把资源切清楚(MIG/时间片)、把节点标明白(标签/taint),再把调度说准确(亲和性/反亲和/拓扑)。
当这些原则在 Debian + Kubernetes 的链路里逐个落位,GPU 就不再是“凭缘分”的黑盒,而是可预期、可复用、可度量的生产力。
如果你也在香港的机房里吹过半夜的冷风,希望这份笔记能让你少走几步回头路。祝你今天的队列,也能在天亮之前清空。