多租户 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 资源。
2.2 多租户调度的三层架构
要实现 GPU 资源的高效共享,需要在三个层面建立隔离机制:
调度层 负责决定哪个任务获得 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 时间片共享配置
yaml
# 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 的计算能力
yaml
# 推理服务 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 弹性配额调度器实现
go
// 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 利用率监控与自动伸缩
go
// 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 自动伸缩,根据实时利用率动态调整推理服务副本数。每一步都建立在前一步稳定运行的基础上,避免一次性引入过多变量导致排障困难。