用 KEDA 外部扩展器实现 Kubernetes GPU 自动伸缩

2026-05-27 27 预计阅读时间:1 分钟
来源:cncf.io AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

跑 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。

架构分两部分:

  1. Scaler(gRPC 服务):实现 KEDA 定义的 ExternalScaler gRPC 接口,负责返回指标值和触发判定。它跑在集群外或集群内都可以,KEDA Operator 会主动调用它。
  2. 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/gpu resource limit,否则扩出来的 Pod 没卡可用
  • [ ] 集群 GPU 节点池有足够余量承接 maxReplicaCount,或配合 Cluster Autoscaler

GPU 伸缩的核心矛盾很简单:默认路径不看 GPU,而 GPU 才是真正的瓶颈。KEDA External Scaler 让你把判断逻辑拉到自己手里——看什么指标、怎么算阈值、什么时候冷启动,全部可控。写一个 Scaler 的代价不过几百行代码和一次 gRPC 对接,换来的是不再被 CPU 假象误导的伸缩决策。


相关推荐