KServe 集成 KEDA:基于 VLLM 指标自动扩缩容

KServe 集成 KEDA:基于 vLLM 指标自动扩缩容

模型服务运行后,固定副本数很难同时兼顾突发请求和资源利用率。请求增加时需要扩容,流量回落后又希望及时缩容。

本文通过一个简单 Demo,为 KServe 模型服务接入 KEDA,根据 vLLM 的请求指标自动调整副本数,并验证服务从 1 -> 2 -> 1 的扩缩容过程。

环境准备

实现思路

需要在额外安装 KEDA 和 Prometheus,工作流程如下:

  1. KServe 创建 Predictor Deployment 启动模型服务,底层推理引擎 vLLM 通过 /metrics 暴露运行指标。
  2. Prometheus 采集这些指标,例如 vllm:num_requests_runningvllm:num_requests_waiting
  3. KEDA 查询 Prometheus,并通过 External Metrics API 将查询结果暴露为 HPA 可以使用的外部指标。
  4. HPA 比较指标当前值与目标值,计算期望副本数并调整 Predictor Deployment。

KServe 集成 KEDA 的自动扩缩容链路

安装 KEDA

本文实测 KEDA 2.17.2:

1
2
3
4
5
6
7
helm repo add kedacore https://kedacore.github.io/charts
helm repo update

helm upgrade --install keda kedacore/keda \
  -n keda --create-namespace \
  --version 2.17.2 \
  --wait

安装后确认 Operator、Admission Webhook 和 Metrics API Server 都已启动:

1
2
kubectl get pod -n keda
kubectl get apiservice v1beta1.external.metrics.k8s.io
1
2
3
4
5
6
keda-admission-webhooks-...          1/1   Running
keda-operator-...                    1/1   Running
keda-operator-metrics-apiserver-...  1/1   Running

NAME                                     SERVICE                              AVAILABLE
v1beta1.external.metrics.k8s.io          keda/keda-metrics-apiserver          True

只有 CRD 不够。external.metrics.k8s.io 不可用时,ScaledObject 可以创建,但 HPA 读不到 KEDA 提供的指标。

部署 Prometheus

本文使用 kube-prometheus-stack 部署 Prometheus 和 Prometheus Operator,Grafana、Alertmanager、Exporter 和默认告警规则都关闭,只保留指标采集需要的组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
helm upgrade --install prometheus \
  oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack \
  -n monitoring --create-namespace \
  --version 88.2.0 \
  --set grafana.enabled=false \
  --set alertmanager.enabled=false \
  --set kubeStateMetrics.enabled=false \
  --set nodeExporter.enabled=false \
  --set defaultRules.create=false \
  --set kubeApiServer.enabled=false \
  --set kubelet.enabled=false \
  --set kubeControllerManager.enabled=false \
  --set coreDns.enabled=false \
  --set kubeDns.enabled=false \
  --set kubeEtcd.enabled=false \
  --set kubeScheduler.enabled=false \
  --set kubeProxy.enabled=false \
  --set prometheusOperator.admissionWebhooks.enabled=false \
  --set prometheusOperator.tls.enabled=false \
  --wait

先确认 Prometheus Service 已经创建。下面的 InferenceService 和后续查询都使用这个 Service;如果命令没有输出,应先检查 Helm Release 和 Prometheus Pod,不要继续创建 InferenceService。

1
2
kubectl get svc prometheus-kube-prometheus-prometheus -n monitoring
kubectl get pod -n monitoring

当前配置不创建 PrometheusRule,因此一并关闭了只用于校验规则的 Admission Webhook。安装完成后,集群中应保留 Prometheus Operator 和 Prometheus 两个 Pod:

1
kubectl get pod,svc -n monitoring

配置自动扩缩容

创建 InferenceService

当前测试节点只有一张 GPU,而 Demo 需要把服务扩到两个副本,因此继续使用 HAMi DRA 共享 GPU。

创建 ResourceClaimTemplate 和完整的 InferenceService,同时配置每个副本的 GPU 配额、模型和 KEDA 自动扩缩容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
cat <<'EOF' > qwen-llm.yaml
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
  name: qwen-hami-gpu
  namespace: kserve-test
spec:
  spec:
    devices:
      requests:
        - name: gpu
          exactly:
            deviceClassName: hami-core-gpu.project-hami.io
            allocationMode: ExactCount
            count: 1
            capacity:
              requests:
                memory: 3Gi
                cores: "20"
---
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: qwen-llm
  namespace: kserve-test
  annotations:
    serving.kserve.io/deploymentMode: Standard
    serving.kserve.io/autoscalerClass: keda
spec:
  predictor:
    minReplicas: 1
    maxReplicas: 2
    resourceClaims:
      - name: gpu
        resourceClaimTemplateName: qwen-hami-gpu
    autoScaling:
      metrics:
        - type: External
          external:
            metric:
              backend: prometheus
              serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc:9090
              query: >-
                sum(vllm:num_requests_running{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
                +
                sum(vllm:num_requests_waiting{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
            target:
              type: Value
              value: "1"
    model:
      modelFormat:
        name: huggingface
      image: docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu
      storageUri: pvc://qwen-model
      args:
        - --model_name=qwen
        - --max_model_len=4096
        - --max-num-seqs=32
        - --gpu-memory-utilization=0.8
      resources:
        requests:
          cpu: "1"
          memory: 4Gi
        limits:
          cpu: "2"
          memory: 6Gi
        claims:
          - name: gpu
EOF

kubectl apply -f qwen-llm.yaml

kubectl wait --for=condition=Ready \
  inferenceservice/qwen-llm -n kserve-test --timeout=10m

每个 Predictor 副本通过 qwen-hami-gpu 申请 3Gi 显存和 20% GPU 核心,因此单 GPU 节点可以同时运行两个副本。
KServe 创建 Predictor Deployment 和 ScaledObject,KEDA 再创建 HPA 管理副本数。

扩缩容配置解析

这份 YAML 中扩缩容配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
metadata:
  annotations:
    serving.kserve.io/autoscalerClass: keda
spec:
  predictor:
    minReplicas: 1
    maxReplicas: 2
    autoScaling:
      metrics:
        - type: External
          external:
            metric:
              backend: prometheus
              serverAddress: http://prometheus-kube-prometheus-prometheus.monitoring.svc:9090
              query: >-
                sum(vllm:num_requests_running{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
                +
                sum(vllm:num_requests_waiting{namespace="kserve-test",pod=~"qwen-llm-predictor-.*"})
            target:
              type: Value
              value: "1"

各参数含义:

  • autoscalerClass: keda:使用 KEDA 进行扩缩容
  • minReplicasmaxReplicas:限制最小、最大副本数
  • serverAddress:Prometheus 地址
  • query:使用 running + waiting,同时统计正在执行和已经进入 vLLM 队列的请求
  • target.value:每个副本期望承载的目标值

Prometheus 查询得到的是所有 Predictor Pod 的请求总数,可以理解为:

1
Query = running 请求数 + waiting 请求数

target.value: 1 表示希望每个副本平均处理 1 个请求。当前副本范围是 1~2,扩缩容过程可以直接这样看:

  • 当前只有 1 个副本时,如果 Query = 01,保持 1 个副本。
  • 当前只有 1 个副本时,如果 Query >= 2,说明请求超过单个副本的目标值,扩到 2 个副本。
  • 扩容后即使请求继续增加,也不会超过 maxReplicas: 2
  • 负载停止后,Query 回到 01,等待 300 秒缩容稳定窗口结束,再缩回 1 个副本。

配置 ServiceMonitor

Prometheus Stack 不会自动采集 qwen-llm。创建 ServiceMonitor,通过 InferenceService Label 选择 KServe 生成的 Predictor Service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
cat <<'EOF' > qwen-llm-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: qwen-llm
  namespace: monitoring
  labels:
    release: prometheus
spec:
  namespaceSelector:
    matchNames:
      - kserve-test
  selector:
    matchLabels:
      serving.kserve.io/inferenceservice: qwen-llm
  endpoints:
    - port: qwen-llm-predictor
      path: /metrics
      interval: 5s
EOF

kubectl apply -f qwen-llm-monitor.yaml

ServiceMonitor 不会通过 Service 的 ClusterIP 随机抓取一个后端。Prometheus Operator 会读取 Service 对应的 EndpointSlice,并把每个 Predictor Pod 都作为独立 Target。

检查指标和扩缩容资源

通过 Kubernetes Service Proxy 检查 Target。当前只有一个 Predictor,因此应看到一行 qwen-llm-predictor-*,状态为 up

1
2
3
kubectl get --raw \
  '/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/targets?state=active' | \
  jq -r '.data.activeTargets[] | select(.labels.namespace == "kserve-test") | [.labels.pod, .health, .scrapeUrl] | @tsv'

再检查空闲时的指标:

1
2
3
kubectl get --raw \
  '/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/query?query=sum(vllm:num_requests_running%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)%2Bsum(vllm:num_requests_waiting%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)' | \
  jq -r '[.status, .data.resultType, (.data.result | length), .data.result[0].value[1]] | @tsv'

实测输出是:

1
success vector 1 0

KServe 创建 Deployment 和 ScaledObject,KEDA 再创建并管理 HPA:

1
2
3
4
5
6
kubectl get inferenceservice qwen-llm -n kserve-test
kubectl get scaledobject qwen-llm-predictor -n kserve-test
kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test
kubectl get deployment qwen-llm-predictor -n kserve-test
kubectl get pod -n kserve-test \
  -l serving.kserve.io/inferenceservice=qwen-llm

空闲状态下,各条命令输出中的关键字段是下面这些。这里是整理后的字段,不是上一段单条命令的原始输出:

1
2
3
qwen-llm-predictor                  READY=True   ACTIVE=False   MIN=1   MAX=2
keda-hpa-qwen-llm-predictor         TARGETS=0/1                 REPLICAS=1
qwen-llm-predictor                  READY=1/1

直接读取 HPA,确认缩容稳定窗口:

1
2
kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test \
  -o jsonpath='{.spec.behavior.scaleDown.stabilizationWindowSeconds}{"\n"}'

当前结果是:

1
300

验证自动扩缩容

验证扩容

先查询 Envoy Gateway 的地址,发送一次推理请求,确认服务正常:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
ENVOY_SERVICE=$(kubectl get service -n envoy-gateway-system \
  -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway \
  -o jsonpath='{.items[0].metadata.name}')

NODE_PORT=$(kubectl get service -n envoy-gateway-system "$ENVOY_SERVICE" \
  -o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')

NODE_IP=$(kubectl get node \
  -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')

GATEWAY_ADDR="${NODE_IP}:${NODE_PORT}"

curl -H 'Host: qwen-llm-kserve-test.example.com' \
  -H 'Content-Type: application/json' \
  "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
  -d '{
    "model": "qwen",
    "messages": [{"role": "user", "content": "Answer only with the number: 2+3"}],
    "max_tokens": 8,
    "temperature": 0
  }'

产生持续负载

使用以下命令产生负载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
BODY='{"model":"qwen","messages":[{"role":"user","content":"Write a detailed numbered list from 1 to 300, with a complete sentence for every item."}],"max_tokens":768,"temperature":0.7}'
export BODY GATEWAY_ADDR

seq 12 | xargs -n 1 -P 12 bash -c '
  while true; do
    curl -sS --max-time 180 \
      -H "Host: qwen-llm-kserve-test.example.com" \
      -H "Content-Type: application/json" \
      "http://${GATEWAY_ADDR}/openai/v1/chat/completions" \
      -d "$BODY" >/dev/null
  done
'

这条命令会一直占用当前终端,并将并发数保持为 12 持续发送请求。

观察扩容结果

另开两个终端,同时观察 Prometheus 指标以及扩缩容资源:

1
watch -n 5 'kubectl get --raw "/api/v1/namespaces/monitoring/services/http:prometheus-kube-prometheus-prometheus:http-web/proxy/api/v1/query?query=sum(vllm:num_requests_running%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)%2Bsum(vllm:num_requests_waiting%7Bnamespace=%22kserve-test%22,pod=~%22qwen-llm-predictor-.*%22%7D)" | jq -r ".data.result[0].value[1]"'
1
watch -n 5 'kubectl get scaledobject,hpa,deployment,pod -n kserve-test'

本次实测时间线是:

1
2
3
4
启动负载后  Prometheus 查询值升到 12
              ScaledObject ACTIVE=True
              HPA TARGETS=6/1,Deployment REPLICAS=2
约 250 秒后   第二个 vLLM 完成模型加载,两个 Pod 均为 1/1 Running

KServe 模型服务自动扩缩容时间线

指标值是整个 Deployment 的 running + waiting 总数。两个副本、总值 12 时,平均每个副本为 6,因此 HPA 的目标列显示约 6/1

扩容期间再调用一次 Chat Completions,仍然返回正确结果。至此可以确认 KEDA 已经根据 vLLM 指标把服务从 1 个副本扩到 2 个。

验证缩容

回到运行压测的终端,按 Ctrl+C 停止全部请求。

先重复前面的 Prometheus 查询,确认结果回到 0,再检查 ScaledObject 变成 ACTIVE=False。Deployment 不会立即缩容,因为当前生成的 HPA 带有 300 秒缩容稳定窗口:

1
2
3
behavior:
  scaleDown:
    stabilizationWindowSeconds: 300

实际缩容时间还会受到 KEDA 轮询、HPA 同步周期和 Pod 终止时间影响。用下面的命令等待 Deployment 回到一个 Ready 副本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
  --for=jsonpath='{.status.replicas}'=1 --timeout=8m
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
  --for=jsonpath='{.spec.replicas}'=1 --timeout=2m
kubectl wait deployment/qwen-llm-predictor -n kserve-test \
  --for=jsonpath='{.status.readyReplicas}'=1 --timeout=2m

kubectl get hpa keda-hpa-qwen-llm-predictor -n kserve-test
kubectl get deployment qwen-llm-predictor -n kserve-test \
  -o custom-columns=NAME:.metadata.name,SPEC:.spec.replicas,STATUS:.status.replicas,READY:.status.readyReplicas
kubectl get pod -n kserve-test \
  -l serving.kserve.io/inferenceservice=qwen-llm

本次停止负载后,Prometheus 查询值回到 0,ScaledObject 变成 ACTIVE=False;约 299 秒后,HPA 将 Deployment 从 2 缩回 1。最后重新执行前面的 Chat Completions 请求,仍然返回 5

总结

本文验证了模型服务从 1 -> 2 -> 1 的完整链路,最后总结一下整个流程:

  1. KServe 创建 Predictor Deployment 启动模型服务,底层推理引擎 vLLM 通过 /metrics 暴露运行指标。
  2. Prometheus 采集这些指标,例如 vllm:num_requests_runningvllm:num_requests_waiting
  3. KEDA 查询 Prometheus,并通过 External Metrics API 将查询结果暴露为 HPA 可以使用的外部指标。
  4. HPA 比较指标当前值与目标值,计算期望副本数并调整 Predictor Deployment。

KServe 集成 KEDA 的自动扩缩容链路

0%