11 minute read

En mi opinión, una de las mayores ventajas de tener nuestra arquitectura desplegada sobre un orquestador como Kubernetes, es la capacidad de automatización que nos brinda para múltiples aspectos de nuestra aplicación y su ciclo de vida. En este post quería hablar sobre autoescalado horizontal de pods en Kubernetes, HPA (horizontal pod autoscaling) de ahora en adelante.

El HPA de Kubernetes nos permite variar el número de pods desplegados mediante un replication controller o un deployment en función de diferentes métricas. Estas métricas se obtienen a día de hoy de dos fuentes:

  1. El API de resource metrics, que nos da sin necesidad de configurar nada el consumo de memoria y CPU de los pods. Un ejemplo de qué nos ofrece este API lo podríamos tener con la ejecución del siguiente comando:
kubectl get --raw /apis/metrics.k8s.io/v1beta1/pods | 
  jq -r '
    .items[] |
    select(.metadata.name=="my-kafka-0") |
    .containers[].usage
  '

(Si aún no conocéis jq, os recomiendo que lo instaléis y probéis).

Siempre y cuando sustituyamos el nombre del pod my-kafka-0 por uno que esté desplegado en nuestro cluster y estemos seguros de estar usando el api metrics.k8s.io/v1beta1 en nuestro cluster, cosa que podemos comprobar con: kubectl api-versions

  1. El API de custom metrics.

Básicamente, por debajo lo que hay es un bucle de control que comprueba de forma periódica el valor de la métrica configurada en el HPA. La duración del periodo de evaluación de la métrica se configura a nivel del controller de Kubernetes y se hace mediante el flag --horizontal-pod-autoscaler-sync-period que tiene un valor por defecto de 15 segundos. Al ser un parámetro de la configuración del controller de Kubernetes, es probable que usando una solución de Kubernetes gestionada como GKE, EKS o AKS, no podamos modificarlo.

En el caso del autoescalado usando métricas del API de resource metrics, para que el HPA pueda funcionar, es necesario que los pods (en la definición en el deployment o el replication controller) tengan configurado un request del recurso que vamos a tener en cuenta para el autoescalado. Configurando simplemente un limit para el recurso también valdría ya que limit configura un request por defecto de su mismo valor, en caso de no haber una definición explícita del request.

A la hora de configurar un HPA sobre un replication controller vamos a tener en cuenta varias cosas:

  • De qué API vamos a sacar las métricas del recurso que usaremos para escalar en función de su valor.
  • Qué API de autoescalado estamos usando.

Escalado en función del API de resource metrics.

Antes de nada, comprobaremos la versión del API de autoescalado estamos usando, ya que dependiendo de la versión la sintaxis variará o no:

kubectl api-versions

Si usamos un YAML apuntando a una versión del API no soportada, o usando una configuración válida para una versión diferente del API, podemos obtener los siguientes mensajes de error:

  • error: the server doesn't have a resource type "hpa"
  • error: unable to recognize "hpa.yaml": no matches for kind "HorizontalPodAutoscaler" in version "autoscaling/v2beta2"

Así podemos ver que si la versión de Kubernetes que estamos usando es la 1.13, el API de autoscaling que se usa por defecto es el autoscaling/v2beta2 y que un ejemplo de YAML para configurar nuestro HPA sería:

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: resource-consumer
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: resource-consumer
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 50

Como podemos ver en el API el struct relacionado con resource, referenciado en el struct del spec de métricas espera dos campos: name y target:

type ResourceMetricSource struct {
    // name is the name of the resource in question.
    Name v1.ResourceName `json:"name" protobuf:"bytes,1,name=name"`
    // target specifies the target value for the given metric
    Target MetricTarget `json:"target" protobuf:"bytes,2,name=target"`
}

Sin embargo si estamos usando Kubernetes en su versión 1.11, como es mi caso al estar haciendo estas pruebas sobre GKE, la versión de API que usaremos es autoscaling/v2beta1 y el YAML que usaremos para generar nuestro HPA será como este:

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: resource-consumer
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: resource-consumer
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: memory
      targetAverageUtilization: 50

Y si vemos la definición en la documentación del API, efectivamente es directamente en el struct relacionado con resource del que hablábamos antes donde se especifica el tipo de métrica y de valor que vamos a usar en el HPA:

type ResourceMetricSource struct {
    // name is the name of the resource in question.
    Name v1.ResourceName `json:"name" protobuf:"bytes,1,name=name"`
    // targetAverageUtilization is the target value of the average of the
    // resource metric across all relevant pods, represented as a percentage of
    // the requested value of the resource for the pods.
    // +optional
    TargetAverageUtilization *int32 `json:"targetAverageUtilization,omitempty" protobuf:"varint,2,opt,name=targetAverageUtilization"`
    // targetAverageValue is the target value of the average of the
    // resource metric across all relevant pods, as a raw value (instead of as
    // a percentage of the request), similar to the "pods" metric source type.
    // +optional
    TargetAverageValue *resource.Quantity `json:"targetAverageValue,omitempty" protobuf:"bytes,3,opt,name=targetAverageValue"`
}

Además, nos familiarizaremos con el uso de un pod/herramienta muy útil para estos menesteres: resource-consumer. Esta herramienta expone en el puerto 8080 por defecto una serie de endpoints con los que podemos generar la carga de CPU, memoria, o una métrica fake de estilo prometheus que queramos. De forma que ejecutando:

kubectl run resource-consumer --image=gcr.io/kubernetes-e2e-test-images/resource-consumer:1.5 --expose --service-overrides='{ "spec": { "type": "LoadBalancer" } }' --port 8080 --requests='cpu=500m,memory=256Mi'

Generaremos un deployment de la imagen de resource-consumer haciendo un request de 500 milicores y 256Mi (recordad que para el autoescalado se comparan los valores de las métricas actuales con los valores del request).

Además, para este post guardaremos el ejemplo de YAML de la versión de API autoscaling/v2beta1 en un archivo, hpa-cpu.yaml por ejemplo, y ejecutaremos kubectl create -f hpa-cpu.yaml para generar un HPA sobre el deployment recién creado, monitorizando la CPU.

Con kubectl get services resource-consumer al cabo de unos minutos podremos ver qué external IP se ha asignado a nuestro servicio y podremos lanzar las peticiones que queramos para forzar un consumo objetivo de la siguiente forma:

curl --data "millicores=300&durationSec=600" http://<EXTERNAL-IP>:8080/ConsumeCPU

Si lanzamos esa petición, al haber configurado un HPA con una utilización media objetivo del 50% y al haber generado un deployment haciendo un request de 500 milicores, cuando la aplicación empiece a generar los 300 milicores de carga, el sistema tendrá que escalar a dos pods para llegar a una carga media por debajo del 50%:

NAME                REFERENCE                      TARGETS          MINPODS   MAXPODS   REPLICAS   AGE
resource-consumer   Deployment/resource-consumer   <unknown>/50%    1         10        0          19s

resource-consumer   Deployment/resource-consumer   0%/50%    1         10        1         31s
resource-consumer   Deployment/resource-consumer   15%/50%   1         10        1         1m
resource-consumer   Deployment/resource-consumer   59%/50%   1         10        1         2m
resource-consumer   Deployment/resource-consumer   29%/50%   1         10        2         4m
resource-consumer   Deployment/resource-consumer   30%/50%   1         10        2         10m
resource-consumer   Deployment/resource-consumer   26%/50%   1         10        2         11m
resource-consumer   Deployment/resource-consumer   0%/50%    1         10        2         13m
resource-consumer   Deployment/resource-consumer   0%/50%    1         10        1         13m

Ahí vemos como nada más crear el HPA no se tiene una referencia de la carga de CPU del deployment. A los 30 segundos se toma la primera medida de la CPU del deployment (0%/50%). A los casi 2 minutos hacemos la petición para que la aplicación pase a consumir 300 milicores, llegando al 59% de carga. A los 4 minutos el deployment ya ha escalado teniendo dos pods, con lo que la carga media ahora es del 30%. A los casi 12 minutos la aplicación comienza a reducir la carga, llegando a 0 a los 13 minutos, que es cuando se produce el desescalado.

Si quisiéramos escalar nuestro deployment en función de la memoria procederíamos de forma similar aunque no puedo evitar recomendar NO hacer autoescalado en función de la memoria por varios motivos:

  • Hay lenguajes (java, te miro a ti) que habitualmente hacen un uso bastante estable de la memoria independientemente de la carga que tienen.
  • Las aplicaciones no suelen liberar la memoria rápidamente tras un descenso de la carga y esto puede generar problemas. Esto requiere una explicación un poco más exhaustiva:

La implementación de HPA está basada en un fórmula simple: \(número\_de\_replicas\_deseadas = uso\_total / uso\_objetivo\) Imaginemos que tenemos 4 pods usando un 50% de su requested CPU y su uso objetivo es del 40%. En este caso el hpa calculará: \(4 pods * 50\%\ uso = 200\%\ uso\_total => 200\%\ uso\_total / 40\%\ uso\_objetivo = 5\ réplicas\_deseadas\) Con lo que el HPA añadirá un nuevo pod asumiendo que el uso de CPU del resto de pods caerá rápidamente al 40% al distribuirse la carga entre todos los pods, incluyendo al nuevo. Esta asunción es totalmente razonable con CPU. Con la memoria, sin embargo, es muy probable acabar en una situación donde el nuevo pod efectivamente tendrá una carga del 40%, pero los pods existentes mantendrán su consumo de memoria al 50% durante un periodo de tiempo prolongado con lo que el hpa volverá a hacer el ćalculo descrito anteriormente y escalará una y otra vez. En realidad la forma de escalar del HPA es más compleja que la fórmula que he puesto, aunque sirve para hacernos a la idea y es probable que haya aplicaciones y escenarios que realmente necesiten escalar en función del consumo de memoria, pero en general desaconsejo esta idea.

Escalado en función del API de custom metrics.

Como siempre, antes de empezar:

helm repo update

Instalamos el operador de Prometheus:

helm install -f values_prometheus.yml --namespace monitoring --name prom-operator stable/prometheus-operator

Para poder acceder fácilmente a la GUI de gestión de Prometheus (siempre y cuando estemos en una nube que permita los servicios tipo LoadBalancer):

kubectl expose svc prom-operator-prometheus-o-prometheus -n monitoring --type LoadBalancer --name prom-expose --port 80 --target-port 9090

El archivo de values_prometheus.yml que usamos es el de este gist, que es el de los valores por defecto salvo por:

    ruleNamespaceSelector:
      matchNames:
      - kube-system
      - default
      - monitoring

Que le dice a prometheus en qué namespaces tiene que buscar los servicemonitors. Un servicemonitor detalla de forma declarativa cómo deben ser monitorizados los servicios, si no tuviéramos un servicemonitor o estuviera mal configurado, prometheus no podría hacer el autodescubrimiento de lo que queramos monitorizar, de esta forma en la consola gráfica de prometheus, en Status > Service Discovery, solo encontraríamos los servicios desplegados durante la instalación de prometheus que acabamos de hacer. El que usaremos para el ejemplo y que desplegaremos en el namespace default (el mismo donde desplegaremos el resource-consumer) es el siguiente:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
    name: resource-consumer-sm
    labels:
        run: resource-consumer
        release: prom-operator
spec:
    selector:
        matchLabels:
            run: resource-consumer
    namespaceSelector:
        any: true
    endpoints:
    - port: rm-port
      interval: 10s
      honorLabels: true

De aquí hay que destacar varias cosas:

  • Tiene que tener la etiqueta que busca prometheus dentro de los namespaces que hemos configurado. Con un kubectl get -n monitoring prometheus -o yaml: ruleSelector: matchLabels: app: prometheus-operator release: prom-operator podemos ver que buscará cualquier servicemonitor con la etiqueta “app: prometheus-operator” y/o “release: prom-operator”
  • Este servicemonitor buscará servicios con la etiqueta “run=resource-consumer”
  • En concreto monitorizará el puerto con nombre “rm-port” de los servicios que cumplan con la etiqueta de búsqueda.

Una vez tenemos prometheus desplegado en nuestro cluster (podemos comprobarlo con kubectl get po -n monitoring) podemos desplegar el deploy y el servicio que usaremos para hacer nuestras pruebas de carga y autoescalado, al igual que hemos hecho en el apartado anterior:

kubectl run resource-consumer --image=gcr.io/kubernetes-e2e-test-images/resource-consumer:1.5 --expose --service-overrides='{ "spec": { "type": "LoadBalancer" } }' --port 8080 --requests='cpu=500m,memory=256Mi'

Tal y como hicimos antes, con kubectl get svc podemos ver la ip en la que se expone nuestro resource-consumer y con

curl --data "metric=myawesomemetric&delta=300&durationSec=1600" http://{IP_DEL_SVC}:8080/BumpMetric

haremos que muestre métricas (myawesomemetric en este caso) en formato prometheus en /metrics.

Ahora que estamos exponiendo algo en /metrics, ya deberíamos poder verlo en la página principal de prometheus. Solo queda exponer esta información que llega a Prometheus en el API de custom-metrics de Kubernetes, para que pueda ser consumido por el HPA. Para esto usaremos un adaptador de Prometheus, al que te tendremos que decir dónde puede encontrar el servicio de Prometheus del que sacar las métricas. En el caso de este ejemplo lo haremos con:

helm install stable/prometheus-adapter --namespace monitoring --set prometheus.url=http://prom-operator-prometheus-o-prometheus

Con esto ya estamos listos, solo queda comprobar que efectivamente se están volcando datos en el api de custom metrics:

kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq .

Y podemos pasar a configurar un HPA para nuestro deploy:

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: resource-consumer
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: resource-consumer
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Pods
    pods:
      metricName: myawesomemetric
      targetAverageValue: 100

Recordad que estamos sobre Kubernetes 1.11, con otras versiones podríamos necesitar cambiar el apiVersion. Podemos ver qué APIs tenemos activas en el cluster y con qué versiones con kubectl api-versions

Podemos ver cómo escala de una a tres réplicas para cumplir con que la media de “myawesomemetrics” en mis pods sea de 100:

(tests-hpa)$ kubectl get hpa -w
NAME                REFERENCE                      TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
resource-consumer   Deployment/resource-consumer   300/100   1         10        3         1m
resource-consumer   Deployment/resource-consumer   300/100   1         10        3         2m
resource-consumer   Deployment/resource-consumer   300/100   1         10        3         3m
(tests-hpa)$ kubectl get po
NAME                                 READY     STATUS    RESTARTS   AGE
resource-consumer-757b8f9f5f-g2gmc   1/1       Running   0          3m
resource-consumer-757b8f9f5f-mdgg9   1/1       Running   0          17h
resource-consumer-757b8f9f5f-nw2p5   1/1       Running   0          3m

Es complicado acordarse de dónde viene toda la información vertida en este post, pero sin duda me he apoyado mucho en:
https://prometheus.io/ https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/ https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale-walkthrough/ https://github.com/DirectXMan12/k8s-prometheus-adapter/blob/master/docs/walkthrough.md https://github.com/DirectXMan12/k8s-prometheus-adapter/blob/master/docs/config.md