GPU 稀缺与弹性伸缩:大模型服务的容器化部署与资源调度策略

一、GPU 利用率不足与扩缩容滞后——AI 部署的两大资源困境

大模型服务部署面临的核心挑战,与传统微服务有本质区别:计算资源从 CPU 转向 GPU,而 GPU 是稀缺且昂贵的资源。两个结构性问题在生产环境中反复出现。

第一,GPU 利用率不足。大模型推理服务通常需要独占一张 GPU,但推理请求的到达模式具有明显的波峰波谷特征。在低峰期,GPU 利用率可能不到 10%,但 GPU 仍然被独占,无法被其他服务共享。某 AI 平台的统计数据显示,其 20 张 GPU 的平均利用率仅为 35%,但 GPU 成本占基础设施总支出的 60%。

第二,扩缩容滞后。传统微服务的 HPA(水平 Pod 自动扩缩容)基于 CPU 利用率,响应时间在 30-60 秒之间。但大模型服务的扩容涉及模型加载(通常 30-120 秒),从触发扩容到新 Pod 就绪可能需要 2-5 分钟。在这段等待时间内,请求排队或超时,用户体验严重劣化。

这两个问题的根因在于:大模型服务的资源模型与传统微服务不匹配。GPU 不可分、模型加载慢、推理延迟高,这些特性要求部署策略做出根本性调整。本文将深入分析大模型服务的容器化部署方案和资源调度策略。

二、大模型部署架构——从单模型独占到多模型共享的演进

大模型服务的部署架构经历了三个阶段的演进:单模型独占 GPU、多模型时分复用 GPU、以及基于请求特征的动态调度。

flowchart TB
    subgraph 阶段1-单模型独占
        A1[请求路由] --> B1[模型A Pod<br/>GPU-0 独占]
        A1 --> C1[模型B Pod<br/>GPU-1 独占]
        A1 --> D1[模型C Pod<br/>GPU-2 独占]
    end

    subgraph 阶段2-时分复用
        A2[请求路由] --> E2[推理引擎<br/>vLLM/TGI]
        E2 --> F2[GPU-0: 模型A + 模型B<br/>动态批处理]
        E2 --> G2[GPU-1: 模型C<br/>连续批处理]
    end

    subgraph 阶段3-动态调度
        A3[智能调度器] --> H3[GPU-0: 高优模型<br/>实时推理]
        A3 --> I3[GPU-1: 低优模型<br/>批处理推理]
        A3 --> J3[CPU 节点: 小模型<br/>兜底推理]
        H3 --> K3[显存池化管理]
        I3 --> K3
    end

    阶段1-单模型独占 --> 阶段2-时分复用 --> 阶段3-动态调度

    style 阶段1-单模型独占 fill:#ffebee
    style 阶段3-动态调度 fill:#e8f5e9

阶段1中,每个模型独占一张 GPU,资源利用率最低但隔离性最好。这是最简单的部署方式,适合模型数量少、流量稳定的场景。

阶段2中,通过推理引擎(如 vLLM、TGI)的动态批处理和连续批处理能力,在同一张 GPU 上运行多个模型。推理引擎将多个请求合并为一个 Batch,充分利用 GPU 的并行计算能力。这种方式可以将 GPU 利用率从 35% 提升到 70% 以上。

阶段3中,引入智能调度器,根据请求的优先级、延迟要求和模型特征,动态分配 GPU 资源。高优先级请求分配到专用 GPU,低优先级请求进入批处理队列。显存池化管理允许不同模型共享 GPU 显存,按需加载和卸载模型权重。

三、生产级代码:大模型容器化部署与资源调度实现

3.1 基于 vLLM 的多模型推理服务

# Kubernetes Deployment: vLLM 多模型推理服务
# 核心配置:显存预分配、动态批处理、量化推理
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-inference-server
  namespace: ai-serving
spec:
  replicas: 2
  selector:
    matchLabels:
      app: vllm-inference
  template:
    metadata:
      labels:
        app: vllm-inference
    spec:
      # GPU 资源请求:使用 MIG 切片或整卡
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          resources:
            limits:
              nvidia.com/gpu: 1  # 请求 1 张 GPU
            requests:
              nvidia.com/gpu: 1
          ports:
            - containerPort: 8000
          env:
            # 模型配置:支持动态加载多个模型
            - name: VLLM_MODEL
              value: "deepseek-ai/deepseek-v3"
            # 量化配置:INT4 量化减少显存占用
            - name: QUANTIZATION
              value: "awq"
            # 最大模型长度:控制 KV Cache 大小
            - name: MAX_MODEL_LEN
              value: "8192"
            # GPU 显存利用率:预留 10% 给系统开销
            - name: GPU_MEMORY_UTILIZATION
              value: "0.90"
            # 动态批处理参数
            - name: MAX_NUM_SEQS
              value: "256"  # 最大并发序列数
            - name: MAX_NUM_BATCHED_TOKENS
              value: "32768"  # 单批次最大 Token 数
          # 健康检查:推理服务就绪后才开始接收流量
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 60  # 模型加载需要时间
            periodSeconds: 10
          # 存活检查:推理卡死时自动重启
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 120
            periodSeconds: 30
            failureThreshold: 3

3.2 预测性扩缩容——基于请求队列的主动调度

/**
 * AI 服务预测性扩缩容控制器
 * 核心思路:不等 GPU 利用率达标才扩容,而是根据请求队列深度
 * 预判未来资源需求,提前触发扩容
 */
@Component
public class PredictiveScaler {

    private final KubernetesClient k8sClient;
    private final MetricsService metricsService;
    private final ModelLoadEstimator loadEstimator;

    /**
     * 扩缩容决策:每 15 秒执行一次
     * 决策依据:当前队列深度 + 请求到达速率 + 模型加载时间
     */
    @Scheduled(fixedRate = 15000)
    public void scaleDecision() {
        String deployment = "vllm-inference-server";

        // 获取当前指标
        int currentReplicas = k8sClient.getReplicas(deployment);
        int queueDepth = metricsService.getQueueDepth(deployment);
        double arrivalRate = metricsService.getArrivalRate(deployment);  // 请求/秒
        double avgInferenceTime = metricsService.getAvgInferenceTime(deployment);  // 秒
        int modelLoadTime = loadEstimator.estimateLoadTime(deployment);  // 秒

        // 计算当前处理能力
        double processingCapacity = currentReplicas / avgInferenceTime;

        // 计算队列清空时间(秒)
        double queueDrainTime = queueDepth / Math.max(processingCapacity - arrivalRate, 0.1);

        // 预测:如果队列清空时间 > 模型加载时间的 2 倍,需要扩容
        // 提前量 = 模型加载时间,确保新 Pod 在队列堆积前就绪
        if (queueDrainTime > modelLoadTime * 2) {
            int targetReplicas = calculateTargetReplicas(
                queueDepth, arrivalRate, avgInferenceTime, modelLoadTime
            );

            if (targetReplicas > currentReplicas) {
                log.info("触发扩容: current={}, target={}, " +
                    "queueDepth={}, arrivalRate={}/s",
                    currentReplicas, targetReplicas,
                    queueDepth, String.format("%.1f", arrivalRate));
                k8sClient.scale(deployment, targetReplicas);
                MetricsCollector.record("scaler.scale_up",
                    targetReplicas - currentReplicas);
            }
        }

        // 缩容条件:队列深度为 0 且持续 5 分钟
        if (queueDepth == 0 &&
            metricsService.getIdleDuration(deployment).toMinutes() >= 5) {
            int targetReplicas = Math.max(1, currentReplicas - 1);
            if (targetReplicas < currentReplicas) {
                log.info("触发缩容: current={}, target={}",
                    currentReplicas, targetReplicas);
                k8sClient.scale(deployment, targetReplicas);
                MetricsCollector.record("scaler.scale_down", 1);
            }
        }
    }

    /**
     * 计算目标副本数:确保队列清空时间小于模型加载时间
     */
    private int calculateTargetReplicas(int queueDepth, double arrivalRate,
                                         double avgInferenceTime, int modelLoadTime) {
        // 目标:在 modelLoadTime 秒内清空队列
        double requiredCapacity = (queueDepth + arrivalRate * modelLoadTime)
            / (double) modelLoadTime;
        int requiredReplicas = (int) Math.ceil(
            requiredCapacity * avgInferenceTime
        );
        // 上限:不超过 GPU 总数
        return Math.min(requiredReplicas, 20);
    }
}

3.3 模型预热与缓存策略

/**
 * 模型预热服务:在低峰期预加载模型权重到 GPU 显存
 * 核心目的:消除模型加载延迟,使扩容后的 Pod 立即可用
 */
@Service
public class ModelWarmupService {

    private final InferenceClient inferenceClient;
    private final ModelRegistry modelRegistry;

    /**
     * 低峰期预热:每天凌晨 3 点执行
     * 向所有推理 Pod 发送预热请求,触发模型加载
     */
    @Scheduled(cron = "0 0 3 * * *")
    public void warmupModels() {
        List<ModelConfig> activeModels = modelRegistry.getActiveModels();

        for (ModelConfig model : activeModels) {
            try {
                // 发送一个极短的推理请求,触发模型加载
                // 使用最小 Token 数,减少预热成本
                String response = inferenceClient.chat(model.getModelId(),
                    "hi", 1, 1);  // max_tokens=1, temperature=1

                log.info("模型预热完成: model={}, tokens={}",
                    model.getModelId(),
                    response != null ? "ok" : "failed");
                MetricsCollector.increment("model.warmup.success",
                    "model=" + model.getModelId());
            } catch (Exception e) {
                log.error("模型预热失败: model={}", model.getModelId(), e);
                MetricsCollector.increment("model.warmup.failed",
                    "model=" + model.getModelId());
            }
        }
    }

    /**
     * 新 Pod 就绪后立即预热
     * 通过 Kubernetes Watch 监听 Pod 创建事件
     */
    @EventListener
    public void onPodCreated(PodCreatedEvent event) {
        if (event.getPod().getLabels().containsKey("ai-model")) {
            String modelId = event.getPod().getLabels().get("ai-model");
            // 等待 Pod 就绪后发送预热请求
            CompletableFuture.delayedExecutor(30, TimeUnit.SECONDS)
                .execute(() -> {
                    try {
                        inferenceClient.chat(modelId, "warmup", 1, 1);
                        log.info("新 Pod 预热完成: model={}, pod={}",
                            modelId, event.getPod().getName());
                    } catch (Exception e) {
                        log.warn("新 Pod 预热失败: model={}, pod={}",
                            modelId, event.getPod().getName(), e);
                    }
                });
        }
    }
}

四、GPU 共享的隔离风险与冷启动的延迟陷阱

1. GPU 共享的显存竞争

多模型共享 GPU 时,显存是最大的竞争资源。如果某个模型的 KV Cache 占用过多显存,其他模型可能因显存不足而 OOM。vLLM 的 GPU_MEMORY_UTILIZATION 参数虽然可以限制总显存使用,但无法精确控制单个模型的显存上限。

解决方案:对每个模型设置 MAX_NUM_SEQSMAX_MODEL_LEN,限制 KV Cache 的大小;使用 AWQ/GPTQ 量化将模型权重压缩到 1/4,为 KV Cache 腾出更多空间。

2. 模型冷启动的延迟陷阱

模型权重从磁盘加载到 GPU 显存的时间与模型大小成正比。7B 模型约 5-10 秒,70B 模型约 30-60 秒。在突发流量场景下,冷启动延迟可能导致请求超时。

解决方案:维护一定数量的预热 Pod(Warm Pool),始终处于就绪状态。预热 Pod 的数量根据历史流量模式预测,在流量高峰前 30 分钟自动扩容预热池。

3. 缩容的模型卸载成本

缩容时,GPU 上的模型权重被卸载,下次扩容需要重新加载。频繁的扩缩容会导致模型反复加载卸载,浪费大量 GPU 计算时间。

解决方案:设置缩容冷却期(至少 10 分钟),避免因短暂流量低谷触发缩容;使用模型权重缓存(将权重保存在节点的本地磁盘上),使重新加载时间从 30-60 秒缩短到 5-10 秒。

部署策略 GPU 利用率 延迟 复杂度 适用场景
单模型独占 30-40% 最低 核心高优服务
多模型共享 60-80% 中等 通用推理服务
动态调度 70-90% 波动 多租户平台
Warm Pool 40-60% 最低 突发流量场景

五、总结

大模型服务的部署策略需要从"CPU 思维"转向"GPU 思维"。GPU 的稀缺性和不可分性要求通过多模型共享和动态调度提升利用率;模型加载的冷启动延迟要求通过预测性扩缩容和预热策略提前准备资源;显存竞争和模型卸载成本则要求在扩缩容策略中增加冷却期和缓存机制。

落地路线建议:第一步,将推理服务从裸机部署迁移到 Kubernetes 容器化部署,使用 vLLM 作为推理引擎,启用动态批处理提升 GPU 利用率;第二步,对核心模型实施 AWQ/GPTQ 量化,将显存占用降低 60-75%,为多模型共享创造空间;第三步,引入基于队列深度的预测性扩缩容,替代基于 GPU 利用率的被动扩缩容;第四步,建立预热 Pod 池,在流量高峰前自动扩容,消除冷启动延迟对用户体验的影响。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐