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.

A día de hoy, Kubernetes se ha convertido en el estándar de facto en cuanto a la orquestación de contenedores y nos facilita cosas como el autoescalado, autodiscovery, self-healing de nuestras aplicaciones.

También nos da la capacidad de desplegar estas aplicaciones con diferentes estrategias, configurar diferentes parámetros en cuanto al tráfico de red e incluso configurar políticas de seguridad, tanto a nivel de pod, como a nivel de roles de acceso al cluster. Estas son algunas de las muchas utilidades que nos aporta el uso de Kubernetes.

En este post profundizaremos un poco más en el universo de Kubernetes hablando de autoescalado horizontal de pods (también conocido como horizontal pod autoscaling, HPA).

¡Arrancamos!

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:

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.

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.

También nos podría valer para el recurso configurar únicamente un limit, ya que esto configuraría un request por defecto con el mismo valor que el limit, en caso de que no se especificase ningún request.

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

Escalado en función del API de resource metrics

Primero, comprobaremos la versión del API de autoescalado que 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 1:

error: the server doesn't have a resource type "hpa"

Error 2:

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 en este 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, 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. Para ejecutarlo:

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. Este es el recorrido que hace:

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:

La implementación de HPA está basada en un fórmula simple:

número_de_replicas_deseadas = uso_total/uso_objetivo

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 este 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".

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 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 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, podéis apoyaros en la bibliografía que os dejo a continuación:

Cuéntanos qué te parece.

Enviar.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.