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:
- 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.
- 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.
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:
- 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
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:
- 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
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.
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".
- 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 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:
Tell us what you think.