Llevamos desde el módulo 2 escribiendo bloques resources en todos los manifiestos de Rutas Norte —cpu: 250m, memory: 256Mi— sin haber explicado nunca qué significan exactamente ni de dónde salen esos números. La lección anterior incluso los expuso al contenedor con resourceFieldRef para que api-reservas se dimensionara a sí mismo. Ha llegado el momento de tomárselos en serio, porque son la pieza que decide en qué nodo cabe cada pod, cuánta CPU recibe cuando hay competencia y qué proceso muere cuando falta memoria. Y arrastramos además la última deuda del módulo 2: ningún namespace tiene cuota, así que hoy mismo un despliegue equivocado en rutas-norte-dev puede comerse la capacidad del clúster y dejar sin sitio a los pods de producción. En esta lección entenderás el modelo de recursos completo y pondrás una ResourceQuota a cada uno de los tres entornos.

Contenido

  1. El modelo de recursos: requests y limits
  2. Unidades: milicores, Mi frente a M
  3. Quién usa las requests: el planificador
  4. Quién usa los limits: el kubelet y los cgroups
  5. CPU comprimible frente a memoria incomprimible
  6. Diagnosticar el estrangulamiento de CPU
  7. Diagnosticar un OOMKilled
  8. ephemeral-storage y el desalojo por disco
  9. Sobrecompromiso del clúster
  10. ResourceQuota: qué limita y cómo se lee
  11. El efecto que obliga a declarar recursos
  12. Las cuotas de los tres entornos de Rutas Norte
  13. Cómo elegir valores razonables

  1. El modelo de recursos: requests y limits

Cada contenedor de un pod puede declarar dos números por cada tipo de recurso:

        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          resources:
            requests:                 # lo que el contenedor NECESITA GARANTIZADO
              cpu: 250m
              memory: 256Mi
            limits:                   # el TECHO que no puede superar
              cpu: "1"
              memory: 512Mi

La diferencia conceptual, que es la base de todo:

requests limits
Significado "Reserva esto para mí" "No me dejes pasar de aquí"
Quién lo usa El planificador, al elegir nodo El kubelet, en tiempo de ejecución
Cuándo actúa Una vez, al crear el pod Continuamente, mientras el pod vive
Si el nodo no tiene El pod queda en Pending
Si se supera No se puede superar: está garantizado CPU: se estrangula. Memoria: OOMKilled
Afecta a la factura del clúster : es capacidad comprometida Indirectamente
Si se omite Se asume 0 (peligroso) Se asume ilimitado (peligroso)

Una analogía útil, y que encaja con Rutas Norte: piensa en un autobús de la flota. La request es el asiento reservado: está pagado y nadie más puede ocuparlo, lo uses o no. El limit es el equipaje máximo permitido: puedes llevar menos, pero si intentas pasar del máximo te lo impiden en la puerta.

Y las dos consecuencias que hay que fijar desde el principio:

  1. requests es lo que "cuesta" en el clúster. Un pod con requests: 4Gi bloquea 4 GiB de capacidad del nodo aunque su proceso use 100 MiB. El planificador considera ese nodo con 4 GiB menos disponibles para todo lo demás.
  2. limits no reserva nada. Un pod con limits: 8Gi no reserva 8 GiB; simplemente no podrá pasar de ahí si intenta usarlos.

Puedes declarar solo requests, solo limits, ambos o ninguno:

Declaración Efecto
Ambos, con requests < limits Lo habitual: garantía mínima con capacidad de ráfaga
Ambos, iguales Máxima previsibilidad y máxima prioridad (lo verás en 03-05)
Solo limits Kubernetes copia el limit en la request. Cuidado: reservas más de lo que crees
Solo requests Sin techo: el contenedor puede consumir todo el nodo
Ninguno El peor caso: sin garantía y sin techo

  1. Unidades: milicores, Mi frente a M

CPU

La unidad de CPU es el core (más exactamente, un hilo de ejecución: un vCPU en la nube, un hiperhilo en metal). Se puede expresar de dos formas:

Escritura Significado
1 o 1000m Un core completo
500m Medio core
250m Un cuarto de core
100m Una décima de core
0.5 Equivalente a 500m, pero se desaconseja
1m El mínimo admitido

La m es de mili: 1000m = 1 core. La forma con m es la preferida porque evita los errores de coma flotante y porque 0.1 puede sufrir problemas de representación. Escribe siempre milicores.

La CPU es fraccionable y elástica: si pides 250m no te dan un cuarto de procesador físico, sino un cuarto del tiempo de CPU disponible en una ventana de tiempo. Y si el nodo está ocioso, puedes usar más (hasta tu limit).

Memoria

La memoria se mide en bytes y admite dos familias de sufijos que no son equivalentes:

Sufijo Base Valor Ejemplo
Ki 2 1.024 512Ki = 524.288 bytes
Mi 2 1.048.576 256Mi = 268.435.456 bytes
Gi 2 1.073.741.824 1Gi = 1.073.741.824 bytes
Ti 2 2⁴⁰
k 10 1.000 512k = 512.000 bytes
M 10 1.000.000 256M = 256.000.000 bytes
G 10 1.000.000.000 1G = 1.000.000.000 bytes

256M es un 6,9 % menos memoria que 256Mi. Y 1G es 74 MiB menos que 1Gi. En un límite de memoria, ese 6,9 % es exactamente la diferencia entre un contenedor que va justo y uno que muere con OOMKilled bajo carga.

Regla para Rutas Norte y para cualquier proyecto serio: usa siempre Mi y Gi, nunca M ni G. Es lo que hacen todas las herramientas de Kubernetes y lo que espera cualquiera que lea tu manifiesto.

Y un error tipográfico que cuesta caro:

            limits:
              memory: 512m           # <- MINUSCULA: 512 MILIbytes = 0.512 bytes

Kubernetes lo acepta sin rechistar: m es un sufijo válido (mili). El contenedor recibe un límite de medio byte y muere al instante. El síntoma es un CrashLoopBackOff desconcertante. m solo tiene sentido en CPU.

kubectl describe pod api-reservas-x -n rutas-norte-dev | grep -A4 Limits
    Limits:
      memory:  512m
    Requests:
      memory:  512m

Si ves eso, ya sabes cuál es el problema.

  1. Quién usa las requests: el planificador

El kube-scheduler que estudiaste en el módulo 1 tiene un trabajo: elegir un nodo para cada pod nuevo. Y la decisión se basa exclusivamente en las requests, no en el uso real.

flowchart TB
    A["Pod nuevo:<br/>requests cpu=250m mem=256Mi"] --> B["FILTRADO<br/>Que nodos tienen capacidad ASIGNABLE libre?"]
    B --> C{"nodo-1<br/>libre: 200m CPU"}
    B --> D{"nodo-2<br/>libre: 1500m CPU"}
    B --> E{"nodo-3<br/>libre: 800m CPU"}
    C -->|"NO cabe"| F["Descartado"]
    D -->|"cabe"| G["PUNTUACION"]
    E -->|"cabe"| G
    G --> H["Elegido: el de mejor puntuacion"]
    H --> I["El pod se ATA al nodo<br/>spec.nodeName"]

El cálculo que hace el planificador para cada nodo es:

libre = asignable - suma de las REQUESTS de todos los pods ya asignados a ese nodo

Dos matices críticos:

Matiz 1: asignable no es la capacidad total del nodo. Kubernetes reserva una parte para el sistema operativo y para sus propios componentes (kubeReserved, systemReserved, evictionHard).

kubectl describe node rutas-norte | grep -A8 "Capacity:"
Capacity:
  cpu:                4
  ephemeral-storage:  61202244Ki
  memory:             8039484Ki
  pods:               110
Allocatable:
  cpu:                4
  ephemeral-storage:  56403448552
  memory:             7937084Ki
  pods:               110

En este minikube la diferencia es pequeña, pero en un nodo gestionado de la nube puede ser el 10-15 % de la memoria. Planifica siempre sobre Allocatable, no sobre Capacity.

Matiz 2: se suman las requests, no el uso real. Esta es la fuente de la confusión más común. Un nodo puede estar al 8 % de uso real de CPU y aun así rechazar un pod nuevo porque la suma de requests de lo que ya hay asignado no deja hueco.

kubectl describe node rutas-norte | grep -A12 "Allocated resources"
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests      Limits
  --------           --------      ------
  cpu                2350m (58%)   5200m (130%)
  memory             2816Mi (36%)  4608Mi (59%)
  ephemeral-storage  0 (0%)        0 (0%)

Léelo con atención: los Requests de CPU suman el 58 % del asignable, pero los Limits suman el 130 %. Eso es el sobrecompromiso del apartado 9, y ese aviso entre paréntesis lo advierte explícitamente.

Cuando no hay hueco en ningún nodo, el pod se queda en Pending:

kubectl get pods -n rutas-norte-dev
kubectl describe pod api-reservas-6d8f7c-abc -n rutas-norte-dev | tail -5
NAME                       READY   STATUS    RESTARTS   AGE
api-reservas-6d8f7c-abc    0/1     Pending   0          2m14s

Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  2m    default-scheduler  0/3 nodes are available:
    3 Insufficient memory. preemption: 0/3 nodes are available: 3 No preemption victims found.

Insufficient memory con el pod en Pending significa siempre lo mismo: las requests no caben. Las soluciones son bajar las requests (si estaban infladas), añadir un nodo, o esperar a que el autoescalador de clúster lo haga por ti.

Un aviso sobre las requests infladas: es tentador pedir de más "por si acaso". Pero cada MiB de request que no usas es capacidad del clúster que nadie puede usar y que estás pagando. En clústeres reales es normal encontrar un 30-40 % de capacidad comprometida y sin usar solo por requests mal calibradas.

  1. Quién usa los limits: el kubelet y los cgroups

Los limits no los ve el planificador. Los aplica el kubelet en el nodo, y no por su cuenta: se lo pide al kernel de Linux a través de los cgroups (control groups), el mecanismo que permite limitar los recursos de un conjunto de procesos.

Podemos verlo desde dentro del contenedor. Con limits: cpu: "1" y memory: 512Mi en cgroups v2:

kubectl exec -n rutas-norte-dev deploy/api-reservas -- sh -c \
  'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/cpu.max; cat /sys/fs/cgroup/cpu.weight'
536870912
100000 100000
10

Descifremos las tres líneas, porque explican el comportamiento entero:

  • memory.max = 536870912: son exactamente 512 MiB. Es un tope duro. Si el proceso intenta reservar el byte 536870913, el kernel dispara el OOM killer.
  • cpu.max = 100000 100000: son cuota y periodo en microsegundos. Significa "100.000 µs de CPU cada 100.000 µs", es decir, un core completo. Con limits: cpu: 500m sería 50000 100000.
  • cpu.weight = 10: deriva de la request de CPU (250m). Es el peso relativo con el que el planificador del kernel reparte el tiempo de CPU cuando hay competencia. Un contenedor con el doble de request recibe el doble de tiempo.

Esa última línea es importante y poco conocida: la request de CPU no es solo para el planificador de Kubernetes; también determina la prioridad relativa dentro del nodo. Dos pods en el mismo nodo compitiendo por CPU se reparten el tiempo en proporción a sus requests.

  1. CPU comprimible frente a memoria incomprimible

Esta es la distinción más importante de la lección y la que hay que entender de verdad.

CPU Memoria
Tipo de recurso Comprimible Incomprimible
Se puede quitar en caliente Sí: basta con darle menos tiempo No: lo que está reservado, está reservado
Al superar el limit Estrangulamiento (throttling) OOMKilled: el proceso muere
Consecuencia El proceso va más lento El contenedor se reinicia y se pierde el trabajo en curso
Se ve en Métricas de container_cpu_cfs_throttled_seconds kubectl describe pod, RESTARTS
Gravedad Latencia alta, tiempos de espera Pérdida de peticiones, corte de servicio

CPU: estrangulamiento. Si tu contenedor tiene limits: cpu: 500m e intenta usar más, el kernel simplemente no le da más tiempo de CPU en ese periodo de 100 ms. El proceso no muere: se queda esperando. El efecto visible es latencia. Para api-reservas esto significa que una petición que tardaba 80 ms empieza a tardar 400 ms, y si el TIEMPO_ESPERA_MS de tienda-web está en 2000, con suficiente estrangulamiento se empiezan a ver errores de tiempo de espera en el navegador del cliente.

Memoria: muerte. No hay forma de "estrangular" la memoria. Si un proceso ha reservado 512 MiB y pide más, el kernel no puede quitarle nada: o le da memoria o mata a alguien. Con limits: memory: 512Mi, mata al proceso que se pasó, dentro del cgroup del contenedor. El contenedor muere con código de salida 137 y el kubelet lo reinicia según el restartPolicy.

Consecuencia práctica para el diseño:

Los limits de memoria hay que calibrarlos con cuidado, porque quedarse corto mata el proceso. Los limits de CPU son mucho menos peligrosos, pero también más discutibles.

Y aquí hay un debate real en la comunidad que conviene conocer: ¿poner o no limits de CPU?

A favor de poner límite de CPU En contra de poner límite de CPU
Evita que un proceso desbocado degrade a sus vecinos Estrangula aunque el nodo esté ocioso: se desperdicia capacidad
Hace el rendimiento predecible entre entornos Puede provocar estrangulamiento en ráfagas cortas de arranque
Permite detectar antes que la aplicación necesita más Con hilos, el estrangulamiento afecta a todo el proceso, no solo al hilo culpable
Necesario para la clase Guaranteed (03-05) Muchos equipos grandes los omiten deliberadamente

Postura de Rutas Norte, que es la recomendable para un equipo que empieza:

  • requests de CPU y memoria: siempre, en todos los contenedores. Sin negociación.
  • limits de memoria: siempre. Un pod sin límite de memoria puede tumbar el nodo entero y provocar desalojos en cadena.
  • limits de CPU: sí en dev y pre (para detectar pronto el consumo excesivo) y generosos en pro, entre 2 y 4 veces la request, para permitir absorber los picos de puentes y vacaciones.

  1. Diagnosticar el estrangulamiento de CPU

El estrangulamiento es traicionero porque no aparece en ningún estado del pod. El pod está Running, READY 1/1, sin reinicios, y aun así responde mal.

La señal está en el propio cgroup:

kubectl exec -n rutas-norte-pro deploy/api-reservas -- cat /sys/fs/cgroup/cpu.stat
usage_usec 184920331
user_usec 152014882
system_usec 32905449
nr_periods 918442
nr_throttled 214883
throttled_usec 41220984

La lectura:

  • nr_periods: cuántos periodos de 100 ms han transcurrido.
  • nr_throttled: en cuántos de ellos el contenedor agotó su cuota y fue estrangulado.
  • throttled_usec: microsegundos totales que ha pasado esperando.

El indicador clave es el cociente:

throttling = nr_throttled / nr_periods = 214883 / 918442 = 23,4 %

Casi una cuarta parte de los periodos han sido estrangulados. Interpretación:

Cociente Diagnóstico
< 1 % Normal. Ráfagas puntuales
1-5 % Vigilar. Aceptable en procesos por lotes
5-25 % Problema real de latencia. Subir el limit
> 25 % Grave. El límite está claramente mal puesto

Un comando para revisar todos los pods de un componente:

for P in $(kubectl get pods -n rutas-norte-pro -l app=api-reservas -o name); do
  echo "--- $P"
  kubectl exec -n rutas-norte-pro "$P" -- sh -c \
    'awk "/nr_periods|nr_throttled/ {print \$1, \$2}" /sys/fs/cgroup/cpu.stat'
done
--- pod/api-reservas-7f4b8c9d6-2xkqp
nr_periods 918442
nr_throttled 214883
--- pod/api-reservas-7f4b8c9d6-8mvtr
nr_periods 917210
nr_throttled 198445

La forma correcta de vigilar esto de manera continua es con métricas: la serie container_cpu_cfs_throttled_periods_total de cAdvisor, que verás en Monitoreo con Prometheus. El comando de arriba sirve para un diagnóstico puntual.

La corrección: subir el limit de CPU. Si api-reservas tiene requests: 250m y limits: 500m con un 23 % de estrangulamiento, subir el límite a "1" suele resolverlo sin coste real, porque el limit no reserva capacidad.

  1. Diagnosticar un OOMKilled

El caso opuesto es escandaloso y fácil de identificar:

kubectl get pods -n rutas-norte-pro -l app=api-reservas
NAME                           READY   STATUS      RESTARTS      AGE
api-reservas-7f4b8c9d6-2xkqp   1/1     Running     4 (2m ago)    41m
api-reservas-7f4b8c9d6-8mvtr   0/1     OOMKilled   3 (18s ago)   41m

El detalle está en describe:

kubectl describe pod api-reservas-7f4b8c9d6-8mvtr -n rutas-norte-pro
Containers:
  api:
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Wed, 05 Aug 2026 23:12:04 +0200
      Finished:     Wed, 05 Aug 2026 23:14:41 +0200
    Restart Count:  3
    Limits:
      cpu:     1
      memory:  512Mi
    Requests:
      cpu:     250m
      memory:  256Mi

Las tres pistas inequívocas: Reason: OOMKilled, Exit Code: 137 (que es 128 + 9, la señal SIGKILL) y un Restart Count que crece.

Cuidado con el Last State: describe el contenedor anterior. Si el pod está Running ahora mismo pero tiene RESTARTS 4, el Last State te dice por qué murió la vez pasada. Es la primera cosa que hay que mirar ante un pod con reinicios.

Y una advertencia sobre kubectl logs: al reiniciarse el contenedor, los logs empiezan de cero. Para ver los del contenedor muerto, que son los que contienen la pista:

kubectl logs api-reservas-7f4b8c9d6-8mvtr -n rutas-norte-pro --previous | tail -6
2026-08-05T23:14:38.221Z INFO  [pod=api-reservas-7f4b8c9d6-8mvtr] consulta_disponibilidad
  ruta=Bilbao-Santander fechas=2026-08-14..2026-08-17 resultados=8412
2026-08-05T23:14:40.887Z WARN  [pod=api-reservas-7f4b8c9d6-8mvtr] heap 478MB / 512MB

Ahí está la causa: una consulta de disponibilidad de un puente devolvió 8.412 resultados y la aplicación los cargó todos en memoria.

Las cinco causas típicas de un OOMKilled y su tratamiento:

Causa Señal Solución
Límite demasiado bajo Muere siempre bajo carga normal Subir limits.memory
Fuga de memoria Muere periódicamente, cada vez más rápido Arreglar el código; mientras tanto, subir el límite retrasa el problema, no lo resuelve
Pico puntual (consulta grande) Muere solo con ciertas peticiones Paginar la consulta, limitar resultados
Entorno de ejecución que no ve el límite La JVM o Node reservan según la RAM del nodo -XX:MaxRAMPercentage o --max-old-space-size con resourceFieldRef (03-03)
Contenedor equivocado El que muere es el sidecar, no la app Mirar el nombre del contenedor en describe

La cuarta merece una nota, porque es la más frecuente y la más invisible: un proceso Java sin MaxRAMPercentage en un nodo de 32 GiB con un limit de 512 MiB dimensiona su montón según los 32 GiB del nodo y muere en cuanto empieza a trabajar. La lección anterior ya te dio la herramienta para arreglarlo.

  1. ephemeral-storage y el desalojo por disco

Hay un tercer recurso que casi nadie declara y que provoca incidentes desconcertantes: el almacenamiento efímero, es decir, el disco del nodo que usa el contenedor para:

  • La capa de escritura de su sistema de ficheros (todo lo que escriba fuera de un volumen).
  • Los volúmenes emptyDir.
  • Los logs del contenedor (lo que escribe a stdout/stderr, que el kubelet guarda en el disco del nodo).

Se declara igual que los demás:

          resources:
            requests:
              cpu: 250m
              memory: 256Mi
              ephemeral-storage: 1Gi
            limits:
              cpu: "1"
              memory: 512Mi
              ephemeral-storage: 2Gi

Qué pasa al superar el límite: el kubelet desaloja el pod, con Reason: Evicted:

kubectl get pods -n rutas-norte-pro | grep Evicted
kubectl describe pod worker-notificaciones-6f7d9-abcde -n rutas-norte-pro | grep -A3 "Status:"
worker-notificaciones-6f7d9-abcde   0/1   Evicted   0   34m

Status:   Failed
Reason:   Evicted
Message:  Pod ephemeral local storage usage exceeds the total limit of containers 2Gi.

Un matiz relevante: a diferencia del OOMKilled, el desalojo por disco no es inmediato. El kubelet comprueba el uso cada 10 segundos aproximadamente, así que un proceso puede llenar el disco antes de que lo desalojen.

Y el problema mayor: si el disco del nodo se llena, el kubelet entra en presión y desaloja pods aunque tengan sus límites en orden. Es un fallo que afecta a todo el nodo. Las señales:

kubectl describe node rutas-norte | grep -A6 Conditions
Conditions:
  Type             Status  Reason                       Message
  ----             ------  ------                       -------
  MemoryPressure   False   KubeletHasSufficientMemory   kubelet has sufficient memory available
  DiskPressure     True    KubeletHasDiskPressure       kubelet has disk pressure
  PIDPressure      False   KubeletHasSufficientPID      kubelet has sufficient PID available
  Ready            True    KubeletReady                 kubelet is posting ready status

DiskPressure: True explica desalojos aparentemente aleatorios. El orden en que el kubelet elige a las víctimas depende de la clase de QoS, que es el tema de la lección siguiente.

En Rutas Norte, el candidato natural a llenar el disco es worker-notificaciones: si genera un log por cada correo enviado y en un puente se envían decenas de miles, sin rotación de logs el disco del nodo se llena. La regla práctica: declara ephemeral-storage en cualquier componente que escriba logs voluminosos o use emptyDir, y vigila el DiskPressure de los nodos.

  1. Sobrecompromiso del clúster

Volvamos a esa línea de antes:

  Resource  Requests      Limits
  cpu       2350m (58%)   5200m (130%)

La suma de los límites de CPU es el 130 % del asignable del nodo. ¿Está el clúster mal configurado? No: eso es normal y hasta deseable.

flowchart TB
    subgraph N["Nodo: 4 cores asignables"]
        R["REQUESTS comprometidas: 2350m<br/>(garantizadas, el planificador no las sobrepasa)"]
        L["LIMITS sumados: 5200m<br/>(el planificador NO los mira)"]
    end
    R --> S["Seguro: el planificador nunca<br/>compromete mas requests que capacidad"]
    L --> P["Sobrecompromiso: solo es un problema<br/>si TODOS pican a la vez"]

La lógica del sobrecompromiso: los pods no consumen su máximo simultáneamente. worker-notificaciones trabaja a ráfagas, api-reservas tiene picos en horas concretas, tienda-web sirve estáticos con poco consumo. Permitir que la suma de límites supere la capacidad aprovecha esos huecos.

Dónde está el peligro, según el recurso:

Recurso Si todos piden a la vez
CPU Todos se estrangulan proporcionalmente a su request. Latencia alta, sin caídas. Recuperable
Memoria El nodo se queda sin memoria. El kubelet desaloja pods. Si va muy rápido, el OOM killer del kernel mata procesos. Caídas reales

La conclusión operativa es asimétrica y hay que grabarla:

Sobrecomprometer CPU es aceptable y habitual. Sobrecomprometer memoria de forma agresiva es peligroso.

Para Rutas Norte, las relaciones recomendadas:

Recurso Relación limit / request Motivo
CPU Entre 2 y 4 Absorber picos de puentes sin desperdiciar capacidad
Memoria Entre 1 y 1,5 Cuanto más ajustado, menos riesgo de desalojos en cadena
Memoria de postgres-reservas Exactamente 1 (iguales) Una base de datos nunca debe ser candidata a desalojo

Ese último caso, con requests == limits, tiene un nombre y consecuencias muy concretas sobre quién muere primero: es la clase Guaranteed, y es el tema de la lección siguiente.

Y una advertencia sobre memoria que sorprende a mucha gente: Kubernetes desactiva el swap por defecto (aunque desde 1.30 hay soporte beta configurable). Sin swap, el kernel no tiene amortiguador: cuando la memoria se acaba, se acaba. Es un motivo más para no sobrecomprometerla.

  1. ResourceQuota: qué limita y cómo se lee

Todo lo anterior es por contenedor. La ResourceQuota actúa a otro nivel: pone un techo al conjunto de un namespace. Es el objeto que salda la última deuda del módulo 2.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-dev
  namespace: rutas-norte-dev
spec:
  hard:
    # --- Computo ---
    requests.cpu: "2"                    # suma de las requests de CPU de todos los pods
    requests.memory: 4Gi
    limits.cpu: "4"                      # suma de los limits
    limits.memory: 8Gi
    requests.ephemeral-storage: 10Gi

    # --- Numero de objetos ---
    pods: "20"
    services: "10"
    configmaps: "20"
    secrets: "20"
    persistentvolumeclaims: "5"
    services.loadbalancers: "0"          # prohibido crear LoadBalancers en dev
    services.nodeports: "0"
    count/deployments.apps: "10"
    count/jobs.batch: "10"
    count/cronjobs.batch: "5"

    # --- Almacenamiento ---
    requests.storage: 20Gi               # suma de lo pedido por todos los PVC

Las tres familias de límites:

Familia Ejemplos Qué controla
Cómputo requests.cpu, limits.memory Que un entorno no consuma toda la capacidad
Número de objetos pods, services, secrets Que nadie sature etcd o el plano de control
Almacenamiento requests.storage, persistentvolumeclaims El coste de los discos (módulo 5)

Puntos que definen su comportamiento:

  • Es un objeto con namespace y solo afecta a su namespace. No existen cuotas globales de clúster.
  • Se aplica en el momento de la creación. Un pod que superaría la cuota se rechaza; los que ya existen no se tocan.
  • Puede haber varias ResourceQuota en un namespace. Se aplican todas y hay que cumplirlas todas.
  • Los pods terminados no cuentan. Los que están en Succeeded o Failed se excluyen del cómputo.
  • services.loadbalancers: "0" es una herramienta de control de coste muy útil: cada LoadBalancer en la nube cuesta dinero.

La lectura de una cuota:

kubectl describe resourcequota cuota-dev -n rutas-norte-dev
Name:                     cuota-dev
Namespace:                rutas-norte-dev
Resource                  Used    Hard
--------                  ----    ----
configmaps                6       20
count/cronjobs.batch      0       5
count/deployments.apps    5       10
limits.cpu                2300m   4
limits.memory             3200Mi  8Gi
persistentvolumeclaims    1       5
pods                      9       20
requests.cpu              1150m   2
requests.memory           1600Mi  4Gi
secrets                   4       20
services                  4       10
services.loadbalancers    0       0

Dos columnas: Used (lo consumido ahora) y Hard (el techo). Este namespace está al 57 % de las requests de CPU y al 40 % de memoria. Es la vista que responde a "¿cuánto margen me queda en desarrollo?".

Cuando alguien intenta pasarse:

kubectl scale deploy/api-reservas --replicas=12 -n rutas-norte-dev
kubectl get events -n rutas-norte-dev --sort-by=.lastTimestamp | tail -3
deployment.apps/api-reservas scaled

LAST SEEN   TYPE      REASON         OBJECT                            MESSAGE
3s          Warning   FailedCreate   replicaset/api-reservas-6d8f7c9b   Error creating: pods
  "api-reservas-6d8f7c9b-" is forbidden: exceeded quota: cuota-dev, requested: requests.cpu=250m,
  used: requests.cpu=1900m, limited: requests.cpu=2

Presta atención a un detalle muy importante para el diagnóstico: el comando kubectl scale ha tenido éxito. El Deployment ahora dice 12 réplicas. Lo que falla es la creación de los pods por parte del ReplicaSet, y ese fallo no aparece en kubectl get deploy más que como un READY 8/12 que dura para siempre.

kubectl get deploy api-reservas -n rutas-norte-dev
kubectl describe deploy api-reservas -n rutas-norte-dev | grep -A4 Conditions
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   8/12    12           8           3h

Conditions:
  Type             Status  Reason
  ----             ------  ------
  Available        True    MinimumReplicasAvailable
  ReplicaProgressing False ProgressDeadlineExceeded

Ante un Deployment atascado en READY x/y, mira siempre los eventos del ReplicaSet y la cuota del namespace. Es una de las causas más frecuentes y de las menos evidentes, y conecta directamente con el diagnóstico de despliegues atascados que viste en el módulo 2.

Ámbitos (scopes)

Una cuota puede aplicarse solo a un subconjunto de pods:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-alta-prioridad
  namespace: rutas-norte-pro
spec:
  hard:
    requests.cpu: "8"
    requests.memory: 16Gi
  scopeSelector:
    matchExpressions:
      - operator: In
        scopeName: PriorityClass
        values: ["alta"]

Ámbitos disponibles: Terminating y NotTerminating (según tengan activeDeadlineSeconds), BestEffort y NotBestEffort (según la clase de QoS de la lección siguiente), y PriorityClass. Sirven para dar más margen a lo crítico que a lo experimental dentro del mismo namespace.

  1. El efecto que obliga a declarar recursos

Aquí está el comportamiento más útil y a la vez más desconcertante de las ResourceQuota:

Si un namespace tiene una cuota que limita requests.cpu o limits.memory, entonces TODO pod creado en él debe declarar ese recurso. Si no lo declara, se rechaza.

La lógica es evidente en cuanto se piensa: para contabilizar la suma de requests.cpu del namespace, Kubernetes necesita que todos los pods declaren requests.cpu. Un pod sin declararla haría el cómputo imposible.

Demostración. Con la cuota de dev activa, intentamos crear un pod sin recursos:

kubectl run prueba-sin-recursos --image=nginx:1.27.1-alpine -n rutas-norte-dev
Error from server (Forbidden): pods "prueba-sin-recursos" is forbidden: failed quota: cuota-dev:
  must specify limits.cpu for: prueba-sin-recursos; limits.memory for: prueba-sin-recursos;
  requests.cpu for: prueba-sin-recursos; requests.memory for: prueba-sin-recursos

Este es, de largo, el efecto secundario más valioso de las cuotas: convierte la disciplina de declarar recursos en algo obligatorio a nivel de plataforma, no en una recomendación que la gente olvida.

Pero tiene un coste inmediato: rompe todo lo cómodo. Un pod efímero para depurar, un kubectl run rápido, un Job puntual... todos fallan. Y aquí es donde aparece la pieza que falta, que es el tema de la lección siguiente: un LimitRange en el namespace inyecta valores por defecto en los contenedores que no declaran nada, con lo que el pod pasa la validación de la cuota sin que nadie escriba resources a mano.

flowchart LR
    A["Pod sin resources"] --> B{"Hay LimitRange<br/>en el namespace?"}
    B -->|"Si"| C["Se inyectan<br/>default y defaultRequest"]
    B -->|"No"| D["El pod sigue<br/>sin resources"]
    C --> E{"Hay ResourceQuota<br/>de computo?"}
    D --> E
    E -->|"Si, y el pod declara"| F["Se contabiliza. ACEPTADO"]
    E -->|"Si, y no declara"| G["RECHAZADO<br/>must specify requests.cpu"]
    E -->|"No"| F

ResourceQuota y LimitRange son inseparables. Poner una cuota sin un LimitRange es una decisión defendible (obliga a todo el mundo a pensar sus recursos), pero convierte el día a día en una molestia constante. La combinación de las dos es la configuración estándar de cualquier namespace serio.

  1. Las cuotas de los tres entornos de Rutas Norte

Ahora sí: aplicamos las cuotas y la deuda queda saldada. El criterio es que dev no pueda hacer daño y pro tenga margen para los picos de puentes y vacaciones.

rutas-norte-dev

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-dev
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  hard:
    requests.cpu: "2"                    # 2 cores comprometidos como maximo
    requests.memory: 4Gi
    limits.cpu: "4"
    limits.memory: 8Gi
    pods: "25"
    services: "10"
    services.loadbalancers: "0"          # nada de balanceadores de pago en dev
    services.nodeports: "2"
    persistentvolumeclaims: "4"
    requests.storage: 10Gi
    configmaps: "30"
    secrets: "30"
    count/deployments.apps: "15"
    count/cronjobs.batch: "5"

rutas-norte-pre

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-pre
  namespace: rutas-norte-pre
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pre
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 12Gi
    pods: "30"
    services: "15"
    services.loadbalancers: "1"          # uno, para probar el Ingress real
    persistentvolumeclaims: "6"
    requests.storage: 50Gi
    configmaps: "40"
    secrets: "40"
    count/deployments.apps: "20"

rutas-norte-pro

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-pro
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  hard:
    requests.cpu: "24"                   # margen para el autoescalado del modulo 9
    requests.memory: 48Gi
    limits.cpu: "48"
    limits.memory: 64Gi
    pods: "150"
    services: "25"
    services.loadbalancers: "3"
    persistentvolumeclaims: "12"
    requests.storage: 500Gi
    configmaps: "60"
    secrets: "60"
    count/deployments.apps: "30"

Comparativa y justificación:

Recurso dev pre pro Por qué
requests.cpu 2 4 24 pro sirve tráfico real y debe crecer en puentes
requests.memory 4Gi 8Gi 48Gi Ídem, más el caché y la base de datos
pods 25 30 150 El HPA necesita techo alto en pro
services.loadbalancers 0 1 3 Cada uno cuesta dinero al mes
requests.storage 10Gi 50Gi 500Gi Los datos de reservas solo crecen en producción
Relación limits/requests CPU Sobrecompromiso moderado y uniforme
Relación limits/requests memoria 1,5× 1,33× Más conservador cuanto más crítico

Aplicación y verificación:

kubectl apply -f k8s/entornos/dev/resourcequota.yaml
kubectl apply -f k8s/entornos/pre/resourcequota.yaml
kubectl apply -f k8s/entornos/pro/resourcequota.yaml
kubectl get resourcequota -A
resourcequota/cuota-dev created
resourcequota/cuota-pre created
resourcequota/cuota-pro created

NAMESPACE         NAME        AGE   REQUEST                                              LIMIT
rutas-norte-dev   cuota-dev   8s    pods: 9/25, requests.cpu: 1150m/2, ...               limits.cpu: 2300m/4, ...
rutas-norte-pre   cuota-pre   7s    pods: 4/30, requests.cpu: 500m/4, ...                limits.cpu: 1000m/8, ...
rutas-norte-pro   cuota-pro   6s    pods: 14/150, requests.cpu: 4200m/24, ...            limits.cpu: 9600m/48, ...

La deuda está saldada. Ahora, si alguien escala api-reservas a 50 réplicas en desarrollo por error, o si un bucle de reinicio crea pods sin parar, el daño queda contenido dentro de rutas-norte-dev: los pods se quedarán sin crear y producción no se entera. Recuerda del módulo 2 que el namespace no aísla la red ni el DNS; la cuota es una de las pocas cosas que el namespace sí aísla de verdad, y por eso es tan valiosa.

Una nota final sobre la suma total. Si sumas las requests.cpu de las tres cuotas salen 30 cores, y tu clúster puede tener menos. Eso es intencionado: la cuota es un techo por entorno, no una reserva. Nadie garantiza que puedas llegar al techo de los tres a la vez; lo que garantiza es que ninguno solo pueda pasar de su techo.

  1. Cómo elegir valores razonables

Los números anteriores no salen de la intuición. Este es el método.

Paso 1: medir, no adivinar. Necesitas metrics-server, que activamos como addon en el módulo 1:

kubectl top pods -n rutas-norte-pro --sort-by=memory
NAME                                   CPU(cores)   MEMORY(bytes)
postgres-reservas-5d8f6b9c4-k2mnp      180m         1842Mi
api-reservas-7f4b8c9d6-2xkqp           310m         387Mi
api-reservas-7f4b8c9d6-8mvtr           285m         371Mi
redis-cache-6c9d8f7b5-vn4qx            22m          148Mi
worker-notificaciones-6f7d9c8b4-lm3pt  95m          212Mi
tienda-web-5b7c9f4d8-h7pnk             8m           14Mi

El detalle de este comando y sus limitaciones (es una foto instantánea, no un histórico) es el tema de Servidor de Métricas. Para calibrar bien hace falta un histórico con percentiles, que es lo que da Prometheus.

Paso 2: aplicar las fórmulas.

Recurso Fórmula Razonamiento
requests.memory Percentil 95 del uso × 1,2 La memoria no se estrangula: quédate corto y mueres
limits.memory requests.memory × 1,3 a 1,5 Margen para picos, sin sobrecomprometer
requests.cpu Percentil 50 (mediana) del uso La CPU sí se comprime: la mediana basta para la garantía
limits.cpu requests.cpu × 2 a 4 Absorber ráfagas sin estrangular

Fíjate en la asimetría: memoria por el percentil 95, CPU por la mediana. Es la aplicación directa del apartado 5.

Paso 3: calcular para api-reservas en producción. Con un histórico de dos semanas, incluyendo un puente:

CPU:     mediana 280m, p95 620m, pico 940m
Memoria: mediana 370Mi, p95 445Mi, pico 502Mi
          resources:
            requests:
              cpu: 300m               # ~ mediana
              memory: 534Mi           # 445Mi x 1,2 -> redondeamos a 512Mi
            limits:
              cpu: "1"                # ~ 3x la request, cubre el pico de 940m
              memory: 768Mi           # 512Mi x 1,5

Paso 4: iterar. Los valores iniciales están mal por definición. Revísalos:

  • Tras cada cambio significativo de la aplicación.
  • Tras cada temporada alta (en Rutas Norte, después de cada puente y del verano).
  • Cuando aparezca estrangulamiento por encima del 5 % o cualquier OOMKilled.
  • Trimestralmente, para recuperar capacidad comprometida y sin usar.

Cuatro reglas que ahorran disgustos:

  1. Empieza generoso y baja. Un límite alto de más cuesta capacidad; uno bajo de más provoca caídas en producción. En la primera iteración, prefiere el desperdicio.
  2. Multiplica por réplicas antes de mirar la cuota. requests.cpu: 300m × 8 réplicas = 2400m del presupuesto de pro.
  3. Los componentes con estado son distintos. postgres-reservas debe tener requests == limits de memoria: es la clase Guaranteed de la lección siguiente, y una base de datos nunca debe ser la primera candidata a morir.
  4. Documenta de dónde salen los números. Un comentario en el YAML con la fecha de la medición y el percentil usado convierte el manifiesto en algo revisable:
          resources:
            # Calibrado 2026-07-28 sobre 14 dias (incluye puente de julio)
            # CPU: mediana 280m, p95 620m | Memoria: p95 445Mi, pico 502Mi
            requests:
              cpu: 300m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 768Mi

Errores Comunes y Consejos

Error Síntoma Solución
memory: 512m en minúscula CrashLoopBackOff inmediato m es mili. Usa 512Mi
Confundir M con Mi OOMKilled inexplicable con el 6,9 % de memoria de menos Usa siempre Mi/Gi
Pod en Pending con Insufficient memory No hay nodo con hueco para las requests Bajar requests, añadir nodo o autoescalar
Mirar el uso real esperando que el planificador lo use "Si el nodo está al 10 %, ¿por qué no cabe?" El planificador solo mira requests
Solo limits sin requests Se reserva más de lo previsto Kubernetes copia el limit en la request
Ningún limit de memoria Un pod puede tumbar el nodo Declara siempre limits.memory
Límite de CPU muy ajustado Latencia alta sin reinicios ni errores visibles Mirar cpu.stat: nr_throttled/nr_periods
JVM o Node sin conocer su límite OOMKilled con un límite aparentemente suficiente MaxRAMPercentage o resourceFieldRef (03-03)
No mirar --previous en los logs "No hay nada en los logs" tras un reinicio kubectl logs --previous
Cuota sin LimitRange kubectl run falla siempre Añade un LimitRange (03-05)
Deployment atascado en READY 8/12 La cuota rechaza los pods nuevos, sin error visible en el Deployment kubectl get events y describe quota
Cuota puesta sin avisar al equipo Despliegues que fallan sin explicación Comunícalo y deja margen inicial
No declarar ephemeral-storage Pods Evicted y DiskPressure en el nodo Declararlo en lo que escriba logs o use emptyDir

Consejos:

  1. Toda plantilla de manifiesto debe traer resources. Ponlo en tu plantilla base para que no haya que recordarlo.
  2. Revisa la cuota antes de escalar. kubectl describe quota -n <ns> antes de un kubectl scale.
  3. Vigila las requests sin usar. Es el gasto invisible más grande de un clúster: capacidad comprometida que nadie aprovecha.
  4. Añade una alerta de cuota al 80 %. Que te enteres antes de que un despliegue falle, no durante.
  5. ephemeral-storage no es opcional en producción. Un nodo con DiskPressure desaloja pods sanos.

Ejercicios

Ejercicio 1: Provocar y diagnosticar los dos fallos

  1. Crea en rutas-norte-dev un pod devorador-memoria con limits.memory: 128Mi que ejecute un proceso que intente reservar 300 MiB. Usa la imagen polinux/stress o busybox con un fichero en /dev/shm.
  2. Observa su estado, identifica el Reason y el Exit Code, y explica qué significa 137.
  3. Crea un pod devorador-cpu con limits.cpu: 100m que ejecute un bucle infinito.
  4. Comprueba con kubectl top cuánta CPU consume realmente y lee /sys/fs/cgroup/cpu.stat para calcular el porcentaje de estrangulamiento.
  5. Explica en una tabla por qué uno murió y el otro no, a pesar de que ambos superaron su límite.

Ejercicio 2: Poner cuota a los tres entornos

  1. Aplica las tres ResourceQuota del apartado 12.
  2. Muestra el consumo actual de cada namespace con kubectl describe.
  3. Intenta escalar api-reservas en rutas-norte-dev a un número de réplicas que supere la cuota. ¿Qué comando falla y cuál tiene éxito? Localiza el mensaje de error exacto.
  4. Intenta crear un pod con kubectl run sin declarar recursos en rutas-norte-dev. Copia el error y explícalo.
  5. Calcula cuántas réplicas de api-reservas (con requests: cpu 250m, memory 256Mi) caben como máximo en rutas-norte-dev según cada uno de los cuatro límites de cómputo, e indica cuál es el que manda.

Ejercicio 3: Calibrar worker-notificaciones

Dispones de estas mediciones de dos semanas en producción, incluyendo un puente:

CPU:     mediana 95m,  p95 340m,  pico 780m
Memoria: mediana 212Mi, p95 268Mi, pico 295Mi
Disco:   logs 400 MiB/dia, emptyDir de adjuntos hasta 600 MiB
  1. Calcula requests y limits de CPU y memoria aplicando las fórmulas del apartado 13, y justifica por qué la memoria usa el p95 y la CPU la mediana.
  2. Decide un valor de ephemeral-storage y razónalo.
  3. Escribe el bloque resources completo, con el comentario de trazabilidad.
  4. Con 3 réplicas en rutas-norte-pro, calcula qué porcentaje de la cuota de producción consume este componente.
  5. Si el pico de CPU es de 780m y pones limits.cpu: 700m, ¿qué le pasará al componente durante el puente? ¿Es aceptable para un trabajador de correos? Compara con la respuesta si fuera api-reservas.

Soluciones

Solución 1

apiVersion: v1
kind: Pod
metadata:
  name: devorador-memoria
  namespace: rutas-norte-dev
  labels:
    app: laboratorio
    entorno: dev
spec:
  restartPolicy: Never
  containers:
    - name: estres
      image: polinux/stress:1.0.4
      command: ["stress"]
      args: ["--vm", "1", "--vm-bytes", "300M", "--vm-hang", "1"]
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          cpu: 100m
          memory: 128Mi
kubectl apply -f devorador-memoria.yaml
sleep 15
kubectl get pod devorador-memoria -n rutas-norte-dev
kubectl describe pod devorador-memoria -n rutas-norte-dev | grep -A6 "Last State"
pod/devorador-memoria created

NAME                READY   STATUS      RESTARTS   AGE
devorador-memoria   0/1     OOMKilled   0          15s

    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Wed, 05 Aug 2026 23:41:02 +0200
      Finished:     Wed, 05 Aug 2026 23:41:04 +0200

El 137 es 128 + 9. Por convención de Unix, un proceso terminado por una señal devuelve 128 + número de señal, y la señal 9 es SIGKILL. Es la firma inequívoca de una muerte violenta: el kernel no le dio al proceso ninguna oportunidad de limpiar. (Su pariente, el 143 = 128 + 15, es SIGTERM: terminación ordenada, la del módulo 2.)

El devorador de CPU:

apiVersion: v1
kind: Pod
metadata:
  name: devorador-cpu
  namespace: rutas-norte-dev
  labels:
    app: laboratorio
    entorno: dev
spec:
  containers:
    - name: bucle
      image: busybox:1.36
      command: ["sh", "-c", "while true; do :; done"]
      resources:
        requests:
          cpu: 50m
          memory: 32Mi
        limits:
          cpu: 100m
          memory: 64Mi
kubectl apply -f devorador-cpu.yaml
sleep 60
kubectl top pod devorador-cpu -n rutas-norte-dev
kubectl exec devorador-cpu -n rutas-norte-dev -- cat /sys/fs/cgroup/cpu.stat
pod/devorador-cpu created

NAME            CPU(cores)   MEMORY(bytes)
devorador-cpu   100m         1Mi

nr_periods 604
nr_throttled 601
throttled_usec 53420118

El bucle infinito querría consumir un core entero (1000m), pero kubectl top muestra exactamente 100m: el límite se cumple al milicore. Y el estrangulamiento es de 601/604 = 99,5 %: prácticamente todos los periodos han sido recortados. Aun así, el pod está Running y sin reinicios.

devorador-memoria devorador-cpu
Superó su límite Sí (300 MiB > 128 MiB) Sí (querría 1000m > 100m)
Recurso Incomprimible Comprimible
Qué pudo hacer el kernel Nada: la memoria pedida no se puede "dar más despacio" Darle menos tiempo de CPU
Resultado OOMKilled, código 137 Running, estrangulado al 99,5 %
Impacto en el negocio Pérdida del trabajo en curso Lentitud

Solución 2

kubectl apply -f k8s/entornos/dev/resourcequota.yaml \
              -f k8s/entornos/pre/resourcequota.yaml \
              -f k8s/entornos/pro/resourcequota.yaml
kubectl describe quota -n rutas-norte-dev
Name:                     cuota-dev
Namespace:                rutas-norte-dev
Resource                  Used    Hard
--------                  ----    ----
count/deployments.apps    5       15
limits.cpu                2300m   4
limits.memory             3200Mi  8Gi
pods                      9       25
requests.cpu              1150m   2
requests.memory           1600Mi  4Gi
services                  4       10
services.loadbalancers    0       0
kubectl scale deploy/api-reservas --replicas=12 -n rutas-norte-dev
kubectl get deploy api-reservas -n rutas-norte-dev
kubectl get events -n rutas-norte-dev --field-selector reason=FailedCreate | tail -2
deployment.apps/api-reservas scaled

NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   3/12    12           3           3h

LAST SEEN   TYPE      REASON         OBJECT                             MESSAGE
5s          Warning   FailedCreate   replicaset/api-reservas-6d8f7c9b   Error creating: pods
  "api-reservas-6d8f7c9b-" is forbidden: exceeded quota: cuota-dev,
  requested: requests.cpu=250m, used: requests.cpu=1900m, limited: requests.cpu=2

kubectl scale tiene éxito y la creación de los pods falla. El Deployment queda con READY 3/12 indefinidamente. Esta asimetría es la clave del diagnóstico: el error nunca está en el objeto que tocaste, sino en los eventos del ReplicaSet.

kubectl run prueba --image=nginx:1.27.1-alpine -n rutas-norte-dev
Error from server (Forbidden): pods "prueba" is forbidden: failed quota: cuota-dev:
  must specify limits.cpu for: prueba; limits.memory for: prueba;
  requests.cpu for: prueba; requests.memory for: prueba

La cuota controla requests.cpu, requests.memory, limits.cpu y limits.memory. Para poder contabilizar esas cuatro sumas necesita que todos los pods las declaren, así que rechaza el que no lo hace. La solución elegante, sin obligar a escribirlas a mano, es el LimitRange de la lección siguiente.

El cálculo de réplicas máximas, con requests: cpu 250m, memory 256Mi y limits: cpu 500m, memory 512Mi:

Límite de la cuota Techo Consumo por réplica Réplicas máximas
requests.cpu 2000m 250m 8
requests.memory 4096Mi 256Mi 16
limits.cpu 4000m 500m 8
limits.memory 8192Mi 512Mi 16
pods 25 1 25

Manda requests.cpu (y limits.cpu, empatados): 8 réplicas. El límite efectivo es siempre el más restrictivo, y aquí es la CPU. Y ojo: esas 8 réplicas serían el namespace entero, así que hay que descontar lo que ya consumen tienda-web, postgres-reservas, redis-cache y worker-notificaciones.

Solución 3

  1. Aplicando las fórmulas:
requests.memory = p95 x 1,2 = 268Mi x 1,2 = 321,6Mi  -> redondeamos a 320Mi
limits.memory   = 320Mi x 1,5 = 480Mi                -> redondeamos a 512Mi (cubre el pico de 295Mi con holgura)
requests.cpu    = mediana = 95m                      -> redondeamos a 100m
limits.cpu      = 100m x 4 = 400m                    -> subimos a 800m para cubrir el pico de 780m

La asimetría entre memoria y CPU es exactamente la del apartado 5. La memoria usa el p95 porque quedarse corto no significa "ir despacio", significa OOMKilled: se pierde el correo de confirmación que se estaba enviando y el cliente no recibe su billete. La CPU usa la mediana porque quedarse corto solo significa que el trabajador tarda más en vaciar la cola; el limit generoso permite que en un puente acelere y recupere.

  1. ephemeral-storage: 400 MiB/día de logs más hasta 600 MiB de emptyDir de adjuntos. Asumiendo rotación diaria de logs y un margen de seguridad:
requests.ephemeral-storage = 400Mi (logs) + 600Mi (emptyDir) = 1Gi
limits.ephemeral-storage   = 1Gi x 2 = 2Gi

El factor 2 cubre el caso de que la rotación de logs falle un día o de que un puente genere el doble de adjuntos. Sin este límite, un fallo de rotación llenaría el disco del nodo y provocaría DiskPressure, desalojando también a los pods sanos que comparten nodo, incluido postgres-reservas.

  1. El bloque completo:
        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:1.8.0
          resources:
            # Calibrado 2026-08-01 sobre 14 dias (incluye puente del 15 de agosto)
            # CPU:     mediana 95m,  p95 340m,  pico 780m  -> request=mediana, limit>pico
            # Memoria: mediana 212Mi, p95 268Mi, pico 295Mi -> request=p95 x1,2
            # Disco:   400Mi/dia de logs + 600Mi de emptyDir de adjuntos
            requests:
              cpu: 100m
              memory: 320Mi
              ephemeral-storage: 1Gi
            limits:
              cpu: 800m
              memory: 512Mi
              ephemeral-storage: 2Gi
  1. Con 3 réplicas en rutas-norte-pro:
Recurso Consumo (3 réplicas) Cuota de pro Porcentaje
requests.cpu 300m 24000m 1,25 %
requests.memory 960Mi 49152Mi 1,95 %
limits.cpu 2400m 48000m 5,0 %
limits.memory 1536Mi 65536Mi 2,3 %
pods 3 150 2,0 %

Consumo muy modesto: hay margen de sobra para que api-reservas escale en los picos.

  1. Con limits.cpu: 700m y un pico de 780m, el trabajador se estrangula durante el puente. No muere: procesa más despacio. El efecto concreto es que la cola de notificaciones crece y los correos de confirmación se retrasan, quizá de 30 segundos a varios minutos.

¿Es aceptable? Para worker-notificaciones, sí, con matices. Es un proceso asíncrono: el cliente ya tiene su reserva confirmada en pantalla cuando el correo se envía. Un retraso de minutos es molesto pero no rompe el negocio, y la cola se vacía sola cuando pasa el pico. Habría que vigilar que el retraso no crezca sin límite: si la tasa de llegada supera a la de proceso, la cola no se recupera nunca y ahí sí hay incidente.

Para api-reservas la respuesta sería que no. Es síncrono: el cliente está esperando delante de la pantalla. Un estrangulamiento del 20 % convierte una respuesta de 80 ms en una de 400 ms, tienda-web empieza a agotar su TIEMPO_ESPERA_MS de 2000, y el cliente ve errores justo en el momento de máxima venta del año. En un componente de cara al usuario, el límite de CPU debe cubrir el pico con holgura.

Esa diferencia —entre lo que solo va más despacio y lo que el cliente sufre— es la que hay que tener en la cabeza al calibrar cada componente.

Conclusión

Ya no escribes resources a ojo. Entiendes el modelo completo: las requests son lo que el planificador reserva al elegir nodo y lo que determina la prioridad relativa de CPU dentro del nodo; los limits son el techo que el kubelet impone a través de cgroups en tiempo de ejecución. Sabes que el planificador solo mira requests, nunca el uso real, lo que explica por qué un nodo al 10 % de uso puede rechazar un pod. Y dominas las unidades, incluidas las dos trampas que cuestan incidentes reales: la diferencia del 6,9 % entre M y Mi, y el 512m en minúscula que fija un límite de medio byte.

Tienes clara la distinción fundamental: la CPU es comprimible y superarla solo produce estrangulamiento —diagnosticable con el cociente nr_throttled/nr_periods de cpu.stat, invisible en el estado del pod—, mientras que la memoria es incomprimible y superarla produce OOMKilled con código 137, con las cinco causas típicas y el reflejo de mirar kubectl logs --previous. Conoces el ephemeral-storage y el desalojo por disco que puede tumbar pods sanos de todo un nodo, y entiendes por qué sobrecomprometer CPU es normal y sobrecomprometer memoria es peligroso.

Y has saldado la última deuda del módulo 2: los tres entornos de Rutas Norte tienen ResourceQuota. rutas-norte-dev no puede pasar de 2 cores y 4 GiB comprometidos, ni crear un solo LoadBalancer; rutas-norte-pro tiene 24 cores y 48 GiB de margen para los picos de puentes y vacaciones. Sabes leer kubectl describe quota, reconocer el error de cuota superada, y diagnosticar el caso traicionero en que kubectl scale tiene éxito y el Deployment se queda para siempre en READY 8/12. Y tienes un método para elegir valores: medir con kubectl top, aplicar el p95 a la memoria y la mediana a la CPU, documentar la fecha y el percentil en el propio manifiesto, e iterar tras cada temporada alta.

Queda un cabo suelto que ha aparecido dos veces en esta lección. Poner cuota de cómputo obliga a todos los pods a declarar recursos, y eso rompe cualquier kubectl run rápido y cualquier manifiesto de un tercero. La pieza que falta es el LimitRange, que inyecta valores por defecto en los contenedores que no declaran nada. Y hay otro cabo aún más interesante: sabemos que cuando falta memoria en un nodo alguien muere, pero no quién. La respuesta no es aleatoria: depende de una clasificación que Kubernetes asigna a cada pod según cómo haya declarado sus recursos. La lección siguiente, LimitRanges y Clases de Calidad de Servicio (QoS), cubre las dos cosas: los campos default, min, max y maxLimitRequestRatio, su interacción exacta con la ResourceQuota, y las clases Guaranteed, Burstable y BestEffort con su efecto sobre el oom_score_adj del kernel y el orden de desalojo del kubelet. Al terminar sabrás por qué postgres-reservas debe ser Guaranteed y podrás provocar un desalojo en tu minikube para ver con tus propios ojos qué pod cae primero.

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