AI 推理服务弹性扩容:从 HPA 到 GPU 感知调度的自动伸缩实践
一、AI 服务的扩容困境:CPU 规则在 GPU 场景的失效
Kubernetes 原生 HPA(Horizontal Pod Autoscaler)基于 CPU 利用率自动扩缩容,对传统微服务效果良好。但 AI 推理服务的瓶颈在 GPU 而非 CPU,CPU 利用率可能只有 30%,而 GPU 显存已接近满载。更关键的是,LLM 推理服务的延迟与批处理大小强相关——单请求延迟 200ms,10 个并发请求可能需要 2 秒,而 CPU 利用率变化不大。
某 AI 平台使用原生 HPA 管理 LLM 推理服务,CPU 阈值设为 70%,结果在高峰期 CPU 仅 40% 时服务 P99 延迟已达 5 秒,HPA 未触发扩容,用户体验严重劣化。手动将阈值调低到 30% 后,又出现低峰期过度扩容,GPU 资源浪费严重。AI 服务的弹性扩容需要一套基于 GPU 指标和推理延迟的自定义伸缩策略。
二、GPU 感知自动伸缩架构
2.1 整体架构
graph TD
A[Prometheus] –> B[GPU指标采集]
A –> C[推理延迟指标]
A –> D[队列深度指标]
B –> E[自定义指标适配器]
C –> E
D –> E
E –> F[GPU-Aware HPA Controller]
F –> G{伸缩决策引擎}
G –> H[扩容策略]
H –> H1[GPU利用率>80%]
H –> H2[P99延迟>阈值]
H –> H3[队列深度>上限]
G –> I[缩容策略]
I –> I1[GPU利用率<30%]
I –> I2[延迟低于阈值持续5分钟]
I –> I3[缩容冷却期5分钟]
F –> J[Kubernetes API]
J –> K[Deployment Scale]
2.2 伸缩决策流程
sequenceDiagram
participant M as Metrics Server
participant H as HPA Controller
participant D as 决策引擎
participant K as K8s API
participant P as Pod
loop 每15秒
M->>H: 拉取GPU/延迟指标
H->>D: 输入当前指标
D->>D: 计算期望副本数
alt 需要扩容
D–>>H: desiredReplicas=5(当前3)
H->>K: Scale Deployment to 5
K->>P: 创建2个新Pod
Note over P: 新Pod加载模型约30-60秒<br/>需预热后才能接收流量
else 需要缩容
D–>>H: desiredReplicas=2(当前3)
H->>H: 检查冷却期
H->>K: Scale Deployment to 2
K->>P: 优雅终止1个Pod
else 无需调整
D–>>H: desiredReplicas=3(当前3)
end
end
三、生产级 GPU 感知 HPA 实现
3.1 自定义指标采集与暴露
"""
GPU指标采集器 – 部署在每个推理节点
通过nvidia-smi采集GPU指标,暴露为Prometheus指标
"""
import subprocess
import time
import threading
from prometheus_client import Gauge, start_http_server
class GPUMetricsCollector:
"""GPU指标采集与Prometheus暴露"""
def __init__(self, scrape_interval: int = 5):
self.scrape_interval = scrape_interval
# Prometheus指标定义
self.gpu_utilization = Gauge(
'gpu_utilization_percent',
'GPU compute utilization percentage',
['node', 'gpu_index', 'model_name']
)
self.gpu_vram_usage = Gauge(
'gpu_vram_usage_percent',
'GPU VRAM usage percentage',
['node', 'gpu_index', 'model_name']
)
self.gpu_vram_used_mb = Gauge(
'gpu_vram_used_mb',
'GPU VRAM used in MB',
['node', 'gpu_index', 'model_name']
)
self.gpu_temperature = Gauge(
'gpu_temperature_celsius',
'GPU temperature in celsius',
['node', 'gpu_index']
)
def start(self, port: int = 9101):
"""启动指标采集与HTTP暴露"""
start_http_server(port)
collector_thread = threading.Thread(target=self._collect_loop, daemon=True)
collector_thread.start()
def _collect_loop(self):
"""定期采集GPU指标"""
while True:
try:
metrics = self._query_nvidia_smi()
for gpu in metrics:
labels = {
'node': gpu['node_name'],
'gpu_index': str(gpu['index']),
'model_name': gpu['name'],
}
self.gpu_utilization.labels(**labels).set(gpu['utilization'])
self.gpu_vram_usage.labels(**labels).set(gpu['vram_usage_percent'])
self.gpu_vram_used_mb.labels(**labels).set(gpu['vram_used_mb'])
self.gpu_temperature.labels(
node=gpu['node_name'],
gpu_index=str(gpu['index'])
).set(gpu['temperature'])
except Exception as e:
print(f"GPU指标采集异常: {e}")
time.sleep(self.scrape_interval)
def _query_nvidia_smi(self) -> list:
"""
调用nvidia-smi采集GPU指标
使用xml输出格式便于解析
"""
try:
result = subprocess.run(
['nvidia-smi', '–query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu',
'–format=csv,noheader,nounits'],
capture_output=True, text=True, timeout=5
)
metrics = []
for line in result.stdout.strip().split('\\n'):
parts = [p.strip() for p in line.split(',')]
if len(parts) >= 6:
vram_total = float(parts[4])
vram_used = float(parts[3])
metrics.append({
'index': int(parts[0]),
'name': parts[1],
'utilization': float(parts[2]),
'vram_used_mb': vram_used,
'vram_total_mb': vram_total,
'vram_usage_percent': (vram_used / vram_total * 100) if vram_total > 0 else 0,
'temperature': float(parts[5]),
'node_name': self._get_node_name(),
})
return metrics
except subprocess.TimeoutExpired:
return []
except Exception:
return []
def _get_node_name(self) -> str:
import os
return os.environ.get('NODE_NAME', 'unknown')
3.2 GPU 感知 HPA 控制器
package hpa
import (
"context"
"math"
"sync"
"time"
)
// ScalingPolicy 伸缩策略配置
type ScalingPolicy struct {
MinReplicas int32 // 最小副本数
MaxReplicas int32 // 最大副本数
CooldownPeriod time.Duration // 缩容冷却期
// 扩容触发条件(满足任一即触发)
ScaleUpGPUUtilization float64 // GPU利用率阈值
ScaleUpP99LatencyMs float64 // P99延迟阈值
ScaleUpQueueDepth int // 请求队列深度阈值
// 缩容触发条件(需全部满足)
ScaleDownGPUUtilization float64 // GPU利用率低于此值
ScaleDownP99LatencyMs float64 // P99延迟低于此值
}
// MetricsSnapshot 当前指标快照
type MetricsSnapshot struct {
AvgGPUUtilization float64
P99LatencyMs float64
QueueDepth int
CurrentReplicas int32
Timestamp time.Time
}
// GPUAwareHPAController GPU感知HPA控制器
type GPUAwareHPAController struct {
policy ScalingPolicy
metrics MetricsSnapshot
lastScaleUp time.Time
lastScaleDown time.Time
mu sync.Mutex
}
func NewGPUAwareHPAController(policy ScalingPolicy) *GPUAwareHPAController {
return &GPUAwareHPAController{
policy: policy,
}
}
// ComputeDesiredReplicas 计算期望副本数
// 核心伸缩算法:基于多指标加权计算
func (c *GPUAwareHPAController) ComputeDesiredReplicas(
ctx context.Context, current MetricsSnapshot) int32 {
c.mu.Lock()
defer c.mu.Unlock()
c.metrics = current
desired := current.CurrentReplicas
// 扩容判断:任一指标超过阈值即触发
needScaleUp := false
if current.AvgGPUUtilization > c.policy.ScaleUpGPUUtilization {
needScaleUp = true
}
if current.P99LatencyMs > c.policy.ScaleUpP99LatencyMs {
needScaleUp = true
}
if current.QueueDepth > c.policy.ScaleUpQueueDepth {
needScaleUp = true
}
if needScaleUp {
// 扩容计算:取各指标计算结果的最大值
gpuBased := c.calculateReplicasByGPU(current)
latBased := c.calculateReplicasByLatency(current)
queueBased := c.calculateReplicasByQueue(current)
desired = int32(math.Max(math.Max(float64(gpuBased), float64(latBased)),
math.Max(float64(queueBased), float64(current.CurrentReplicas))))
// 扩容步长限制:单次最多扩容当前副本数的50%
maxStep := current.CurrentReplicas + int32(math.Ceil(float64(current.CurrentReplicas)*0.5))
if desired > maxStep {
desired = maxStep
}
c.lastScaleUp = time.Now()
}
// 缩容判断:所有指标均低于阈值,且冷却期已过
needScaleDown := current.AvgGPUUtilization < c.policy.ScaleDownGPUUtilization &&
current.P99LatencyMs < c.policy.ScaleDownP99LatencyMs &&
time.Since(c.lastScaleDown) > c.policy.CooldownPeriod &&
time.Since(c.lastScaleUp) > c.policy.CooldownPeriod
if needScaleDown {
// 缩容步长:单次最多缩容1个副本,避免震荡
desired = current.CurrentReplicas – 1
c.lastScaleDown = time.Now()
}
// 边界限制
if desired < c.policy.MinReplicas {
desired = c.policy.MinReplicas
}
if desired > c.policy.MaxReplicas {
desired = c.policy.MaxReplicas
}
return desired
}
// calculateReplicasByGPU 基于GPU利用率计算所需副本数
func (c *GPUAwareHPAController) calculateReplicasByGPU(m MetricsSnapshot) int32 {
if m.AvgGPUUtilization <= 0 {
return m.CurrentReplicas
}
// 目标利用率设为扩容阈值的70%,留出缓冲空间
targetUtil := c.policy.ScaleUpGPUUtilization * 0.7
desired := float64(m.CurrentReplicas) * (m.AvgGPUUtilization / targetUtil)
return int32(math.Ceil(desired))
}
// calculateReplicasByLatency 基于P99延迟计算所需副本数
func (c *GPUAwareHPAController) calculateReplicasByLatency(m MetricsSnapshot) int32 {
if m.P99LatencyMs <= c.policy.ScaleUpP99LatencyMs {
return m.CurrentReplicas
}
// 延迟超标的扩容比例:延迟超标越多,扩容越激进
ratio := m.P99LatencyMs / c.policy.ScaleUpP99LatencyMs
desired := float64(m.CurrentReplicas) * ratio
return int32(math.Ceil(desired))
}
// calculateReplicasByQueue 基于队列深度计算所需副本数
func (c *GPUAwareHPAController) calculateReplicasByQueue(m MetricsSnapshot) int32 {
if m.QueueDepth <= c.policy.ScaleUpQueueDepth {
return m.CurrentReplicas
}
// 每个副本能处理的队列深度估算
perReplicaQueue := float64(c.policy.ScaleUpQueueDepth)
desired := float64(m.QueueDepth) / perReplicaQueue
return int32(math.Ceil(desired))
}
四、AI 弹性扩容的架构权衡
4.1 扩容延迟与模型加载时间
LLM 推理 Pod 从启动到就绪需要 30-60 秒(模型加载+预热),这意味着扩容不是即时的。在突发流量场景下,30 秒的扩容延迟可能导致大量请求超时。应对策略:预留缓冲副本(minReplicas 高于最低需求)、模型预热(启动时发送预热请求)、模型权重预加载到节点本地磁盘。
4.2 缩容震荡与冷却期
缩容后流量突然增加,又触发扩容,形成震荡。冷却期(通常 5-10 分钟)可缓解,但冷却期过长导致资源浪费,过短仍会震荡。更稳健的方案是:缩容采用逐步策略(每次只缩 1 个副本),扩容采用激进策略(可一次扩 50%),这与传统 HPA 的行为一致。
4.3 多模型混部的伸缩冲突
同一集群运行多个模型服务时,GPU 资源是共享的。模型 A 扩容可能挤占模型 B 的资源。需要引入全局资源配额(ResourceQuota)和优先级(PriorityClass),确保核心模型的扩容优先于非核心模型。
4.4 禁用场景
- 流量稳定且可预测的服务,固定副本数更简单可靠
- 模型加载时间超过 5 分钟的超大模型,弹性扩容的响应速度无法满足需求
- GPU 资源固定的私有化部署环境,无法动态申请 GPU 节点
五、总结
AI 推理服务的弹性扩容需要从 CPU 指标驱动转向 GPU 指标与推理延迟联合驱动。GPU 利用率、P99 延迟和请求队列深度是三个核心伸缩指标,扩容策略需激进以应对突发流量,缩容策略需保守以避免震荡。模型加载延迟是弹性伸缩的关键瓶颈,需通过预留缓冲、模型预热等手段缓解。架构选型的核心原则是:根据流量波动特征和 SLA 要求,在资源利用率与服务稳定性之间找到平衡点。






