多租户 GPU 集群调度:云原生 AI 平台资源隔离与弹性分配的工程实践
一、GPU 资源争抢与成本失控:AI 平台落地的核心瓶颈
在 AI 平台落地过程中,GPU 资源管理往往是最先暴露问题的环节。当多个团队共享同一批 GPU 服务器时,资源争抢导致训练任务排队、推理服务延迟飙升,而闲置时段又造成大量算力浪费。根据生产环境的监控数据,未经优化的 GPU 集群利用率通常只有 30%—40%,剩余 60% 的算力在等待中被白白消耗。
问题的根源在于:Kubernetes 原生的调度器仅支持 CPU/内存维度的资源分配,对 GPU 这种昂贵且不可压缩的资源缺乏细粒度的管控能力。一个训练任务独占整张 A100,但实际显存占用可能只有 40%;而另一个推理服务因为调度不到 GPU 而持续排队。这种"一边浪费、一边饥饿"的矛盾,正是多租户 GPU 调度需要解决的核心痛点。
二、GPU 资源隔离与调度的底层机制
2.1 Kubernetes GPU 调度的原生局限
Kubernetes 通过 Device Plugin 机制接入 GPU 资源,NVIDIA Device Plugin 将每张 GPU 注册为一个nvidia.com/gpu资源,调度器以整卡为最小分配单位。这意味着一个 Pod 要么独占一张 GPU,要么无法获得任何 GPU 资源。
graph TD A[Pod 请求 GPU] --> B{调度器检查} B -->|整卡可用| C[分配整张 GPU] B -->|整卡不可用| D[Pod Pending] E[GPU 显存占用 40%] --> F[剩余 60% 闲置] F --> D style D fill:#f96,stroke:#333 style F fill:#ff9,stroke:#3332.2 多租户调度的三层架构
要实现 GPU 资源的高效共享,需要在三个层面建立隔离机制:
graph TB subgraph 调度层 S1[队列调度器<br/>Capactiy/Coscheduling] S2[弹性配额管理<br/>Elastic Quota] end subgraph 隔离层 I1[GPU 时间片<br/>MPS/Time-Slicing] I2[显存隔离<br/>GPU Memory隔离] end subgraph 运行层 R1[容器运行时<br/>NVIDIA Container Runtime] R2[监控采集<br/>DCGM Exporter] end S1 --> I1 S2 --> I2 I1 --> R1 I2 --> R2调度层负责决定哪个任务获得 GPU、分配多少份额;隔离层确保同一张 GPU 上的多个任务互不干扰;运行层提供容器化的 GPU 访问能力与实时监控数据。
2.3 GPU 时间片与 MPS 两种共享模式
NVIDIA 提供了两种 GPU 共享机制,适用场景截然不同:
| 机制 | 原理 | 隔离级别 | 适用场景 | 性能损耗 |
|---|---|---|---|---|
| Time-Slicing | 时间片轮转,多个 CUDA Context 交替执行 | 软隔离 | 推理服务、低优先级任务 | 10%—30% |
| MPS(Multi-Process Service) | 多进程共享同一 CUDA Context | 硬隔离(显存) | 训练任务、高吞吐场景 | 5%—15% |
Time-Slicing 的实现更简单,但上下文切换开销较大;MPS 减少了切换开销,但要求所有共享进程使用相同的 GPU 计算模式,且不支持 CUDA 12.0 以上版本的某些特性。
三、多租户 GPU 调度的生产级实现
3.1 GPU 时间片共享配置
# nvidia-device-plugin-config.yaml # 配置 GPU 时间片共享,将 1 张物理 GPU 切分为 4 个逻辑 GPU version: v1 flags: migStrategy: none failOnInitError: true deviceListStrategy: envvar sharing: timeSlicing: resources: - name: nvidia.com/gpu replicas: 4 # 每个 replicas 对应一个时间片单元 # 调度时 Pod 可请求 nvidia.com/gpu: 1 # 实际获得 1/4 物理 GPU 的计算能力# 推理服务 Deployment —— 请求 1 个时间片单元 apiVersion: apps/v1 kind: Deployment metadata: name: llm-inference namespace: team-a spec: replicas: 2 selector: matchLabels: app: llm-inference template: metadata: labels: app: llm-inference spec: containers: - name: inference image: llm-server:latest resources: limits: nvidia.com/gpu: 1 # 请求 1 个时间片单元(1/4 物理 GPU) requests: nvidia.com/gpu: 1 env: - name: CUDA_VISIBLE_DEVICES valueFrom: fieldRef: fieldPath: "" # 限制显存使用量,防止单个任务占用过多 - name: GPU_MEMORY_LIMIT value: "8Gi"3.2 弹性配额调度器实现
// quota_scheduler.go // 基于弹性配额的 GPU 调度器,支持配额借用与回收 package scheduler import ( "context" "fmt" "sync" "time" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" ) // ElasticQuota 定义租户的 GPU 配额 type ElasticQuota struct { TenantID string MinGPU int // 保底 GPU 数量,任何情况下保证可用 MaxGPU int // 最大 GPU 数量,可借用的上限 UsedGPU int // 当前已使用 GPU 数量 BorrowedGPU int // 从其他租户借用的 GPU 数量 Priority float64 // 租户优先级权重,影响借用决策 LastUsed time.Time // 最后使用时间,用于空闲回收判断 } // QuotaManager 管理所有租户的弹性配额 type QuotaManager struct { mu sync.RWMutex quotas map[string]*ElasticQuota // tenantID -> ElasticQuota client kubernetes.Interface } func NewQuotaManager(client kubernetes.Interface) *QuotaManager { return &QuotaManager{ quotas: make(map[string]*ElasticQuota), client: client, } } // TryBorrowGPU 尝试从空闲租户借用 GPU 资源 // 借用策略:优先从空闲时间最长的租户借用,且不超过其 MinGPU 保底量 func (qm *QuotaManager) TryBorrowGPU(tenantID string, requestGPU int) (int, error) { qm.mu.Lock() defer qm.mu.Unlock() quota, exists := qm.quotas[tenantID] if !exists { return 0, fmt.Errorf("tenant %s not found", tenantID) } // 计算可借用的最大数量:MaxGPU - UsedGPU availableBorrow := quota.MaxGPU - quota.UsedGPU if availableBorrow <= 0 { return 0, fmt.Errorf("tenant %s already at max capacity", tenantID) } // 实际借用数量取请求量和可用量的较小值 borrowGPU := min(requestGPU, availableBorrow) // 从空闲租户回收资源 borrowed := 0 for _, other := range qm.quotas { if other.TenantID == tenantID { continue } // 只能借用超出保底量的空闲 GPU freeGPU := other.UsedGPU - other.MinGPU - other.BorrowedGPU if freeGPU <= 0 { continue } // 空闲超过 30 分钟的租户才允许被借用 if time.Since(other.LastUsed) < 30*time.Minute { continue } take := min(freeGPU, borrowGPU-borrowed) other.UsedGPU -= take borrowed += take if borrowed >= borrowGPU { break } } quota.UsedGPU += borrowed quota.BorrowedGPU += borrowed quota.LastUsed = time.Now() klog.Infof("tenant %s borrowed %d GPU (requested %d)", tenantID, borrowed, requestGPU) return borrowed, nil } // ReclaimBorrowedGPU 回收借用超时的 GPU 资源 // 当出借方需要资源时,强制回收借出的 GPU func (qm *QuotaManager) ReclaimBorrowedGPU(ownerTenantID string, needGPU int) (int, error) { qm.mu.Lock() defer qm.mu.Unlock() owner, exists := qm.quotas[ownerTenantID] if !exists { return 0, fmt.Errorf("tenant %s not found", ownerTenantID) } reclaimed := 0 for _, borrower := range qm.quotas { if borrower.TenantID == ownerTenantID || borrower.BorrowedGPU <= 0 { continue } // 从借用方回收 GPU,优先回收低优先级租户 take := min(borrower.BorrowedGPU, needGPU-reclaimed) borrower.UsedGPU -= take borrower.BorrowedGPU -= take reclaimed += take if reclaimed >= needGPU { break } } owner.UsedGPU += reclaimed klog.Infof("tenant %s reclaimed %d GPU", ownerTenantID, reclaimed) return reclaimed, nil } func min(a, b int) int { if a < b { return a } return b }3.3 GPU 利用率监控与自动伸缩
// gpu_autoscaler.go // 基于 GPU 利用率的自动伸缩控制器 package autoscaler import ( "context" "math" "sync" "time" prometheus "github.com/prometheus/client_golang/api" promv1 "github.com/prometheus/client_golang/api/prometheus/v1" ) // GPUMetrics GPU 监控指标 type GPUMetrics struct { GPUUtilization float64 // GPU 计算利用率 (%) MemoryUtilization float64 // 显存利用率 (%) PowerUsage float64 // 功耗 (W) Temperature float64 // 温度 (°C) Timestamp time.Time } // ScalingDecision 伸缩决策 type ScalingDecision struct { Namespace string DeployName string TargetReplicas int32 Reason string } // GPUAutoscaler GPU 自动伸缩器 type GPUAutoscaler struct { promClient promv1.API mu sync.Mutex decisions map[string]*ScalingDecision scaleUpThreshold float64 // 扩容阈值 scaleDownThreshold float64 // 缩容阈值 cooldownPeriod time.Duration lastScaleTime map[string]time.Time } func NewGPUAutoscaler(promURL string) *GPUAutoscaler { client, _ := prometheus.NewClient(prometheus.Config{ Address: promURL, }) return &GPUAutoscaler{ promClient: promv1.NewAPI(client), decisions: make(map[string]*ScalingDecision), scaleUpThreshold: 80.0, scaleDownThreshold: 30.0, cooldownPeriod: 5 * time.Minute, lastScaleTime: make(map[string]time.Time), } } // CalculateDesiredReplicas 根据当前 GPU 利用率计算期望副本数 // 采用线性扩缩策略,避免激进扩缩导致资源震荡 func (ga *GPUAutoscaler) CalculateDesiredReplicas( currentReplicas int32, avgUtilization float64, ) int32 { if avgUtilization > ga.scaleUpThreshold { // 利用率超过阈值,按比例扩容 ratio := avgUtilization / ga.scaleUpThreshold desired := int32(math.Ceil(float64(currentReplicas) * ratio)) // 单次扩容不超过当前副本数的 2 倍,防止过激扩容 maxScale := currentReplicas * 2 if desired > maxScale { desired = maxScale } return desired } if avgUtilization < ga.scaleDownThreshold { // 利用率低于阈值,按比例缩容 ratio := avgUtilization / ga.scaleDownThreshold desired := int32(math.Ceil(float64(currentReplicas) * ratio)) // 保底至少 1 个副本 if desired < 1 { desired = 1 } return desired } return currentReplicas }四、多租户 GPU 调度的架构权衡
4.1 时间片共享 vs MPS 的取舍
时间片共享的部署成本最低,只需修改 Device Plugin 配置即可生效,但上下文切换带来的性能损耗在高负载场景下不可忽视。MPS 的性能更优,却引入了额外的运维复杂度:MPS Server 进程需要独立管理,且当任何一个共享进程崩溃时,同一 Context 下的所有进程都会受影响。
在生产环境中,推荐采用混合策略:推理服务使用时间片共享(对延迟不敏感、可容忍 10%—20% 的性能损耗),训练任务使用 MPS(对吞吐量敏感、需要更低的切换开销)。
4.2 弹性配额的公平性问题
弹性配额允许租户借用空闲资源,但引入了"回收风险"——当资源出借方突然需要 GPU 时,借用方的任务可能被强制驱逐。这对长时间运行的训练任务来说是不可接受的。
解决方案是为训练任务设置nvidia.com/gpu-exclusive: "true"标注,调度器会将其排除在借用资源之外,确保训练任务独占 GPU 直到完成。推理服务则可以安全地使用借用资源,因为其无状态特性使得 Pod 驱逐后可以快速恢复。
4.3 监控与可观测性的开销
DCGM Exporter 采集 GPU 指标时会产生约 2%—3% 的 GPU 开销,在极致性能场景下可能需要降低采集频率或使用采样策略。建议训练集群使用 30 秒采集间隔,推理集群使用 10 秒间隔,在精度与开销之间取得平衡。
五、总结
多租户 GPU 调度的核心挑战在于:在资源利用率最大化与租户隔离保障之间找到平衡点。通过时间片共享与 MPS 的混合部署,可以在推理与训练两种场景下分别获得最优的共享策略;弹性配额机制让空闲资源得到充分利用,同时通过保底配额确保关键任务不受影响;基于 GPU 利用率的自动伸缩则让资源分配动态适配负载变化。
落地建议分三步推进:第一步,部署 NVIDIA Device Plugin 的时间片共享,将推理服务的 GPU 利用率从 30% 提升到 70% 以上;第二步,引入弹性配额调度器,实现跨团队的资源借用与回收;第三步,接入 GPU 自动伸缩,根据实时利用率动态调整推理服务副本数。每一步都建立在前一步稳定运行的基础上,避免一次性引入过多变量导致排障困难。