跑 GPU 工作负载的开发者迟早会撞上同一堵墙:Kubernetes 默认的 HPA 只看 CPU 和内存,而你的瓶颈在 GPU。vLLM 推理服务排队不是因为 CPU 满了,而是显存吃紧、SM 占用率拉满;Triton 模型服务在 GPU 利用率 90% 时还在被 HPA 判定为"负载正常"。训练任务更离谱——一个 job 占满整张卡,HPA 对此毫无感知。
KEDA 的外部扩展器(External Scaler)给了我们一条绕过 HPA 盲区的路:把 GPU 指标从集群外拉进来,让伸缩决策基于真实的工作负载信号,而不是被 CPU/内存的假象蒙蔽。
为什么 HPA 不够用
HPA 的核心逻辑是 desiredReplicas = ceil(currentReplicas × (currentMetric / targetMetric))。问题出在 currentMetric 从哪来——默认是 Metrics Server 提供的 CPU/Memory。GPU 指标不在其中。
有人装过 DCGM Exporter,把 GPU 利用率塞进 Prometheus,再用 Prometheus Adapter 暴露为自定义指标给 HPA。这条路能走,但弯路太多:
- Adapter 的自定义指标 API 和 HPA 的版本对齐是个坑;
- 单一指标阈值无法表达"显存占用率 + 推理队列深度"的联合判断;
- 训练类任务的生命周期和推理服务完全不同,HPA 的扩缩逻辑对 job 类负载不友好。
KEDA 的 Scaler 架构天然更适合这种场景——它不依赖 Metrics API,而是主动从外部数据源拉指标,每个 Scaler 可以独立定义触发条件。
KEDA 外部扩展器的工作机制
KEDA 的内置 Scaler 覆盖了 Kafka、Prometheus、AWS CloudWatch 等几十种数据源。当你的指标源不在列表里——比如自建的 GPU 监控服务——就需要写一个 External Scaler。
架构分两部分:
- Scaler(gRPC 服务):实现 KEDA 定义的
ExternalScalergRPC 接口,负责返回指标值和触发判定。它跑在集群外或集群内都可以,KEDA Operator 会主动调用它。 - ScaledObject / ScaledJob:集群内的 CRD,声明用哪个 Scaler、伸缩目标是谁、触发参数是什么。
关键 gRPC 方法有三个:
| 方法 | 作用 |
|---|---|
IsActive |
返回 bool,决定是否从 0 扩到 1(冷启动) |
GetMetricSpec |
返回目标指标名和阈值 |
GetMetrics |
返回当前实际指标值 |
KEDA Operator 循环调用这三个方法,算出期望副本数,然后驱动 Deployment 或 Job 的伸缩。
实战:写一个 GPU 利用率 External Scaler
下面用一个最小但完整的例子演示整个链路。假设你有一个 HTTP 服务暴露各节点 GPU 利用率,格式如下:
{"gpu_utilization": {"node-a": 87, "node-b": 45, "node-a-pending-requests": 12}}
第一步:定义 gRPC 协议
KEDA 的 proto 文件在官方仓库,核心片段如下。实际开发时直接引用官方 proto,不要手写。
syntax = "proto3";
package externalscaler;
service ExternalScaler {
rpc IsActive(ScaledObjectRef) returns (IsActiveResponse);
rpc GetMetricSpec(ScaledObjectRef) returns (GetMetricSpecResponse);
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse);
}
message ScaledObjectRef {
string name = 1;
string namespace = 2;
map<string, string> scalerMetadata = 3;
}
message IsActiveResponse {
bool result = 1;
}
message GetMetricSpecResponse {
repeated MetricSpec metricSpecs = 1;
}
message MetricSpec {
string metricName = 1;
int64 targetSize = 2;
}
message GetMetricsRequest {
ScaledObjectRef scaledObjectRef = 1;
}
message GetMetricsResponse {
repeated MetricValue metricValues = 1;
}
message MetricValue {
string metricName = 1;
int64 metricValue = 2;
}
第二步:用 Python 实现 Scaler 服务
# scaler_server.py
# 依赖: pip install grpcio grpcio-tools requests
import grpc
from concurrent import futures
import externalscaler_pb2
import externalscaler_pb2_grpc
import requests
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
GPU_MONITOR_URL = "http://gpu-monitor.default.svc.cluster.local:8080/metrics"
class GPUExternalScaler(externalscaler_pb2_grpc.ExternalScalerServicer):
def _fetch_gpu_metrics(self, metadata):
"""从 GPU 监控服务拉取指标,metadata 可传入 url 覆盖默认值"""
url = metadata.get("monitorUrl", GPU_MONITOR_URL)
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.error(f"Failed to fetch GPU metrics: {e}")
return None
def IsActive(self, request, context):
"""有排队请求或 GPU 利用率超阈值就激活,从 0 扩到 1"""
metadata = request.scalerMetadata
data = self._fetch_gpu_metrics(metadata)
if data is None:
return externalscaler_pb2.IsActiveResponse(result=False)
threshold = int(metadata.get("activationThreshold", "30"))
pending = data.get("gpu_utilization", {}).get("pending_requests", 0)
max_util = max(
[v for k, v in data["gpu_utilization"].items() if "pending" not in k],
default=0
)
active = pending > 0 or max_util > threshold
logger.info(f"IsActive: max_util={max_util}, pending={pending}, result={active}")
return externalscaler_pb2.IsActiveResponse(result=active)
def GetMetricSpec(self, request, context):
"""返回指标名和目标阈值,KEDA 用此计算 desiredReplicas"""
metadata = request.scalerMetadata
target = int(metadata.get("targetUtilization", "70"))
return externalscaler_pb2.GetMetricSpecResponse(
metricSpecs=[
externalscaler_pb2.MetricSpec(
metricName="gpu_utilization_max",
targetSize=target
)
]
)
def GetMetrics(self, request, context):
"""返回当前指标值"""
metadata = request.scalerMetadata
data = self._fetch_gpu_metrics(metadata)
if data is None:
return externalscaler_pb2.GetMetricsResponse(metricValues=[])
util_values = {
k: v for k, v in data["gpu_utilization"].items()
if "pending" not in k
}
max_util = max(util_values.values(), default=0)
logger.info(f"GetMetrics: gpu_utilization_max={max_util}")
return externalscaler_pb2.GetMetricsResponse(
metricValues=[
externalscaler_pb2.MetricValue(
metricName="gpu_utilization_max",
metricValue=max_util
)
]
)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
externalscaler_pb2_grpc.add_ExternalScalerServicer_to_server(
GPUExternalScaler(), server
)
server.add_insecure_port("[::]:6000")
logger.info("External Scaler listening on :6000")
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()
运行前先从官方 proto 生成 Python 绑定:
# 克隆 KEDA proto 定义
git clone --depth 1 https://github.com/kedacore/keda.git /tmp/keda
# 生成 Python gRPC 代码
python -m grpc_tools.protoc \
-I/tmp/keda/api/proto \
--python_out=. \
--grpc_python_out=. \
/tmp/keda/api/proto/externalscaler.proto
然后启动服务:
pip install grpcio grpcio-tools requests
python scaler_server.py
第三步:部署 Scaler 到集群
把 Scaler 打包成镜像跑在集群内,KEDA Operator 通过 Service 访问它:
# scaler-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: gpu-external-scaler
namespace: keda
spec:
replicas: 1
selector:
matchLabels:
app: gpu-external-scaler
template:
metadata:
labels:
app: gpu-external-scaler
spec:
containers:
- name: scaler
image: your-registry/gpu-external-scaler:latest
ports:
- containerPort: 6000
---
apiVersion: v1
kind: Service
metadata:
name: gpu-external-scaler
namespace: keda
spec:
selector:
app: gpu-external-scaler
ports:
- port: 6000
targetPort: 6000
kubectl apply -f scaler-deployment.yaml
第四步:创建 ScaledObject 连接 Scaler 和工作负载
这是把所有东西串起来的关键 CRD:
# scaledobject-vllm.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-gpu-scaler
namespace: inference
spec:
scaleTargetRef:
name: vllm-server # 要伸缩的 Deployment 名
minReplicaCount: 0 # 允许缩到 0,省 GPU 费
maxReplicaCount: 8
cooldownPeriod: 60 # 缩容冷却秒数,避免抖动
triggers:
- type: external
metadata:
scalerAddress: gpu-external-scaler.keda.svc.cluster.local:6000
targetUtilization: "70" # GPU 利用率 70% 为扩容阈值
activationThreshold: "30" # 利用率超 30% 或有排队请求才从 0 拉起
monitorUrl: "http://gpu-monitor.default.svc.cluster.local:8080/metrics"
# 确保 KEDA 已安装
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace
# 部署 ScaledObject
kubectl apply -f scaledobject-vllm.yaml
部署完成后,观察伸缩行为:
# 查看 ScaledObject 状态
kubectl get scaledobject vllm-gpu-scaler -n inference -o wide
# 查看 HPA(KEDA 内部会创建一个关联的 HPA)
kubectl get hpa -n inference
# 实时观察副本数变化
kubectl get deployment vllm-server -n inference -w
训练任务怎么办——用 ScaledJob
推理服务是长跑的 Deployment,训练任务是一次性的 Job。KEDA 提供了 ScaledJob CRD,逻辑类似但驱动的是 Job 创建而非副本数调整。
关键区别:
ScaledJob每次触发创建一个新 Job,而不是调 replicas;- 适合"队列里有 N 个待训练任务就启动 N 个 Job"的模式;
- 需要在
IsActive中判断队列是否有待处理项。
一个简化示例:
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: training-job-scaler
namespace: training
spec:
jobTargetRef:
template:
spec:
containers:
- name: trainer
image: your-registry/trainer:latest
resources:
limits:
nvidia.com/gpu: 1
restartPolicy: Never
minReplicaCount: 0
maxReplicaCount: 4
triggers:
- type: external
metadata:
scalerAddress: gpu-external-scaler.keda.svc.cluster.local:6000
targetUtilization: "80"
activationThreshold: "50"
几个容易踩的坑
gRPC 连接超时:KEDA Operator 调 Scaler 有默认超时。如果你的 GPU 监控服务响应慢,Scaler 的 _fetch_gpu_metrics 会拖垮整个 gRPC 调用。把 HTTP 请求超时控制在 3 秒内,失败时返回保守值(不扩容),而不是卡住。
指标抖动:GPU 利用率天然波动大——一个推理请求进来,利用率瞬间从 20% 跳到 95%。不加冷却窗口,HPA 会频繁扩缩。设置 cooldownPeriod: 120 或在 Scaler 内部做滑动平均平滑。
冷启动代价:GPU 服务从 0 拉起要加载模型到显存,vLLM 加一个 7B 模型可能要 30-60 秒。activationThreshold 设低一点(比如 20%),让服务在负载刚冒头时就启动,别等到排队严重才拉起。
显存 vs 利用率:DCGM 的 DCGM_FI_DEV_GPU_UTIL 只反映 SM 计算占用,不反映显存占用。一个模型占了 80% 显存但计算利用率只有 30%,HPA 会认为"负载不高"。如果你的场景是显存瓶颈,在监控服务里加上 DCGM_FI_DEV_FB_USED 指标,在 Scaler 里做联合判断。
上线前的检查清单
- [ ] GPU 监控服务(DCGM Exporter 或自建)稳定可用,指标延迟 < 5 秒
- [ ] Scaler gRPC 服务通过
grpcurl手动验证三个方法返回正确 - [ ] ScaledObject 的
scalerAddress端口和命名空间对齐 - [ ]
minReplicaCount设为 0 前确认冷启动时间业务可接受 - [ ]
cooldownPeriod大于模型加载时间的 2 倍,防止缩容后立即又扩 - [ ] 目标 Deployment 的 Pod 有
nvidia.com/gpuresource limit,否则扩出来的 Pod 没卡可用 - [ ] 集群 GPU 节点池有足够余量承接 maxReplicaCount,或配合 Cluster Autoscaler
GPU 伸缩的核心矛盾很简单:默认路径不看 GPU,而 GPU 才是真正的瓶颈。KEDA External Scaler 让你把判断逻辑拉到自己手里——看什么指标、怎么算阈值、什么时候冷启动,全部可控。写一个 Scaler 的代价不过几百行代码和一次 gRPC 对接,换来的是不再被 CPU 假象误导的伸缩决策。