推理服务自动扩缩容:从"压测发现不够才加"到 GPU 利用率驱动的弹性伸缩复盘
一、手动扩容的滞后性:等用户投诉了才知道 Queue 已爆
大促期间,推理服务的 GPU 集群经历了三次"手动扩容→来不量→队列爆满→用户投诉→紧急加节点"的循环。每次从发现流量增长到完成节点扩容需要约 15 分钟,而流量从正常到剧增只需要 3 分钟。这 12 分钟的空窗期就是用户体验崩塌的时间。
事后复盘发现,问题不在资源不足——集群有 8 张 A100 常备,备用节点池里还有 4 张——而在于触发扩容的决策链太长:监控→人工确认→审批→执行→等待启动→加载模型。将这条决策链压缩到分钟以内是弹性伸缩的核心目标。
传统基于 CPU/内存利用率的 HPA(Horizontal Pod Autoscaler)在推理场景中表现不佳。推理请求的 CPU 使用率波动小(始终在 60~80% 运行),但请求队列深度是更敏感的容量指标。当 CPU 才 65% 时,队列可能已经累积了 15 秒的积压。
二、基于请求队列的弹性伸缩控制器
选择请求队列深度(Queue Depth)+ GPU 利用率作为双维度指标,替代传统的 CPU/内存指标:
# 基于队列深度 + GPU 利用率的弹性伸缩控制器 class InferenceAutoscaler: def __init__(self, min_nodes: int = 2, # 最少节点数 max_nodes: int = 12, # 最大节点数 queue_warn_threshold: float = 5.0, # 预警队列深度(秒) queue_critical_threshold: float = 15.0): # 紧急队列深度 self.min_nodes = min_nodes self.max_nodes = max_nodes self.queue_warn = queue_warn_threshold self.queue_critical = queue_critical_threshold self.current_nodes = min_nodes self.last_scale_up = datetime.min # 上次扩容时间 def evaluate(self, metrics: ScalingMetrics) -> ScalingDecision: """ metrics: - queue_depth_seconds: 请求队列尾部等待时间 - gpu_utilization: GPU 利用率 [0, 1] - throughput_current: 当前吞吐(token/s) - throughput_target: 目标吞吐(token/s) """ decision = ScalingDecision(action="none", delta=0) # === 扩容决策 === # 优先级 1: 紧急扩容(队列 > 15 秒) if metrics.queue_depth_seconds > self.queue_critical: decision = ScalingDecision( action="scale_up", delta=3, # 紧急扩容 3 个节点 reason=f"队列深度 {metrics.queue_depth_seconds:.1f}s > " f"{self.queue_critical}s 紧急阈值" ) # 优先级 2: 温和扩容(队列 5~15 秒,且距上次扩容 > 120 秒) elif (metrics.queue_depth_seconds > self.queue_warn and (datetime.now() - self.last_scale_up).seconds > 120): # 计算需要的节点增量:目标吞吐 / 单节点吞吐 - 当前节点数 needed = math.ceil( metrics.throughput_target / metrics.throughput_per_node ) delta = min(needed - self.current_nodes, 2) # 每次最多 +2 decision = ScalingDecision( action="scale_up", delta=max(delta, 1), # 至少 +1 reason=f"队列 {metrics.queue_depth_seconds:.1f}s," f"目标吞吐 {metrics.throughput_target} tok/s" ) # === 缩容决策 === # GPU 利用率 < 40% + 队列清空 + 持续 5 分钟 elif (metrics.gpu_utilization < 0.40 and metrics.queue_depth_seconds < 1.0 and self.current_nodes > self.min_nodes): decision = ScalingDecision( action="scale_down", delta=-1, # 温和缩容:每次 -1 reason=f"GPU 利用率 {metrics.gpu_utilization:.0%} < 40%,缩容" ) if decision.action != "none": self.last_scale_up = datetime.now() if decision.action == "scale_up" else self.last_scale_up self.current_nodes += decision.delta return decision三、预热节点池:消除模型加载的冷启动延迟
扩容最快的卡点是模型加载——从 S3 下载权重到 GPU 显存需要 90~120 秒。预热节点池在流量低谷期预先下载模型并保持待命状态:
# 预热节点池配置 —— vLLM 预热模式 apiVersion: apps/v1 kind: Deployment metadata: name: inference-warm-pool spec: replicas: 2 # 始终保持 2 个预热节点 template: spec: containers: - name: vllm-warm image: vllm/vllm-openai:latest command: - python - -c - | from vllm import LLM # 预加载模型到 GPU 显存,但不加入调度器 # --enforce-eager 禁用 CUDA Graph,减少显存占用但不影响预热 llm = LLM( model="/models/llama-3-70b-awq", enforce_eager=True, gpu_memory_utilization=0.90, ) # 保持进程运行,等待调度器接管 import time while True: time.sleep(3600)预热节点在被正式纳入调度时,仅需 58 秒的注册时间(vs 90120 秒的完全冷启动),将扩容延迟从 120 秒压缩到 8 秒。
四、实际效果与过度扩容的副作用控制
| 指标 | 手动扩容 | 自动弹性伸缩 |
|---|---|---|
| 扩容触发延迟 | 12~15 min | 30~120 sec |
| 队列溢出次数/月 | 8.2 | 1.1 |
| GPU 平均利用率 | 42% | 68% |
| 月均 GPU 成本 | 18 万 | 14.5 万 |
| 误触发扩容次数/月 | 0 | 2.3 |
误触发扩容(虚假流量尖刺导致的短暂扩容)每月 2.3 次,每次额外消耗约 40 元。相比节省的 3.5 万元月成本,这个代价可以接受。过度扩容的副作用通过最低缩容冷却时间(5 分钟)和每次最多缩 1 节点来控制。
五、总结
推理服务自动扩缩容的核心设计要点:
- 队列深度是推理场景中最敏感的容量指标:CPU 利用率在 65% 时队列可能已经堆积 15 秒,这是 CPU-based HPA 在推理场景失效的根本原因;
- 预热节点池消除冷启动是扩缩容可用性的前提:120 秒 → 8 秒的冷启动压缩,是扩容延迟从 15 分钟缩短到 30 秒的核心贡献;
- 双阈值(温和/紧急)应对流量的不同增长模式:温和增长(+30%/分钟)用温和扩容,突发流量(+200%/分钟)触发紧急扩容;
- 缩容比扩容更需要保守:过度缩容会导致服务中断,过度扩容只是多花点钱。缩容冷却时间设为扩容冷却的 2.5 倍是经验值。
适用边界:本方案适用于请求复杂度均匀(单次推理 100~500ms)的推理场景。长短请求混合(如 50ms 的翻译 + 10s 的长文生成)会导致队列深度指标失真,需引入请求分类维度。