第1652篇:云原生AI应用的资源调度——GPU节点池管理与亲和性策略
第1652篇:云原生AI应用的资源调度——GPU节点池管理与亲和性策略
这篇文章源自一次真实的生产故障复盘。
某个晚上,我们的AI服务突然大面积超时,告警疯狂响。查下去发现:一个数据分析任务临时申请了一批GPU节点,把原本给推理服务预留的GPU全占了,推理Pod调度不上去,在Pending状态堆积。
这个问题暴露的不是某个代码Bug,而是我们当时对GPU节点池的管理太粗放——所有需要GPU的任务都往同一个资源池里扔,没有优先级区分,没有隔离策略,迟早会出这种问题。
花了大概两周时间系统性地重新设计了GPU资源管理方案,这篇文章把整个思路和实现细节记录下来。
GPU节点的特殊性:为什么不能和CPU节点一样管
在Kubernetes里,CPU和内存是可以被多个Pod共享的,但GPU目前(截至主流生产环境使用的K8s版本)默认情况下是整卡分配的。也就是说,如果你申请了nvidia.com/gpu: 1,那这块GPU就被你独占了,哪怕你只用了30%的显存。
这个特性带来了几个在CPU节点上不存在的问题:
碎片化问题。假设你有3块GPU,有3个Pod各申请1块,都跑满了。现在来一个需要2块GPU的任务,没法调度——明明有空闲的GPU,但就是凑不出来。这种情况在CPU节点上很少见(CPU是可以共享的),但GPU节点上是个真实挑战。
显存泄漏的影响面更大。CPU进程泄漏内存,顶多影响这一个Pod。GPU显存泄漏,可能直接影响同一块GPU上的所有进程(虽然现在CUDA MPS可以隔离,但很多生产环境没用上)。
节点故障影响更严重。一台4卡GPU服务器如果挂了,同时影响4个推理服务实例。而且GPU服务器价格昂贵,不会像CPU节点一样随便堆数量来做冗余。
调度延迟高。Pod调度到GPU节点后,还需要时间加载CUDA驱动、分配显存、加载模型,这段时间可能要几分钟。普通CPU Pod通常几秒就能服务。
理解了这些,才能理解为什么GPU节点池的设计需要专门考量。
节点池的分层设计
我们最终确定了三层节点池的设计:
推理节点池(Inference Pool):专门给在线推理服务用。这类服务对延迟敏感,不能被其他任务抢占资源。节点配置倾向于高频卡(T4/A10),显存不需要特别大,但需要多卡并行处理并发请求。
训练节点池(Training Pool):给离线训练任务用。这类任务对延迟不敏感,可以等,但需要大显存(A100/H100),跑完一个任务可能需要几小时到几天。
弹性节点池(Elastic Pool):作为缓冲区。当推理池或训练池资源紧张时,可以临时借用弹性池的节点。使用Spot实例来降低成本。
这种分层设计的关键是通过节点标签和污点(Taint)来实现硬隔离。
节点标签和污点设置
给不同节点池的节点打标签:
# 推理节点
kubectl label node gpu-node-01 node-role=inference gpu-type=t4 workload-tier=online
kubectl label node gpu-node-02 node-role=inference gpu-type=t4 workload-tier=online
# 训练节点
kubectl label node gpu-node-10 node-role=training gpu-type=a100 workload-tier=offline
kubectl label node gpu-node-11 node-role=training gpu-type=a100 workload-tier=offline
# 弹性节点
kubectl label node gpu-node-20 node-role=elastic gpu-type=t4 workload-tier=elastic spot=true然后给节点打污点,强制要求Pod明确表示容忍才能调度:
# 推理节点只接受推理服务
kubectl taint node gpu-node-01 workload=inference:NoSchedule
kubectl taint node gpu-node-02 workload=inference:NoSchedule
# 训练节点只接受训练任务
kubectl taint node gpu-node-10 workload=training:NoSchedule
kubectl taint node gpu-node-11 workload=training:NoSchedule
# 弹性节点另外还有Spot实例的污点
kubectl taint node gpu-node-20 workload=elastic:NoSchedule
kubectl taint node gpu-node-20 cloud.google.com/gke-spot=true:NoSchedule推理服务的亲和性配置
有了节点池的硬隔离,还需要配置亲和性策略,让推理服务能更合理地分布在推理节点上。
基本配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference-service
namespace: ai-prod
spec:
replicas: 4
selector:
matchLabels:
app: llm-inference
template:
metadata:
labels:
app: llm-inference
model: llm-7b
spec:
# 必须容忍推理节点的污点,否则无法调度
tolerations:
- key: "workload"
operator: "Equal"
value: "inference"
effect: "NoSchedule"
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
affinity:
# 节点亲和性:必须调度到推理节点
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role
operator: In
values:
- inference
- key: gpu-type
operator: In
values:
- t4
- a10g # 兼容两种GPU类型
# Pod反亲和性:同一个模型的多个副本不要集中在同一个节点
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: model
operator: In
values:
- llm-7b
topologyKey: kubernetes.io/hostname
containers:
- name: inference-server
image: your-registry/llm-inference:v2.1.0
resources:
requests:
cpu: "4"
memory: "16Gi"
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: "32Gi"
nvidia.com/gpu: "1"这里有一个地方值得单独说:podAntiAffinity用的是preferredDuringSchedulingIgnoredDuringExecution(软亲和性)而不是requiredDuringSchedulingIgnoredDuringExecution(硬亲和性)。
原因是:如果用硬反亲和性,当副本数超过节点数时,新的Pod会永远Pending。软亲和性会尽量分散,但实在没得选的时候也会挤在一起,至少服务不会中断。
多模型共存时的亲和性策略
生产环境通常不止一个模型,可能同时有7B、13B、70B等不同规格的模型在跑。不同模型对GPU资源的需求差异很大,调度时需要考虑得更细致。
我们设计了一个基于模型规格的节点亲和性配置:
# 大模型服务(需要多卡)
spec:
template:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-role
operator: In
values:
- inference
- key: gpu-count
operator: In
values:
- "4" # 只调度到4卡节点
- "8"
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: gpu-type
operator: In
values:
- a100 # 优先A100节点
- weight: 20
preference:
matchExpressions:
- key: gpu-type
operator: In
values:
- a10g # 次选A10GGPU节点池的动态扩缩
静态的节点池在流量稳定的情况下够用,但AI服务的流量经常有明显的峰谷。白天高峰,夜里低谷,如果一直维持峰值的节点数量,成本会很高。
Cluster Autoscaler配置
云上的K8s集群一般用Cluster Autoscaler来动态扩缩节点池。配置有几个关键参数:
# cluster-autoscaler的核心配置(以annotations形式配在节点组上)
# 这里以GKE为例,其他云厂商类似
apiVersion: v1
kind: ConfigMap
metadata:
name: cluster-autoscaler-config
namespace: kube-system
data:
nodes: |
[
{
"name": "inference-nodepool",
"minSize": 2,
"maxSize": 10,
"expanderStrategy": "least-waste"
},
{
"name": "elastic-nodepool",
"minSize": 0,
"maxSize": 20,
"expanderStrategy": "least-waste"
}
]重要的是minSize设置:推理节点池最小保持2个节点,保证基础服务能力,不能缩到0。弹性节点池可以缩到0,节约成本。
节点缩容的坑
Cluster Autoscaler在决定缩容某个节点时,会先检查节点上有没有Pod。如果有,会先驱逐(evict)Pod,等Pod被调度走了,再把节点下线。
但AI推理服务有一个问题:如果Pod正在处理推理请求,被驱逐会导致请求失败。所以需要配置PodDisruptionBudget(PDB):
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: llm-inference-pdb
namespace: ai-prod
spec:
minAvailable: 2 # 任何时候至少保持2个推理Pod可用
selector:
matchLabels:
app: llm-inference有了PDB,Cluster Autoscaler在驱逐Pod前会先检查是否满足PDB约束,如果驱逐这个Pod会导致可用Pod数低于minAvailable,就不会驱逐,也就不会缩容这个节点。
但这里有个微妙的点:PDB和HPA要配合好。如果HPA把副本数缩到2,同时PDB要求最少2个可用,那即使有节点需要缩容,也永远无法驱逐Pod,节点卡住无法下线。一般建议minAvailable < minReplicas,或者用maxUnavailable替代minAvailable:
spec:
maxUnavailable: 1 # 同时最多1个Pod不可用(而不是规定最少多少可用)GPU资源的监控与告警
节点池管理离不开完善的监控。光靠Kubernetes默认的资源监控是不够的,还需要GPU层面的指标。
部署DCGM Exporter
NVIDIA的DCGM(Data Center GPU Manager)Exporter能够暴露详细的GPU指标给Prometheus:
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: dcgm-exporter
namespace: monitoring
spec:
selector:
matchLabels:
app: dcgm-exporter
template:
metadata:
labels:
app: dcgm-exporter
spec:
tolerations:
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
nodeSelector:
accelerator: "true"
containers:
- name: dcgm-exporter
image: nvcr.io/nvidia/k8s/dcgm-exporter:3.1.7-3.1.4-ubuntu20.04
ports:
- name: metrics
containerPort: 9400
securityContext:
runAsNonRoot: false
runAsUser: 0
volumeMounts:
- name: proc
mountPath: /proc
readOnly: true
volumes:
- name: proc
hostPath:
path: /procDCGM Exporter能暴露这些关键指标:
DCGM_FI_DEV_GPU_UTIL:GPU核心利用率DCGM_FI_DEV_MEM_COPY_UTIL:显存带宽利用率DCGM_FI_DEV_FB_USED:已用显存DCGM_FI_DEV_FB_FREE:空闲显存DCGM_FI_DEV_GPU_TEMP:GPU温度DCGM_FI_DEV_POWER_USAGE:功耗
Java服务上报GPU指标
在Java推理服务里,还需要上报推理层面的指标,这些是DCGM无法提供的:
@Component
@Slf4j
public class InferenceGPUMetrics {
private final MeterRegistry registry;
private final Timer inferenceLatency;
private final Counter inferenceSuccess;
private final Counter inferenceFailure;
private final AtomicLong activeInferences = new AtomicLong(0);
public InferenceGPUMetrics(MeterRegistry registry) {
this.registry = registry;
this.inferenceLatency = Timer.builder("ai.inference.duration")
.description("推理请求耗时")
.publishPercentiles(0.5, 0.90, 0.95, 0.99)
.publishPercentileHistogram()
.register(registry);
this.inferenceSuccess = Counter.builder("ai.inference.requests.total")
.tag("status", "success")
.register(registry);
this.inferenceFailure = Counter.builder("ai.inference.requests.total")
.tag("status", "failure")
.register(registry);
Gauge.builder("ai.inference.active.count")
.description("当前正在执行的推理请求数")
.register(registry, activeInferences, AtomicLong::get);
}
public <T> T recordInference(String modelName, int inputTokens,
Supplier<T> inferenceTask) {
activeInferences.incrementAndGet();
Timer.Sample sample = Timer.start(registry);
try {
T result = inferenceTask.get();
inferenceSuccess.increment();
return result;
} catch (Exception e) {
inferenceFailure.increment();
throw e;
} finally {
activeInferences.decrementAndGet();
sample.stop(Timer.builder("ai.inference.duration")
.tag("model", modelName)
.tag("input_size", categorizeInputSize(inputTokens))
.register(registry));
}
}
private String categorizeInputSize(int tokens) {
if (tokens < 100) return "small";
if (tokens < 500) return "medium";
if (tokens < 2000) return "large";
return "xlarge";
}
}关键告警规则
配置Prometheus告警,让资源问题在变成事故之前被发现:
groups:
- name: gpu-resource-alerts
rules:
# GPU显存使用率超过85%持续5分钟
- alert: GPUMemoryHighUsage
expr: |
(DCGM_FI_DEV_FB_USED / (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE)) * 100 > 85
for: 5m
labels:
severity: warning
annotations:
summary: "GPU显存使用率告警"
description: "节点 {{ $labels.instance }} GPU {{ $labels.gpu }} 显存使用率 {{ $value }}%"
# 推理Pod处于Pending状态超过3分钟(可能是资源不足调度不上)
- alert: InferencePodPending
expr: |
kube_pod_status_phase{namespace="ai-prod", phase="Pending"} == 1
for: 3m
labels:
severity: critical
annotations:
summary: "推理Pod长时间Pending"
description: "Pod {{ $labels.pod }} 已Pending超过3分钟,检查GPU资源是否充足"
# GPU温度过高
- alert: GPUTemperatureHigh
expr: DCGM_FI_DEV_GPU_TEMP > 85
for: 2m
labels:
severity: warning
annotations:
summary: "GPU温度过高"
description: "节点 {{ $labels.instance }} GPU温度 {{ $value }}°C,请检查散热"多模型混部的资源隔离
一个生产系统里通常跑多个不同的AI模型,怎么保证它们互不干扰是个有意思的问题。
使用ResourceQuota做租户隔离
如果多个团队或业务线共用同一个K8s集群,用ResourceQuota做资源隔离:
# 给AI-A项目组分配固定的GPU配额
apiVersion: v1
kind: ResourceQuota
metadata:
name: ai-team-a-quota
namespace: ai-team-a
spec:
hard:
requests.nvidia.com/gpu: "4"
limits.nvidia.com/gpu: "4"
requests.cpu: "32"
requests.memory: "128Gi"
pods: "20"
---
# 给AI-B项目组分配固定的GPU配额
apiVersion: v1
kind: ResourceQuota
metadata:
name: ai-team-b-quota
namespace: ai-team-b
spec:
hard:
requests.nvidia.com/gpu: "2"
limits.nvidia.com/gpu: "2"
requests.cpu: "16"
requests.memory: "64Gi"
pods: "10"这样即使AI-A项目疯狂扩容,也无法超过它的配额,不会把AI-B项目的资源抢走。
LimitRange防止单Pod资源霸占
ResourceQuota控制命名空间总量,LimitRange控制单个Pod/Container的上下限:
apiVersion: v1
kind: LimitRange
metadata:
name: ai-inference-limits
namespace: ai-prod
spec:
limits:
- type: Container
max:
cpu: "8"
memory: "32Gi"
nvidia.com/gpu: "2" # 单容器最多2块GPU
min:
cpu: "500m"
memory: "1Gi"
default:
cpu: "2"
memory: "8Gi"
defaultRequest:
cpu: "1"
memory: "4Gi"总结:GPU节点池管理的核心原则
回头看,我们处理那次生产故障之后,整个GPU资源管理体系变得更健壮了。核心原则可以归结为几点:
分层隔离:在线推理、离线训练、弹性伸缩,三个池子各司其职,用污点和标签做硬隔离。
亲和性兜底:节点亲和性保证Pod调度到正确的节点池,Pod反亲和性保证同类服务分散,避免单点故障。
动态伸缩有保障:Cluster Autoscaler做节点级伸缩,HPA做Pod级伸缩,PDB保证缩容时不影响可用性。
监控必须到GPU层:DCGM Exporter + 自定义应用指标,构建完整的可观测性,让问题在成为事故前被发现。
GPU资源这么贵,管好了能省不少钱,管差了不只是钱的问题,还会影响服务稳定性。
