HorizontalPodAutoscaler + Cluster Autoscaler

Kubernetes 内置HorizontalPodAutoscaler可以很方便地根据 CPU 和内存做水平扩容缩容。

Kubernetes 在启动Pod和销毁Pod的时候对生命周期的控制也做的非常好,并不会对一个服务有太大的影响。只要把优雅启动和优雅关闭做好,并且把 Requests 和 Limits 配置到合适的数值,这一切会变得非常便捷。

另外再配合 Kubernetes Cluster Autoscaler,还可以实现整个集群机器的自动扩容缩容。

Cluster Autoscaler 可不是简单地根据机器剩余多少资源来判断是否要扩容缩容的。一般的机器自动扩容缩容都只是判断一下 CPU 用了多少,内存用了多少,如果剩余很多就尝试缩减机器。但 Cluster Autoscaler 的判断逻辑没这么简单,它还会和 Kubernetes 污点,污点容忍性,Pod亲和性,Node 亲和性相结合。例如你有一台机器有特殊的 Label,上面有一个Pod只能跑在有这个 Label 的机器上,占用的资源非常少。如果是别的程序,看到这台机器占用资源少,就直接把它干掉了,但实际上它不能去掉,因为上面的这个Pod只能在这台机器上跑。

 

IO 密集型应用

本来一切都跑的很好,直到我们集群里出现了 IO 密集型的 Worker 程序。

业务场景很简单,这个 Worker 会不断消费队列里的消息,并调用别的服务来处理这些消息。

这种场景下Pod占用的 CPU 会很少,而且也不太稳定,所以根据 CPU 来做自动扩容缩容就很困难了。

我最根本的需求是希望用尽量少的 Pod,尽量让队列一直保持空的状态。所以Pod的数量应该是根据队列堆积任务的数量来决定。

 

Prometheus to Custom Metrics

Kubernetes 从 1.6 开始支持自定义指标:https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/#support-for-custom-metrics

在 Kubernetes 中 Prometheus 是常用的组件,那是否可以把 Prometheus 里的指标作为自动扩容缩容的依据呢?

有一个指标专门用来统计队列中还在等待的消息数,这个数字大了就扩容,数字小了就缩容。完美~

这种常见需求自己做一个不难,开源社区也有了现成的东西:k8s-prometheus-adapter

 

安装配置 k8s-prometheus-adapter

安装非常简单,利用helm一行命令就可以,但配置就比较麻烦了。原因在于配置太自由,所以你必须要想清楚自己的需求,然后用它特定的语法写转换规则。

第一种配置方法是按需配置,用到什么指标就写一条转换规则,优点是性能更好,不需要的配置就不会拉取。缺点当然就是不灵活,一旦有新的需求就要修改k8s-prometheus-adapter的配置。

第二种配置方法的优缺点就正好相反,直接把所有指标都配进来,所有指标随时可用。

对于第二种配置方法还有一个优化的点,就算你集群内本身就有 Prometheus,还是建议单独搭建一个。然后在 Prometheus 里把不需要的指标直接去掉,提高采样率,缩短数据存储时间(这个需求中的指标只要实时的就行)。

最后我们的配置如下:

prometheus:
  url: http://prometheus-for-adapter-server.kube-system.svc
  port: 80

replicas: 2

resources:
  requests:
    cpu: 200m
    memory: 2Gi
  limits:
    cpu: 1
    memory: 2Gi

rules:
  default: false
  custom:
  - seriesQuery: '{job="kubernetes-pods",kubernetes_namespace!="",kubernetes_pod_name!=""}'
    resources:
      overrides:
        kubernetes_namespace:
          resource: "namespace"
        kubernetes_pod_name:
          resource: "pod"
    name:
      matches: "(.*)"
      as: "counter_${1}"
    metricsQuery: "sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)"
  - seriesQuery: '{job="kubernetes-nodes-cadvisor",container!="POD",namespace!="",pod!=""}'
    resources:
      overrides:
        namespace:
          resource: "namespace"
        pod:
          resource: "pod"
    name:
      matches: "(.*)"
      as: "counter_${1}"
    metricsQuery: "sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)"
  - seriesQuery: '{job="kubernetes-pods",kubernetes_namespace!="",kubernetes_pod_name!=""}'
    resources:
      overrides:
        kubernetes_namespace:
          resource: "namespace"
        kubernetes_pod_name:
          resource: "pod"
    name:
      matches: "(.*)"
      as: "gauge_${1}"
    metricsQuery: "avg(avg_over_time(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)"
  - seriesQuery: '{job="kubernetes-nodes-cadvisor",container!="POD",namespace!="",pod!=""}'
    resources:
      overrides:
        namespace:
          resource: "namespace"
        pod:
          resource: "pod"
    name:
      matches: "(.*)"
      as: "gauge_${1}"
    metricsQuery: "avg(avg_over_time(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)"

配置中核心的点有两个,首先它只会抓取Pod自己的指标,这部分都是开发自己暴露的。另外还抓取了 cadvisor 的指标。这里一般都会Pod相关的一些运行情况,除了 CPU 和内存外,还会有磁盘,网络等信息,更全面一点。

而像机器相关的指标,在这里其实都是无用的,所以这里不会抓取,Prometheus 里也不会抓取。

 

HorizontalPodAutoscaler 配置

HorizontalPodAutoscaler的配置就非常简单了:

kind: HorizontalPodAutoscaler
apiVersion: autoscaling/v2beta1
metadata:
    name: example-hpa
    labels:
        app: example
spec:
    scaleTargetRef:
        apiVersion: apps/v1
        kind: Deployment
        name: example
    minReplicas: 2
    maxReplicas: 10
    metrics:
    - type: Pods
      pods:
        metricName: counter_queue_size
        targetAverageValue: 10

这里的配置非常清晰,一方面它依然支持根据 CPU 做扩容缩容,如果 CPU 使用率超过 90% 就扩容。

另一方面,它会根据 counter_queue_size 这个指标做扩容缩容,Kubernetes 会保证这个数值保持在 10 附近。如果大了就扩容,如果小了就缩容。

 

非线性指标带来的问题

本来以为这里配置完就万事大吉了,但实际并非如此。根据上面的配置,在实际运行中会出现问题。

首先,Kubernetes 在做扩容缩容的时候,认为这些指标都是线性的。例如目标 CPU 使用率是 80%,当前 CPU 使用率是 40%,Pod数量是 8 个,那么它会认为如果把Pod缩减为 4 个就可以应付了。

其次,当队列堆积的消息接近 0 的时候,对它进行采样出来的数字波动会非常大,特别是 QPS 大的系统中,这个数字往往会在 0,10 甚至 100 之间跳动,非常不稳定。就算用平均值抹平这个波动,最后也只能稍微改善一下。

那基于这两个特点会产生什么现象呢?目标队列堆积任务数是 10,当前Pod数字刚好够用,队列是接近空的状态,最后取到的指标数值是 5,Kubernetes 一看,这可不行啊,太浪费了,根据计算,只要一半的Pod就够了。于是立刻干掉了一半的 Pod。

这下可好,一半的Pod没了,任务立刻开始堆积了,10 个,100 个,甚至到了 1000。等到下一次运算的时候,Kubernetes 又慌了,这可不行啊,目标 10个,实际任务堆积 1000,那么要把当前Pod扩容 100 倍啊!

就这样,这个 Worker 的Pod数在最小Pod数和最大Pod数之间跳动。

这里的核心问题在哪?核心问题就在于队列堆积任务数这个指标在接近 0 的时候,和Pod数量不是线性关系。

 

找一个更合适的指标

对于一个计算密集型的应用,用 CPU 占用来代表它忙不忙就非常合适。但是对于 IO 密集型的应用用这个指标就不行了。还有什么更合适的指标代表它忙不忙吗?QPS 是否可以?

判断所有Pod的 QPS,当业务平稳的时候,它们的 QPS 就可以代表它们忙不忙了。

例如给所有Pod分配 1 核的 CPU 资源,根据压测它们的最大队列处理能力是 100 每秒。

我们设定一个 QPS 的指标,取最大处理能力的 80%,也就是 80 每秒。

如果所有Pod的平均 QPS 大于 80 了,说明它们有点负荷了,需要再加点 Pod。

如果所有Pod的平均 QPS 小于 80 了,说明它们有点闲了,可以尝试去掉几个 Pod。

最终实验下来这个指标运行起来也非常稳定,业务稳定的时候Pod数也很稳定,队列也一直不存在堆积的现象。

 

应对突发流量

这个指标看似完美了,但在另一种场景下又有问题了。

这个队列实际上是用来处理搜索引擎索引变更事件的,平时业务都是平稳的。但是每隔一段时间我们又会用脚本去刷全量数据。这就导致有时候这个队列会严重堆积。会堆积到几千几万。

而基于刚才的设计 QPS 指标,我们取目标 QPS 80,它实际最大处理能力是 100,当队列堆积的时候,Kubernetes 发现数值是 100,比目标 QPS 多了 25%,那么只要扩 25% 的Pod就够了。

然而,这里当 QPS 到达它最大处理能力后,它也不再是线性的了,无论任务再怎么堆积,这个数值永远不会增长。虽然它可以扩容,但每次只有扩 25%,要在好几轮后,才可以扩大到理想状态。

既然一个在忙的时候不准,一个在闲的时候不准,那是否可以把它们结合起来呢?

kind: HorizontalPodAutoscaler
apiVersion: autoscaling/v2beta1
metadata:
    name: example-hpa
    labels:
        app: example
spec:
    scaleTargetRef:
        apiVersion: apps/v1
        kind: Deployment
        name: example
    minReplicas: 2
    maxReplicas: 10
    metrics:
    - type: Pods
      pods:
        metricName: counter_qps
        targetAverageValue: 80
    - type: Pods
      pods:
        metricName: counter_queue_size
        targetAverageValue: 1000

这是修改完后的HorizontalPodAutoscaler,同时counter_queue_size这个指标要设置地大一点。平时不堆积的时候,这个条件不会触发。而当遇到突发流量的时候,例如堆积到了 10000 个任务后,因为目标任务是 1000,相差十倍,Kubernetes 会立刻扩出 10 倍的Pod来应对这些任务。

而当队列处理完毕的时候,Kubernetes 又会根据所有Pod的实际 QPS,把它们缩减到合适的大小。

这样两种场景就都可以应对了。不仅可以快速响应需求,Pod数也不再会反复跳跃了。

本作品由 Dozer 创作,采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。