基于HPA的极致弹性调度最佳实践
自 0.10.0 版本开始,OpenKruise 提出了一种基于旁路(by-pass)架构的多域管理组件 —- WorkloadSpread。它允许用户将 Workload 的副本在不同节点、不同机房、甚至不同云厂商中进行多域化编排,并允许用户对不同域的副本进行差异化配置。WorkloadSpread 可以以无侵入的方式,赋予存量的/增量的 Workload 多域打散、弹性调度、精细化管理的能力。
接下来,本文将基于 WorkloadSpread 的特性,以一个简单的 Web 应用为例,结合 KEDA、Prometheus、阿里云弹性实例等,来帮助用户构建一个基于自定义指标的自动化极致弹性调度方案。
方案
方案架构
本文将会以一个 PHP 实现的 Hello-World Web 程序来模拟用户应用,整体方案架构如下: 
特别说明:
在该方案中,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 节点,用于申请和管理弹性实例,模拟弹性资源池:
$ k get nodeNAME STATUS ROLES AGE VERSIONus-west-1.192.168.0.47 Ready <none> 153d v1.20.11-aliyun.1us-west-1.192.168.0.48 Ready <none> 153d v1.20.11-aliyun.1us-west-1.192.168.0.49 Ready <none> 153d v1.20.11-aliyun.1virtual-kubelet-us-west-1a Ready agent 19d v1.20.11-aliyun.1
安装 OpenKruise
更多安装细节请参考官方安装文档,这里建议安装最新版本。
安装 KEDA
$ helm repo add kedacore https://kedacore.github.io/charts$ helm repo update$ kubectl create namespace keda$ helm install keda kedacore/keda --namespace keda
安装 Ingress-Nginx-Controller
首先,创建相应的 Namespace:
$ kubectl create ns ingress-nginx
因为 Exporter 需要能够访问 Nginx Status 接口,以便获取连接数等基础数据。因此,在安装该 Controller 之前,我们需要先下发一个 Nginx Configuration 相关的 ConfigMap,目的是把默认的一些配置进行覆盖,将 Status 接口暴露出来,供 Nginx-Prometheus-Exporter 消费:
apiVersion: v1data:allow-snippet-annotations: "true"http-snippet: |server {listen 8080;server_name _ ;location /stub_status {stub_status on;}location / {return 404;}}kind: ConfigMapmetadata:annotations:meta.helm.sh/release-name: ingress-nginxmeta.helm.sh/release-namespace: ingress-nginxlabels:app.kubernetes.io/component: controllerapp.kubernetes.io/instance: ingress-nginxapp.kubernetes.io/managed-by: Helmapp.kubernetes.io/name: ingress-nginxapp.kubernetes.io/version: 1.1.0helm.sh/chart: ingress-nginx-4.0.13name: ingress-nginx-controllernamespace: ingress-nginx
准备一个 values.yaml 文件,以便在部署 Ingress-Nginx-Controller Deployment 时将 8080 端口暴露出来:
# values.yamlcontroller:containerPort:http: 80https: 443status: 8080
安装部署 Ingress-Nginx-Controller:
$ 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 端口,使其只在集群内部暴露:
kind: ServiceapiVersion: v1metadata:name: ingress-nginx-controller-8080namespace: ingress-nginxspec:selector:app.kubernetes.io/component: controllerapp.kubernetes.io/instance: ingress-nginxapp.kubernetes.io/name: ingress-nginxtype: ClusterIPports:- name: myappport: 8080targetPort: status
安装 Nginx-Prometheus-Exporter
nginx 暴露出的 Status 数据并未遵循 Prometheus 的格式标准,因此需要一个 Exporter 组件进行数据采集和格式转换,此处采用 Nginx 官方提供的 Nginx-Prometheus-Exporter:
apiVersion: apps/v1kind: Deploymentmetadata:name: ingress-nginx-exporternamespace: ingress-nginxlabels:app: ingress-nginx-exporterspec:selector:matchLabels:app: ingress-nginx-exporterstrategy:rollingUpdate:maxSurge: 1maxUnavailable: 1type: RollingUpdatetemplate:metadata:labels:app: ingress-nginx-exporterspec:containers:- image: nginx/nginx-prometheus-exporter:0.10imagePullPolicy: IfNotPresentargs:- -nginx.scrape-uri=http://ingress-nginx-controller-8080.ingress-nginx.svc.cluster.local:8080/stub_statusname: mainports:- name: httpcontainerPort: 9113protocol: TCPresources:limits:cpu: "200m"memory: "256Mi"
安装 Prometheus-Operator
$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts$ helm repo update$ helm install [RELEASE] prometheus-community/kube-prometheus-stack --namespace prometheus --create-namespace
本文 [RELEASE] 设置为 kube-prometheus-stack-1640678515, 这串字符决定了后续的一些配置,如需改动,后续一些 yaml 文件中的一些配置也需改动。
Prometheus 安装完成后下发 ServiceMonitor, 来监控 Ingress-Nginx 暴露出的指标:
apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata:labels:release: kube-prometheus-stack-1640678515name: ingress-nginx-monitornamespace: ingress-nginxspec:selector:matchLabels:app: ingress-nginx-exporterendpoints:- interval: 10sport: exporter
测试环境配置是否正确
上述环境安装配置完成后,我们需要先检查一下环境配置的正确性。
测试 Nginx Status 接口是否正常
首先,我们随便拉起一个带 shell 和 curl 等工具的 Pod,例如:
apiVersion: v1kind: Podmetadata:name: centosnamespace: ingress-nginxspec:containers:- name: mainimage: centos:latestcommand: ["/bin/sh", "-c", "sleep 100000000"]resources:limits:memory: "512Mi"cpu: "500m"ports:- containerPort: 8080
然后,登入该 Pod main 容器进行连接测试:
$ k exec busybox -n ingress-nginx -it -- /bin/shsh-4.4# curl -L http://ingress-nginx-controller-8080.ingress-nginx.svc.cluster.local:8080/stub_statusActive connections: 6server accepts handled requests12092 12092 23215Reading: 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 中解析得到:
user: adminpassword: prom-operator
登入 Grafana 后,点击左侧导航栏中的 Explore ,在 Metrics browser 中可以看到 Prometheus 采集存储的指标列表,如果我们关注的指标存在,则表示采集成功。
弹性部署
完成上述环境准备就绪,并确认一切正常后,接下来便可以部署应用以及弹性组件。
应用部署
以 Hello-Web 应用为例,访问该应用会返回一个简单的 html 页面,内容类似如下:
Hello WebCurrent Backend Server InfoServer Name: hello-web-57b767f456-bnw24Server IP: 47.89.252.93Server Port: 80Current Client Request InfoRequest Time Float: 1640766227.537Client IP: 10.64.0.65Client Port: 52230User 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.36Request Method: GETThank you for using PHP.Request URI: /
使用 CloneSet 将其进行部署:
apiVersion: apps.kruise.io/v1alpha1kind: CloneSetmetadata:name: hello-webnamespace: ingress-nginxlabels:app: hello-webspec:replicas: 1selector:matchLabels:app: hello-webtemplate:metadata:labels:app: hello-webspec:containers:- name: hello-webimage: zhangsean/hello-webports:- containerPort: 80resources:requests:cpu: "1"memory: "256Mi"limits:cpu: "2"memory: "512Mi"---kind: ServiceapiVersion: v1metadata:name: hello-webnamespace: ingress-nginxspec:type: ClusterIPselector:app: hello-webports:- protocol: TCPport: 80targetPort: 80---apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: ingress-webnamespace: ingress-nginxspec:rules:- http:paths:- path: /pathType: Prefixbackend:service:name: hello-webport:number: 80ingressClassName: nginx
部署 WorkloadSpread
apiVersion: apps.kruise.io/v1alpha1kind: WorkloadSpreadmetadata:name: workloadspread-samplenamespace: ingress-nginxspec:targetRef:apiVersion: apps.kruise.io/v1alpha1kind: CloneSetname: ingress-nginx-controllerscheduleStrategy:type: Adaptiveadaptive:rescheduleCriticalSeconds: 2subsets:- name: fixed-resource-poolrequiredNodeSelectorTerm:matchExpressions:- key: typeoperator: NotInvalues:- virtual-kubeletpatch:metadata:labels:resource-pool: fixed- name: elastic-resource-poolrequiredNodeSelectorTerm:matchExpressions:- key: typeoperator: Invalues:- virtual-kubelettolerations:- effect: NoSchedulekey: virtual-kubelet.io/provideroperator: Existspatch:metadata:labels: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
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata:name: ingress-nginx-scaledobjectnamespace: ingress-nginxspec:maxReplicaCount: 10minReplicaCount: 1pollingInterval: 10cooldownPeriod: 2advanced:horizontalPodAutoscalerConfig:behavior:scaleDown:stabilizationWindowSeconds: 10scaleTargetRef:apiVersion: apps.kruise.io/v1alpha1kind: CloneSetname: hello-webtriggers:- type: prometheusmetadata:serverAddress: http://kube-prometheus-stack-1640-prometheus.prometheus:9090/metricName: nginx_http_requests_totalquery: sum(rate(nginx_http_requests_total{job="ingress-nginx-exporter"}[12s]))threshold: '100'
效果展示
首先,检查一下配置是否都已经下发:

然后,使用 go-stress-testing 压测工具对上述应用进行压测。
当第一波流量到来,可以看到应用正在自动扩容,并且扩容到固定资源池:

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

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