Administrar los recursos para tus contenedores en Kubernetes

Cuando empiezas a trabajar en serio con Kubernetes, necesitas poner orden a cuánto y cómo se aprovecha tu clúster. Sobre todo si el mismo se comparte entre diferentes aplicaciones y/o servicios, donde tienes que evitar que “unos se coman a otros”, en cuanto a recursos se refiere 🙂 En este artículo quiero hablarte de cómo Kubernetes permite establecer cuántos recursos necesita un pod, límites, valores por defectos y cuotas a través de diferentes objetos.

Requests

Cuando creas un pod es posible, y más que recomendable, establecer la cantidad de recursos mínimos que necesita para funcionar. La sección requests, o peticiones, dentro del apartado resources define por cada contenedor dentro de un pod cuánta memoria y cuánta CPU va a necesitar como mínimo para poder ejecutarse:

apiVersion: v1
kind: Pod
metadata:
  name: pod-requests
spec:
  containers:
  - name: web
    image: nginx
    resources:
        requests:
          cpu: "500m"
          memory: "10Mi"

En este caso se han establecido de CPU 500m y de memoria 10Mi. Quizás la unidad en la que se define la CPU a utilizar te parezca extraña porque se mide en unidades de CPU, esto es: una CPU en Kubernetes equivale a un core virtual. Las peticiones pueden ser fraccionadas, es decir que puedes especificar menos de una CPU, como es el caso. En el ejemplo se establece que necesitas 0.5, o 500m, lo cual se lee como “quinietas milicpu”, o lo que es lo mismo: la mitad de una CPU.
Por otro lado, para la memoria la unidad de medida son los bytes, por lo que ya es más familiar. Sin embargo, en el ejemplo he utilizado la cantidad 10Mi, la cual no es lo mismo que 10MB, y creo que también es importante que lo tengas en cuenta. 10Mi se lee como “diez Mebibytes” y son 10.48576MB. Más información aquí.

Cómo saber cuántos recursos tienen tus nodos

Si estás trabajando con un Kubernetes gestionado en la nube puedes saber qué características has elegido para tus nodos a través del portal del proveedor. Sin embargo, la forma de saber la capacidad exacta de los mismos es a través de describe:

kubectl describe nodes

Entre toda la información que devuelve, verás que existen dos secciones llamadas Capacity y Allocatable:

Capacity:
  attachable-volumes-azure-disk:  8
  cpu:                            2
  ephemeral-storage:              101445900Ki
  hugepages-1Gi:                  0
  hugepages-2Mi:                  0
  memory:                         7113828Ki
  pods:                           110
Allocatable:
  attachable-volumes-azure-disk:  8
  cpu:                            1900m
  ephemeral-storage:              93492541286
  hugepages-1Gi:                  0
  hugepages-2Mi:                  0
  memory:                         4668516Ki
  pods:                           110

En el caso de mi nodo puedes ver que tiene 2 CPUs y 7113828Ki (7284.55MB ó 7.2GB) de memoria en el apartado Capacity. Esta sección representa la cantidad total de recursos que tiene un nodo, lo cual no significa que todo esté disponible. Estos es así porque, evidentemente, el nodo tiene otros recursos del sistema que también consumen recursos.
Es por ello que existe otra sección llamada Allocatable donde puedes ver el espacio real que queda disponible para tus pods. El Scheduler, que es la pieza encargada dentro de Kubernetes en decidir dónde irán los recursos que queremos desplegar, utiliza este apartado, entre otras cosas, como parte de su decisión para valorar si nuestro nuevo recurso tiene cabida en un nodo u otro. Para saber cuántos pods caben dentro de un nodo, el Scheduler sumará todas las requests que se han definido y comprobará si no sobrepasan los valores de esta sección. De hecho, puede ocurrir que queramos desplegar un pod como el anterior, solicitando 1 CPU, y que no tengamos nodos con espacio suficiente y se quede en estado Pending. Si esto ocurre, podrás ver a través del comando describe, sobre el pod, que el problema viene de que el Scheduler no ha encontrado un nodo donde colocarlo:

Events:
  Type     Reason            Age                 From               Message
  ----     ------            ----                ----               -------
  Warning  FailedScheduling  51s (x10 over 11m)  default-scheduler  0/1 nodes are available: 1 Insufficient cpu.

Debemos evitar esta situación en la medida de lo posible.

También puede ser útil saber cómo están tus nodos gastando sus recursos. Con el mismo comando describe, puedes ver también una sección llamada Non-terminated Pods donde se ve el número total de pods en dicho nodo, así como la cantidad de recursos que tienen solicitados cada uno:

Non-terminated Pods:          (18 in total)
  Namespace                   Name                                     CPU Requests  CPU Limits  Memory Requests  Memory Limits  AGE
  ---------                   ----                                     ------------  ----------  ---------------  -------------  ---
  bookstore                   books-api-58cbc86667-g8nzk               0 (0%)        0 (0%)      0 (0%)           0 (0%)         11h
  bookstore                   books-api-58cbc86667-nnjhv               0 (0%)        0 (0%)      0 (0%)           0 (0%)         11h
  bookstore                   books-api-58cbc86667-rlncv               0 (0%)        0 (0%)      0 (0%)           0 (0%)         11h
  default                     booksapi-db4858f68-9gnhf                 0 (0%)        0 (0%)      0 (0%)           0 (0%)         23h
  default                     pod-requests                             500m (26%)    0 (0%)      10Mi (0%)        0 (0%)         15m
  kings-landing               mongo-express-57cc9f88f8-b977n           0 (0%)        0 (0%)      0 (0%)           0 (0%)         23h
  kings-landing               mongo-express-57cc9f88f8-xjxjl           0 (0%)        0 (0%)      0 (0%)           0 (0%)         23h
  kings-landing               nginx-kings-landing                      0 (0%)        0 (0%)      0 (0%)           0 (0%)         7d20h
  kube-system                 coredns-698c77c5d7-bjd88                 100m (5%)     0 (0%)      70Mi (1%)        170Mi (3%)     8d
  kube-system                 coredns-698c77c5d7-ldfrp                 100m (5%)     0 (0%)      70Mi (1%)        170Mi (3%)     8d
  kube-system                 coredns-autoscaler-79b778686c-9jc4s      20m (1%)      0 (0%)      10Mi (0%)        0 (0%)         8d
  kube-system                 kube-proxy-2hjcb                         100m (5%)     0 (0%)      0 (0%)           0 (0%)         39h
  kube-system                 kubernetes-dashboard-74d8c675bc-p7trr    100m (5%)     100m (5%)   50Mi (1%)        500Mi (10%)    8d
  kube-system                 metrics-server-7d654ddc8b-69xbp          44m (2%)      0 (0%)      55Mi (1%)        0 (0%)         39h
  kube-system                 tunnelfront-59bc4f75f5-7mk4h             10m (0%)      0 (0%)      64Mi (1%)        0 (0%)         8d
  the-north-remembers         mongo-0                                  0 (0%)        0 (0%)      0 (0%)           0 (0%)         40h
  the-north-remembers         mongo-1                                  0 (0%)        0 (0%)      0 (0%)           0 (0%)         40h
  the-north-remembers         mongo-2                                  0 (0%)        0 (0%)      0 (0%)           0 (0%)         40h

Limits

Especificar los requests en un pod te asegura de que cada contenedor tiene el mínimo que necesita para funcionar. Sin embargo, también es posible establecer el máximo a través de la sección limits:

apiVersion: v1
kind: Pod
metadata:
  name: pod-requests
spec:
  containers:
  - name: web
    image: nginx
    resources:
        limits:
          cpu: "1"
          memory: "50Mi"        

Estos límites se configuran en el mismo sitio que los requests y tienen el mismo formato. Es importante limitar sobre todo la memoria que un pod puede llegar a utilizar ya que, a diferencia de la CPU, esta es más difícil de liberar. Si no limitamos la memoria, un pod podría adueñarse de toda la que está disponible y afectar así a otros pods en el nodo, e incluso a los nuevos que se instalen, ya que el Scheduler asigna pods en base al espacio disponible teniendo en cuenta la sección Allocatable del nodo, pero no en base a la memoria que se está usando.

Nota: Si solo se especifican los límites, Kubernetes tomará estos también como valor para los requests.

Cuando un proceso dentro de un contenedor intenta usar una cantidad mayor de la memoria que tiene permitida se mata al proceso. De hecho, pasa al estado Terminated y la razón es OOMKilled (OOM proviene de Out Of Memory). Si el pod tiene establecida la política de reinicio en Always o OnFailure el proceso se reiniciará de manera inmediata y es posible que inicialmente no te des cuenta de que esto ha ocurrido. Sin embargo, si sigue necesitando más memoria de la que tiene permitida se reiniciará una y otra vez y el proceso de reinicio será cada vez más lento, ya que Kubernetes establece unos retrasos cuando los reinicios son muy seguidos, hasta llegar a 5 minutos, o lo que es lo mismo: tu contenedor estará 5 minutos sin dar servicio, intentará funcionar de nuevo y, si vuelve a caer, otros 5 minutos más esperando a ser reiniciado y así indefinidamente. Por ello también es importante que los límites no sean demasiado bajos para evitar esta situación.

Por último, es importante tener en cuenta que los recursos que tiene un nodo para alojar pods no son una restricción para establecer los límites. Es decir que la suma de todos los límites establecidos podrían llegar a exceder el 100% de la capacidad del nodo. Esto tiene una consecuencia importante, ya que si el nodo llega a estar al 100% de uso en algún momento ciertos contenedores podrían ser matados/reiniciados para liberar recursos.

Kubernetes mata tus pods cuando se queda sin recursos

Como un nodo puede tener pods que suman más del 100% de su capacidad, en cuanto a límites se refiere, esto significa que en algún momento podría no tener recursos disponibles para todos los pods a la vez. Imagina que tienes dos pods, donde uno de ellos está utilizando más memoria de la que debe y de repente un segundo pod necesita utilizar dicha memoria, pero el nodo no puede proporcionársela. En este caso uno de los dos debe morir ¿Pero cuál? Llegado el caso Kubernetes necesita conocer sus prioridades 🙂 Para ello, categoriza a los pods en tres clases llamadas Quality of Service (QoS):

  • BestEffort: Son aquellos a los cuales no les hemos asignado ni requests ni limits, por los que serán los primeros en morir si fuera necesario.
  • Guaranteed: Son aquellos pods que sus requests son iguales a sus límites. Para estos se garantiza la supervivencia.
  • Burstable: Aquellos entre medias del BestEffort y Guaranteed. Los límites del contenedor no se corresponden con los requests y/o no todos los contenedores dentro del pod tienen establecidos estos valores.

Para verlo con un ejemplo, voy a utilizar esta definición de pod:

apiVersion: v1
kind: Pod
metadata:
  name: pod-best-effort
spec:
  containers:
  - name: web
    image: nginx

En este caso el resultado será BestEffort porque no tiene especificado ni los requests ni los limits. Puedes verlo haciendo un describe del pod, en el campo QoS Class:

Si “queremos” que el pod entre en la categoría Burstable lo conseguiríamos poniendo solo los requests, pero no los limits, o bien poniendo unos límites que no coincidan con los requests.

apiVersion: v1
kind: Pod
metadata:
  name: pod-burstable
spec:
  containers:
  - name: web
    image: nginx
    resources:
        requests:
          cpu: "100m"
          memory: "10Mi"
        # limits:
        #   cpu: "500m"
        #   memory: "20Mi"

Por último, para que un pod sea bueno bueno, y garantizarnos que no estará entre las prioridades de Kubernetes a la hora de liberar espacio, lo mejor es que establezcamos los límites. Como te decía antes, si solamente añadimos estos, igualará los requests a los mismos y tendremos nuestro pod categorizado como Guaranteed.

apiVersion: v1
kind: Pod
metadata:
  name: pod-guaranteed
spec:
  containers:
  - name: web
    image: nginx
    resources:
        limits:
          cpu: "500m"
          memory: "20Mi"

Como te puedes imaginar, es muy importante definir estos valores ya que de ellos dependen la vida de tus pods en un momento crítico, si el sistema se ve comprometido.

Configurar requests y limits por defecto en un namespace

Para obligar a que tu clúster siga esta buena práctica, es posible establecer a nivel de namespace los limits y requests por defecto a través de un objeto llamado LimitRange, así como los mínimos y máximos permitidos. De esta manera evitamos que los usuarios del clúster den de alta recursos sin estos valores:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-requests-and-limits
spec:
  limits:
    - type: Pod
      min:
        cpu: 50m
        memory: 5Mi
      max:
        cpu: "1"
        memory: 1Gi
    - type: Container
      defaultRequest: #default requests
        cpu: 100m
        memory: 10Mi
      default: #default limits
        cpu: 200m
        memory: 100Mi
      min:
        cpu: 50m
        memory: 5Mi
      max:
        cpu: "1"
        memory: 1Gi
      maxLimitRequestRatio:
        cpu: "4"
        memory: "10"
    - type: PersistentVolumeClaim
      min:
        storage: 1Gi
      max:
        storage: 10Gi

De hecho, puedes ver que es posible establecer límites a nivel de pod, de contenedor e incluso otros tipos, como el tamaño del almacenamiento, entre otros. Puedes consultar los límites para un namespace a través del siguiente comando:

[email protected] limits-requests-quotas % kubectl describe limitrange
Name:                  default-requests-and-limits
Namespace:             default
Type                   Resource  Min  Max   Default Request  Default Limit  Max Limit/Request Ratio
----                   --------  ---  ---   ---------------  -------------  -----------------------
Pod                    cpu       50m  1     -                -              -
Pod                    memory    5Mi  1Gi   -                -              -
Container              cpu       50m  1     100m             200m           4
Container              memory    5Mi  1Gi   10Mi             100Mi          10
PersistentVolumeClaim  storage   1Gi  10Gi  -                -              -

Si creas un pod sin estos valores se asignarán de manera automática los elegidos por defecto. Además, si intentas crear un pod que no encaja con los mínimos y máximos elegidos Kubernetes no lo creará y lanzará un error:

[email protected] k8s-in-action % kubectl run requests-pod --image=busybox --restart Never --requests='cpu=3,memory=20Mi' -- dd if=/dev/zero of=/dev/null
The Pod "requests-pod" is invalid: spec.containers[0].resources.requests: Invalid value: "3": must be less than or equal to cpu limit

Esto es genial porque es una de las medidas para evitar que un pod no se quede en estado Pending por no disponer de recursos.

Establecer cuotas

Cuando varios usuarios o equipos comparten un clúster con un número fijo de nodos es probable que quieras controlar que nadie se pase consumiendo recursos, perjudicando de este modo al resto. Con LimitRange solamente indicamos el valor por defecto y el máximo y mínimo que se puede establecer a un pod/contenedor, pero siempre que se cumplan dichos valores dejaría crearlo, incluso si nos estamos aprovechando de recursos que son compartidos con otros equipos/aplicaciones. Para controlar esto tenemos otro objeto llamado ResourceQuota.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cpu-and-mem
spec:
  hard:
    requests.cpu: 400m
    requests.memory: 200Mi
    limits.cpu: 600m
    limits.memory: 500Mi

Gracias a él restringimos cuántos recursos puede usar el equipo o aplicación asignado a un namespace:

[email protected] limits-requests-quotas % kubectl describe quota
Name:            cpu-and-mem
Namespace:       default
Resource         Used   Hard
--------         ----   ----
limits.cpu       1      600m
limits.memory    40Mi   500Mi
requests.cpu     1100m  400m
requests.memory  40Mi   200Mi

Si te fijas en la salida anterior, si ResourceQuota se aplica a posteriori en un namespace con unos valores por debajo de lo ya consumido este no corregirá este desfase de ningún modo. Sin embargo, si intentas crear un nuevo pod y este no tiene espacio dentro del namespace, en lugar de desplegarse y quedarse en estado Pending, o adueñarse de recursos de otra aplicación, se denegará directamente su creación:

[email protected] k8s-in-action % kubectl run requests-pod --image=busybox --restart Never --requests='cpu=60m,memory=20Mi' -- dd if=/dev/zero of=/dev/null
Error from server (Forbidden): pods "requests-pod" is forbidden: exceeded quota: cpu-and-mem, requested: limits.cpu=200m,requests.cpu=60m, used: limits.cpu=1,requests.cpu=1100m, limited: limits.cpu=600m,requests.cpu=400m

Con todas estas herramientas deberías de tener tus recursos bajo control 🙂

¡Saludos!