HPA (Horizontal Pod Autoscaler) 是 Kubernetes 提供的一种自动扩容机制,它可以根据指定的指标自动调整 Pods 的副本数量以满足应用的负载需求。在 1.6 版本之前,HPA 只支持基于 CPU 和内存的指标,即根据 Pod 的 CPU 和内存使用情况来进行扩容,但这种方式并不能全面反映应用的实际负载情况,例如,一个 Web 应用(I/O 密集型)可能更多地依赖磁盘或网络带宽,而这些资源的使用情况可能不会直接反映在 CPU 或内存指标上。因此在 HPA v2 中新增了对 Metrics API 的支持,允许使用者通过外部监控系统提供的指标驱动 HPA 扩容。下面我们将探讨如何使用 HPA 结合 Metrics API 来解决一类常见场景:生产者-消费者模型中,生产者生产的消息速度大于消费者的处理速度导致消息积压。为了更好的演示这里准备了一个小程序来模拟这种场景,包含两个模块:

  • 生产者 (Producer):接收 HTTP 请求后向队列中写入消息,提供以下 Prometheus 指标:

    • hpa_app_queue_length: Gauge 类型,队列长度
    • hpa_app_produce_count: Counter 类型,生产消息数量
  • 消费者 (Consumer):从队列中读取消息并处理 (sleep 10ms),提供以下 Prometheus 指标:

    • hpa_app_consume_count: Counter 类型,消费消息数量

首先在集群中部署实验环境,需要注意集群需要部署 Prometheus Operator,具体方法可以参考 文档 这里不再赘述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git clone https://github.com/yxwuxuanl/hpa-example.git

$ cd hpa-example/deploy

$ helm dep build

$ helm install hpa-app . -n default

$ kubectl get po -n default -l app.kubernetes.io/instance=hpa-app
NAME READY STATUS RESTARTS AGE
hpa-app-consumer-6cdf8f8f8d-d5gk9 1/1 Running 0 18m
hpa-app-consumer-6cdf8f8f8d-g8rbr 1/1 Running 0 18m
hpa-app-producer-686d547c4d-pxfbn 1/1 Running 0 18m
hpa-app-redis-ffcbd9b8-525lm 1/1 Running 0 18m

完成以上步骤后会在集群中部署一个生产者和两个消费者副本并且创建 Prometheus 采集规则,等到所有 Pod 都启动后运行 helm test hpa-app --timeout=30m 命令执行测试用例向生产者发送请求,测试启动后 1 分钟内会达到 100 QPS,随后会在 1 分钟内上升到 500 QPS 并持续 2 分钟,这个流量曲线符合业务中突发流量的特征:

20240923170628
20240923170628

消费者处理一条消息需要 10ms 因此最大消费速度为 100 条/秒,部署了 2 个消费者副本的情况下队列最大消费速度为 200 条/秒,当 消息生产数量 超过 200 警戒线后 队列消息数量 快速增长并持续了一段时间,这显然不是我们想要的结果,接下来我们将使用 HPA 来解决这个问题。

Metrics API

在进入正题之前先来介绍一下上面提到的 Metrics API,顾名思义 Metrics API 的作用是给 Kubernetes 提供指标,HPA 控制器和 kubectl top 命令都会用到,具体来说 Metrics API 包含两个部分:

  • custom.metrics.k8s.io 用于提供 Kubernetes 对象的 自定义指标,例如:某个 Pod 对象的数据库连接数、某个 Service 对象的请求数等,这些指标由用户的监控系统提供,但必须要关联 Kubernetes 中的对象。

  • external.metrics.k8s.io 用于提供 Kubernetes 对象无关的 外部指标,例如:消息队列系统的消息数量、云厂商提供的负载均衡器的请求数等。

Kubernetes 仅提供了 Metrics API 的定义,因此需要 Prometheus Adapter 作为适配器来提供数据,它可以将 Metrics API 请求转换为 Prometheus 查询并将查询结果转换为 Metrics API 支持的数据结构。Helm Chart 中已经包含了 Prometheus Adapter,使用 helm upgrade 命令更新 Release:

1
2
3
$ helm upgrade hpa-app . -n default \
--set=prometheus-adapter.enabled=true \
--set=prometheus-adapter.prometheus.url=<PROMETHEUS_URL>

<PROMETHEUS_URL> 替换为 Prometheus 的集群地址

部署完成后会创建一个 API Service 对象来「申领」Metrics API 的请求,我们可以通过 /apis/custom.metrics.k8s.io/v1beta1 检查 Metrics API:

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
$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq
{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "custom.metrics.k8s.io/v1beta1",
"resources": [
{
"name": "namespaces/hpa_app_consume_per_second",
"singularName": "",
"namespaced": false,
"kind": "MetricValueList",
"verbs": [
"get"
]
},
{
"name": "pods/hpa_app_consume_per_second",
"singularName": "",
"namespaced": true,
"kind": "MetricValueList",
"verbs": [
"get"
]
}
]
}

custom.metrics.k8s.io API 组下有两个资源,这是由 Prometheus Adapter 提供的,稍后会介绍这个过程。这里重点关注 pods/hpa_app_consume_per_secondpods 表示 Kubernetes 的 Pods 资源,hpa_app_consume_per_second 是自定义指标的名称,从名字可以看出这个自定义指标是由消费者的 hpa_app_consume_count 指标转换而来。我们可以通过 URL 设计规范 进一步查看数据:

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
$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hpa_app_consume_per_second | jq
{
"kind": "MetricValueList",
"apiVersion": "custom.metrics.k8s.io/v1beta1",
"metadata": {},
"items": [
{
"describedObject": {
"kind": "Pod",
"namespace": "default",
"name": "hpa-app-consumer-6cdf8f8f8d-g8rbr",
"apiVersion": "/v1"
},
"metricName": "hpa_app_consume_per_second",
"timestamp": "2024-09-22T15:55:46Z",
"value": "0",
"selector": null
},
{
"describedObject": {
"kind": "Pod",
"namespace": "default",
"name": "hpa-app-consumer-6cdf8f8f8d-q64mg",
"apiVersion": "/v1"
},
"metricName": "hpa_app_consume_per_second",
"timestamp": "2024-09-22T15:55:46Z",
"value": "0",
"selector": null
}
]
}

这个 URL 的含义是获取 default 命名空间下所有 pods 对象的 hpa_app_consume_per_second 自定义指标,返回结果中包含自定义指标关联的对象
describedObject 以及指标值 `value。接下来我们来看如何在 HPA 中使用自定义指标。

使用自定义指标驱动 HPA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: autoscaling/v2 # 使用 HPA v2
kind: HorizontalPodAutoscaler
metadata:
name: hpa-app-consumer
spec:
maxReplicas: 6 # 最大副本数
minReplicas: 2 # 最小副本数
scaleTargetRef: # 扩缩容目标对象
kind: Deployment
name: hpa-app-consumer
apiVersion: apps/v1
metrics: # 扩缩容指标
- type: Pods # 使用与 Pods 资源关联的指标
pods:
metric: # 目标指标
name: hpa_app_consume_per_second
target: # 目标值
averageValue: 70 # 平均值
type: Value

这个 HPA 对象扩缩容目标是 Deployment/hpa-app-consumer,指标来源于它所控制的 Pods 对象关联的 hpa_app_consume_per_second 自定义指标,目标值为 70,目标值类型为 平均值。当消费者 Pods 的消费平均值 > 70 条/秒 时 HPA 会进行扩容操作并且最多允许扩容到 6 个副本。创建好 HPA 再次运行压测脚本:

20240923162615
20240923162615

可以看到这次情况好很多,当 平均消费数 超过告警线 (HPA 目标值) 后 HPA 如预期一样开始扩容消费者副本,虽然还是发生了消息积压但很快就被消费完。

Prometheus Adapter 配置

通过上面的例子我们已经初步领略了 HPA + 自定义指标的「威力」,在继续介绍外部指标之前我们先来了解一下自定义指标是如何配置的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# deploy/values.yaml
...
prometheus-adapter:
rules:
default: false
custom:
- seriesQuery: 'hpa_app_consume_count{}'
resources:
overrides:
namespace: { resource: "namespace" }
pod: { resource: "pod" }
name:
as: hpa_app_consume_per_second
matches: ''
metricsQuery: "sum by (<<.GroupBy>>) (rate(<<.Series>>{<<.LabelMatchers>>}[30s]))"

rules.custom 数组中每个元素都表示一个自定义指标的生成规则,官方文档 中将这个过程分为四个步骤,对应配置中的四个字段:

  1. seriesQuery: 过滤 Prometheus 指标,这里使用消费数指标作为来源,也可以写成 {__name__="hpa_app_consume_count"}

  2. resources: 关联 Kubernetes 对象和 Prometheus 指标。也就是将特定 Kubernetes 对象的名称转换为 Prometheus 指标的标签值用于查询。

  3. name: 指定自定义指标名称。

  4. metricsQuery: 指定 Prometheus 查询语句,<<.GroupBy>><<.Series>><<.LabelMatchers>> 三个占位符分别表示分组、指标序列和标签匹配器,这些占位符会在运行时被替换为实际的值。

resources 字段是配置的核心,它决定了 HPA 能否正确获取指定 Kubernetes 对象的指标。resources.overrides 允许我们对两者进行映射操作,Key 表示 Prometheus 指标的标签名称Value 表示标签值来源,配置 resources.overrides.pod: {resource: pod} 的含义是 Prometheus 指标的 pod 标签值映射到 Kubernetes 的 pod 资源,namespace 标签同理。Prometheus Adapter 是如何找到自定义指标对应的 Pod 对象的呢?我们先来看 HPA 控制器是如何查询自定义指标的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"kind": "Event",
"apiVersion": "audit.k8s.io/v1",
"level": "Metadata",
"requestURI": "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hpa_app_consume_per_second?labelSelector=app.kubernetes.io/instance=hpa-app,app.kubernetes.io/name=consumer",
"verb": "get",
"objectRef": {
"resource": "pods",
"namespace": "default",
"name": "*",
"apiGroup": "custom.metrics.k8s.io",
"apiVersion": "v1beta1",
"subresource": "hpa_app_consume_per_second"
}
}

从 api-server 的审计日志中可以找到 HPA 控制器的查询请求,从 objectRef.name 字段可以看出 HPA 控制器并没有指定要查询的具体 Pod 名称,而是使用了 labelSelector 参数来过滤 Pods,这个参数来自于扩缩容目标的 spec.selector 字段。Prometheus Adapter 会使用 labelSelector 参数的标签选择器在集群中找出对应的 Pods 后将名称作为 pod 标签的值,最终 Prometheus Adapter 会生成下面的 PromQL 查询语句:

1
sum by (pod) (rate(hpa_app_consume_count{namespace="default",pod=~"hpa-app-consumer-6cd8cdb47b-ddd9g|hpa-app-consumer-6cd8cdb47b-nbqnk"}[30s]))

使用外部指标驱动 HPA

了解完 Prometheus Adapter 配置后我们再回头看上面的图表,虽然 HPA 触发了扩容但消息积压的情况还是持续了一小段时间,这是因为在 HPA 中使用的是 平均值 作为目标值,因此当增加了新的副本后 HPA 需要再次计算指标值,超过目标值后才会继续下一轮扩容,这个过程会导致一定的延迟。虽然可以将 HPA 目标值调低让 HPA 尽早扩容来缓解这个问题,但这样可能会导致频繁触发扩容。最理想的解决方案是 HPA 能根据队列里的消息数量「一步到位」进行扩容,这需要使用外部指标来实现:

1
2
3
4
5
6
7
8
prometheus-adapter:
rules:
external:
- seriesQuery: 'hpa_app_queue_length{}'
resources:
overrides:
namespace: { resource: "namespace" }
metricsQuery: "sum by (queue_name) (<<.Series>>{<<.LabelMatchers>>})"

rules.external 数组中每个元素表示一个外部指标的生成规则,这次我们使用 hpa_app_queue_length 指标,这个指标反映的是队列长度与具体的 Pod 对象无关,因此在 resources 字段中只需要映射 namespace 标签即可。在 metricsQuery 中我们使用了 queue_name 标签来分组避免多个队列的指标混在一起。同样的我们可以使用kubectl get 命令查看外部指标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ kubectl get --raw /apis/external.metrics.k8s.io/v1beta1/namespaces/default/hpa_app_queue_length | jq 
{
"kind": "ExternalMetricValueList",
"apiVersion": "external.metrics.k8s.io/v1beta1",
"metadata": {},
"items": [
{
"metricName": "hpa_app_queue_length",
"metricLabels": {
"queue_name": "hpa-app"
},
"timestamp": "2024-09-22T05:20:52Z",
"value": "0"
}
]
}

配置中省略了 name 字段,Prometheus Adapter 会使用 seriesQuery 指定的指标名称作为自定义指标名称。接下来我们修改 HPA 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: autoscaling/v2 # 使用 HPA v2
kind: HorizontalPodAutoscaler
metadata:
name: hpa-app-consumer
spec:
maxReplicas: 6 # 最大副本数
minReplicas: 2 # 最小副本数
scaleTargetRef: # 扩缩容目标对象
kind: Deployment
name: hpa-app-consumer
apiVersion: apps/v1
metrics: # 扩缩容指标
- type: External # 使用外部指标
external:
metric:
name: hpa_app_queue_length # 目标指标
selector: # 指标标签选择器
matchLabels:
queue_name: hpa-app
target: # 目标值
type: Value
value: 100

这次我们将 type 字段设置为 External 来使用外部指标,需要注意的是由于外部指标不与 Kubernetes 对象进行关联,我们需要使用 external.metric.selector 来选择正确的外部指标。目标值也不再使用平均值,当队列长度超过 100 时 HPA 会进行扩容。更新 HPA 对象后再次运行压测:

20240923160117
20240923160117

可以看到当 队列长度 超过目标值时 HPA 控制器开始扩容,但这次并不是逐步增加而是直接扩容到 6 个副本,积压的消息很快就被消费完毕,很显然这种方式更适合于生产者-消费者模型中的场景。