MoreRSS

site iconLixueduan | 李学端修改

博客名:指月小筑。专注云原生,Go,坚持分享最佳实践、经验干货。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Lixueduan | 李学端的 RSS 预览

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

2026-08-27 04:00:00

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 的自动扩缩容链路

GPT-5.6 之后,Superpowers 可以卸载了

2026-08-17 04:00:00

GPT-5.6 之后,Superpowers 可以卸载了

之前我写过一篇文章 OpenSpec + Superpowers,SDD+TDD 双驱动 AI 编程工作流,记录了我当时把 OpenSpecSuperpowers 组合成一套工作流的尝试。

那时候我觉得这两个东西搭在一起还挺顺,OpenSpec 负责需求和 Spec 对齐,Superpowers 负责计划、TDD、子 Agent 实现和代码审查。

后来,我发现社区里很多人也在用这套工作流,先写需求和设计,拆任务,再实现和验证,大家都在试图给 Agent 加一套更稳定的工作方法。

GPT-5.6 之后,Superpowers 还需要吗

这件事本来挺美好的,直到 GPT-5.6 出现。

我用 GPT-5.6 Sol 做一个很简单的需求,本来以为读一下代码、改完、跑个测试就结束了,结果任务跑了很久。

看执行过程发现 Codex 并不是卡住了,而是 Superpowers 把头脑风暴、计划、子 Agent 实现、TDD 和代码审查一层层串了起来,一个简单需求就被升级成了一套完整的软件工程流程。

而在社区里,我也看到了很多类似的使用反馈,大致可以分成两类:

  • 卸载后的体感更轻。 Agent 思考的时间短了一些,做出来的结果却大差不差。
  • 通用流程出现重复。 Superpowers 会串起头脑风暴、计划、子 Agent、TDD 和代码审查,但 Codex 自己也在规划和验证,MCP 与本地 Skills 里还可能有另一套规则。

当然,这里也不能把变慢全部归到 Superpowers 身上。

从使用体感看,GPT-5.6 Sol 本身就比 GPT-5.5 慢。再叠加头脑风暴、计划和 Review 等完整流程,等待时间和额度消耗又被进一步放大。

所有讨论最后都落到了同一个问题上:GPT-5.6 Sol 模型能力已经这么强了,还要不要继续使用 Superpowers 这类 Skill?

Comet Native 工作流:放松过程,收紧结果

只靠使用体感,很难回答前面的问题,或者说可信度不够。

恰好 Comet 最近做了一次很有参考价值的调整,新增了 Native 模式。

Native 到底改了什么

0.4.0-beta.7 新增了面向强模型的 Native 模式。

原来的 Classic 模式会串联 OpenSpec 和 Superpowers,依次经过 Open、Design、Build、Verify、Archive。

Native 不再依赖这两个外部 Skill,只保留 Shape、Build、Verify、Archive 四个阶段:

  • Shape 阶段先和人讨论需求,把目标、范围、不做什么、验收示例和风险边界写清楚。
  • 进入 Build 之后,怎么规划、要不要写计划、测试做到多深、怎么调试和审查,都交给模型自己决定。
  • Verify 再拿前面定义的验收条件逐项核对,失败就带着未满足项返回 Build,通过后才进入 Archive。

它的思路很直接:过程放松,结果收紧。

Native 没有去掉验证,只是不再强制模型按照固定的方法完成任务。外层工作流负责锁住验收规则、验证证据和风险边界,具体怎么实现则交给模型决定。

具体如何完成任务交给模型自由发挥,我们要做的就是完成后验证任务是否真的完成

Comet Classic 与 Native 工作流对比

Comet 还做了一轮 NativeClassic 的官方对齐实验。两种模式处理相同的 16 个业务任务,每个任务各跑 3 次,每种模式 48 次,总共 96 次运行。

完成质量有没有下降

Comet Native 与 Classic 完成质量对比

  • pass@1 单次运行的成功率,反映模型不依赖重试、一次完成任务的能力。
  • pass@3 是三次里至少成功一次,代表这个任务能不能做。
  • pass^3 更严格,要求三次全部成功,看的则是能不能稳定地做。

两种模式的 pass@3 都是 100%,说明去掉 OpenSpec 和 Superpowers 之后,Native 没有丢掉这 16 个任务的覆盖能力。反而在单次严格通过率和三次连续成功率上更高。

执行成本降低了多少

为了避免「任务提前失败,所以看起来更省」这种误差,官方报告这里只统计 Native 和 Classic 都严格通过的 41 组配对样本。

Comet Native 相对 Classic 的执行资源降幅

效率图将 Classic 归一化为 100%;数据只统计两种模式都通过的 41 组配对样本,表中均为单个成功样本的平均值。

最明显的是,总 Token 下降了 76.8%,模型成本下降了 75.1%,Agent 轮次和工具调用也减少了一半以上。

在这组实验里,流程变轻之后,任务覆盖没有下降,执行成本却明显降低了。

当然,这只是 Comet 的第一方实验,不能直接外推到所有模型和任务。
但至少在这 16 个任务里,去掉固定方法论后,完成质量没有下降,执行成本却明显降低了。

通用能力正在被模型和 Runtime 内化

但数据只能告诉我们发生了什么,解释不了为什么。

我倒不觉得是 Superpowers 突然没用了。

上一篇文章 OpenSpec + Superpowers,SDD+TDD 双驱动 AI 编程工作流 里,我说用上 Superpowers 之后,代码质量比让模型直接动手好不少。这个判断放在当时依然成立。模型容易跳过设计、忘记测试,Superpowers 就用一套完整的需求讨论、计划、TDD 和 Review 流程把这些缺口补上。

但现在,大人,时代变了。😏

Superpowers 没变,变的是模型。

对强模型来说,能力瓶颈已经不在「会不会写代码」,而在「能不能被信任地完成任务」。

GPT-5.6 已经会主动理解需求、调查代码、形成方案,再根据改动风险选择测试和验证方式。很多过去需要 Superpowers 反复提醒的工作习惯,模型自己已经学会了。

这时再套上 Superpowers,一个简单需求就可能重新经历 brainstorming、计划、TDD 和独立 Review。两套流程未必会得出相反结论,但每多一个 Agent、每多一次 Review,都要重新读取上下文并调用模型。如果没有发现新的问题,增加的就只有 Token 和时间。

问题不是模型和 Skill 在打架,而是通用 Skill 的边际收益正在下降。

为什么会这样?我觉得有两层变化。

一部分能力,被模型学会了。

规划、调试、测试和自我检查都是通用方法,还能通过代码、测试与评测反复验证。使用轨迹积累得足够多,后续模型自然有机会把这些习惯吸收进去。

另一部分能力,被 Agent Runtime 接管了。

任务状态、子 Agent 调度、工具调用、上下文压缩和结果验证,已经逐渐成为 Codex、Claude Code 这类产品的原生能力。过去需要 Skill 串起来的执行循环,现在 Runtime 自己就能跑。

所以 Superpowers 不是变差了。它原本负责解决的问题,一部分进入了模型,另一部分进入了 Runtime。

什么样的 Skill 会被长期保留下来

我的答案很直接,垂直 Skill。

这里说的垂直,不只是某个行业专用的 Skill,而是那些带着具体团队、具体项目和真实业务环境的 Skill。

越接近通用方法论,Skill 的生命周期反而越短

因为它可以在大量任务里反复运行和评测,有效的方法最终可能进入模型训练,或者直接成为 Codex、Claude Code 这类 Agent Runtime 的原生能力。

就像一个特别好用的第三方插件,如果它解决的是所有用户都会遇到的问题,最后很可能被官方直接集成。能力还在,只是大家不再需要单独安装它。

Skill 也是一样。今天还要额外挂载的规划、测试和审查流程,明天可能就会成为默认能力。

但有些东西,通用模型不会预先知道。

真正会被长期保留下来的 Skill,往往包含三类东西:

  • 模型不知道的私有知识。 团队怎么协作、文档放在哪里、项目有哪些特殊约定。
  • 模型拿不到的执行能力。 内部系统、专用工具、账号与权限。
  • 模型不能擅自决定的边界。 什么结果才算完成,发布前需要谁确认,出了问题由谁负责。

强模型时代长期保留的 Skill

这些信息敏感、零散,还会随着业务不断变化,很难进入通用模型的训练数据,却决定了 Agent 能不能在真实环境里完成工作。

真正会留下来的 Skill,解决的是模型不知道、拿不到,也不能擅自决定的问题。

这类 Skill 往往更简单,也更轻。它不负责教 Agent 怎么思考,只负责告诉它,在这里工作需要知道什么、可以调用什么,以及做到什么才算完成。

小结

回到开头的问题,GPT-5.6 Sol 还需要 Superpowers 吗?

答案很明确:对于 Fable 5、GPT-5.6 这类已经能自主规划和验证的强模型,我更倾向于先移除 Superpowers,直接运行一段时间,再根据真实缺口补回必要的约束

Comet Native 的实验也提供了一个参考:在这 16 个任务里,去掉固定方法论之后,完成质量没有下降,Token、耗时和成本却明显减少。

现在的趋势是,通用 Skill 会逐渐被模型和 Runtime 吸收。真正长期留下来的,是模型不知道的私有知识、拿不到的执行能力,以及不能擅自决定的业务边界。

Skill 不会消失,它只是会回到自己原本该在的位置。

KServe + HAMi:一张 GPU 如何运行多个推理服务

2026-08-10 04:00:00

HAMi GPU 共享:KServe 原生 DRA 实战

前面已经用 KServe 跑起了 Qwen,但一个小模型独占一张 GPU 有些浪费。这篇则是在此基础上引入 HAMi,通过 HAMi 实现 GPU 共享,让多个小模型共用一张 GPU。

准备环境

本次测试环境如下:

1
2
3
4
5
6
7
Kubernetes v1.36.1
KServe v0.18.0
HAMi 0.2.1
HAMi NVIDIA DRA Driver v0.1.0
containerd 2.2.4
NVIDIA Driver 580.173.02
Tesla T4 15 GiB x 1

本文从 HAMi 安装和原生 DRA 资源声明开始,完整走通 KServe 集成 HAMi GPU 共享部分。

安装 GPU Operator

由 GPU Operator 安装 NVIDIA Driver、Container Toolkit 和监控组件,但关闭原生 NVIDIA Device Plugin,后续由 HAMi DRA Driver 管理 GPU:

1
2
3
4
5
6
7
8
9
helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update

helm upgrade --install gpu-operator nvidia/gpu-operator \
 -n gpu-operator --create-namespace \
 --version=v26.3.1 \
 --set driver.enabled=true \
 --set devicePlugin.enabled=false \
 --wait

如果节点已经预装 NVIDIA Driver,可以把 driver.enabled 改为 false。无论驱动由谁安装,devicePlugin.enabled=false 都不能省略,否则原生 Device Plugin 和 HAMi-DRA 会同时管理同一设备。

安装 cert-manager

HAMi-DRA Webhook 需要 TLS 证书,测试环境使用 cert-manager 签发:

1
2
3
4
5
6
7
helm repo add cert-manager https://charts.jetstack.io
helm repo update

helm upgrade --install cert-manager cert-manager/cert-manager \
 -n cert-manager --create-namespace \
 --set crds.enabled=true \
 --wait

安装 HAMi-DRA

为需要接管的 GPU 节点添加 gpu=on 标签,再安装本文实测的 HAMi-DRA 0.2.1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
helm repo add hami-dra https://project-hami.github.io/HAMi-DRA/
helm repo update

GPU_NODE=lixd-test-gpu # 替换为实际 GPU 节点名
kubectl label node "$GPU_NODE" gpu=on

helm upgrade --install hami-dra hami-dra/hami-dra \
 -n hami-dra \
 --create-namespace \
 --version 0.2.1 \
 --wait

上面的命令适用于 GPU Operator 安装 Driver 的场景。如果 NVIDIA Driver 由宿主机预装,则增加:

1
--set drivers.nvidia.containerDriver=false

确认 GPU 共享容量

安装完成后,HAMi 创建了一个 DeviceClass:

1
2
3
root@lixd-test-gpu:~# kubectl get deviceclass
NAME AGE
hami-core-gpu.project-hami.io 3d4h

同时节点插件通过 ResourceSlice 发布 GPU 信息:

1
kubectl get resourceslice -o yaml

输出中只保留本文关心的字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
spec:
 driver: hami-core-gpu.project-hami.io
 nodeName: lixd-test-gpu
 devices:
 - name: hami-gpu-0
 allowMultipleAllocations: true
 attributes:
 productName:
 string: Tesla T4
 type:
 string: hami-gpu
 capacity:
 cores:
 value: "100"
 memory:
 value: 15Gi

allowMultipleAllocations: true 表示同一个设备可以接受多份分配。这里的 memorycores 是 HAMi 用于调度和限制的可消耗容量,不是 Node 上的传统扩展资源。

创建共享 GPU 推理服务

KServe 0.18 版本已经支持原生 DRA,可以在 Predictor 级引用 ResourceClaimTemplate,再由容器级 resources.claims 使用对应的 Claim。

我们只需要提前创建一个 ResourceClaimTemplate,然后在 InferenceService 中引用即可,完整的 YAML 如下:

 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
cat <<'EOF' > qwen-llm-hami.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
spec:
 predictor:
 minReplicas: 2
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 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-hami.yaml

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

minReplicas: 2 保证 Demo 期间至少存在两个 Predictor,用于验证它们能否同时获得共享 GPU 配额,不展开副本自动调整行为。

这里没有再声明 nvidia.com/gpunvidia.com/gpumemnvidia.com/gpucores。CPU 和内存仍使用普通 requests/limits,GPU 完全使用 DRA Claim 形式声明。

原生 DRA 通过 ResourceClaim 分配共享 GPU

确认 KServe 写入 DRA 引用

KServe 的 HuggingFace Runtime 原本根据 GPU limit 选择 -gpu 镜像。原生 DRA 配置里没有这个 limit,因此本文显式指定已经验证过的 GPU 镜像。docker.m.daocloud.io 是测试环境使用的镜像代理;如果环境可以直接访问 Docker Hub,可以改为 kserve/huggingfaceserver:v0.18.0-gpu

KServe 最终生成的 Deployment 保留了两级引用:

1
2
kubectl get deployment -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o yaml
1
2
3
4
5
6
7
8
9
spec:
 resourceClaims:
 - name: gpu
 resourceClaimTemplateName: qwen-hami-gpu
 containers:
 - name: kserve-container
 resources:
 claims:
 - name: gpu

Deployment 创建两个 Pod 后,Kubernetes 会根据同一个 ResourceClaimTemplate 为每个 Pod 生成独立 Claim。不能让多个副本直接引用一份固定 ResourceClaim,否则它们不会获得各自独立的 3Gi/20 配额。

验证 DRA 容量分配

查看 ResourceClaim 的申请与分配

Kubernetes 为每个 Pod 生成一份 Claim。下面只保留其中一份 Claim 的申请字段;它由 Pod 持有,Pod 删除后会一起清理:

1
2
CLAIM=$(kubectl get resourceclaim -n kserve-test -o name | head -n 1)
kubectl get -n kserve-test "$CLAIM" -o yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: resource.k8s.io/v1
kind: ResourceClaim
spec:
 devices:
 requests:
 - name: gpu
 exactly:
 allocationMode: ExactCount
 count: 1
 deviceClassName: hami-core-gpu.project-hami.io
 capacity:
 requests:
 memory: 3Gi
 cores: "20"

调度完成后,Claim 状态中的关键字段记录了实际分配:

1
2
3
4
5
6
7
8
9
status:
 allocation:
 devices:
 results:
 - device: hami-gpu-0
 driver: hami-core-gpu.project-hami.io
 consumedCapacity:
 memory: 3Gi
 cores: "20"

ResourceClaimTemplate 只定义申请规格,Kubernetes 为每个 Pod 生成 Claim,并完成设备选择和容量扣减。HAMi DRA Driver 随后响应 kubelet 的 NodePrepareResources,生成 CDI 配置并返回设备信息,最终由 containerd 把对应 GPU 和 HAMi-Core 运行环境应用到容器。

查看容器内的显存限制

进入其中一个 Predictor 容器执行完整的 nvidia-smi

1
2
3
4
5
POD=$(kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm \
 -o name | head -n 1)

kubectl exec -n kserve-test "$POD" -- nvidia-smi

可以看到 HAMi 把同一张 GPU 的可见显存限制为 3072 MiB。下面是本次实测的完整状态表;命令前后的 HAMI 初始化和退出日志不属于 nvidia-smi 输出,这里没有混入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Thu Aug 6 03:46:03 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.173.02 Driver Version: 580.173.02 CUDA Version: 13.0 |
+-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 Off | 00000000:00:06.0 Off | 0 |
| N/A 44C P0 28W / 70W | 3015MiB / 3072MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+

验证两个副本共享同一张 GPU

检查 Pod 和 Claim

把 Predictor 的最小副本数设为 2,两份 Pod 使用相同的 3Gi、cores=20 配置。

整卡分配与 HAMi-DRA 共享对比

先查看 Pod 和 Claim:

1
2
3
4
5
6
7
8
9
kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o wide

kubectl get resourceclaim -n kserve-test

for claim in $(kubectl get resourceclaim -n kserve-test -o name); do
 kubectl get -n kserve-test "$claim" \
 -o jsonpath='{.metadata.name}{" device="}{.status.allocation.devices.results[0].device}{" memory="}{.status.allocation.devices.results[0].consumedCapacity.memory}{" cores="}{.status.allocation.devices.results[0].consumedCapacity.cores}{"\n"}'
done

本次新建的两份 Claim 都分配成功:

1
2
3
4
5
6
NAME STATE AGE
qwen-llm-predictor-544cc75b4-7fvcc-gpu-g8b6c allocated,reserved 3m3s
qwen-llm-predictor-544cc75b4-fz5hq-gpu-9lxw2 allocated,reserved 2m48s

qwen-llm-predictor-544cc75b4-7fvcc-gpu-g8b6c device=hami-gpu-0 memory=3Gi cores=20
qwen-llm-predictor-544cc75b4-fz5hq-gpu-9lxw2 device=hami-gpu-0 memory=3Gi cores=20

两个 Pod 都调度到 lixd-test-gpu,并分别看到 3072 MiB 显存:

1
2
3
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
qwen-llm-predictor-544cc75b4-7fvcc 1/1 Running 0 3m3s 172.25.118.13 lixd-test-gpu <none> <none>
qwen-llm-predictor-544cc75b4-fz5hq 1/1 Running 0 2m48s 172.25.118.3 lixd-test-gpu <none> <none>

检查两个容器的可见显存

逐个进入容器检查可见 GPU:

1
2
3
4
5
for pod in $(kubectl get pod -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm -o name); do
 kubectl exec -n kserve-test "$pod" -- \
 nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
done

两个容器都返回:

1
2
Tesla T4, 3072 MiB
Tesla T4, 3072 MiB

发起推理请求

通过 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
 }'

API 可以正常返回,说明在使用 GPU 共享之后,服务依旧可以正常运行。

清理资源

验证完成后删除 InferenceService 和 ResourceClaimTemplate:

1
2
kubectl delete inferenceservice qwen-llm -n kserve-test
kubectl delete resourceclaimtemplate qwen-hami-gpu -n kserve-test

两份由 Pod 生成的 ResourceClaim 会随 Pod 一起删除。

总结

KServe 可以直接通过 ResourceClaimTemplateresources.claims 使用 HAMi DRA 模式,不需要额外适配。每个 Predictor Pod 都会生成一份独立 ResourceClaim,再由 HAMi 分配显存和算力配额。

通过 HAMi 共享后,一张 GPU 可以同时承载多个推理副本或服务工作负载,避免小模型整卡独占,让空闲的显存和算力得到更充分的利用。

KServe 工作流程:从 InferenceService 到 vLLM 服务

2026-08-02 04:00:00

KServe 工作流程:从 InferenceService 到 vLLM 服务

上一篇完成了 KServe 的安装,并通过 InferenceService 跑通了一个 Qwen 模型服务。

当时提交的 InferenceService 只包含模型格式、存储地址、GPU 资源和启动参数,KServe 却创建出了 Deployment、Service 和 HTTPRoute。这份 YAML 中没有容器镜像,那么 KServe 如何选中 HuggingFaceServer,Pod 中又为什么会运行 vLLM?

这一篇先看 KServe 的整体架构和核心 CRD,再结合上一篇的 qwen-llm,梳理从提交 InferenceService 到请求进入 vLLM 的完整流程。

以下内容基于 KServe 0.18,部署模式为 Standard。

1. KServe 整体架构

KServe 是一个构建在 Kubernetes 之上的 AI 推理平台: KServe AI 推理平台架构概览

整体分为控制面和数据面:

KServe Standard 模式控制面与数据面架构

1.1 控制面:从 InferenceService 到推理服务

控制面负责创建和维护推理服务。整个过程从用户提交 InferenceService 开始:

  1. 用户通过 kubectl apply 将 InferenceService 提交到 Kubernetes API Server。
  2. Admission Webhook 对配置进行默认化和校验。例如补充默认部署模式,并拒绝不符合 API 约束的配置。
  3. InferenceService 写入集群后,KServe Controller 通过 Watch 感知对象变化,开始执行调谐。
  4. Controller 根据 modelFormat 匹配 ServingRuntime,从 Runtime 中取得模型服务器镜像和默认启动参数,再与 InferenceService 中的模型地址、运行参数和资源需求合并。
  5. 在 Standard 模式下,Controller 根据合并后的配置创建 Deployment、Service 和 HTTPRoute。Pod 创建时,KServe Pod Mutating Webhook 根据存储注解注入 PVC volume 和 volumeMount;接下来由 Kubernetes 负责调度 Pod、挂载存储和分配 GPU,Gateway Controller 负责让 HTTPRoute 生效。
  6. Controller 持续观察 Deployment 和 HTTPRoute 的状态。当 Predictor 可用、路由被 Gateway 接受后,会更新 PredictorReadyIngressReady 和最终的 Ready 状态。

所以,控制面管理的是推理服务的生命周期:创建、更新、状态同步,以及配置变更后的持续调谐。

1.2 数据面:访问已经创建的推理服务

数据面由实际运行模型和处理请求的资源组成。上一篇的环境使用 Envoy Gateway 作为 Gateway API 的实现。模型服务 Ready 后,客户端就可以通过数据面访问推理服务。

客户端访问模型域名时,请求先到达 Envoy Gateway 管理的 Envoy Proxy。Envoy Proxy 应用 HTTPRoute/qwen-llm 的规则,并根据其中指向 Service/qwen-llm-predictorbackendRef,将请求发送到 Ready 的 Predictor Pod。这里的 Service 用于标识和发现后端,并不是一个独立的代理进程。Pod 中运行的是 HuggingFaceServer,当前配置使用 vLLM 作为推理后端。vLLM 完成推理后,响应沿原链路返回客户端。

1
2
3
4
5
6
Client
 -> Envoy Proxy(匹配 HTTPRoute)
 -> Predictor Pod(后端由 Predictor Service 标识)
 -> HuggingFaceServer
 -> vLLM Backend
 -> NVIDIA GPU

控制面决定服务应该如何部署,并保证它处于期望状态;数据面负责服务创建后的请求转发和模型推理。在线请求不会经过 KServe Controller。

2. 核心 CRD

KServe 通过 CRD 描述模型服务、运行时、推理图和模型存储初始化方式。对于上一篇的 Qwen Demo,最核心的是 InferenceService 和 ClusterServingRuntime。

二者之间的关系如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
InferenceService
 ├── 模型格式
 ├── 模型地址
 ├── 启动参数
 └── 资源需求
 |
 | 根据 modelFormat 匹配
 v
ServingRuntime / ClusterServingRuntime
 ├── 模型服务器镜像
 ├── 默认启动参数
 ├── 支持的模型格式
 └── 推理协议

2.1 InferenceService

InferenceService 用于描述一个模型服务需要什么,包括模型格式、模型地址、运行参数、副本数和资源需求。

一个最小的 InferenceService 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
spec:
 predictor:
 model:
 modelFormat:
 name: huggingface
 storageUri: pvc://qwen-model

InferenceService 的 Spec 可以包含以下三个逻辑部分:

组成部分 是否必需 作用
Predictor 加载模型并执行预测或生成
Transformer 请求预处理和响应后处理
Explainer 生成模型解释

对于大模型推理,通常只需要 Predictor。上一篇的 Qwen 服务也只配置了 Predictor。

这里的 Predictor 是一个逻辑角色。到了 Standard 模式下,它最终会变成名为 qwen-llm-predictor 的 Deployment 和 Service。

2.2 ServingRuntime 和 ClusterServingRuntime

InferenceService 描述要运行什么模型,ServingRuntime 描述使用什么模型服务器运行。

Runtime API 按作用范围分为两种:

  • ServingRuntime 只在当前 Namespace 中生效
  • ClusterServingRuntime 则可以被整个集群中的 InferenceService 使用

一个简化后的 HuggingFace Runtime 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: serving.kserve.io/v1alpha1
kind: ClusterServingRuntime
metadata:
 name: kserve-huggingfaceserver
spec:
 containers:
 - name: kserve-container
 image: kserve/huggingfaceserver:v0.18.0
 supportedModelFormats:
 - name: huggingface
 version: "1"
 autoSelect: true

containers 定义模型服务器的镜像和启动方式,supportedModelFormats 声明它支持哪些模型格式。

当 InferenceService 没有显式指定 Runtime 时,KServe 会根据 modelFormat、版本、协议以及 Runtime 的 autoSelectpriority 自动匹配。

因此:

1
2
modelFormat:
 name: huggingface

表示模型格式为 huggingface,KServe 会选择支持该格式的 Runtime。它并不表示 Pod 中只能使用 Hugging Face Transformers 进行推理。

2.3 其他 CRD

除了 InferenceService 和 Runtime,KServe 还提供了一些面向特定场景的 CRD:

  • InferenceGraph 编排多个 InferenceService,实现路由、分支和服务组合
  • ClusterStorageContainer 定义不同模型存储协议对应的初始化容器和匹配规则
  • TrainedModel 在 ModelMesh 场景中声明需要加载或卸载的模型

上一篇的 Demo 没有使用这些资源,后续遇到对应场景时再单独分析。

3. 核心组件

CRD 只描述期望状态,真正读取这些对象并创建模型服务的是 KServe 控制面。

3.1 Webhook

Webhook 位于资源写入 Kubernetes API 的入口。InferenceService Admission Webhook 负责补充默认值,并拒绝不符合 API 约束的配置;Pod Mutating Webhook 则会在 Pod 创建时注入模型存储相关配置。

在当前环境中,Webhook 和 Controller 运行在同一个 kserve-controller-manager Pod 中,但承担不同职责。

3.2 KServe Controller

KServe Controller 持续 Watch InferenceService,并通过 Reconcile 循环让实际状态与期望状态保持一致。

它主要负责:

  • 匹配 ServingRuntime 或 ClusterServingRuntime
  • 合并 Runtime 与 InferenceService 中的容器配置
  • 创建工作负载、Service 和网络入口
  • 处理模型存储和健康检查配置
  • 观察底层资源状态并回写 InferenceService Status

Controller 只负责资源编排。Deployment 创建以后,Pod 由 Kubernetes Scheduler 调度,GPU 由 NVIDIA Device Plugin 分配;Gateway Controller 负责让 HTTPRoute 生效,实际流量由 Envoy Proxy 转发。

3.3 Predictor Pod 内部结构

以上一篇的 Qwen 服务为例,Predictor Pod 内部的层次如下:

1
2
3
4
Predictor Pod
 └── HuggingFaceServer(容器主进程)
 └── vLLM Backend
 └── NVIDIA GPU

Predictor Pod 是模型服务的运行载体,容器主进程是 HuggingFaceServer。它负责启动模型服务并提供 OpenAI 兼容接口。

当前模型使用 vLLM 作为 HuggingFaceServer 的推理后端。HuggingFaceServer 会在同一个容器中创建 vLLM 引擎,由 vLLM 使用 GPU 执行推理。

4. Qwen Demo 工作流程

前面介绍的 CRD 和组件,最终会通过 Controller 的调谐过程串起来。

上一篇的 qwen-llm 使用 Standard 模式,下面只分析该模式下的资源创建和请求流程。

上一篇使用的核心配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
spec:
 predictor:
 model:
 modelFormat:
 name: huggingface
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
 resources:
 limits:
 nvidia.com/gpu: "1"

qwen-model PVC 是上一篇提前创建的,InferenceService 只负责引用它。

4.1 控制面资源创建流程

从提交 InferenceService 到服务 Ready,完整流程如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
1. 用户提交 InferenceService/qwen-llm
 |
2. Webhook 默认化并校验配置
 |
3. Controller 根据 huggingface 匹配 ClusterServingRuntime
 |
4. 合并启动参数、镜像和 GPU 资源,并写入模型存储注解
 |
5. 创建 Deployment、Service 和 HTTPRoute
 |
6. Pod Mutating Webhook 注入 PVC volume 和 volumeMount
 |
7. Kubernetes 调度 Pod、挂载 PVC 并分配 GPU
 |
8. Pod 启动并加载模型,Controller 汇总状态并回写 InferenceService

可以从 InferenceService Status 查看最终选择结果:

1
2
kubectl get inferenceservice qwen-llm -n kserve-test \
 -o jsonpath='{.status.deploymentMode}{"\n"}{.status.clusterServingRuntimeName}{"\n"}'
1
2
Standard
kserve-huggingfaceserver

Controller 选择 kserve-huggingfaceserver,再把 InferenceService 中的参数和 GPU 资源合并到 Runtime 提供的容器模板中。最终 Deployment 使用 GPU 版本的 HuggingFaceServer 镜像:

1
docker.m.daocloud.io/kserve/huggingfaceserver:v0.18.0-gpu

Standard 模式下创建的资源关系如下:

1
2
3
4
5
6
7
InferenceService/qwen-llm
├── Deployment/qwen-llm-predictor
│ └── ReplicaSet
│ └── Pod
├── Service/qwen-llm-predictor
├── HTTPRoute/qwen-llm
└── HTTPRoute/qwen-llm-predictor

Deployment 可用、HTTPRoute 就绪后,Controller 会把底层状态汇总到 InferenceService:

1
2
3
4
5
6
7
8
status:
 conditions:
 - type: IngressReady
 status: "True"
 - type: PredictorReady
 status: "True"
 - type: Ready
 status: "True"

4.2 模型 Pod 启动流程

storageUri 描述模型的来源,不是容器内的最终路径,也不是 HuggingFaceServer 的启动参数。Controller 会将 pvc://qwen-model 写入 Pod 模板注解;ReplicaSet 创建 Pod 时,KServe Pod Mutating Webhook 读取该注解,将 qwen-model 解析为 PVC 名称,并向模型容器注入 PVC volume 和挂载到 /mnt/models 的 volumeMount。

如果这里使用 hf:// 地址,Webhook 则会注入 Storage Initializer init container,先从 Hugging Face Hub 下载模型,再通过共享的 EmptyDir volume 将模型提供给模型容器。

HuggingFaceServer 启动后,backend 默认为 auto。GPU 镜像中包含 vLLM;当 vLLM 可用且模型架构在 vLLM 的支持列表中时,HuggingFaceServer 会创建 vLLM 后端并从 /mnt/models 加载模型。

实际日志如下:

1
2
3
Initializing a V1 LLM engine (v0.19.0)
device_config=cuda
Starting to load model /mnt/models...

kserve-huggingfaceserver 是本次部署选择的 Runtime,vLLM 是 HuggingFaceServer 容器内自动选择的推理后端。因此,这次部署不需要单独指定 vLLM Runtime;集群中是否还存在其他自定义 vLLM Runtime,与这条执行链路无关。

4.3 在线请求流程

服务 Ready 后,上一篇的 OpenAI Chat Completions 请求沿着下面的路径进入模型:

1
2
3
4
5
6
Client
 -> Envoy Proxy(匹配 HTTPRoute/qwen-llm)
 -> Predictor Pod:8080(backendRef: Service/qwen-llm-predictor:80)
 -> HuggingFaceServer
 -> vLLM Backend
 -> NVIDIA GPU

这条请求链路属于数据面。KServe Controller 会继续维护 Deployment 和 InferenceService 状态,但不会参与请求转发。

5. 总结

结合上一篇的 Qwen Demo,KServe 的整个工作过程可以概括为:

KServe Standard 模式控制面与数据面架构

  • InferenceService 描述模型服务需要什么
    • modelFormat 声明模型格式,供 KServe 匹配 Runtime
    • storageUri 指定模型存储地址
    • args 设置模型服务器和推理引擎的启动参数
    • resources 声明 CPU、内存和 GPU 等资源需求
  • ClusterServingRuntime/kserve-huggingfaceserver 提供模型服务器镜像和启动方式
  • KServe Controller 匹配 Runtime、合并配置,并创建 Deployment、Service 和 HTTPRoute
  • Kubernetes 负责调度 Pod、挂载 PVC 和分配 GPU
  • Envoy Proxy 应用 HTTPRoute 规则,通过 Predictor Service 标识的后端将请求发送到 Ready Pod
  • HuggingFaceServer 使用 vLLM Backend,由 vLLM 通过 GPU 执行推理

一个 Deployment 就能跑 vLLM,为什么还需要 KServe?

2026-07-29 04:00:00

KServe 入门:部署第一个 vLLM 推理服务

在 Kubernetes 上启动一个推理服务并不难。如果只有一个模型,vLLM + Deployment 确实够了。但服务多起来以后,模型从哪里加载、使用哪个 Runtime、GPU 怎么分配、服务怎么暴露,每个服务都要重复处理一遍,配置很快就会散落在一堆 YAML 里。KServe 解决的不是怎么启动 vLLM,而是怎么用统一的方式管理这些模型服务。

本文从零安装 KServe Standard 模式和 Envoy Gateway,通过本地 PVC 部署 Qwen2.5-0.5B-Instruct 模型。

KServe 是什么

KServe 官方给出的定位是:

Standardized Distributed Generative and Predictive AI Inference Platform for Scalable, Multi-Framework Deployment on Kubernetes.

KServe 是一个面向 Kubernetes 的可扩展、多框架部署的标准化分布式生成式和预测式 AI 推理平台

即:KServe 是一个 AI 推理平台

KServe AI 推理平台架构概览

KServe 不直接执行模型计算,它负责管理模型服务。我们创建一个 InferenceService,写清楚模型地址、模型格式和资源需求,KServe Controller 就会选择对应的 Runtime,并创建底层的 Deployment、Service、HTTPRoute 等资源。

KServe 最初叫 KFServing,由 Google、IBM、Bloomberg、NVIDIA 和 Seldon 等团队在 2019 年共同发起。2021 年,项目从 Kubeflow 组织迁移到独立的 KServe GitHub 组织,并正式更名。现在 KServe 是独立的 CNCF 孵化项目,同时仍是 Kubeflow 生态中的重要组件,既可以随 Kubeflow 使用,也可以独立安装。

安装

本文使用 Kubernetes 1.32+。假设节点已经安装 NVIDIA 驱动、Container Toolkit 和 Device Plugin,开始前先确认节点能够看到 GPU:

1
2
kubectl get nodes \
 -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu

输出中的 GPU 应该大于 0。本文使用标准的 nvidia.com/gpu 资源;如果这里是 <none>,后面的推理 Pod 会一直处于 Pending

服务 API 与部署模式

这里需要分成两层理解。

InferenceService 是通用模型服务 API,它有 Standard 和 Knative 两种部署模式:

  • Standard:使用原生 Kubernetes Deployment、Service 和 Pod 承载模型服务,依赖少、链路清晰,可以通过 HPA 或 KEDA 扩缩容,适合传统模型以及常驻的 GPU、vLLM 推理服务。
  • Knative/Serverless:使用 Knative Serving 承载 InferenceService,支持 Scale to Zero、请求驱动扩缩容和流量切分,适合突发或不可预测的流量,但会增加 Knative Serving 及对应网络层依赖,部署和排障更加复杂。

LLMInferenceService 是另一套独立 API,面向大语言模型的分布式工作负载、Prefill/Decode 分离和智能路由等场景。它不是 InferenceService 的第三种部署模式;如果只是部署单 GPU vLLM,使用 InferenceService Standard 模式通常更简单。

本文后面的安装流程只覆盖 InferenceService。LLMInferenceService 需要额外安装 Addon 和相关依赖,系列最后一篇再单独展开。

可以根据需求选择:

1
2
3
4
5
6
是否需要高级 LLM 分布式推理能力?
├── 是 → LLMInferenceService
└── 否
 └── 是否需要 Scale to Zero、Revision 和请求驱动扩缩容?
 ├── 是 → InferenceService(Knative)
 └── 否 → InferenceService(Standard)

这里我们选择比较简单的 InferenceService(Standard) 模式进行演示。

安装 cert-manager

KServe 依赖 cert-manager,需要提前安装,支持的最低版本是 1.15.0。

1
2
3
4
5
6
7
8
helm repo add cert-manager https://charts.jetstack.io
helm repo update

helm upgrade --install cert-manager cert-manager/cert-manager \
 -n cert-manager --create-namespace \
 --version v1.21.0 \
 --set crds.enabled=true \
 --wait --timeout=10m

查看 Pod:

1
kubectl -n cert-manager get pod
1
2
3
4
NAME READY STATUS RESTARTS AGE
cert-manager-6c8f88f5b9-4b8zv 1/1 Running 0 25m
cert-manager-cainjector-8577b58487-r6cnt 1/1 Running 0 25m
cert-manager-webhook-7c7694dfd-df4ft 1/1 Running 0 25m

安装 Network Controller

KServe 可以通过 Gateway API 或 Ingress 暴露服务。本文使用 Gateway API,并选择 Envoy Gateway 作为具体实现。

安装 Envoy Gateway

使用 Helm 安装 Envoy Gateway,包括 Gateway API CRD 和 Controller:

1
2
3
4
5
6
helm upgrade --install eg \
 oci://docker.io/envoyproxy/gateway-helm \
 --version v1.8.2 \
 --namespace envoy-gateway-system \
 --create-namespace \
 --wait --timeout=10m

创建 Gateway

先创建 GatewayClass:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cat <<'EOF' > gatewayclass.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
 name: envoy
spec:
 controllerName: gateway.envoyproxy.io/gatewayclass-controller
EOF

kubectl apply -f gatewayclass.yaml

然后创建 Gateway。本文的测试集群没有 LoadBalancer,因此使用 EnvoyProxy 将 Envoy Service 配置为 NodePort;如果集群已经有可用的 LoadBalancer,可以删掉 EnvoyProxy 以及 Gateway 中的 parametersRef,直接使用默认配置。

 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
cat <<'EOF' > gateway.yaml
apiVersion: v1
kind: Namespace
metadata:
 name: kserve
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
 name: kserve-envoy-proxy
 namespace: kserve
spec:
 provider:
 type: Kubernetes
 kubernetes:
 envoyService:
 type: NodePort
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
 name: kserve-ingress-gateway
 namespace: kserve
spec:
 gatewayClassName: envoy
 listeners:
 - name: http
 protocol: HTTP
 port: 80
 allowedRoutes:
 namespaces:
 from: All
 infrastructure:
 labels:
 serving.kserve.io/gateway: kserve-ingress-gateway
 parametersRef:
 group: gateway.envoyproxy.io
 kind: EnvoyProxy
 name: kserve-envoy-proxy
EOF

kubectl apply -f gateway.yaml

kubectl wait --for=condition=Programmed \
 gateway/kserve-ingress-gateway \
 -n kserve \
 --timeout=5m

安装 KServe

  1. 安装 KServe CRD:
1
2
3
4
5
helm -n kserve install kserve-crd \
 oci://ghcr.io/kserve/charts/kserve-crd \
 --version v0.18.0 \
 --create-namespace \
 --wait --timeout=10m
  1. 安装 KServe Controller,使用 Standard + Gateway API:
1
2
3
4
5
6
7
8
helm -n kserve upgrade --install kserve \
 oci://ghcr.io/kserve/charts/kserve-resources \
 --version v0.18.0 \
 --set kserve.controller.deploymentMode=Standard \
 --set kserve.controller.gateway.disableIstioVirtualHost=true \
 --set kserve.controller.gateway.ingressGateway.enableGatewayApi=true \
 --set kserve.controller.gateway.ingressGateway.kserveGateway=kserve/kserve-ingress-gateway \
 --wait --timeout=10m
  1. 安装默认 ClusterServingRuntime:
1
2
3
4
5
6
helm -n kserve upgrade --install kserve-runtime-configs \
 oci://ghcr.io/kserve/charts/kserve-runtime-configs \
 --version v0.18.0 \
 --set kserve.servingruntime.enabled=true \
 --set kserve.llmisvcConfigs.enabled=false \
 --wait --timeout=10m

查看 HuggingFaceServer Runtime 使用的基础镜像:

1
2
kubectl get clusterservingruntime kserve-huggingfaceserver \
 -o jsonpath='{.spec.containers[0].image}{"\n"}'

这里配置的是 v0.18.0 基础标签;InferenceService 申请 NVIDIA GPU 后,KServe 会自动在标签后追加 -gpu,最终 Pod 使用的是 v0.18.0-gpu

查看 KServe Controller:

1
kubectl -n kserve get pod
1
2
NAME READY STATUS RESTARTS AGE
kserve-controller-manager-54bb5b957f-m82fd 2/2 Running 0 4m35s

使用

下载模型

KServe 支持从 Hugging Face、PVC、S3 等位置加载模型。由于当前环境不能稳定访问 Hugging Face,本文提前从 ModelScope 下载模型,再通过 PVC 挂载。

1
2
3
4
5
pip install modelscope

modelscope download \
 --model Qwen/Qwen2.5-0.5B-Instruct \
 --local-dir /opt/models/Qwen2.5-0.5B-Instruct

检查模型文件:

1
ls -lh /opt/models/Qwen2.5-0.5B-Instruct

创建 PV 和 PVC

本文只有一个 GPU 节点,因此使用 hostPath 静态 PV。生产环境更适合使用 CephFS、NFS、JuiceFS 等共享存储。

1
kubectl create namespace kserve-test
 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
cat <<'EOF' > qwen-model.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
 name: qwen-model
spec:
 capacity:
 storage: 3Gi
 accessModes:
 - ReadOnlyMany
 persistentVolumeReclaimPolicy: Retain
 storageClassName: ""
 hostPath:
 path: /opt/models/Qwen2.5-0.5B-Instruct
 type: Directory
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
 name: qwen-model
 namespace: kserve-test
spec:
 accessModes:
 - ReadOnlyMany
 storageClassName: ""
 volumeName: qwen-model
 resources:
 requests:
 storage: 3Gi
EOF

kubectl apply -f qwen-model.yaml

创建 InferenceService

然后部署 InferenceService:

 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
cat <<'EOF' > qwen-llm.yaml
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
 name: qwen-llm
 namespace: kserve-test
spec:
 predictor:
 model:
 modelFormat:
 name: huggingface
 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
 nvidia.com/gpu: "1"
 limits:
 cpu: "2"
 memory: 6Gi
 nvidia.com/gpu: "1"
EOF

kubectl apply -f qwen-llm.yaml

核心参数是这部分:

1
2
3
4
5
6
7
8
9
model:
 modelFormat:
 name: huggingface
 storageUri: pvc://qwen-model
 args:
 - --model_name=qwen
 - --max_model_len=4096
 - --max-num-seqs=32
 - --gpu-memory-utilization=0.8
  • model.modelFormat:声明模型格式,会用来匹配 Runtime。当前指定的 huggingface 会匹配到 ClusterServingRuntime/kserve-huggingfaceserver
  • storageUri:模型来源。当前 pvc://qwen-model 说明模型在 qwen-model 这个 PVC 里面。
  • args:传给 HuggingFaceServer 的启动参数。--model_name 设置 API 中的模型名;其余三个参数用于 vLLM,分别限制最大上下文长度、单次调度最多处理的序列数和 GPU 显存使用比例。

查看推理服务状态

1
2
3
4
5
6
7
kubectl wait --for=condition=Ready \
 inferenceservice/qwen-llm \
 -n kserve-test \
 --timeout=30m

kubectl get inferenceservice qwen-llm -n kserve-test
kubectl get pod,deploy,svc,httproute -n kserve-test

服务就绪后,READY 会变成 True

首次启动需要读取模型、初始化 CUDA 并完成 CUDA Graph 预热。普通环境通常只需要几分钟,但磁盘较慢的单节点环境可能需要十几分钟,因此这里将等待上限设置为 30 分钟。

1
2
NAME URL READY
qwen-llm http://qwen-llm-kserve-test.example.com True

Standard 模式下,KServe 会为这个 InferenceService 创建 Deployment、Service 和 HTTPRoute:

1
2
3
4
5
InferenceService/qwen-llm
├── Deployment/qwen-llm-predictor
├── Service/qwen-llm-predictor
├── HTTPRoute/qwen-llm
└── HTTPRoute/qwen-llm-predictor

KServe InferenceService 资源创建与请求链路

查看模型服务日志:

1
2
3
kubectl logs -n kserve-test \
 -l serving.kserve.io/inferenceservice=qwen-llm \
 --tail=200

日志中出现下面的内容,说明 HuggingFaceServer 已经使用 vLLM 加载 /mnt/models

1
2
3
4
Initializing a V1 LLM engine (v0.19.0)
model='/mnt/models'
Using max model len 4096
Uvicorn running on http://0.0.0.0:8080

请求 API

KServe 创建的 HTTPRoute 使用域名匹配:

1
qwen-llm-kserve-test.example.com

如果没有配置 DNS,可以直接访问 Envoy 的 NodePort,同时手动设置 Host Header。

查看 Envoy Service:

1
2
kubectl get service -n envoy-gateway-system \
 -l gateway.envoyproxy.io/owning-gateway-name=kserve-ingress-gateway

NodePort 是动态分配的,先读取实际访问地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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}')

echo "http://${NODE_IP}:${NODE_PORT}"

查看模型列表

1
2
curl -H "Host: qwen-llm-kserve-test.example.com" \
 "http://${NODE_IP}:${NODE_PORT}/openai/v1/models"

返回结果:

1
2
3
4
5
6
7
8
9
{
 "object": "list",
 "data": [
 {
 "id": "qwen",
 "object": "model"
 }
 ]
}

发送 Chat Completions 请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
curl -H "Host: qwen-llm-kserve-test.example.com" \
 -H "Content-Type: application/json" \
 "http://${NODE_IP}:${NODE_PORT}/openai/v1/chat/completions" \
 -d '{
 "model": "qwen",
 "messages": [
 {
 "role": "user",
 "content": "请用一句话介绍 Kubernetes。"
 }
 ],
 "max_tokens": 64,
 "temperature": 0.2
 }'

返回 chat.completion,说明下面这条链路已经打通。这里的 HTTPRoute 是 Envoy Proxy 使用的路由配置,Envoy Gateway Controller 不在在线请求链路中:

1
2
3
4
5
6
Client
 -> Envoy Proxy(应用 HTTPRoute/qwen-llm 的规则)
 -> Predictor Pod(后端由 Service/qwen-llm-predictor 标识)
 -> KServe HuggingFaceServer
 -> vLLM
 -> GPU

Qwen2.5-0.5B-Instruct 主要用于验证部署链路,模型规模很小,输出质量不能代表生产模型。

总结

到这里,我们已经用 KServe 跑通了一条完整的 LLM 推理链路,从本地 PVC 加载模型,再通过 Envoy Gateway 管理的 Envoy Proxy 调用 OpenAI 兼容接口。

KServe 的价值不只是启动一个模型进程,而是把模型地址、Runtime、GPU 资源和访问入口收敛到 InferenceService 中,让推理服务也能沿用 Kubernetes 的声明式方式统一管理。

这也是理解 KServe 的起点。下一篇继续往下拆,看看一份 InferenceService YAML 提交之后,KServe 在集群里到底创建了什么。

我的博客,被 47 万次请求刷慢了

2026-07-19 04:00:00

我的博客,被 47 万次请求刷慢了

事情是这样的。

前几天有读者跟我反馈,说博客变慢了。

不是偶尔卡一下,是点开文章以后,文字出来了,图片还要在原地转上好几秒。

我自己试了几次,确实不对劲。更奇怪的是,前不久我才压缩过图片。

一个纯静态博客,为什么会突然慢成这样?

1. CPU 没忙,3M 带宽却满了

我第一反应是服务器扛不住了。

毕竟这就是一台很普通的小服务器,2C2G。真遇上请求高峰,被打得喘不过气也不奇怪。

结果我打开监控一看,CPU 使用率还不到 10%。

机器闲得很。

真正出问题的,是带宽。

我这台服务器的公网出口只有 3 Mbps。正常情况下,机器的流出带宽很低,只有 70.793 Kbit/s

正常情况下,公网流出带宽只有几十 Kbit/s

但被大量请求刷起来以后,曲线完全变了。把粒度缩到 1 分钟,公网流出带宽直接冲到 3.289 Mbit/s,贴着 3M 上限跑。

大量请求出现后,公网流出带宽达到 3.289 Mbit/s

70.793 Kbit/s3.289 Mbit/s,相差超过 46 倍。

Caddy 返回静态页面几乎不费 CPU,所以没有收到报警信息。但出口一旦被占满,正常读者再打开一张图片,就只能在后面排队。

顺着带宽曲线往下查,我发现 Caddy Access Log 最近轮转得比平时快了不少。

我一开始还没太当回事。博客嘛,被搜索引擎和 RSS 阅读器抓一抓很正常。

直到我把 27 天的轮转日志全部合到一起,才发现里面有一批访问频率异常的请求。

2. 27 天日志里,到底藏了什么

这份日志快照实际覆盖 27.46 天,统计结果如下:

指标 数值
全站请求 1,132,456
异常请求 477,314
异常请求占比 42.15%
异常响应流量 9.00 GB
异常 IP 75 个
返回 200 475,806 次

47 万是累计总量,短时间内的请求频率更值得看。

行为 观测结果
最高一天 25,936 次
最高一小时 6,864 次,占该小时全站请求 85.98%
最高一分钟 627 次,全部来自同一个 IP
最快 100 次 6.281 秒
最快 500 次 43.412 秒
最长连续突发 7 分 28 秒内请求 3,539 次

那段最长突发里,相邻请求没有一次停顿超过 1 秒。这段时间共切换了 45 种 User-Agent,成功请求文章页面 3,308 次。

从文章覆盖范围看,这些请求也不是偶尔访问几篇。

当前 sitemap.xml 中有 278 篇文章。这批 IP 访问了全部 278 篇,仅对这些文章就请求了 394,112 次。按篇数粗略折算,相当于把整个文章库读取了 1,418 遍。

这就离谱😂

单篇文章被读取的中位数是 1,163 次,152 篇文章超过 1,000 次,最高一篇达到 5,551 次。

不过,请求多并不能直接说明这些访问不正常。

搜索引擎、RSS 阅读器,甚至某个读者连续打开很多文章,都可能制造高峰。还需要继续看这些请求的具体特征。

3. 从日志识别异常抓取

先看 IP。

27 天里一共出现了 75 个相关地址,单个 IP 通常只活跃 1~6 天,随后换成同一地址空间里的另一个。

再看 User-Agent。同一个 IP 在不到一秒内留下过这样的记录:

1
2
3
4
5
6
10:36:19.436 Chrome/127 /posts/kubernetes/58-dra-p4-my-dra-driver/
10:36:19.955 Chrome/110 /index.xml
10:36:20.029 Chrome/115 /tags/rtk/
10:36:20.075 Chrome/133 /posts/kubernetes/59-kubeclipper-release-1.6.0/
10:36:20.148 Chrome/120 /posts/cloudnative/01-metallb/
10:36:20.321 Chrome/109 /posts/kubernetes/57-dra-p3-workflow/

Chrome 100~144 一共 45 个版本全部出现,每个版本的请求量都在 9,441~10,045 次之间。排除每个 IP 的第一条记录后,97.77% 的相邻请求都会更换 User-Agent。

正常浏览器不会请求一次就换一个 Chrome 版本。

再看请求目标。这批地址主要请求 RSS 和文章 HTML,几乎不加载文章图片。

4. 从 IP 黑名单转向请求频率限流

我第一反应就是封 IP。

但对方 27 天换了 75 个 IP,逐个封禁只能短期止血,批量封禁又容易误伤正常用户。

继续判断请求来自谁没有太大意义,我只需要限制访问频率。

问题变成了,每分钟允许多少次,既能拦住抓取,又不影响正常读者?

为了避免阈值设得太紧,误伤正常访问,我先拿最近 24 小时的日志做了一次固定自然分钟回放:

阈值 回放结果
全部请求 60 次/分钟 命中 3 个主要异常 IP 和 1 个额外 IP
全部请求 120 次/分钟 只命中 3 个主要异常 IP
页面请求 30 次/分钟 命中 3 个主要异常 IP 和 1 个额外 IP
页面请求 60 次/分钟 只命中 3 个主要异常 IP

结合回放结果,我设置了两个独立限流区:

1
2
全部请求:120 次/分钟/IP
页面请求: 60 次/分钟/IP

页面请求同时计入两个区。HTML、分类、标签和 RSS 每分钟最多 60 次,CSS、JavaScript 等其他资源只受每分钟 120 次的兜底限制。

日志回放按固定自然分钟统计,后面使用的模块是滑动窗口,两者不会完全一致。这组阈值只对应当时的访问情况,后续还要根据 429 和误伤情况调整。

5. 在 Caddy 中启用 Rate Limit

Caddy v2.11.4 的官方标准二进制不包含 rate_limit,需要通过 xcaddy 构建自定义版本。本文使用的是第三方模块 mholt/caddy-ratelimit

为了以后换服务器还能复现,我固定了 Caddy、xcaddy 和插件提交:

1
2
3
4
5
go install github.com/caddyserver/xcaddy/cmd/[email protected]

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
 xcaddy build v2.11.4 \
 --with github.com/mholt/caddy-ratelimit@5625512f24f6f59d6f64fb3aafe5eecff0b286db

构建完成后确认模块已经包含在二进制中:

1
2
./caddy version
./caddy list-modules | grep -Fx http.handlers.rate_limit

限流配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
rate_limit {
 zone blog_all {
 key {remote_host}
 events 120
 window 1m
 ipv6_prefix 64
 }

 zone blog_pages {
 match {
 method GET HEAD
 path / /posts/* /tags/* /categories/* /about/* /index.xml
 }
 key {remote_host}
 events 60
 window 1m
 ipv6_prefix 64
 }

 log_key
}

将配置写入实际使用的 Caddyfile 后,先用新二进制校验配置,再替换当前版本并重启服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
install -m 0755 ./caddy /usr/local/bin/caddy.new

/usr/local/bin/caddy.new validate \
 --config /etc/caddy/Caddyfile

cp /usr/local/bin/caddy \
 /usr/local/bin/caddy.$(date +%Y%m%d%H%M%S).bak

mv /usr/local/bin/caddy.new /usr/local/bin/caddy
systemctl restart caddyd
systemctl status caddyd --no-pager

只替换磁盘上的二进制还不够,正在运行的旧进程不会自动加载新模块,因此这里需要重启 Caddy。

配置生效后,我从同一出口 IP 并发请求页面:

1
2
3
4
seq 1 65 | xargs -P 10 -I {} \
 curl --http1.1 -s -o /dev/null -w '%{http_code}\n' \
 https://www.lixueduan.com/posts/ \
 | sort | uniq -c

超过窗口额度后,客户端开始收到 429:

1
2
3
4
HTTP/1.1 429 Too Many Requests
Retry-After: 58
Server: Caddy
Content-Length: 0

看到 429 后,可以确认限流配置已经生效。

6. 限流上线后的同一时段

限流在 2026-07-16 10:55:50 随新 Caddy 进程正式生效。

前面的云监控截图中,带宽打满发生在 2026-07-15 20:29~21:28。我把这 1 小时作为上线前窗口,再取限流上线后相同钟点的 2026-07-16 20:29~21:28 做对比。

上线后的同一小时内,异常 IP 成功获取的 HTML 减少了 4,478 次,Caddy 少返回了 89.46 MB 响应数据,还有 1,287 次请求被直接挡在 429。

指标 上线前 上线后 实际变化
异常地址成功返回 HTML 5,297 819 减少 4,478(-84.5%)
异常地址响应流量 112.03 MB 22.57 MB 减少 89.46 MB(-79.9%)
异常地址返回 200 5,664 932 减少 4,732(-83.5%)
异常地址返回 429 0 1,287 新增 1,287 次拦截
异常地址请求 5,684 2,223 减少 3,461(-60.9%)
全站请求 6,637 3,071 减少 3,566(-53.7%)

成功返回的 HTML 和异常响应流量分别下降 84.5% 和 79.9%,限流生效后有 1,287 次请求返回 429。全站请求量也下降了 53.7%,不过里面混着正常访问,只能作为参考。

单个小时还不能代表长期效果,后续仍要观察 429 来源以及搜索引擎是否被误伤。

7. 总结

文章发出来,本来就是给大家看的。有人搜索、订阅,甚至批量抓取文章,我其实都无所谓。我介意的是,持续的高频请求已经影响到正常读者打开文章和图片。

120 次/分钟60 次/分钟 不是通用答案,只是我根据这次日志回放选出的起点,后面还要继续观察是否存在误伤。

限流只是控制访问节奏,让真正来看文章的人能够顺畅打开页面。