大模型服务自动扩缩容:基于Token吞吐与GPU利用率的HPA策略设计

一、模型服务扩缩容的指标困境

大模型推理服务的扩缩容远比传统微服务复杂。传统服务通常以CPU使用率或QPS作为核心指标,但GPU场景下这两个指标都存在明显缺陷。CPU使用率无法反映GPU的计算饱和度——模型在做矩阵运算时CPU可能很闲;QPS则忽略了请求复杂度的差异,一次处理512 token的请求和一次处理8192 token的请求成本完全不同。

真正有效的指标需要同时覆盖两个维度:请求处理速率和硬件资源利用率。Token吞吐量(tokens per second)衡量实际计算产出,GPU利用率反映硬件使用效率。这两者结合才能准确描述模型服务的健康状态。

二、KEDA Prometheus Scaler配置实践

KEDA(Kubernetes Event-Driven Autoscaling)提供了比原生HPA更灵活的扩缩容策略。对于GPU推理服务,我们通过Prometheus Scaler来对接自定义指标。

以下是基于Token吞吐量和GPU利用率的ScaledObject配置:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-inference-scaler
  namespace: ai-serving
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-inference-deployment
  pollingInterval: 15
  cooldownPeriod: 300
  minReplicaCount: 1
  maxReplicaCount: 20
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: token_throughput_utilization
        threshold: "0.8"
        query: |
          sum(rate(http_tokens_per_second{service="llm-inference"}[2m])) 
          / sum(kube_deployment_spec_replicas{deployment="llm-inference-deployment"})
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: gpu_utilization_percentage
        threshold: "85"
        query: |
          avg(DCGM_FI_DEV_GPU_UTIL{pod=~"llm-inference-deployment-.*"})
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: request_queue_depth
        threshold: "10"
        query: |
          avg(inference_request_queue_depth{pod=~"llm-inference-deployment-.*"})
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 300
          policies:
            - type: Percent
              value: 50
              periodSeconds: 60
        scaleUp:
          stabilizationWindowSeconds: 60
          policies:
            - type: Pods
              value: 2
              periodSeconds: 30

多指标触发器的设计遵循"任一触发即扩容"的原则。实际压测数据显示,Token吞吐量指标通常最先触发扩容——当单Pod处理能力接近饱和时,Token生产速率会出现明显拐点。GPU利用率作为辅助指标用于防止资源浪费,队列深度则提供最终兜底。

三、冷启动延迟与扩容窗口的博弈

模型服务的最大痛点是冷启动。一个70B参数的模型从镜像拉取到权重加载完成,可能需要3-8分钟。这意味着在流量突增场景下,新Pod启动期间老Pod已经在承受超额负载。

解决冷启动问题的策略分层:

第一层:模型预热池。维持一定数量的预热Pod,权重已加载到GPU显存中,处于待命状态。这类似于数据库连接池的思路——用空间换时间。

# 模型预热管理器(Python侧示例)
import asyncio
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

class ModelWarmPool:
    """模型预热池,确保最小数量的Pod处于就绪状态"""
    
    def __init__(self, model_path: str, min_warm_pods: int = 2):
        self.model_path = model_path
        self.min_warm_pods = min_warm_pods
        self._ready_event = asyncio.Event()
    
    async def preload_weights(self) -> None:
        """在Pod启动时预加载模型权重到GPU"""
        torch.cuda.synchronize()
        model = AutoModelForCausalLM.from_pretrained(
            self.model_path,
            torch_dtype=torch.float16,
            device_map="auto",
            trust_remote_code=True
        )
        # 触发初次推理以完成KV Cache预热
        tokenizer = AutoTokenizer.from_pretrained(self.model_path)
        inputs = tokenizer("warmup", return_tensors="pt").to("cuda")
        with torch.no_grad():
            _ = model.generate(**inputs, max_new_tokens=1)
        
        self._ready_event.set()
        # 向就绪探针暴露状态
        await self._mark_ready()
    
    async def _mark_ready(self) -> None:
        import os
        ready_file = "/tmp/model_ready"
        os.makedirs(os.path.dirname(ready_file), exist_ok=True)
        with open(ready_file, "w") as f:
            f.write("1")

第二层:渐进式引流。新Pod就绪后不立即接收全量流量,而是通过Service Mesh逐步增加权重,让模型在前几个请求中完成KV Cache的热身。

第三层:溢出队列。当所有Pod都在处理请求时,新请求进入有界队列等待,而非直接返回503。队列长度也是KEDA的触发指标之一。

四、缩容时的优雅退出与请求排空

缩容比扩容更容易出问题。粗暴地终止Pod会导致正在推理中的请求被中断,已消耗的计算资源白白浪费。对于自回归生成任务,一个请求可能持续数十秒甚至数分钟。

优雅退出流程的核心实现:

// Go侧:推理网关的优雅退出处理器
package gateway

import (
    "context"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

type GracefulShutdown struct {
    inflightRequests sync.WaitGroup
    draining         atomic.Bool
    drainTimeout     time.Duration
}

func (gs *GracefulShutdown) OnSIGTERM(ctx context.Context, server *http.Server) {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
    
    <-sigCh
    gs.draining.Store(true)
    
    // Step 1: 标记节点为不可调度,停止接收新请求
    // 通过更新Endpoint的ready状态实现
    
    // Step 2: 等待正在处理的请求完成
    done := make(chan struct{})
    go func() {
        gs.inflightRequests.Wait()
        close(done)
    }()
    
    select {
    case <-done:
        // 所有请求正常完成
    case <-time.After(gs.drainTimeout):
        // 超时强制退出,但记录未完成请求数量
        log.Warn("drain timeout, forcing shutdown",
            "remaining", gs.remainingCount())
    }
    
    // Step 3: 释放GPU资源
    gs.releaseGPUResources()
    
    // Step 4: 关闭HTTP服务器
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    server.Shutdown(shutdownCtx)
}

func (gs *GracefulShutdown) WrapHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if gs.draining.Load() {
            // 已经处于排空状态,将请求转发到其他Pod
            w.Header().Set("X-Draining", "true")
            http.Error(w, "node draining", http.StatusServiceUnavailable)
            return
        }
        gs.inflightRequests.Add(1)
        defer gs.inflightRequests.Done()
        next.ServeHTTP(w, r)
    })
}

func (gs *GracefulShutdown) releaseGPUResources() {
    // 通知设备插件释放GPU
    // 避免GPU资源泄漏导致后续调度问题
}

排空超时的设定需要权衡。对于推理服务,建议排空超时设置在60-120秒之间——足以让大部分请求完成生成,但不至于让Kubernetes等待过久导致Pod卡在Terminating状态。

五、总结

大模型推理服务的扩缩容策略核心在于指标选择和生命周期管理。Token吞吐量配合GPU利用率构成更准确的饱和度衡量体系;KEDA的Prometheus Scaler提供了灵活的指标对接能力。冷启动问题需要通过权重预热、渐进式引流和溢出队列三层策略来缓解。缩容时的优雅退出尤其重要——推理请求的生命周期远比HTTP请求长,粗暴终止的成本不可忽视。合理的排空超时和流量转发机制是保障服务稳定性的最后一道防线。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐