Kubernetes: jugando con node pools, cuando el escalamiento horizontal no basta
Hoy quiero compartir una solución aplicada en GKE (Google Kubernetes Engine). El objetivo era deployar en GKE una receta del stack ELK (Elastic, Logstash y Kibana) en el cluster mismo de GKE.
Resulta que nuestro cluster de kubernetes estaba funcionando sin problemas, con varios pods de artefactos corriendo:
El concepto de Pod es fundamental en Kubernetes, es la mímina unidad funcional, que encapsula al contenedor que contenrá la aplicación que queremos correr.
Las configuraciones de los nodos eran las por defecto, usando un pool de nodos standard, con 4 nodos y el tier más básico de características (1 CPU standar y 3.75 GB de memoria):
En términos generales, un Nodo representa una máquina (en alguna parte del mundo) con recursos previamente asigndos, tanto de CPU como memoria RAM.
Así que, para los que pensaban que trabajar en GCP (o en cualquier otra plataforma cloud) era trabajar literalmente en los aires, les tengo una mala noticia:
Debo reconocer que me encanta ese meme :). Bueno, sigamos… el tema es que según la documentación, la “receta” de los pod a deployar tenían una definición de recursos bastante alta (1 CPU y 4GB de memoria RAM):
Para hacerse una idea, un pod “normal” por defecto ocupa en general menos de un CPU, por ejemplo 100mCPU.
Pero, ¿qué signica que ocupe 100mCPU (100 mili CPU)? Significa que el pod ocupará sólo un décimo de CPU, ya que 1000mCPU equivalen a 1 CPU.
Entonces, la solución obvia fue escalar horizontalmente los nodos, es decir, aumentar el número de nodos y reservarlos para nuestros “pesados” pods de ELK.
Además, se bajó el requerimiento original de memoria que tenía la definición de la receta de 4GB a 3.5 GB (sólo basta con editar el archivo .yml del deployment) de tal forma de no sobrepasar el recurso asignado pues estos vienen con 3.75GB de memoria RAM por defecto como pudiste apreciar en la imagen de más arriba. Veamos como se hace eso.
Cada cluster de GKE tiene asociado por defecto un “node pool”, que como su nombre lo dice es un pool (agrupación) de nodos.
Al editar el node pool, se puede habilitar el auto-escalado (aunque también podríamos agregar nodos manualmente), definiendo el número de nodos mínimos y máximos que tendrá el node pool, de acuerdo a la demanda de recursos:
En el detalle del node pool, se puede consultar sobre la actual demanda de recursos de nuestros nodos que está siendo requerida respecto a los recursos asignados originalmente:
Recordemos que 256m de CPU requeridos equivalen a más o menos un cuarto de CPU (1 CPU = 1000 mCPU). Así podemos ver la carga de uso tanto de CPU como de memoria de cada nodo.
Al escalar horizontalmente se asignaron 3 nuevos nodos, pero nuevamente los pods no levantaron por un tema de recursos.
¿Porqué pasó esto? ¿Acaso no debiera haber funcionado con mis nuevos nodos?
La respuesta es que al crear un nodo nuevo, este por defecto usa recursos para pods propios del entorno de GKE, es decir, ocupan recursos de entrada. Acá podemos ver los pods que se crean de forma automática en el namespace “kube-system”:
Entonces ahora se presenta el gran problema:
Mi nodo que tiene asignado sólo un CPU, y ya está ocupando recursos de entrada, por lo que actualmente tengo menos de 1 CPU disponible para usar, y según mi receta necesito al menos 1 CPU.
El escalamiento horizontal (aumentar el número de nodos) no fue la solución acertada para satisfacer los recursos requeridos por la receta.
Ahora la solución obvia sería aplicar un escalamiento vertical a los nodos (es decir, dar más CPU y memoria a cada nodo), pero de entrada descarté esa idea, ya que cada vez que los recursos sean insuficientes, se tendrá que escalar nuevamente, dejando de lado algo de las buenas prácticas (salvo ciertas excepciones). Ahí fue cuando pensé:
¿Y qué pasa si hago un nuevo node pool independiente? De esta forma, los nodos tendrán los recursos suficientes y podrán escalar horizontalmente cuando se requiera, de acuerdo a la propia necesidad y funcionamiento del ELK.
Entonces rápidamente cree un node pool nuevecito de paquete, en el cual cada uno de los nodos sería del segundo tier, cada uno tendría al menos 2 CPU y 4GB de memoria:
Ahora…. ¿Cómo hago para que los pods se deployen en mi nuevo node pool y no en otro ya existente?
Bueno, aquí entra un concepto muy importante en Kubernetes que tienes que manejar sí o sí: el concepto de selector-label. Este concepto se usa mucho en Kubernetes, como por ejemplo cuando mapeamos un servicio con los respectivos pods (balanceo de carga definiendo “endpoints”).
En el archivo .yml del deployment definí el corpontamiento que esperaba del pod cuando este se levante a través de la anotación “nodeSelector”:
En palabras simples, le estoy diciendo a mi deployment que seleccione el nodo cuyo label (etiqueta) sea “pool: elk-pool”.
Perfecto, ahora sólo necesito etiquetar mis nuevos nodos con un label para que el nodeSelector de mi deployment lo seleccione como node pool por defecto.
Dejame darte buenas noticias, NO necesitas etiquetar cada nodo (imagina tuvieras 100 nodos… ). Cuando tu creas un node pool, en el apartado de la metadata (la metadata en Kubernetes es otro concepto fundamental) puedes asignarle un label (llave, valor), lo que implica que todos los nodos de ese node pool tendrán ese label (estarán etiquetados), por lo que según nuestra estrategia, serán los nodos en los cuales se deployarán nuestros “pesados” pods del stack ELK:
Nota: todo esto se puede hacer a través de la línea de comandos, por ejemplo para asignar un label a un nodo sería algo como esto:
kubectl label nodes <node-name> <label-key>=<label-value>
Listo, llegó la hora de probar nuestra solución, hacemos el deploy de nuestra receta, que incluye nuestra alta asignación de recursos y nuestro nodeSelector:
kubectl apply -f …….yml
Lo hemos logrado, nuestros pods de ELK están corriendo en un node pool independiente, y sin problemas de recursos, más aún, con su respectiva configuración de autoescalado horizontal de acuerdo a las propias necesidades del ELK (no vaya a ser que nos quedemos cortos de recursos nuevamente):
Para finalizar, les dejo la documentación oficial, en donde pueden ver más detalles de como funciona la asignación de pods a un nodo, el uso de nodeSelector que es la forma más simple, pero también pueden profundizar aún más a través del concepto de Affinity and anti-affinity, la cual es una forma más sofisticada de realizar la asignación: