La lección anterior puso cuotas a los tres entornos de Rutas Norte y dejó dos cabos sueltos muy concretos. El primero es práctico y molesta cada día: desde que rutas-norte-dev tiene cuota de cómputo, un simple kubectl run falla con must specify requests.cpu, y cualquier manifiesto de un tercero que no declare recursos es inaplicable. El segundo es más profundo: sabemos que cuando a un nodo le falta memoria alguien tiene que morir, pero no sabemos quién, y esa decisión no es aleatoria en absoluto. Esta lección cierra los dos: el LimitRange inyecta valores por defecto y pone topes por contenedor dentro del namespace, y las clases de calidad de servicioGuaranteed, Burstable y BestEffort— determinan el orden exacto en que el kubelet desaloja y en que el kernel mata. Al final asignarás una clase razonada a cada componente de Rutas Norte y provocarás un desalojo real en tu minikube para verlo con tus propios ojos.

Contenido

  1. Qué resuelve un LimitRange
  2. Los campos: default, defaultRequest, min, max, maxLimitRequestRatio
  3. Demostración: un pod sin recursos sale con recursos
  4. Los tipos: Container, Pod y PersistentVolumeClaim
  5. Interacción exacta entre LimitRange y ResourceQuota
  6. Las tres clases de QoS y la regla que las determina
  7. Consultar la clase de un pod
  8. Consecuencia 1: oom_score_adj y el OOM killer del kernel
  9. Consecuencia 2: el orden de desalojo del kubelet
  10. La clase de QoS de cada componente de Rutas Norte
  11. El LimitRange de los tres entornos
  12. Experimento: provocar un desalojo y ver quién cae primero

  1. Qué resuelve un LimitRange

El LimitRange es un objeto con namespace que hace dos cosas distintas sobre cada contenedor que se cree en él:

  1. Inyecta valores por defecto en los contenedores que no declaran requests o limits.
  2. Valida topes: rechaza los contenedores que piden menos de un mínimo o más de un máximo.

La diferencia con la ResourceQuota es el ámbito, y conviene tenerla clarísima porque se confunden constantemente:

ResourceQuota LimitRange
Ámbito El namespace entero, agregado Cada contenedor o pod individual
Pregunta que responde "¿Cuánto puede consumir este entorno en total?" "¿Cuánto puede pedir un solo contenedor?"
Modifica el objeto No: solo acepta o rechaza : inyecta valores por defecto
Cuándo actúa Al crear el objeto Al crear el objeto
Objetos afectados Pods, Services, PVCs, ConfigMaps... Contenedores, pods y PVCs
Efecto típico exceeded quota minimum memory usage per Container is 32Mi
Puede haber varios Sí (se aplican todos) Sí (se aplican todos)

Una analogía con Rutas Norte: la ResourceQuota es el presupuesto anual del departamento (no se pueden gastar más de X euros entre todos). El LimitRange es la política de gastos por persona (nadie puede pedir un billete de más de Y euros, y si no se especifica clase, se asume turista).

Los tres problemas concretos que resuelve:

Problema 1: la cuota rompe todo lo cómodo. Ya lo vimos:

kubectl run depurador --image=busybox:1.36 -n rutas-norte-dev -- sleep 3600
Error from server (Forbidden): pods "depurador" is forbidden: failed quota: cuota-dev:
  must specify limits.cpu for: depurador; limits.memory for: depurador;
  requests.cpu for: depurador; requests.memory for: depurador

Problema 2: un solo contenedor puede acaparar la cuota entera. Sin LimitRange, alguien puede desplegar un pod con requests.memory: 4Gi en rutas-norte-dev y agotar de golpe toda la cuota de memoria del entorno, dejando sin sitio a los cinco componentes.

Problema 3: valores absurdos. Un contenedor con requests.memory: 4Mi no arrancará nunca de forma estable, pero nada lo impide.

  1. Los campos: default, defaultRequest, min, max, maxLimitRequestRatio

apiVersion: v1
kind: LimitRange
metadata:
  name: limites-dev
  namespace: rutas-norte-dev
spec:
  limits:
    - type: Container                    # se aplica a CADA contenedor
      default:                           # LIMITS si el contenedor no los declara
        cpu: 300m
        memory: 256Mi
      defaultRequest:                    # REQUESTS si el contenedor no las declara
        cpu: 100m
        memory: 128Mi
      min:                               # nadie puede pedir MENOS que esto
        cpu: 10m
        memory: 32Mi
      max:                               # nadie puede pedir MAS que esto
        cpu: "2"
        memory: 2Gi
      maxLimitRequestRatio:              # relacion maxima limit/request
        cpu: "10"
        memory: "4"

Campo por campo:

Campo Qué hace Si se incumple
default Rellena los limits que falten — (solo rellena)
defaultRequest Rellena las requests que falten — (solo rellena)
min Mínimo que puede declararse El pod se rechaza
max Máximo que puede declararse El pod se rechaza
maxLimitRequestRatio Tope de la división limit / request El pod se rechaza

Reglas de resolución que hay que conocer con precisión:

Regla 1: lo declarado siempre gana. El LimitRange nunca sobrescribe un valor que el contenedor declara. Solo rellena huecos.

Regla 2: si declaras limits pero no requests, la request toma el valor del limit, no el de defaultRequest. Este comportamiento pilla a mucha gente:

          resources:
            limits:
              memory: 1Gi            # no declaro requests

Con el LimitRange de arriba (defaultRequest.memory: 128Mi) uno esperaría requests: 128Mi. Pero el resultado es:

          resources:
            limits:
              memory: 1Gi
            requests:
              memory: 1Gi            # copiado del limit, NO el defaultRequest

La regla de Kubernetes de "si solo hay limits, la request iguala al limit" tiene prioridad. Consecuencia práctica: reservas 1 GiB del presupuesto del namespace sin quererlo.

Regla 3: si declaras requests pero no limits, se aplica default. Aquí sí funciona como uno espera... salvo que default sea menor que tu request, en cuyo caso el pod se rechaza por incoherente.

Regla 4: maxLimitRequestRatio limita el sobrecompromiso individual. Con memory: "4", un contenedor con requests.memory: 128Mi no puede tener limits.memory mayor de 512Mi. Es la herramienta para impedir que alguien reserve poco y luego consuma mucho, que es justo el patrón que provoca desalojos.

Tabla de los seis escenarios posibles, con el LimitRange del ejemplo:

Lo que declara el contenedor Resultado tras el LimitRange
Nada requests: 100m/128Mi, limits: 300m/256Mi
Solo requests: cpu 200m requests: 200m/128Mi, limits: 300m/256Mi
Solo limits: memory 1Gi requests: 100m/**1Gi**, limits: 300m/1Gi
Ambos completos Sin cambios (si pasa min, max y ratio)
requests.memory: 16Mi Rechazado: menor que min (32Mi)
requests: 128Mi, limits: 1Gi Rechazado: ratio 8 > maxLimitRequestRatio 4

  1. Demostración: un pod sin recursos sale con recursos

Nada convence tanto como verlo. Partimos de rutas-norte-dev con su cuota y aplicamos el LimitRange:

kubectl apply -f k8s/entornos/dev/limitrange.yaml
kubectl describe limitrange limites-dev -n rutas-norte-dev
limitrange/limites-dev created

Name:       limites-dev
Namespace:  rutas-norte-dev
Type        Resource  Min   Max  Default Request  Default Limit  Max Limit/Request Ratio
----        --------  ---   ---  ---------------  -------------  -----------------------
Container   cpu       10m   2    100m             300m           10
Container   memory    32Mi  2Gi  128Mi            256Mi          4

Ahora el mismo kubectl run que antes fallaba:

kubectl run depurador --image=busybox:1.36 -n rutas-norte-dev -- sleep 3600
kubectl get pod depurador -n rutas-norte-dev
pod/depurador created

NAME        READY   STATUS    RESTARTS   AGE
depurador   1/1     Running   0          6s

Funciona. Y lo interesante es ver qué recursos tiene realmente:

kubectl get pod depurador -n rutas-norte-dev -o jsonpath='{.spec.containers[0].resources}' | jq .
{
  "limits": {
    "cpu": "300m",
    "memory": "256Mi"
  },
  "requests": {
    "cpu": "100m",
    "memory": "128Mi"
  }
}

El manifiesto que enviamos no tenía resources y el objeto guardado en etcd sí los tiene. El LimitRange los ha inyectado.

¿Quién hace esa inyección? Un controlador de admisión (admission controller) llamado LimitRanger, que forma parte del apiserver. Recuerda el flujo del módulo 1: una petición pasa por autenticación, autorización y admisión antes de escribirse en etcd. La admisión tiene dos fases:

flowchart LR
    A["kubectl apply"] --> B["Autenticacion<br/>quien eres"]
    B --> C["Autorizacion RBAC<br/>que puedes hacer"]
    C --> D["Admision MUTANTE<br/>LimitRanger inyecta<br/>default y defaultRequest"]
    D --> E["Admision VALIDANTE<br/>LimitRanger comprueba min/max<br/>ResourceQuota comprueba el total"]
    E --> F["etcd<br/>objeto guardado YA MODIFICADO"]

El orden es crucial y explica todo lo del apartado 5: el LimitRange muta primero, la ResourceQuota valida después. Cuando la cuota mira el pod, este ya tiene sus recursos inyectados.

Y una consecuencia que hay que tener presente: el objeto en el clúster ya no es igual al fichero YAML de tu repositorio. Es la primera vez en el curso que esto pasa, y conviene saberlo para no desconcertarse en un kubectl diff. También la deja anotada el propio pod:

kubectl get pod depurador -n rutas-norte-dev -o jsonpath='{.metadata.annotations}' | jq .
{
  "kubernetes.io/limit-ranger": "LimitRanger plugin set: cpu, memory request for container depurador;
   cpu, memory limit for container depurador"
}

Comprobemos también los topes. Un contenedor pidiendo demasiado:

kubectl run gigante --image=nginx:1.27.1-alpine -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"gigante","image":"nginx:1.27.1-alpine",
  "resources":{"requests":{"memory":"3Gi"},"limits":{"memory":"3Gi"}}}]}}'
Error from server (Forbidden): pods "gigante" is forbidden:
  maximum memory usage per Container is 2Gi, but limit is 3Gi

Y uno pidiendo demasiado poco:

kubectl run enano --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"enano","image":"busybox:1.36",
  "resources":{"requests":{"memory":"8Mi"},"limits":{"memory":"8Mi"}}}]}}'
Error from server (Forbidden): pods "enano" is forbidden:
  minimum memory usage per Container is 32Mi, but request is 8Mi

Los tres comportamientos verificados: inyecta, pone techo y pone suelo.

  1. Los tipos: Container, Pod y PersistentVolumeClaim

Un LimitRange puede tener varias entradas en spec.limits, cada una con su type:

apiVersion: v1
kind: LimitRange
metadata:
  name: limites-pro
  namespace: rutas-norte-pro
spec:
  limits:
    # 1. Por CONTENEDOR: el unico que admite default y defaultRequest
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 200m
        memory: 256Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:
        cpu: "4"
        memory: "2"

    # 2. Por POD: suma de TODOS sus contenedores. No admite defaults
    - type: Pod
      max:
        cpu: "8"
        memory: 16Gi
      min:
        cpu: 50m
        memory: 64Mi

    # 3. Por PVC: tamanyo de disco
    - type: PersistentVolumeClaim
      min:
        storage: 1Gi
      max:
        storage: 200Gi

Diferencias entre los tres:

Tipo Ámbito Admite default/defaultRequest Uso típico
Container Cada contenedor por separado El principal: valores por defecto y topes
Pod La suma de los contenedores del pod No Impedir pods con 10 sidecars gigantes
PersistentVolumeClaim Tamaño del disco solicitado No Controlar el coste de almacenamiento

El tipo Pod es útil cuando llegue el módulo 6 con los sidecars e init containers: un pod puede tener tres o cuatro contenedores, cada uno dentro del max de Container, pero sumando entre todos una cantidad desmesurada. El límite de Pod corta eso.

El tipo PersistentVolumeClaim cobrará sentido en el módulo 5. Con max: storage: 200Gi nadie puede pedir un disco de 2 TiB por error de tecleo, algo que en la nube se traduce en una factura desagradable a fin de mes.

  1. Interacción exacta entre LimitRange y ResourceQuota

Este es el punto donde todo encaja. La secuencia, para un pod que llega sin resources a un namespace con ambos objetos:

sequenceDiagram
    participant U as kubectl apply
    participant A as apiserver
    participant LR as LimitRanger (mutante)
    participant LV as LimitRanger (validante)
    participant Q as ResourceQuota (validante)
    participant E as etcd

    U->>A: Pod sin resources
    A->>LR: fase mutante
    LR->>LR: inyecta requests 100m/128Mi<br/>y limits 300m/256Mi
    LR->>LV: fase validante
    LV->>LV: comprueba min, max y ratio -> OK
    LV->>Q: siguiente validador
    Q->>Q: used + 100m <= hard? -> OK
    Q->>E: pod guardado CON recursos
    E-->>U: pod/depurador created

Las cuatro consecuencias de este orden:

Consecuencia 1: el LimitRange salva a la ResourceQuota de su propio rigor. Sin LimitRange, la cuota obliga a declarar recursos a mano en todo. Con LimitRange, el valor por defecto se inyecta y la cuota lo contabiliza sin que nadie escriba nada.

Consecuencia 2: los valores inyectados consumen cuota. Esto no es gratis y hay que dimensionarlo. Si defaultRequest.memory es 128Mi y alguien lanza 20 pods de depuración sin recursos, se comen 2,5 GiB del presupuesto del namespace. Por eso los valores por defecto deben ser modestos: son para pods que nadie ha calibrado.

Consecuencia 3: un pod puede pasar el LimitRange y fallar en la cuota. Son validaciones independientes y hay que cumplir las dos:

kubectl apply -f pod-grande.yaml
Error from server (Forbidden): error when creating "pod-grande.yaml": pods "analitica" is forbidden:
  exceeded quota: cuota-dev, requested: requests.memory=1Gi,
  used: requests.memory=3600Mi, limited: requests.memory=4Gi

Ese pod cumplía el max del LimitRange (2Gi), pero no cabía en lo que quedaba de cuota.

Consecuencia 4: cambiar un LimitRange no afecta a los pods existentes. La admisión actúa solo en la creación. Si subes el defaultRequest, los pods que ya corren conservan lo que se les inyectó. Para aplicar los nuevos valores hay que recrearlos:

kubectl rollout restart deploy -n rutas-norte-dev

Y una regla de coherencia entre ambos objetos que evita un problema desagradable: el max del LimitRange nunca debe superar la cuota del namespace. Si max.memory es 8Gi pero la cuota de requests.memory es 4Gi, alguien puede escribir un manifiesto que pase el LimitRange y sea imposible de desplegar. Es mejor que el rechazo llegue con el mensaje claro del LimitRange ("maximum memory usage per Container is 2Gi") que con el de la cuota, más difícil de interpretar.

  1. Las tres clases de QoS y la regla que las determina

Cambiamos de tema y llegamos a la parte más interesante. Cuando se crea un pod, Kubernetes le asigna automáticamente una clase de calidad de servicio en status.qosClass. No se declara: se deduce de cómo estén puestos los recursos.

La regla exacta, en orden de evaluación:

flowchart TB
    A["Pod creado"] --> B{"Todos los contenedores<br/>declaran requests Y limits<br/>de CPU Y memoria?"}
    B -->|"No"| E{"Algun contenedor<br/>declara alguna request<br/>o algun limit?"}
    B -->|"Si"| C{"Para cada contenedor:<br/>requests == limits<br/>en CPU Y memoria?"}
    C -->|"Si"| D["Guaranteed"]
    C -->|"No"| F["Burstable"]
    E -->|"Si"| F
    E -->|"No: nada en absoluto"| G["BestEffort"]

En palabras precisas:

Guaranteed — se asigna si y solo si, para todos los contenedores del pod (incluidos los init containers):

  • Se declaran limits de CPU y de memoria.
  • Se declaran requests de CPU y de memoria (o se omiten, en cuyo caso igualan a los limits).
  • requests es exactamente igual a limits en ambos recursos.

BestEffort — se asigna si ningún contenedor del pod declara ninguna request ni ningún limit, de ningún recurso.

Burstable — todo lo demás. Es el cajón por defecto: al menos un contenedor declara algo, pero no se cumple la condición estricta de Guaranteed.

Ejemplos que aclaran los casos frontera:

# --- Guaranteed ---
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              cpu: 500m
              memory: 512Mi
# --- Guaranteed TAMBIEN (solo limits: las requests se copian) ---
          resources:
            limits:
              cpu: 500m
              memory: 512Mi
# --- Burstable: la memoria coincide pero la CPU no ---
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              cpu: 500m
              memory: 512Mi
# --- Burstable: falta el limit de CPU ---
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              memory: 512Mi
# --- Burstable: solo requests ---
          resources:
            requests:
              memory: 512Mi
# --- BestEffort: nada de nada ---
          resources: {}

Tres trampas que conviene memorizar:

Trampa 1: basta un contenedor para arruinar la clase. Si un pod tiene la aplicación con requests == limits y un sidecar de registro sin recursos, el pod entero es Burstable. La clase es del pod, no del contenedor.

Trampa 2: con LimitRange activo, BestEffort es casi imposible. El LimitRanger inyecta valores por defecto, así que ningún pod llega a BestEffort. Es un efecto secundario importante y en general deseable.

Trampa 3: defaultRequest distinto de default impide Guaranteed. Si el LimitRange inyecta requests: 100m y limits: 300m, todos los pods sin recursos serán Burstable. Para que un pod sea Guaranteed hay que declararlo explícitamente.

  1. Consultar la clase de un pod

kubectl get pod postgres-reservas-5d8f6b9c4-k2mnp -n rutas-norte-pro \
  -o jsonpath='{.status.qosClass}'; echo
Guaranteed

Para todos los pods del namespace de un vistazo:

kubectl get pods -n rutas-norte-pro \
  -o custom-columns='NOMBRE:.metadata.name,QOS:.status.qosClass,\
CPU_REQ:.spec.containers[0].resources.requests.cpu,\
CPU_LIM:.spec.containers[0].resources.limits.cpu,\
MEM_REQ:.spec.containers[0].resources.requests.memory,\
MEM_LIM:.spec.containers[0].resources.limits.memory'
NOMBRE                                  QOS         CPU_REQ  CPU_LIM  MEM_REQ  MEM_LIM
api-reservas-7f4b8c9d6-2xkqp            Burstable   300m     1        512Mi    768Mi
api-reservas-7f4b8c9d6-8mvtr            Burstable   300m     1        512Mi    768Mi
postgres-reservas-5d8f6b9c4-k2mnp       Guaranteed  1        1        2Gi      2Gi
redis-cache-6c9d8f7b5-vn4qx             Burstable   50m      200m     256Mi    320Mi
tienda-web-5b7c9f4d8-h7pnk              Burstable   50m      200m     64Mi     128Mi
worker-notificaciones-6f7d9c8b4-lm3pt   Burstable   100m     800m     320Mi    512Mi

Un recuento rápido por clase:

kubectl get pods -A -o json | jq -r '.items[] | .status.qosClass' | sort | uniq -c
     28 Burstable
      3 Guaranteed
      6 BestEffort

Esos seis BestEffort merecen una mirada: en un clúster de producción son los primeros candidatos a morir, así que conviene saber quiénes son y si eso es intencionado.

kubectl get pods -A -o json \
  | jq -r '.items[] | select(.status.qosClass=="BestEffort") | "\(.metadata.namespace)/\(.metadata.name)"'
kube-system/coredns-7db6d8ff4d-2vqxn
rutas-norte-dev/depurador
...

describe también la muestra:

kubectl describe pod postgres-reservas-5d8f6b9c4-k2mnp -n rutas-norte-pro | grep -i qos
QoS Class:  Guaranteed

  1. Consecuencia 1: oom_score_adj y el OOM killer del kernel

Aquí está la primera consecuencia real de la clase, y es de vida o muerte, literalmente.

Cuando el kernel de Linux se queda sin memoria, ejecuta el OOM killer, que elige una víctima. La elección se basa en una puntuación por proceso, oom_score, que combina la memoria que consume con un ajuste manual llamado oom_score_adj, un valor entre -1000 y 1000. Cuanto mayor sea, antes muere.

El kubelet establece ese ajuste según la clase de QoS:

Clase de QoS oom_score_adj Orden de sacrificio
Guaranteed -997 El último en morir
Burstable Entre 2 y 999, según la request de memoria En medio
BestEffort 1000 El primero en morir

La fórmula para Burstable es la que da todo el matiz:

oom_score_adj = 1000 - (1000 x requests.memory / memoria asignable del nodo)

Léela despacio: cuanto mayor sea tu request de memoria, menor es tu puntuación y más tarde mueres. Un pod Burstable que pide mucha memoria está casi tan protegido como uno Guaranteed; uno que pide muy poca está casi tan expuesto como un BestEffort.

Comprobémoslo en un nodo con 8 GiB asignables. Para api-reservas con requests.memory: 512Mi:

oom_score_adj = 1000 - (1000 x 512 / 8192) = 1000 - 62 = 938
kubectl exec -n rutas-norte-pro deploy/api-reservas -- cat /proc/1/oom_score_adj
kubectl exec -n rutas-norte-pro deploy/postgres-reservas -- cat /proc/1/oom_score_adj
938
-997

Exactamente lo previsto. Y la conclusión de negocio es directa:

Cuando al nodo le falte memoria, el kernel matará api-reservas mucho antes que postgres-reservas. Y eso es justamente lo que queremos: perder un pod de la API significa perder unas peticiones que el cliente puede reintentar; perder la base de datos significa que toda la plataforma deja de vender billetes y, en el peor caso, que una transacción a medias corrompe datos.

Hay que distinguir dos situaciones distintas que se confunden a menudo:

OOM del cgroup OOM del nodo
Causa Un contenedor supera su propio limits.memory La suma de todo supera la memoria del nodo
Quién muere Ese contenedor concreto El proceso con mayor oom_score del nodo
Influye la clase de QoS No: mueres por tu propio límite : es lo que decide
Se ve como OOMKilled en ese pod OOMKilled en un pod que "no había hecho nada"
Se previene con Un limits.memory bien calibrado No sobrecomprometer memoria + QoS bien asignada

El segundo caso es el desconcertante: un pod muere y sus métricas muestran que estaba muy por debajo de su límite. La causa está en un vecino que se descontroló, y la clase de QoS es lo que determinó que la víctima fuera él y no otro.

  1. Consecuencia 2: el orden de desalojo del kubelet

Antes de que el kernel llegue a matar procesos, el kubelet tiene su propio mecanismo, más ordenado: el desalojo por presión de recursos (eviction).

El kubelet vigila los recursos del nodo cada 10 segundos. Cuando cruza un umbral, marca la condición correspondiente (MemoryPressure, DiskPressure, PIDPressure) y empieza a desalojar pods para recuperar recursos.

Los umbrales por defecto:

Señal Umbral por defecto Condición que activa
memory.available < 100Mi MemoryPressure
nodefs.available < 10% DiskPressure
nodefs.inodesFree < 5% DiskPressure
imagefs.available < 15% DiskPressure

Y el criterio de selección de las víctimas, en este orden estricto:

  1. Primero, si el pod excede sus requests. Los que consumen más de lo que reservaron son candidatos antes que los que se mantienen dentro.
  2. Segundo, la clase de QoS: BestEffort primero, luego Burstable, y Guaranteed en último lugar.
  3. Tercero, la PriorityClass, si está definida.
  4. Cuarto, cuánto excede el uso sobre la request. Entre dos candidatos iguales, cae el que más se ha pasado.

Dicho de forma operativa:

El primer pod en caer es un BestEffort. Si no hay ninguno, cae el Burstable que más se haya pasado de su request. Un pod Guaranteed que se mantiene dentro de sus recursos es prácticamente intocable.

Un pod desalojado se ve así:

kubectl get pods -n rutas-norte-dev
kubectl describe pod analitica-puntual -n rutas-norte-dev | head -14
NAME                READY   STATUS    RESTARTS   AGE
analitica-puntual   0/1     Evicted   0          8m

Name:         analitica-puntual
Namespace:    rutas-norte-dev
Status:       Failed
Reason:       Evicted
Message:      The node was low on resource: memory. Threshold quantity: 100Mi,
              available: 84Mi. Container analitica was using 1204Mi,
              request is 0, which exceeds its request of 0.

Ese mensaje lo dice todo: request is 0 porque era BestEffort, y por tanto cualquier consumo excede su reserva. Fue el candidato perfecto.

Dos apuntes prácticos importantes:

Los pods desalojados no se borran solos. Quedan en Failed ocupando espacio en etcd. Conviene limpiarlos:

kubectl delete pods -A --field-selector=status.phase=Failed

Un pod desalojado que pertenece a un Deployment se recrea. El ReplicaSet del módulo 2 ve que falta una réplica y crea otra, posiblemente en otro nodo. Un pod suelto desalojado, en cambio, no vuelve nunca. Otro motivo más para no desplegar pods sueltos.

  1. La clase de QoS de cada componente de Rutas Norte

Ahora la decisión de diseño, que es lo que da sentido a todo lo anterior. La pregunta es, para cada componente: ¿qué pasa si este pod muere de repente?

Componente Clase Justificación de negocio
postgres-reservas Guaranteed Guarda las reservas y los datos personales de los clientes. Si muere, la plataforma no vende. Una muerte violenta puede dejar transacciones a medias. Debe ser el último en caer y su rendimiento debe ser previsible
api-reservas Burstable Tiene varias réplicas y es sin estado: si una muere, el Service reparte a las demás y el cliente reintenta. Necesita ráfaga de CPU en puentes, y requests == limits desperdiciaría capacidad el 95 % del tiempo
tienda-web Burstable Sirve estáticos con consumo mínimo y muy variable. Tres réplicas, sin estado, arranque en segundos
redis-cache Burstable Tiene estado pero es prescindible: si muere, la caché se pierde y api-reservas vuelve a consultar postgres-reservas. Más lento, pero correcto
worker-notificaciones Burstable Asíncrono: si muere a media tanda, las notificaciones pendientes siguen en la base de datos y la siguiente ejecución las recoge
informes-ocupacion Burstable con recursos bajos Tarea nocturna (módulo 6). Si el nodo tiene presión, que muera: se reintenta mañana
Análisis puntual ad hoc BestEffort Consultas exploratorias de un analista. Es lo primero que debe caer, siempre

Los manifiestos concretos. postgres-reservas como Guaranteed:

        - name: postgres
          image: postgres:16.4
          resources:
            # Guaranteed: requests == limits en CPU y memoria.
            # Decision deliberada: esta base de datos guarda datos personales
            # de clientes y es el ultimo pod que debe morir en el nodo.
            requests:
              cpu: "1"
              memory: 2Gi
            limits:
              cpu: "1"
              memory: 2Gi
kubectl get pod -l app=postgres-reservas -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echo
Guaranteed

api-reservas como Burstable:

        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          resources:
            # Burstable deliberado: 4 replicas sin estado detras de un Service.
            # El limit de CPU triplica la request para absorber los picos de puentes.
            requests:
              cpu: 300m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 768Mi

El análisis puntual como BestEffort:

apiVersion: v1
kind: Pod
metadata:
  name: analisis-ocupacion-julio
  namespace: rutas-norte-dev
  labels:
    app: analisis-puntual
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
  annotations:
    rutasnorte.example/motivo: "analisis exploratorio de ocupacion, RN-612"
    rutasnorte.example/responsable: "[email protected]"
spec:
  restartPolicy: Never
  containers:
    - name: analisis
      image: postgres:16.4
      command: ["sh", "-c", "psql -h postgres-reservas -c 'SELECT ...' > /tmp/salida.csv"]
      resources: {}          # vacio A PROPOSITO: BestEffort, el primero en caer

Ojo: con un LimitRange activo en rutas-norte-dev, ese resources: {} no producirá BestEffort, porque el LimitRanger inyectará los valores por defecto. Para conseguir un pod BestEffort de verdad hay que usar un namespace sin LimitRange, y por eso el experimento del apartado 12 usa uno aparte.

Un caso que merece comentario: redis-cache es Burstable aunque tenga estado. ¿No debería protegerse como postgres-reservas? No, y la diferencia es el valor del dato. redis-cache guarda la disponibilidad de plazas cacheada: si se pierde, se recalcula consultando la base de datos. El impacto es un pico de latencia y de carga sobre postgres-reservas, no una pérdida de información. Lo que determina la clase no es si el componente tiene estado, sino qué se pierde si muere.

  1. El LimitRange de los tres entornos

Cerramos la parte de configuración con los tres objetos, coherentes con las cuotas de la lección anterior.

rutas-norte-dev

apiVersion: v1
kind: LimitRange
metadata:
  name: limites-dev
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  limits:
    - type: Container
      # Valores por defecto modestos: son para pods sin calibrar
      default:
        cpu: 300m
        memory: 256Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      min:
        cpu: 10m
        memory: 32Mi
      # max muy por debajo de la cuota (2 CPU / 4Gi): un solo pod no la agota
      max:
        cpu: "1"
        memory: 1Gi
      maxLimitRequestRatio:
        cpu: "10"          # generoso: en dev interesa iterar sin pelearse con el limite
        memory: "4"

rutas-norte-pre

apiVersion: v1
kind: LimitRange
metadata:
  name: limites-pre
  namespace: rutas-norte-pre
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pre
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 200m
        memory: 256Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "2"
        memory: 4Gi
      maxLimitRequestRatio:
        cpu: "4"           # mas estricto: pre debe parecerse a pro
        memory: "2"
    - type: Pod
      max:
        cpu: "4"
        memory: 6Gi

rutas-norte-pro

apiVersion: v1
kind: LimitRange
metadata:
  name: limites-pro
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  limits:
    - type: Container
      # En pro los defaults son una RED DE SEGURIDAD, no una comodidad:
      # todo componente de produccion debe declarar sus recursos calibrados.
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 200m
        memory: 256Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:
        cpu: "4"
        memory: "2"        # impide reservar poco y consumir mucho: evita desalojos
    - type: Pod
      max:
        cpu: "8"
        memory: 16Gi
    - type: PersistentVolumeClaim
      min:
        storage: 1Gi
      max:
        storage: 200Gi     # nadie pide 2 TiB por un cero de mas

Comparativa de las decisiones:

Parámetro dev pre pro Razonamiento
defaultRequest.memory 128Mi 256Mi 256Mi En dev hay muchos pods pequeños de prueba
max.memory (contenedor) 1Gi 4Gi 8Gi En pro postgres-reservas necesita margen para crecer
maxLimitRequestRatio.memory 4 2 2 En pro el sobrecompromiso agresivo provoca desalojos
Límite de Pod No Se activa cuando aparecen los sidecars del módulo 6
Límite de PVC No No Control de coste del almacenamiento de producción
kubectl apply -f k8s/entornos/dev/limitrange.yaml \
              -f k8s/entornos/pre/limitrange.yaml \
              -f k8s/entornos/pro/limitrange.yaml
kubectl get limitrange -A
limitrange/limites-dev created
limitrange/limites-pre created
limitrange/limites-pro created

NAMESPACE         NAME          CREATED AT
rutas-norte-dev   limites-dev   2026-08-05T21:14:07Z
rutas-norte-pre   limites-pre   2026-08-05T21:14:07Z
rutas-norte-pro   limites-pro   2026-08-05T21:14:08Z

  1. Experimento: provocar un desalojo y ver quién cae primero

Toca comprobar la teoría. Vamos a crear un namespace sin LimitRange (para poder tener pods BestEffort de verdad), desplegar tres pods de las tres clases, y presionar la memoria del nodo hasta que el kubelet empiece a desalojar.

Aviso: hazlo en tu minikube de prácticas, nunca en un clúster compartido. Vamos a provocar deliberadamente presión de memoria en un nodo.

Paso 1: preparar el terreno.

kubectl create namespace laboratorio-qos
kubectl get node rutas-norte -o jsonpath='{.status.allocatable.memory}'; echo
namespace/laboratorio-qos created
7937084Ki

Unos 7,7 GiB asignables.

Paso 2: los tres pods.

# laboratorio-qos.yaml
apiVersion: v1
kind: Pod
metadata:
  name: victima-besteffort
  namespace: laboratorio-qos
  labels: {rol: victima}
spec:
  containers:
    - name: carga
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "700M", "--vm-hang", "0"]
      # resources ausente a proposito -> BestEffort
---
apiVersion: v1
kind: Pod
metadata:
  name: victima-burstable
  namespace: laboratorio-qos
  labels: {rol: victima}
spec:
  containers:
    - name: carga
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "700M", "--vm-hang", "0"]
      resources:
        requests:
          cpu: 100m
          memory: 200Mi        # pide POCO y consume MUCHO: candidato ideal
        limits:
          cpu: 500m
          memory: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: protegido-guaranteed
  namespace: laboratorio-qos
  labels: {rol: protegido}
spec:
  containers:
    - name: carga
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "700M", "--vm-hang", "0"]
      resources:
        requests:
          cpu: 200m
          memory: 1Gi
        limits:
          cpu: 200m
          memory: 1Gi          # requests == limits -> Guaranteed
kubectl apply -f laboratorio-qos.yaml
sleep 20
kubectl get pods -n laboratorio-qos -o custom-columns='NOMBRE:.metadata.name,QOS:.status.qosClass,ESTADO:.status.phase'
pod/victima-besteffort created
pod/victima-burstable created
pod/protegido-guaranteed created

NOMBRE                 QOS          ESTADO
protegido-guaranteed   Guaranteed   Running
victima-besteffort     BestEffort   Running
victima-burstable      Burstable    Running

Las tres clases confirmadas. Verifiquemos también sus oom_score_adj:

for P in victima-besteffort victima-burstable protegido-guaranteed; do
  printf '%-22s ' "$P"
  kubectl exec -n laboratorio-qos "$P" -- cat /proc/1/oom_score_adj
done
victima-besteffort     1000
victima-burstable      975
protegido-guaranteed   -997

El 975 de victima-burstable sale de la fórmula del apartado 8: 1000 - (1000 × 200Mi / 7751Mi) = 1000 - 25 = 975. Pide poquísimo, así que está casi tan expuesto como el BestEffort.

Paso 3: apretar hasta provocar la presión.

kubectl run presion --image=polinux/stress:1.0.4 -n laboratorio-qos --restart=Never -- \
  stress --vm 1 --vm-bytes 4500M --vm-hang 0

Paso 4: observar.

kubectl get pods -n laboratorio-qos -w
NAME                   READY   STATUS    RESTARTS   AGE
presion                1/1     Running   0          12s
protegido-guaranteed   1/1     Running   0          3m
victima-besteffort     1/1     Running   0          3m
victima-burstable      1/1     Running   0          3m
victima-besteffort     0/1     Evicted   0          3m21s     <-- PRIMERO
victima-burstable      0/1     Evicted   0          3m48s     <-- SEGUNDO
protegido-guaranteed   1/1     Running   0          4m10s     <-- SOBREVIVE

El orden es exactamente el previsto: primero el BestEffort, después el Burstable que más se había pasado de su request, y el Guaranteed sigue en pie.

Paso 5: leer los mensajes de desalojo.

kubectl describe pod victima-besteffort -n laboratorio-qos | head -12
Status:   Failed
Reason:   Evicted
Message:  The node was low on resource: memory. Threshold quantity: 100Mi, available: 78Mi.
          Container carga was using 712Mi, request is 0, which exceeds its request of 0.
kubectl describe pod victima-burstable -n laboratorio-qos | head -12
Status:   Failed
Reason:   Evicted
Message:  The node was low on resource: memory. Threshold quantity: 100Mi, available: 92Mi.
          Container carga was using 705Mi, request is 200Mi, which exceeds its request of 200Mi.

Compara las dos últimas frases: el BestEffort excedía una reserva de 0 (todo lo que use excede), y el Burstable excedía su reserva de 200Mi en 505Mi. Ambos eran candidatos; el BestEffort primero por clase.

Paso 6: la condición del nodo y los eventos.

kubectl get node rutas-norte -o jsonpath='{.status.conditions[?(@.type=="MemoryPressure")].status}'; echo
kubectl get events -n laboratorio-qos --sort-by=.lastTimestamp | tail -5
True

LAST SEEN   TYPE      REASON     OBJECT                     MESSAGE
2m          Warning   Evicted    pod/victima-besteffort     The node was low on resource: memory
2m          Normal    Killing    pod/victima-besteffort     Stopping container carga
94s         Warning   Evicted    pod/victima-burstable      The node was low on resource: memory
94s         Normal    Killing    pod/victima-burstable      Stopping container carga

Paso 7: limpiar.

kubectl delete namespace laboratorio-qos
kubectl get node rutas-norte -o jsonpath='{.status.conditions[?(@.type=="MemoryPressure")].status}'; echo
namespace "laboratorio-qos" deleted
False

Lo que este experimento demuestra, traducido a Rutas Norte: si un puente de agosto provoca presión de memoria en el nodo donde vive postgres-reservas, la base de datos no será la que caiga. Caerán antes los pods de análisis puntual, después las réplicas de api-reservas que se hayan pasado de su reserva —y el Service seguirá repartiendo entre las que queden—, y la plataforma seguirá vendiendo billetes. Esa cadena de supervivencia no es casualidad: la has diseñado tú al asignar las clases.

Errores Comunes y Consejos

Error Síntoma Solución
Cuota sin LimitRange Todo kubectl run falla Añade siempre un LimitRange junto a la cuota
Esperar defaultRequest cuando solo hay limits Se reserva mucho más de lo previsto La request copia el limit, no el defaultRequest
max del LimitRange por encima de la cuota Manifiestos válidos pero indesplegables Deja max claramente por debajo del techo del namespace
Cambiar el LimitRange esperando efecto retroactivo Los pods viejos conservan sus valores kubectl rollout restart
Un sidecar sin recursos en un pod Guaranteed El pod entero es Burstable La clase es del pod: todos los contenedores deben cumplirla
Creer que Guaranteed inmuniza contra OOMKilled El pod muere igual Guaranteed protege del OOM del nodo, no de superar tu propio límite
Pods BestEffort en producción Desalojos inesperados Solo para trabajo verdaderamente descartable
Burstable con request mínima y limit enorme Desalojado constantemente Sube la request o baja el maxLimitRequestRatio
Pods Evicted acumulados Ruido en kubectl get pods y en etcd kubectl delete pods -A --field-selector=status.phase=Failed
Pod suelto desalojado Desaparece y no vuelve Usa Deployments, no pods sueltos
Confundir cuota y LimitRange Se pone el objeto equivocado Cuota = total del namespace; LimitRange = por contenedor
No revisar qosClass tras cambiar recursos Se pierde Guaranteed sin darse cuenta Comprobar con -o jsonpath='{.status.qosClass}' en el pipeline

Consejos:

  1. Cuota y LimitRange van siempre juntos. Trátalos como una sola decisión al crear un namespace.
  2. Valores por defecto modestos, max generoso. El valor por defecto es para lo no calibrado; el max es una red de seguridad contra los ceros de más.
  3. Guaranteed solo para lo verdaderamente crítico. Cuesta capacidad real: reservas el máximo todo el tiempo. En Rutas Norte, solo postgres-reservas.
  4. Vigila los Burstable con relación alta. Un pod con request de 200Mi y limit de 4Gi es un candidato permanente al desalojo. maxLimitRequestRatio lo previene desde el namespace.
  5. Añade la clase de QoS a tus revisiones de manifiestos. Es una línea en la revisión de una pull request y evita descubrir en un incidente que la base de datos era Burstable.

Ejercicios

Ejercicio 1: Los seis escenarios del LimitRange

Con el LimitRange limites-dev del apartado 11 aplicado en rutas-norte-dev:

  1. Crea seis pods, uno por cada fila de la tabla del apartado 2 (sin nada, solo requests de CPU, solo limits de memoria de 1Gi, ambos completos, requests.memory: 16Mi, y requests: 128Mi con limits: 1Gi).
  2. Para los que se creen, muestra los resources efectivos y su clase de QoS.
  3. Para los que fallen, copia el mensaje de error exacto.
  4. Explica en particular por qué el tercero acaba con requests.memory: 1Gi en lugar de 128Mi, y qué consecuencia tiene sobre la cuota del namespace.
  5. Calcula cuánta cuota consumirían entre todos si se hubieran creado los seis.

Ejercicio 2: Auditar y corregir las clases de QoS

  1. Lista todos los pods de los tres namespaces de Rutas Norte con su clase de QoS en una sola tabla.
  2. Comprueba si postgres-reservas es Guaranteed en los tres entornos. Si no lo es en alguno, identifica por qué.
  3. Modifica el manifiesto de postgres-reservas para que sea Guaranteed en rutas-norte-pro, verifica el cambio y comprueba su oom_score_adj.
  4. Calcula a mano el oom_score_adj esperado de api-reservas con requests.memory: 512Mi en un nodo con 7751Mi asignables, y compáralo con el valor real.
  5. Añade un sidecar sin recursos a postgres-reservas y observa qué le pasa a la clase del pod. Explica el resultado y revierte el cambio.

Ejercicio 3: Reproducir el desalojo y razonar el diseño

  1. Reproduce el experimento del apartado 12 en tu minikube.
  2. Anota el orden exacto de los desalojos y los mensajes de describe.
  3. Modifica victima-burstable para que su requests.memory sea 1Gi (en lugar de 200Mi) manteniendo el mismo consumo, y repite el experimento. ¿Cambia el orden? Explica por qué usando la fórmula del oom_score_adj.
  4. Explica qué habría pasado si postgres-reservas de Rutas Norte hubiera estado en ese nodo como Burstable con requests.memory: 256Mi y un consumo real de 1,8 GiB.
  5. Propón dos medidas adicionales, más allá de la clase de QoS, para proteger postgres-reservas de este escenario, e indica en qué lección del curso se estudia cada una.

Soluciones

Solución 1

# 1. Sin nada
kubectl run p1 --image=busybox:1.36 -n rutas-norte-dev -- sleep 3600

# 2. Solo requests de CPU
kubectl run p2 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p2","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"cpu":"200m"}}}]}}'

# 3. Solo limits de memoria
kubectl run p3 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p3","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"limits":{"memory":"1Gi"}}}]}}'

# 4. Ambos completos e iguales
kubectl run p4 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p4","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"cpu":"200m","memory":"256Mi"},
  "limits":{"cpu":"200m","memory":"256Mi"}}}]}}'

# 5. Por debajo del minimo
kubectl run p5 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p5","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"memory":"16Mi"}}}]}}'

# 6. Ratio excesivo
kubectl run p6 --image=busybox:1.36 -n rutas-norte-dev \
  --overrides='{"spec":{"containers":[{"name":"p6","image":"busybox:1.36",
  "command":["sleep","3600"],"resources":{"requests":{"memory":"128Mi"},
  "limits":{"memory":"1Gi"}}}]}}'
pod/p1 created
pod/p2 created
pod/p3 created
pod/p4 created
Error from server (Forbidden): pods "p5" is forbidden:
  minimum memory usage per Container is 32Mi, but request is 16Mi
Error from server (Forbidden): pods "p6" is forbidden:
  memory max limit to request ratio per Container is 4, but provided ratio is 8.000000
kubectl get pods -n rutas-norte-dev -o custom-columns='N:.metadata.name,QOS:.status.qosClass,\
RC:.spec.containers[0].resources.requests.cpu,RM:.spec.containers[0].resources.requests.memory,\
LC:.spec.containers[0].resources.limits.cpu,LM:.spec.containers[0].resources.limits.memory' \
  | grep -E "^(N|p[0-9])"
N    QOS         RC     RM      LC     LM
p1   Burstable   100m   128Mi   300m   256Mi
p2   Burstable   200m   128Mi   300m   256Mi
p3   Burstable   100m   1Gi     300m   1Gi
p4   Guaranteed  200m   256Mi   200m   256Mi

El caso p3 es el interesante: declaramos solo limits.memory: 1Gi y el resultado es requests.memory: 1Gi, no los 128Mi del defaultRequest. La razón es el orden de resolución: Kubernetes aplica primero su regla general de "si hay limit y no hay request, la request iguala al limit", y el defaultRequest del LimitRange solo rellena lo que sigue vacío después de eso.

La consecuencia sobre la cuota es seria: este pod, que probablemente consuma 4 MiB de memoria real, ha comprometido 1 GiB del presupuesto de 4 GiB del namespace. Con cuatro pods así, rutas-norte-dev se queda sin cuota de memoria sin que nadie esté usando nada. Es un error muy caro y muy invisible.

Consumo total si se hubieran creado los seis (solo cuentan los cuatro válidos):

Pod requests.cpu requests.memory
p1 100m 128Mi
p2 200m 128Mi
p3 100m 1024Mi
p4 200m 256Mi
Total 600m de 2000m (30 %) 1536Mi de 4096Mi (37,5 %)

p3 solo es el 25 % de los pods y consume el 67 % de la memoria comprometida.

Solución 2

for NS in rutas-norte-dev rutas-norte-pre rutas-norte-pro; do
  kubectl get pods -n $NS -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass' --no-headers
done
rutas-norte-dev   api-reservas-6d8f7c9b-4kx2p           Burstable
rutas-norte-dev   postgres-reservas-5d8f6b9c4-t8wmz     Burstable
rutas-norte-dev   redis-cache-6c9d8f7b5-p2njq           Burstable
rutas-norte-dev   tienda-web-5b7c9f4d8-h7pnk            Burstable
rutas-norte-pre   postgres-reservas-5d8f6b9c4-v4rbd     Burstable
rutas-norte-pro   api-reservas-7f4b8c9d6-2xkqp          Burstable
rutas-norte-pro   postgres-reservas-5d8f6b9c4-k2mnp     Burstable
rutas-norte-pro   redis-cache-6c9d8f7b5-vn4qx           Burstable

postgres-reservas no es Guaranteed en ningún entorno. Motivo:

kubectl get pod -l app=postgres-reservas -n rutas-norte-pro \
  -o jsonpath='{.items[0].spec.containers[0].resources}' | jq .
{
  "limits": { "cpu": "2", "memory": "2Gi" },
  "requests": { "cpu": "500m", "memory": "2Gi" }
}

La memoria coincide pero la CPU no (500m frente a 2). Basta un recurso desigual para perder Guaranteed. Es un error clásico: se iguala la memoria pensando en el OOM y se olvida la CPU.

kubectl patch deployment postgres-reservas -n rutas-norte-pro --type='json' -p='[
  {"op":"replace","path":"/spec/template/spec/containers/0/resources/requests/cpu","value":"1"},
  {"op":"replace","path":"/spec/template/spec/containers/0/resources/limits/cpu","value":"1"}
]'
kubectl rollout status deploy/postgres-reservas -n rutas-norte-pro
kubectl get pod -l app=postgres-reservas -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echo
kubectl exec -n rutas-norte-pro deploy/postgres-reservas -- cat /proc/1/oom_score_adj
deployment.apps/postgres-reservas patched
deployment "postgres-reservas" successfully rolled out
Guaranteed
-997

El cálculo de api-reservas:

oom_score_adj = 1000 - (1000 x 512 / 7751) = 1000 - 66 = 934
kubectl exec -n rutas-norte-pro deploy/api-reservas -- cat /proc/1/oom_score_adj
934

Coincide. La diferencia de 1931 puntos con postgres-reservas es enorme: en la práctica, el kernel sacrificará todas las réplicas de la API antes de tocar la base de datos.

El sidecar:

        - name: exportador-metricas
          image: prometheuscommunity/postgres-exporter:v0.15.0
          # sin resources
kubectl get pod -l app=postgres-reservas -n rutas-norte-pro -o jsonpath='{.items[0].status.qosClass}'; echo
Burstable

El pod ha perdido Guaranteed por culpa de un sidecar sin recursos. En rutas-norte-pro el LimitRange le inyecta requests: 200m/256Mi y limits: 500m/512Mi, que son distintos entre sí, y eso basta. La corrección es dar al sidecar requests == limits también:

        - name: exportador-metricas
          image: prometheuscommunity/postgres-exporter:v0.15.0
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 50m
              memory: 64Mi

Con eso el pod recupera Guaranteed. Lección: en un pod que debe ser Guaranteed, todos sus contenedores deben serlo, incluidos los sidecars y los init containers.

Solución 3

El orden observado es BestEffortBurstable → el Guaranteed sobrevive, con los mensajes del apartado 12.

Con requests.memory: 1Gi en victima-burstable y el mismo consumo de 700Mi:

oom_score_adj = 1000 - (1000 x 1024 / 7751) = 1000 - 132 = 868
kubectl exec -n laboratorio-qos victima-burstable -- cat /proc/1/oom_score_adj
kubectl get pods -n laboratorio-qos -w
868

NAME                   READY   STATUS    RESTARTS   AGE
victima-besteffort     0/1     Evicted   0          2m14s
victima-burstable      1/1     Running   0          3m40s     <-- ahora SOBREVIVE
protegido-guaranteed   1/1     Running   0          3m40s

El Burstable ya no cae. Dos motivos que actúan a la vez:

  1. Su oom_score_adj bajó de 975 a 868, así que el kernel lo prioriza menos como víctima.
  2. Y sobre todo, ya no excede su request: consume 700Mi y reservó 1Gi. El primer criterio de selección del kubelet (apartado 9) es precisamente "pods que exceden sus requests", y este ya no está en ese grupo.

La moraleja operativa es contundente: declarar una request de memoria realista es la protección más barata que existe contra el desalojo. No cambia lo que consumes; cambia si eres candidato o no.

  1. Si postgres-reservas hubiera sido Burstable con requests.memory: 256Mi y un consumo real de 1,8 GiB:
oom_score_adj = 1000 - (1000 x 256 / 7751) = 967

Un 967, casi tan expuesto como un BestEffort, y excediendo su request en más de 1,5 GiB, lo que lo pone en el primer grupo de candidatos. Habría sido de los primeros en caer. Para Rutas Norte eso significa: la base de datos se cae en el pico de venta de un puente, api-reservas empieza a devolver errores de conexión, tienda-web muestra errores a los clientes, y en el peor caso una transacción de reserva a medias deja plazas bloqueadas sin billete emitido. Un Recreate de PostgreSQL además implica recuperación del WAL al arrancar, con minutos de indisponibilidad.

Todo eso lo evita una línea: requests == limits.

  1. Dos medidas adicionales:
Medida Qué aporta Dónde se estudia
PodDisruptionBudget Impide que una operación voluntaria (drenar un nodo para mantenimiento) deje postgres-reservas sin ninguna réplica disponible 09-05
Taints, tolerations y afinidad de nodo Reservar un nodo para la base de datos, sin vecinos ruidosos que puedan generar presión de memoria 06-05

Y una tercera, la más importante a medio plazo: convertir postgres-reservas en un StatefulSet con volumen persistente y copias de seguridad verificadas, de modo que la supervivencia del dato no dependa de la supervivencia del pod. Es el trabajo de los módulos 5 y 6.

Conclusión

Has cerrado los dos cabos sueltos que dejó la lección anterior. El LimitRange convierte una cuota estricta en algo llevable: inyecta default y defaultRequest en los contenedores que no declaran nada, pone suelo y techo con min y max, y limita el sobrecompromiso individual con maxLimitRequestRatio. Sabes que actúa como controlador de admisión mutante antes de que la ResourceQuota valide, que por tanto los valores inyectados consumen cuota, que no es retroactivo, y que los tres tipos —Container, Pod y PersistentVolumeClaim— cubren desde el contenedor suelto hasta el coste de un disco. Y conoces la regla que más dinero cuesta cuando se ignora: si declaras solo limits, la request copia el limit, no el defaultRequest, y con ello reservas mucho más de lo que crees.

Dominas también las clases de calidad de servicio y la regla exacta que las determina: Guaranteed exige requests == limits de CPU y memoria en todos los contenedores del pod, BestEffort exige que ninguno declare nada, y Burstable es todo lo demás. Sabes consultarla con -o jsonpath='{.status.qosClass}' y, sobre todo, sabes qué consecuencias tiene: el oom_score_adj que el kubelet escribe en cada proceso (-997 para Guaranteed, hasta 1000 para BestEffort, y una fórmula proporcional a la request de memoria para Burstable) y el orden de desalojo del kubelet, que mira primero quién excede sus requests y después la clase. Has distinguido el OOM del cgroup —mueres por tu propio límite, sin importar la clase— del OOM del nodo, donde la clase lo decide todo.

Rutas Norte tiene ahora un diseño razonado: postgres-reservas es Guaranteed porque guarda las reservas y los datos personales de los clientes y su muerte para la venta; api-reservas, tienda-web, redis-cache y worker-notificaciones son Burstable porque tienen réplicas, son recuperables y necesitan ráfaga en los puentes; y el análisis exploratorio es BestEffort porque debe ser lo primero en caer. Los tres entornos tienen su LimitRange coherente con su cuota. Y lo has comprobado provocando un desalojo real en el minikube, viendo caer el BestEffort primero, el Burstable que se había pasado de su reserva después, y sobrevivir al Guaranteed; y descubriendo de paso que subir una request a un valor realista es la protección más barata que existe contra el desalojo.

Queda una última pieza del módulo, y es de otra naturaleza. Hemos dado a nuestros componentes su configuración, sus credenciales y sus recursos, pero no les hemos dado identidad. Todos los pods de Rutas Norte están usando ahora mismo la ServiceAccount default de su namespace, con un token montado que ninguno de ellos necesita, y ninguno tiene forma de decirle a la API de Kubernetes quién es. La lección siguiente, ServiceAccounts y Acceso a la API desde los Pods, lo resuelve: verás la diferencia entre usuarios y ServiceAccounts, por qué usar la default es mala idea, cómo han cambiado los tokens (de secretos eternos a tokens proyectados de vida limitada que el kubelet rota), qué hay exactamente en /var/run/secrets/kubernetes.io/serviceaccount/, por qué automountServiceAccountToken: false debe ser tu opción por defecto, y hablarás con la API desde dentro de un pod con curl para ver tanto una respuesta autorizada como un 403 Forbidden bien merecido.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados