基于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 node
NAME                         STATUS   ROLES    AGE    VERSION
us-west-1.192.168.0.47       Ready    <none>   153d   v1.20.11-aliyun.1
us-west-1.192.168.0.48       Ready    <none>   153d   v1.20.11-aliyun.1
us-west-1.192.168.0.49       Ready    <none>   153d   v1.20.11-aliyun.1
virtual-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: v1
data:
  allow-snippet-annotations: "true"
  http-snippet: |
    server {
      listen 8080;
      server_name _ ;
      location /stub_status {
        stub_status on;
      }
      location / {
        return 404;
      }
    }
kind: ConfigMap
metadata:
  annotations:
    meta.helm.sh/release-name: ingress-nginx
    meta.helm.sh/release-namespace: ingress-nginx
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/version: 1.1.0
    helm.sh/chart: ingress-nginx-4.0.13
  name: ingress-nginx-controller
  namespace: ingress-nginx
准备一个 values.yaml 文件,以便在部署 Ingress-Nginx-Controller Deployment 时将 8080 端口暴露出来:
# values.yaml
controller:
  containerPort:
    http: 80
    https: 443
    status: 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: Service
apiVersion: v1
metadata:
  name: ingress-nginx-controller-8080
  namespace: ingress-nginx
spec:
  selector:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
  type:  ClusterIP
  ports:
  - name: myapp
    port:  8080
    targetPort: status
安装 Nginx-Prometheus-Exporter
nginx 暴露出的 Status 数据并未遵循 Prometheus 的格式标准,因此需要一个 Exporter 组件进行数据采集和格式转换,此处采用 Nginx 官方提供的 Nginx-Prometheus-Exporter:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ingress-nginx-exporter
  namespace: ingress-nginx
  labels:
    app: ingress-nginx-exporter
spec:
  selector:
    matchLabels:
      app: ingress-nginx-exporter
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: ingress-nginx-exporter
    spec:
      containers:
      - image: nginx/nginx-prometheus-exporter:0.10
        imagePullPolicy: IfNotPresent
        args:
        - -nginx.scrape-uri=http://ingress-nginx-controller-8080.ingress-nginx.svc.cluster.local:8080/stub_status
        name: main
        ports:
        - name: http
          containerPort: 9113
          protocol: TCP
        resources:
          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/v1
kind: ServiceMonitor
metadata:
  labels:
    release: kube-prometheus-stack-1640678515
  name: ingress-nginx-monitor
  namespace: ingress-nginx
spec:
  selector:
    matchLabels:
      app: ingress-nginx-exporter
  endpoints:
  - interval: 10s
    port: exporter
测试环境配置是否正确
上述环境安装配置完成后,我们需要先检查一下环境配置的正确性。
测试 Nginx Status 接口是否正常
首先,我们随便拉起一个带 shell 和 curl 等工具的 Pod,例如:
apiVersion: v1
kind: Pod
metadata:
  name: centos
  namespace: ingress-nginx
spec:
  containers:
  - name: main
    image: centos:latest
    command: ["/bin/sh", "-c", "sleep 100000000"]
    resources:
      limits:
        memory: "512Mi"
        cpu: "500m"
    ports:
      - containerPort: 8080
然后,登入该 Pod main 容器进行连接测试:
$ k exec busybox -n ingress-nginx -it -- /bin/sh
sh-4.4# curl -L http://ingress-nginx-controller-8080.ingress-nginx.svc.cluster.local:8080/stub_status
Active connections: 6
server accepts handled requests
 12092 12092 23215
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 中解析得到:
user: admin
password: prom-operator
登入 Grafana 后,点击左侧导航栏中的 Explore ,在 Metrics browser 中可以看到 Prometheus 采集存储的指标列表,如果我们关注的指标存在,则表示采集成功。
弹性部署
完成上述环境准备就绪,并确认一切正常后,接下来便可以部署应用以及弹性组件。
应用部署
以 Hello-Web 应用为例,访问该应用会返回一个简单的 html 页面,内容类似如下:
Hello Web
Current Backend Server Info
Server Name: hello-web-57b767f456-bnw24
Server IP: 47.89.252.93
Server Port: 80
Current Client Request Info
Request Time Float: 1640766227.537
Client IP: 10.64.0.65
Client Port: 52230
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
Request Method: GET
Thank you for using PHP.
Request URI: /
使用 CloneSet 将其进行部署:
apiVersion: apps.kruise.io/v1alpha1
kind: CloneSet
metadata:
  name: hello-web
  namespace: ingress-nginx
  labels:
    app: hello-web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-web
  template:
    metadata:
      labels:
        app: hello-web
    spec:
      containers:
      - name: hello-web
        image: zhangsean/hello-web
        ports:
        - containerPort: 80
        resources:
          requests:
            cpu: "1"
            memory: "256Mi"
          limits:
            cpu: "2"
            memory: "512Mi"
---
kind: Service
apiVersion: v1
metadata:
  name: hello-web
  namespace: ingress-nginx
spec:
  type: ClusterIP
  selector:
    app: hello-web
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-web
  namespace: ingress-nginx
spec:
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: hello-web
            port:
              number: 80
  ingressClassName: nginx
部署 WorkloadSpread
apiVersion: apps.kruise.io/v1alpha1
kind: WorkloadSpread
metadata:
  name: workloadspread-sample
  namespace: ingress-nginx
spec:
  targetRef:
    apiVersion: apps.kruise.io/v1alpha1
    kind: CloneSet
    name: ingress-nginx-controller
  scheduleStrategy:
    type: Adaptive
    adaptive:
      rescheduleCriticalSeconds: 2
  subsets:
  - name: fixed-resource-pool
    requiredNodeSelectorTerm:
      matchExpressions:
        - key: type
          operator: NotIn
          values:
          - virtual-kubelet
    patch:
      metadata:
        labels:
          resource-pool: fixed
  - name: elastic-resource-pool
    requiredNodeSelectorTerm:
      matchExpressions:
        - key: type
          operator: In
          values:
          - virtual-kubelet
    tolerations:
    - effect: NoSchedule
      key: virtual-kubelet.io/provider
      operator: Exists
    patch:
      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/v1alpha1
kind: ScaledObject
metadata:
  name: ingress-nginx-scaledobject
  namespace: ingress-nginx
spec:
  maxReplicaCount: 10
  minReplicaCount: 1
  pollingInterval: 10
  cooldownPeriod:  2
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleDown:
          stabilizationWindowSeconds: 10
  scaleTargetRef:
    apiVersion: apps.kruise.io/v1alpha1
    kind: CloneSet
    name: hello-web
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://kube-prometheus-stack-1640-prometheus.prometheus:9090/
      metricName: nginx_http_requests_total
      query: sum(rate(nginx_http_requests_total{job="ingress-nginx-exporter"}[12s]))
      threshold: '100'
效果展示
首先,检查一下配置是否都已经下发:

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

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

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