基于HPA的极致弹性调度最佳实践

自 0.10.0 版本开始,OpenKruise 提出了一种基于旁路(by-pass)架构的多域管理组件 —- WorkloadSpread。它允许用户将 Workload 的副本在不同节点、不同机房、甚至不同云厂商中进行多域化编排,并允许用户对不同域的副本进行差异化配置。WorkloadSpread 可以以无侵入的方式,赋予存量的/增量的 Workload 多域打散、弹性调度、精细化管理的能力。

接下来,本文将基于 WorkloadSpread 的特性,以一个简单的 Web 应用为例,结合 KEDA、Prometheus、阿里云弹性实例等,来帮助用户构建一个基于自定义指标的自动化极致弹性调度方案。

方案

方案架构

本文将会以一个 PHP 实现的 Hello-World Web 程序来模拟用户应用,整体方案架构如下: arch

特别说明:

  • 在该方案中,HPA 通过 KEDA 进行管理。KEDA 是一个基于 Kubernetes HPA 实现的加强版自动化伸缩组件,相较于原生的 HPA 组件,它适配了更丰富的自定义指标度量接口。

  • 在该方案中,Prometheus 采集 Ingress-Nginx 而不是 Web Pod 的指标数据,其实是一个取巧的操作。这是因为,业务接入 Prometheus 需要进行一定的业务改造,较为繁琐,而 Nginx 有暴露链接数目等指标的模块,并且有官方开源的 Exporter。最重要的是,进入 Web Pod 的流量一定要经过 Ingress-Nginx,所以本文直接以 Ingress-Nginx 的指标作为标准,对接 KEDA 组件实现自动化扩缩容。

  • 由于 WorkloadSpread 需要 1.21 及以上的 Kubernetes 版本才能支持 Deployment(因为需要 APIServer PodDeletionCost 特性,该特性在 1.21 开始支持,默认关闭,在 1.22 版本开始默认开启)。然而,本文采用的 ACK Kubernetes 集群目前最高支持到 1.20 版本,因此,本文以 CloneSet 为例进行演示(CloneSet 在 OpenKruise 0.9.0 开始支持 PodDeletionCost 特性)。

方案目标

该方案将基于一段时间窗口内 Nginx 所处理连接数作为指标:

  • 当流量高峰到来,该指标超过了阈值(这里的指标阈值 可以根据实际需要自行进行定义),则认为需要进行自动扩容;
    • 扩缩时,优先将 Pod 扩容至长期持有的固定资源池,当固定资源池的资源不足或 Pod 数量达到设定阈值时,则自动弹性扩容到弹性资源池;
  • 当流量高峰过去,关注的指标低于了阈值,则认为需要进行自动缩容;
    • 缩容时,优先缩容弹性资源池中的副本;

环境配置

本文将基于阿里云 ACK 集群进行演示,其中共包含 3 个ECS节点,模拟固定资源池,1个 Virtual-Kubelet 节点,用于申请和管理弹性实例,模拟弹性资源池:

  1. $ k get node
  2. NAME STATUS ROLES AGE VERSION
  3. us-west-1.192.168.0.47 Ready <none> 153d v1.20.11-aliyun.1
  4. us-west-1.192.168.0.48 Ready <none> 153d v1.20.11-aliyun.1
  5. us-west-1.192.168.0.49 Ready <none> 153d v1.20.11-aliyun.1
  6. virtual-kubelet-us-west-1a Ready agent 19d v1.20.11-aliyun.1

安装 OpenKruise

更多安装细节请参考官方安装文档,这里建议安装最新版本。

安装 KEDA

  1. $ helm repo add kedacore https://kedacore.github.io/charts
  2. $ helm repo update
  3. $ kubectl create namespace keda
  4. $ helm install keda kedacore/keda --namespace keda

安装 Ingress-Nginx-Controller

首先,创建相应的 Namespace:

  1. $ kubectl create ns ingress-nginx

因为 Exporter 需要能够访问 Nginx Status 接口,以便获取连接数等基础数据。因此,在安装该 Controller 之前,我们需要先下发一个 Nginx Configuration 相关的 ConfigMap,目的是把默认的一些配置进行覆盖,将 Status 接口暴露出来,供 Nginx-Prometheus-Exporter 消费:

  1. apiVersion: v1
  2. data:
  3. allow-snippet-annotations: "true"
  4. http-snippet: |
  5. server {
  6. listen 8080;
  7. server_name _ ;
  8. location /stub_status {
  9. stub_status on;
  10. }
  11. location / {
  12. return 404;
  13. }
  14. }
  15. kind: ConfigMap
  16. metadata:
  17. annotations:
  18. meta.helm.sh/release-name: ingress-nginx
  19. meta.helm.sh/release-namespace: ingress-nginx
  20. labels:
  21. app.kubernetes.io/component: controller
  22. app.kubernetes.io/instance: ingress-nginx
  23. app.kubernetes.io/managed-by: Helm
  24. app.kubernetes.io/name: ingress-nginx
  25. app.kubernetes.io/version: 1.1.0
  26. helm.sh/chart: ingress-nginx-4.0.13
  27. name: ingress-nginx-controller
  28. namespace: ingress-nginx

准备一个 values.yaml 文件,以便在部署 Ingress-Nginx-Controller Deployment 时将 8080 端口暴露出来:

  1. # values.yaml
  2. controller:
  3. containerPort:
  4. http: 80
  5. https: 443
  6. status: 8080

安装部署 Ingress-Nginx-Controller:

  1. $ helm upgrade --install ingress-nginx ingress-nginx --repo https://kubernetes.github.io/ingress-nginx --namespace ingress-nginx --values values.yaml

因为 Ingress-Nginx-Controller 80 和 443 端口是对外提供服务,使用的是 LoadBalancer 类型的Service,而 8080 端口只是为了暴露给 Exporter,而 Exporter 和 Prometheus 完全可以部署在集群内部,只对内提供服务,因此此处应使用 ClusterIP 类型 Service 来对接 Nginx 8080 端口,使其只在集群内部暴露:

  1. kind: Service
  2. apiVersion: v1
  3. metadata:
  4. name: ingress-nginx-controller-8080
  5. namespace: ingress-nginx
  6. spec:
  7. selector:
  8. app.kubernetes.io/component: controller
  9. app.kubernetes.io/instance: ingress-nginx
  10. app.kubernetes.io/name: ingress-nginx
  11. type: ClusterIP
  12. ports:
  13. - name: myapp
  14. port: 8080
  15. targetPort: status

安装 Nginx-Prometheus-Exporter

nginx 暴露出的 Status 数据并未遵循 Prometheus 的格式标准,因此需要一个 Exporter 组件进行数据采集和格式转换,此处采用 Nginx 官方提供的 Nginx-Prometheus-Exporter:

  1. apiVersion: apps/v1
  2. kind: Deployment
  3. metadata:
  4. name: ingress-nginx-exporter
  5. namespace: ingress-nginx
  6. labels:
  7. app: ingress-nginx-exporter
  8. spec:
  9. selector:
  10. matchLabels:
  11. app: ingress-nginx-exporter
  12. strategy:
  13. rollingUpdate:
  14. maxSurge: 1
  15. maxUnavailable: 1
  16. type: RollingUpdate
  17. template:
  18. metadata:
  19. labels:
  20. app: ingress-nginx-exporter
  21. spec:
  22. containers:
  23. - image: nginx/nginx-prometheus-exporter:0.10
  24. imagePullPolicy: IfNotPresent
  25. args:
  26. - -nginx.scrape-uri=http://ingress-nginx-controller-8080.ingress-nginx.svc.cluster.local:8080/stub_status
  27. name: main
  28. ports:
  29. - name: http
  30. containerPort: 9113
  31. protocol: TCP
  32. resources:
  33. limits:
  34. cpu: "200m"
  35. memory: "256Mi"

安装 Prometheus-Operator

  1. $ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
  2. $ helm repo update
  3. $ helm install [RELEASE] prometheus-community/kube-prometheus-stack --namespace prometheus --create-namespace

本文 [RELEASE] 设置为 kube-prometheus-stack-1640678515, 这串字符决定了后续的一些配置,如需改动,后续一些 yaml 文件中的一些配置也需改动。

Prometheus 安装完成后下发 ServiceMonitor, 来监控 Ingress-Nginx 暴露出的指标:

  1. apiVersion: monitoring.coreos.com/v1
  2. kind: ServiceMonitor
  3. metadata:
  4. labels:
  5. release: kube-prometheus-stack-1640678515
  6. name: ingress-nginx-monitor
  7. namespace: ingress-nginx
  8. spec:
  9. selector:
  10. matchLabels:
  11. app: ingress-nginx-exporter
  12. endpoints:
  13. - interval: 10s
  14. port: exporter

测试环境配置是否正确

上述环境安装配置完成后,我们需要先检查一下环境配置的正确性。

测试 Nginx Status 接口是否正常

首先,我们随便拉起一个带 shell 和 curl 等工具的 Pod,例如:

  1. apiVersion: v1
  2. kind: Pod
  3. metadata:
  4. name: centos
  5. namespace: ingress-nginx
  6. spec:
  7. containers:
  8. - name: main
  9. image: centos:latest
  10. command: ["/bin/sh", "-c", "sleep 100000000"]
  11. resources:
  12. limits:
  13. memory: "512Mi"
  14. cpu: "500m"
  15. ports:
  16. - containerPort: 8080

然后,登入该 Pod main 容器进行连接测试:

  1. $ k exec busybox -n ingress-nginx -it -- /bin/sh
  2. sh-4.4# curl -L http://ingress-nginx-controller-8080.ingress-nginx.svc.cluster.local:8080/stub_status
  3. Active connections: 6
  4. server accepts handled requests
  5. 12092 12092 23215
  6. Reading: 0 Writing: 1 Waiting: 5

如执行上述 curl 后输出类似内容,则表示接口正常。

测试 Prometheus 数据采集是否正常

我们通过 Helm 安装 Prometheus-Operator 时,其实也已经将 Grafana 安装上了。因此,我们可以登入 Grafana 这个可视化工具,来查看我们想要的 Nginx 的指标有没有被采集到。 因为 Grafana 也部署在 ACK 集群,节点在远端,因此想要使用本地浏览器访问 Grafana,我们需要改动一下 Grafana Service Type,将其改为 LoadBalancer 类型,这样 ACK 会自动给 Grafana 分配一个外部地址。拿到这个外部地址,我们就可以使用本地浏览器访问 Grafana。 Grafana 初始账号密码可以从相应的 Secret 中解析得到:

  1. user: admin
  2. password: prom-operator

登入 Grafana 后,点击左侧导航栏中的 Explore ,在 Metrics browser 中可以看到 Prometheus 采集存储的指标列表,如果我们关注的指标存在,则表示采集成功。

弹性部署

完成上述环境准备就绪,并确认一切正常后,接下来便可以部署应用以及弹性组件。

应用部署

以 Hello-Web 应用为例,访问该应用会返回一个简单的 html 页面,内容类似如下:

  1. Hello Web
  2. Current Backend Server Info
  3. Server Name: hello-web-57b767f456-bnw24
  4. Server IP: 47.89.252.93
  5. Server Port: 80
  6. Current Client Request Info
  7. Request Time Float: 1640766227.537
  8. Client IP: 10.64.0.65
  9. Client Port: 52230
  10. User Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
  11. Request Method: GET
  12. Thank you for using PHP.
  13. Request URI: /

使用 CloneSet 将其进行部署:

  1. apiVersion: apps.kruise.io/v1alpha1
  2. kind: CloneSet
  3. metadata:
  4. name: hello-web
  5. namespace: ingress-nginx
  6. labels:
  7. app: hello-web
  8. spec:
  9. replicas: 1
  10. selector:
  11. matchLabels:
  12. app: hello-web
  13. template:
  14. metadata:
  15. labels:
  16. app: hello-web
  17. spec:
  18. containers:
  19. - name: hello-web
  20. image: zhangsean/hello-web
  21. ports:
  22. - containerPort: 80
  23. resources:
  24. requests:
  25. cpu: "1"
  26. memory: "256Mi"
  27. limits:
  28. cpu: "2"
  29. memory: "512Mi"
  30. ---
  31. kind: Service
  32. apiVersion: v1
  33. metadata:
  34. name: hello-web
  35. namespace: ingress-nginx
  36. spec:
  37. type: ClusterIP
  38. selector:
  39. app: hello-web
  40. ports:
  41. - protocol: TCP
  42. port: 80
  43. targetPort: 80
  44. ---
  45. apiVersion: networking.k8s.io/v1
  46. kind: Ingress
  47. metadata:
  48. name: ingress-web
  49. namespace: ingress-nginx
  50. spec:
  51. rules:
  52. - http:
  53. paths:
  54. - path: /
  55. pathType: Prefix
  56. backend:
  57. service:
  58. name: hello-web
  59. port:
  60. number: 80
  61. ingressClassName: nginx

部署 WorkloadSpread

  1. apiVersion: apps.kruise.io/v1alpha1
  2. kind: WorkloadSpread
  3. metadata:
  4. name: workloadspread-sample
  5. namespace: ingress-nginx
  6. spec:
  7. targetRef:
  8. apiVersion: apps.kruise.io/v1alpha1
  9. kind: CloneSet
  10. name: ingress-nginx-controller
  11. scheduleStrategy:
  12. type: Adaptive
  13. adaptive:
  14. rescheduleCriticalSeconds: 2
  15. subsets:
  16. - name: fixed-resource-pool
  17. requiredNodeSelectorTerm:
  18. matchExpressions:
  19. - key: type
  20. operator: NotIn
  21. values:
  22. - virtual-kubelet
  23. patch:
  24. metadata:
  25. labels:
  26. resource-pool: fixed
  27. - name: elastic-resource-pool
  28. requiredNodeSelectorTerm:
  29. matchExpressions:
  30. - key: type
  31. operator: In
  32. values:
  33. - virtual-kubelet
  34. tolerations:
  35. - effect: NoSchedule
  36. key: virtual-kubelet.io/provider
  37. operator: Exists
  38. patch:
  39. metadata:
  40. labels:
  41. resource-pool: elastic

上述 WorkloadSpread 共包含两个 Subset,分别对应固定资源池和弹性资源池。我们期望名为 hello-web 的 CloneSet 尽量地先将 Pod 往固定资源池去调度,当该资源池不可调度时,再往弹性资源池去调度。

WorkloadSpread 的大概原理是利用了 Kubernetes 的 Webhook 机制。当 APIServer 收到相应 Pod 的创建请求时,会调用 Kruise Webhook,将相应的 WorkloadSpread 的调度规则注入到 Pod。WorkloadSpread 在注入时采用的是追加机制,而不是替换机制。例如,假设 Pod 本身已经有了一些 requiredNodeSelectorTerm 或者 Tolerations 规则定义, WorkloadSpread 会在这些已有配置的基础上,把 Subset 中的调度规则 append 上去。 因此,我们建议:

  • 将一些 共有的不轻易改变 的调度规则写到 Workload,最好能保证不经过 WorkloadSpread 也能调度成功;
  • 将 Subset 个性化的调度规则,配置到 WorkloadSpread Subset;

部署 ScaleObject

  1. apiVersion: keda.sh/v1alpha1
  2. kind: ScaledObject
  3. metadata:
  4. name: ingress-nginx-scaledobject
  5. namespace: ingress-nginx
  6. spec:
  7. maxReplicaCount: 10
  8. minReplicaCount: 1
  9. pollingInterval: 10
  10. cooldownPeriod: 2
  11. advanced:
  12. horizontalPodAutoscalerConfig:
  13. behavior:
  14. scaleDown:
  15. stabilizationWindowSeconds: 10
  16. scaleTargetRef:
  17. apiVersion: apps.kruise.io/v1alpha1
  18. kind: CloneSet
  19. name: hello-web
  20. triggers:
  21. - type: prometheus
  22. metadata:
  23. serverAddress: http://kube-prometheus-stack-1640-prometheus.prometheus:9090/
  24. metricName: nginx_http_requests_total
  25. query: sum(rate(nginx_http_requests_total{job="ingress-nginx-exporter"}[12s]))
  26. threshold: '100'

效果展示

首先,检查一下配置是否都已经下发:

result-show-0

然后,使用 go-stress-testing 压测工具对上述应用进行压测。

当第一波流量到来,可以看到应用正在自动扩容,并且扩容到固定资源池:

result-show-1

当第二波流量高峰到来,固定资源池的资源逐渐不足,开始扩容到弹性资源池:

result-show-2

高峰流量过去,应用开始自动缩容,首先会缩掉弹性资源池中的副本,等弹性资源缩容完毕,再缩容固定资源池中的副本:

result-show-3