La lección anterior terminó señalando el supuesto oculto de todo lo que llevamos construido: hemos dado por sentado que, cuando el HorizontalPodAutoscaler pide treinta réplicas de api-reservas, hay sitio donde ponerlas. No lo hay. El clúster de Rutas Norte tiene cuatro nodos, y treinta pods de api-reservas más quince de tienda-web más veinte de worker-notificaciones no caben en ellos ni de lejos.

El resultado de esa situación tiene un nombre y un aspecto muy concreto: pods en estado Pending, con un evento FailedScheduling que dice Insufficient cpu. Ya sabemos leerlo desde 06-05 y 07-06. Lo que no sabemos todavía es qué hacer al respecto de forma automática.

Este es el tercer y último nivel del escalado. El HPA cambia el número de pods. El VPA cambia el tamaño de cada pod. El autoescalado de clúster cambia el número de nodos. Sin él, los otros dos tienen un techo duro que no pueden atravesar.

Vamos a ver el Cluster Autoscaler en detalle: cómo decide ampliar, la parte delicada de reducir nodos (que es donde están todos los problemas reales), los tiempos que tarda de verdad un nodo en estar disponible, el patrón de sobreaprovisionamiento que compensa esos tiempos, y Karpenter como alternativa moderna. Terminaremos con el plan de capacidad concreto de Rutas Norte para el puente de mayo, con sus números y su coste.

Contenido

  1. El techo duro: cuando el HPA pide y no hay sitio
  2. El síntoma exacto: Pending y FailedScheduling
  3. Qué es el Cluster Autoscaler y dónde vive
  4. Cómo decide ampliar: simulación y expanders
  5. Grupos de nodos y su relación con el proveedor
  6. La reducción de nodos: la parte delicada
  7. Los motivos por los que un nodo no se puede vaciar
  8. Diagnóstico: el ConfigMap de estado y los eventos
  9. Los tiempos reales: cuánto tarda un nodo de verdad
  10. Sobreaprovisionamiento con pods de relleno
  11. Karpenter: la alternativa moderna
  12. Cómo encajan los tres escaladores
  13. Qué se puede practicar en minikube
  14. El plan de capacidad de Rutas Norte y su coste
  15. Errores comunes y consejos
  16. Ejercicios
  17. Conclusión

  1. El techo duro: cuando el HPA pide y no hay sitio

Pongamos el escenario con números reales. El clúster de Rutas Norte en producción:

Cantidad
Nodos 4
CPU por nodo 4 núcleos
Memoria por nodo 16 GiB
CPU total bruta 16 núcleos
Reservado para kubelet y sistema ~0,5 núcleos y 1,5 GiB por nodo
CPU asignable total ~14 núcleos
Memoria asignable total ~58 GiB

Y el consumo en reposo, con todos los componentes en minReplicas:

Componente Réplicas requests.cpu CPU total
api-reservas (api + sidecar) 4 520m 2,08
tienda-web 3 200m 0,60
worker-notificaciones 2 320m 0,64
postgres-reservas 1 2000m 2,00
redis-cache 1 300m 0,30
DaemonSets (Fluentd, Falco, CNI) 4×3 ~150m 1,80
Ingress controller 2 200m 0,40
Total en reposo 7,82 núcleos

Quedan unos 6,2 núcleos libres. Ahora llega el puente de mayo:

El HPA de api-reservas quiere 30 replicas.
Actuales: 4. Nuevas: 26. Cada una: 520m.
CPU necesaria: 26 x 520m = 13,52 nucleos.

CPU disponible: 6,2 nucleos.
CPU que falta: 7,32 nucleos.

Replicas que caben: 6,2 / 0,52 = 11,9 -> 11 nuevas replicas.
Replicas que NO caben: 15.

Quince pods de api-reservas se quedarán en Pending. El HPA habrá hecho su trabajo: pidió 30 réplicas y el Deployment las creó. El ReplicaSet las tiene registradas. Pero el planificador no encuentra dónde ponerlas, y ahí se quedan.

Y esto es lo peor del asunto: el HPA no lo sabe. En kubectl get hpa verás REPLICAS 30. En Grafana, el panel de réplicas dirá 30. La plataforma tendrá 15 pods listos y 15 fantasmas. Y como el HPA calcula la media de CPU solo entre los pods que sí existen y están listos, verá que están al 90 % y pedirá... nada más, porque ya está en maxReplicas.

El resultado es una plataforma que se cae con la ilusión de estar escalada.

  1. El síntoma exacto: Pending y FailedScheduling

Aprendamos a reconocerlo con precisión, porque es el disparador de todo lo demás.

kubectl get pods -n rutas-norte-pro -l app=api-reservas
NAME                            READY   STATUS    RESTARTS   AGE
api-reservas-7c9d4f8b6d-2mk8p   2/2     Running   0          4h
api-reservas-7c9d4f8b6d-5xqzn   2/2     Running   0          4h
...
api-reservas-7c9d4f8b6d-qw8vz   0/2     Pending   0          92s
api-reservas-7c9d4f8b6d-rt4nk   0/2     Pending   0          92s
api-reservas-7c9d4f8b6d-sv7mx   0/2     Pending   0          91s

Pending con 0/2 y sin reinicios: el pod existe en la API pero ningún nodo lo ha aceptado. No hay contenedores corriendo porque no hay dónde.

kubectl describe pod api-reservas-7c9d4f8b6d-qw8vz -n rutas-norte-pro
Name:             api-reservas-7c9d4f8b6d-qw8vz
Namespace:        rutas-norte-pro
Priority:         0
Node:             <none>
Status:           Pending
...
Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  95s    default-scheduler  0/4 nodes are available:
           4 Insufficient cpu. preemption: 0/4 nodes are available:
           4 No preemption victims found for incoming pod.
  Normal   NotTriggerScaleUp 90s    cluster-autoscaler  pod didn't trigger scale-up:
           1 max node group size reached

Dos líneas de oro:

Node: <none> — confirmación de que no está asignado a ningún nodo.

0/4 nodes are available: 4 Insufficient cpu — el planificador evaluó los cuatro nodos y los cuatro fallaron por CPU insuficiente. El mensaje siempre tiene la forma X/Y nodes are available: <razones>, y las razones se agrupan por causa. Las que verás en la práctica:

Mensaje Significado ¿Lo resuelve el CA?
Insufficient cpu No hay CPU asignable suficiente
Insufficient memory No hay memoria asignable suficiente
node(s) had untolerated taint {...} Taints sin toleration (06-05) Depende del grupo de nodos
node(s) didn't match Pod's node affinity/selector Afinidad de nodo no satisfecha (06-05) Solo si hay un grupo que la cumpla
node(s) didn't match pod topology spread constraints Distribución topológica (09-05) A veces
node(s) had volume node affinity conflict El PV está en otra zona (módulo 5) No
pod has unbound immediate PersistentVolumeClaims El PVC no se ha aprovisionado No
Insufficient nvidia.com/gpu Recurso extendido agotado Sí, si hay grupo con GPU

La línea NotTriggerScaleUp es la del Cluster Autoscaler, y en este ejemplo dice max node group size reached: el CA está instalado, ha visto el pod pendiente, y no puede hacer nada porque el grupo de nodos ya está en su tamaño máximo. Volveremos a este mensaje en el apartado 8, porque es la principal herramienta de diagnóstico.

Un comando muy útil para ver todos los pendientes de un vistazo:

kubectl get pods --all-namespaces --field-selector status.phase=Pending

Y para ver la ocupación real de los nodos:

kubectl describe nodes | grep -A 8 "Allocated resources"
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests      Limits
  --------           --------      ------
  cpu                3410m (97%)   6200m (177%)
  memory             9856Mi (67%)  14Gi (98%)

Fíjate en que la columna que importa es Requests, no Limits. El planificador coloca pods según requests. Un nodo al 97 % de requests está lleno a efectos de planificación, aunque el consumo real sea del 30 %. Es la lección del módulo 3, ahora con consecuencias de capacidad.

  1. Qué es el Cluster Autoscaler y dónde vive

El Cluster Autoscaler (CA) es un componente que ajusta el número de nodos del clúster. Como el VPA, vive en el repositorio kubernetes/autoscaler y no forma parte del núcleo de Kubernetes.

Su funcionamiento en una frase: observa los pods que no se pueden planificar y añade nodos; observa los nodos infrautilizados y los retira.

Dónde se ejecuta

Corre como un Deployment dentro del propio clúster, normalmente en kube-system, con un ServiceAccount que tiene permisos amplios (leer pods y nodos, crear eventos, desalojar pods) y credenciales del proveedor de nube para manipular los grupos de nodos.

En Kubernetes gestionado (10-06) el proveedor lo instala y configura por ti: en EKS, AKS y GKE activas el autoescalado con un interruptor y el proveedor se encarga del resto. Es el escenario más habitual, y por eso conviene entender su comportamiento aunque nunca vayas a instalarlo a mano.

Lo que necesita para funcionar

Requisito Por qué
Una API para crear y destruir nodos El CA no arranca máquinas: le pide al proveedor que lo haga
Grupos de nodos con tamaño mínimo y máximo Es la unidad sobre la que opera
Nodos homogéneos dentro de cada grupo Simula usando un nodo de plantilla por grupo
Que todos los pods declaren requests Sin requests no puede simular si un pod cabría

Ese tercer requisito es más importante de lo que parece. El CA asume que todos los nodos de un grupo son idénticos. Cuando simula si un pod cabría en un nodo nuevo del grupo A, usa la plantilla del grupo A. Si el grupo tiene máquinas heterogéneas, la simulación miente y el CA toma decisiones equivocadas. De ahí la recomendación universal: un tipo de máquina por grupo de nodos.

Y el cuarto requisito conecta con todo el módulo 3: un pod sin requests es invisible para el planificador a efectos de capacidad, y también para el CA. Aquí, otra vez, los requests son la base de todo.

  1. Cómo decide ampliar: simulación y expanders

El bucle del CA se ejecuta cada 10 segundos por defecto (--scan-interval).

El proceso de decisión

  1. Lista los pods no planificables. Los que llevan al menos --max-pod-provisioning-time en Pending con FailedScheduling.
  2. Agrupa pods equivalentes. Los pods del mismo controlador con los mismos requisitos se tratan como un grupo, para no simular veinte veces lo mismo.
  3. Para cada grupo de nodos, simula. «Si añado un nodo del grupo A, ¿cuántos de estos pods pendientes cabrían?» La simulación usa el planificador real, con todos sus predicados: recursos, taints, afinidades, distribución topológica, puertos de host.
  4. Descarta los grupos que no ayudan. Si añadir un nodo del grupo B no permite planificar ni un solo pod (porque tiene un taint que el pod no tolera, por ejemplo), ese grupo se descarta.
  5. Elige entre los grupos viables usando el expander.
  6. Calcula cuántos nodos hacen falta y le pide al proveedor que amplíe el grupo.

Un detalle importante del paso 6: el CA puede añadir varios nodos a la vez. Si hay 15 pods pendientes de 520m cada uno y en un nodo de 4 núcleos caben 6, pedirá 3 nodos de golpe, no uno cada vez. Esto está limitado por --max-nodes-total y por el máximo del grupo.

Los expanders

Cuando varios grupos de nodos servirían, el expander decide cuál.

Expander Criterio Cuándo usarlo
random Elige al azar entre los viables Por defecto. Solo válido si todos los grupos son equivalentes
most-pods El grupo que permitiría planificar más pods pendientes Cuando lo prioritario es desatascar rápido
least-waste El grupo que dejaría menos CPU y memoria ociosas tras colocar los pods El más recomendable en general. Optimiza el ajuste
price El grupo más barato (requiere soporte del proveedor) Cuando el coste manda y hay tipos de máquina variados
priority Según una lista de prioridades en un ConfigMap Control explícito: probar primero spot, luego bajo demanda

Ejemplo concreto de least-waste. Hay 3 pods pendientes que necesitan 500m y 512Mi cada uno (total: 1,5 núcleos y 1,5 GiB), y dos grupos disponibles:

Grupo Tipo de nodo Asignable Tras colocar los 3 pods Desperdicio
A 2 núcleos, 8 GiB 1,8 núcleos / 7 GiB 0,3 núcleos y 5,5 GiB libres CPU: 17 %, RAM: 79 %
B 8 núcleos, 32 GiB 7,7 núcleos / 30 GiB 6,2 núcleos y 28,5 GiB libres CPU: 81 %, RAM: 95 %

least-waste elige el grupo A: ajusta mejor y no paga por 6 núcleos ociosos. most-pods elegiría el B (cabrían muchos más pods futuros). price elegiría el A si es más barato.

Configuración del expander:

# Fragmento del Deployment del cluster-autoscaler
spec:
  containers:
    - name: cluster-autoscaler
      image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.30.0
      command:
        - ./cluster-autoscaler
        - --cloud-provider=aws
        - --namespace=kube-system
        - --expander=least-waste
        - --scan-interval=10s
        - --balance-similar-node-groups         # Reparte entre grupos equivalentes (zonas)
        - --skip-nodes-with-local-storage=false
        - --skip-nodes-with-system-pods=false
        - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/rutas-norte

La opción --balance-similar-node-groups merece mención porque resuelve un problema real de alta disponibilidad: si tienes un grupo de nodos por zona de disponibilidad, esta bandera hace que el CA reparta los nodos nuevos equilibradamente entre zonas en lugar de amontonarlos en una. Sin ella, puedes acabar con doce nodos en la zona A y ninguno en la B, y una caída de zona te deja sin plataforma. Conecta directamente con lo que veremos en 09-05.

El expander priority: probar primero las máquinas baratas

Un patrón muy rentable en la nube. Las instancias spot (interrumpibles) cuestan una fracción del precio, pero el proveedor puede reclamarlas con dos minutos de aviso.

apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-autoscaler-priority-expander
  namespace: kube-system
data:
  priorities: |-
    100:
      - rutas-norte-spot-.*        # Primera opcion: spot, mucho mas barato
    50:
      - rutas-norte-ondemand-.*    # Respaldo: bajo demanda, siempre disponible

El CA intenta primero el grupo de prioridad 100. Si el proveedor no tiene capacidad spot disponible, cae al de prioridad 50. Para worker-notificaciones, que tolera interrupciones sin problema, es la opción evidente. Para postgres-reservas, jamás.

  1. Grupos de nodos y su relación con el proveedor

El CA no crea máquinas: manipula grupos de nodos del proveedor. Cada proveedor tiene su nombre para lo mismo:

Proveedor Nombre del concepto
AWS Auto Scaling Group (ASG)
Azure Virtual Machine Scale Set (VMSS) / Agent Pool
Google Cloud Managed Instance Group (MIG) / Node Pool
On-premise / kubeadm Requiere un proveedor personalizado o Cluster API

Un grupo de nodos tiene un tamaño mínimo, un máximo y un actual. El CA solo cambia el actual, dentro de esos límites. Todo lo demás (el tipo de máquina, la imagen, los taints y etiquetas iniciales) lo define el grupo.

Los grupos de Rutas Norte:

rutas-norte-general-a   min=1  max=8   4 nucleos / 16 GiB   zona A
rutas-norte-general-b   min=1  max=8   4 nucleos / 16 GiB   zona B
rutas-norte-general-c   min=1  max=8   4 nucleos / 16 GiB   zona C
rutas-norte-datos       min=2  max=3   8 nucleos / 32 GiB   taint: rol=datos:NoSchedule

Tres decisiones de diseño que conviene entender:

Un grupo por zona. Necesario para que --balance-similar-node-groups reparta entre zonas y para que la afinidad de zona funcione. Con un único grupo multizona, el CA no puede garantizar en qué zona aparecerá el nodo nuevo, y eso rompe la distribución topológica de 09-05.

Un grupo dedicado a datos con taint. postgres-reservas y redis-cache tienen tolerations para rol=datos (06-05) y afinidad de nodo hacia ese grupo. Así las bases de datos viven en máquinas grandes con disco rápido y no comparten nodo con la avalancha de pods de api-reservas. El taint impide que los pods de la aplicación aterricen ahí.

El grupo de datos tiene max=3, muy bajo. Es deliberado: no queremos que el CA arranque máquinas grandes y caras por error. postgres-reservas no escala horizontalmente (09-01), así que ese grupo casi nunca necesita crecer.

Etiquetas y taints en los nodos nuevos

Cuando el CA simula si un pod cabría en un nodo del grupo A, usa una plantilla. Si el nodo real, al arrancar, recibe etiquetas o taints que la plantilla no conocía, la simulación miente.

Caso típico y muy frustrante: un pod con nodeSelector: disco=ssd está Pending. El CA simula con la plantilla del grupo, que no tiene esa etiqueta, concluye que el pod no cabría ni con un nodo nuevo, y no escala. Pero los nodos reales del grupo reciben esa etiqueta al arrancar, mediante un script de arranque.

La solución es declarar las etiquetas y taints en la propia definición del grupo de nodos del proveedor (en AWS, con etiquetas k8s.io/cluster-autoscaler/node-template/label/... en el ASG), para que el CA las conozca antes de arrancar el nodo.

  1. La reducción de nodos: la parte delicada

Ampliar es fácil: hay pods pendientes, se añaden nodos. Reducir es donde están todos los problemas, porque retirar un nodo implica desalojar los pods que tiene y confiar en que reaparecerán en otro sitio sin romper nada.

El algoritmo

Cada 10 segundos, para cada nodo:

  1. ¿Está por debajo del umbral de utilización? Por defecto, --scale-down-utilization-threshold=0.5: la suma de requests de sus pods es menor del 50 % de su capacidad asignable.
  2. ¿Lleva así el tiempo suficiente? --scale-down-unneeded-time=10m: diez minutos consecutivos por debajo del umbral.
  3. ¿Se puede vaciar? Simula si todos sus pods cabrían en otros nodos existentes. Si no caben, no se toca.
  4. ¿Hay algún pod que impida el desalojo? La lista del apartado 7.
  5. Si todo pasa: acordona el nodo, desaloja sus pods respetando los PDB, espera, y le pide al proveedor que lo elimine.

Parámetros principales:

Parámetro Defecto Qué controla
--scale-down-enabled true Interruptor general de la reducción
--scale-down-utilization-threshold 0.5 Umbral de infrautilización
--scale-down-unneeded-time 10m Tiempo consecutivo antes de actuar
--scale-down-delay-after-add 10m Espera tras haber añadido un nodo
--scale-down-delay-after-delete 0s Espera tras haber eliminado un nodo
--scale-down-delay-after-failure 3m Espera tras un fallo de reducción
--max-graceful-termination-sec 600 Máximo esperando a que un pod termine
--max-empty-bulk-delete 10 Nodos vacíos eliminables a la vez

--scale-down-delay-after-add es el parámetro clave para evitar el bailoteo de nodos. Sin él, el CA podría añadir un nodo, ver que el clúster queda infrautilizado y quitarlo inmediatamente. Diez minutos de gracia rompen ese ciclo.

Sobre el umbral de utilización, un cálculo importante:

Umbral por defecto: 50 %.

Nodo de 4 nucleos asignables con estos pods:
  - 2 pods de api-reservas:  2 x 520m = 1040m
  - 1 pod de tienda-web:              200m
  - DaemonSets (fluentd, falco, cni): 450m
  Total requests: 1690m sobre 3500m asignables = 48,3 %

48,3 % < 50 %  ->  candidato a reduccion.

El CA simula: ¿caben esos 4 pods en los otros nodos?
Si si -> los desaloja y elimina el nodo.
Si no -> lo deja.

Fíjate en que los DaemonSets cuentan para la utilización pero no impiden la eliminación (se ignoran en el paso 4, porque desaparecen con el nodo). Con muchos DaemonSets, la utilización base de un nodo vacío ya es alta, y eso hace que el CA reduzca menos de lo que esperarías. En clústeres con muchos agentes (logging, seguridad, malla de servicio, monitorización), es habitual tener que bajar el umbral al 30-40 % para que la reducción funcione.

El caso especial de los nodos vacíos

Un nodo que solo tiene DaemonSets se considera vacío y se elimina por un camino rápido, sin la simulación completa. Por eso, tras una punta de tráfico, los nodos que quedan totalmente libres desaparecen bastante rápido, mientras que los que tienen un pod suelto tardan mucho más.

Esto explica un fenómeno que desconcierta a mucha gente: tras el puente de mayo, ocho nodos se van en veinte minutos y dos se quedan durante horas con un solo pod cada uno. La solución de fondo no es tocar el CA: es la distribución topológica y la consolidación (Karpenter, apartado 11).

  1. Los motivos por los que un nodo no se puede vaciar

Esta es la lista que hay que conocer de memoria, porque explica el 90 % de los casos de «tengo nodos vacíos que el autoescalador no quita y estoy pagando por ellos».

# Motivo Detalle Cómo resolverlo
1 Pods sin controlador Un pod creado a mano (sin Deployment, ReplicaSet, Job, StatefulSet) no se puede recrear en otro sitio: si lo desalojas, desaparece para siempre Usa siempre un controlador. Nunca kubectl run sin --restart en producción
2 Pods con almacenamiento local emptyDir, hostPath o volúmenes locales: el dato vive en ese nodo y se perdería --skip-nodes-with-local-storage=false si asumes la pérdida; o migrar a PV de red
3 PodDisruptionBudget restrictivo El PDB no permite desalojar ese pod sin bajar del mínimo (09-05) Revisar el PDB; asegurar réplicas suficientes
4 Pods del sistema en kube-system Componentes críticos sin PDB --skip-nodes-with-system-pods=false (con cuidado), o darles PDB
5 Anotación safe-to-evict: "false" Marca explícita de «no me muevas» Quitar la anotación si ya no aplica
6 Pods que no caben en ningún otro nodo La simulación falla: el pod es demasiado grande o tiene restricciones Añadir capacidad o relajar restricciones
7 Nodo con la anotación scale-down-disabled: "true" Exclusión explícita del nodo Quitar la anotación

La anotación safe-to-evict

Es la herramienta de control más directa:

apiVersion: v1
kind: Pod
metadata:
  annotations:
    # Este pod NO debe ser desalojado por el Cluster Autoscaler.
    # El nodo que lo aloje nunca sera retirado mientras este aqui.
    cluster-autoscaler.kubernetes.io/safe-to-evict: "false"

Cuándo tiene sentido "false":

  • Un trabajo por lotes largo que perdería horas de cómputo si se reinicia. informes-ocupacion es un candidato: si tarda 40 minutos y el CA lo mata en el minuto 35, la noche se pierde.
  • Una migración de base de datos en curso.
  • Un pod con estado local irreproducible.

Y el valor contrario, "true", sirve para desbloquear situaciones:

metadata:
  annotations:
    # Este pod SI puede ser desalojado aunque use emptyDir.
    # Sabemos que el contenido de su emptyDir es una cache regenerable.
    cluster-autoscaler.kubernetes.io/safe-to-evict: "true"

Es la forma limpia de resolver el motivo 2 sin cambiar la bandera global del CA. En Rutas Norte, tienda-web usa un emptyDir para la caché de nginx: marcarlo como safe-to-evict: "true" permite que el CA retire sus nodos sin problema, porque esa caché se regenera sola.

El PDB como bloqueador (adelanto de 09-05)

El caso más frecuente y el más peligroso. Un PDB así:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: postgres-reservas
  namespace: rutas-norte-pro
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: postgres-reservas

Con una sola réplica de postgres-reservas y minAvailable: 1, ese pod nunca puede ser desalojado: hacerlo dejaría 0 disponibles. El nodo que lo aloje queda anclado permanentemente. El CA lo intentará, fallará, y lo registrará en sus eventos.

En este caso concreto el bloqueo es deseable (no queremos que el CA mueva la base de datos por su cuenta), pero el mismo patrón aplicado por error a un servicio cualquiera produce nodos zombis que nadie sabe por qué no se van. Lo desarrollamos a fondo en 09-05.

  1. Diagnóstico: el ConfigMap de estado y los eventos

El CA publica su estado interno en un ConfigMap. Es el primer sitio donde mirar.

kubectl get configmap cluster-autoscaler-status -n kube-system -o yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-autoscaler-status
  namespace: kube-system
data:
  status: |
    Cluster-autoscaler status at 2026-05-01 10:14:32:
    Cluster-wide:
      Health:      Healthy (ready=7 unready=0 notStarted=1 registered=8 longNotStarted=0)
                   LastProbeTime: 2026-05-01 10:14:31
      ScaleUp:     InProgress (ready=7 registered=8)
                   LastProbeTime: 2026-05-01 10:14:31
      ScaleDown:   NoCandidates (candidates=0)
                   LastProbeTime: 2026-05-01 10:14:31

    NodeGroups:
      Name:        rutas-norte-general-a
      Health:      Healthy (ready=3 unready=0 notStarted=1 registered=4
                   cloudProviderTarget=4 (minSize=1, maxSize=8))
      ScaleUp:     InProgress (ready=3 registered=4)
      ScaleDown:   NoCandidates (candidates=0)

      Name:        rutas-norte-general-b
      Health:      Healthy (ready=2 unready=0 notStarted=0 registered=2
                   cloudProviderTarget=2 (minSize=1, maxSize=8))
      ScaleUp:     NoActivity
      ScaleDown:   NoCandidates (candidates=0)

      Name:        rutas-norte-datos
      Health:      Healthy (ready=2 unready=0 notStarted=0 registered=2
                   cloudProviderTarget=2 (minSize=2, maxSize=3))
      ScaleUp:     NoActivity
      ScaleDown:   NoCandidates (candidates=0)

Cómo leerlo:

Campo Significado
ready Nodos operativos y aceptando pods
unready Nodos registrados pero no listos (problema)
notStarted Nodos pedidos que aún están arrancando. Este es el que hay que mirar durante una punta
registered Total conocidos por el clúster
cloudProviderTarget Cuántos ha pedido el CA al proveedor
ScaleUp: InProgress Hay una ampliación en marcha
ScaleDown: NoCandidates Ningún nodo cumple los criterios de reducción

En la salida de arriba: el grupo A tiene 3 listos y 1 arrancando. El CA ya ha pedido el nodo; solo hay que esperar. Esta información es exactamente lo que necesitas durante un incidente para saber si el CA está trabajando o atascado.

Estados de ScaleDown que verás:

Estado Significado
NoCandidates Ningún nodo está por debajo del umbral
CandidatesPresent Hay candidatos, esperando el unneeded-time
InProgress Vaciando un nodo ahora mismo

Los eventos: por qué NO se escala

Cuando el CA decide no hacer nada, lo dice en un evento sobre el pod pendiente:

kubectl get events -n rutas-norte-pro --field-selector reason=NotTriggerScaleUp \
  --sort-by=.lastTimestamp
LAST SEEN   TYPE     REASON              OBJECT                              MESSAGE
23s         Normal   NotTriggerScaleUp   pod/api-reservas-7c9d4f8b6d-qw8vz   pod didn't trigger scale-up:
                                                                             1 max node group size reached,
                                                                             1 node(s) had untolerated taint {rol: datos}

Este mensaje es un regalo: te dice exactamente por qué cada grupo de nodos fue descartado. Aquí: un grupo está al máximo y otro tiene un taint que el pod no tolera.

Mensajes habituales de NotTriggerScaleUp y su traducción:

Mensaje Significado real Acción
max node group size reached El grupo está en su maxSize Subir el máximo del grupo
node(s) had untolerated taint {...} Los nodos del grupo tienen un taint que el pod no tolera Añadir toleration o crear otro grupo
node(s) didn't match Pod's node affinity El nodeSelector/afinidad no coincide con la plantilla Revisar las etiquetas del grupo
Insufficient cpu (en el mensaje del CA) Ni un nodo nuevo del grupo tendría CPU suficiente para ese pod El pod es demasiado grande. Reducir sus requests o usar un grupo mayor
in backoff after failed scale-up Un intento anterior falló (sin capacidad en el proveedor, cuota agotada) Mirar los logs del CA y la cuota del proveedor
pod has unbound immediate PersistentVolumeClaims Es un problema de almacenamiento, no de capacidad Revisar la StorageClass (módulo 5)

Y para la reducción bloqueada:

kubectl logs -n kube-system deployment/cluster-autoscaler | grep -i "scale.down\|unremovable"
I0501 10:22:14.882  scale_down.go: Node ip-10-0-3-47 is not suitable for removal:
    pod redis-cache-0 is not replicated and has local storage
I0501 10:22:14.883  scale_down.go: Node ip-10-0-2-19 is not suitable for removal:
    pdb-blocked: not enough pod disruption budget to move postgres-reservas-0
I0501 10:22:14.884  scale_down.go: Node ip-10-0-1-88 is not suitable for removal:
    cluster-autoscaler.kubernetes.io/safe-to-evict annotation set to false

Los logs del CA son la fuente definitiva cuando el ConfigMap y los eventos no bastan. Consérvalos en EFK (07-05): son imprescindibles para el análisis posterior de un incidente de capacidad.

  1. Los tiempos reales: cuánto tarda un nodo de verdad

Aquí está el dato que cambia la estrategia, y el que casi nunca se cuenta.

Desglose del tiempo

gantt
    title Tiempo desde el pod Pending hasta el pod Running en un nodo nuevo
    dateFormat  s
    axisFormat  %S s

    section Deteccion
    Pod pasa a Pending           :a1, 0, 5s
    El CA lo detecta (scan)      :a2, after a1, 10s

    section Aprovisionamiento
    Peticion al proveedor        :b1, after a2, 5s
    Arranque de la maquina       :b2, after b1, 45s
    Arranque del sistema         :b3, after b2, 20s

    section Union al cluster
    kubelet arranca y registra   :c1, after b3, 15s
    CNI y DaemonSets listos      :c2, after c1, 30s
    Nodo pasa a Ready            :c3, after c2, 5s

    section Pod
    Planificacion                :d1, after c3, 2s
    Descarga de imagen           :d2, after d1, 40s
    Arranque del contenedor      :d3, after d2, 25s
    Readiness probe OK           :d4, after d3, 10s

Sumando: entre 3 y 4 minutos en el caso bueno. En el caso malo (imagen grande no cacheada, región saturada, DaemonSets pesados) puede pasar de 6 minutos.

Desglose típico en tabla:

Fase Tiempo típico Qué lo empeora
Detección del pod pendiente 10-15 s --scan-interval alto
Petición al proveedor 5-10 s Reintentos por cuota
Arranque de la máquina 30-60 s Tipo de máquina, región saturada
Arranque del sistema operativo 15-30 s Imagen no optimizada
Registro del kubelet 10-20 s Configuración compleja, bootstrap lento
CNI + DaemonSets listos 20-60 s Muchos DaemonSets pesados
Descarga de la imagen del pod 20-120 s Imagen grande, registro lejano
Arranque del contenedor + readiness 20-40 s Aplicación lenta de arrancar
Total 2-6 min

Por qué esto no salva una punta repentina

Volvamos al puente de mayo. La venta abre a las 10:00. El perfil de tráfico real:

10:00:00   Se abre la venta. El trafico pasa de 200 rps a 2400 rps en 40 segundos.
10:00:15   El HPA detecta CPU al 280 %. Pide 12 replicas.
10:00:20   El Deployment crea 8 pods. 4 caben en los nodos existentes.
           4 quedan en Pending.
10:00:30   El CA detecta los pendientes y pide 2 nodos.
10:00:45   El HPA vuelve a evaluar: sigue saturado. Pide 24 replicas.
           Mas pods Pending.
10:01:00   El CA pide 3 nodos mas.
10:03:30   Llega el primer nodo. Se planifican 6 pods.
10:04:10   Los primeros pods del nodo nuevo pasan a Ready.
10:05:00   Llegan los otros nodos. La plataforma alcanza capacidad.

TIEMPO TOTAL DE DEGRADACION: casi CINCO MINUTOS.

Cinco minutos de latencia degradada y errores en el momento de mayor valor comercial del año. Miles de usuarios que abandonan la compra. El Cluster Autoscaler, por bien configurado que esté, no puede resolver una punta que sube en cuarenta segundos, porque la física de arrancar una máquina virtual no lo permite.

De aquí salen dos estrategias, y hacen falta las dos:

  1. Sobreaprovisionamiento: tener capacidad ya arrancada y esperando (apartado 10).
  2. Escalado anticipado: escalar antes del pico usando un disparador temporal, no reactivo. Es el disparador cron de KEDA que veremos en 09-04.

  1. Sobreaprovisionamiento con pods de relleno

El patrón que resuelve el problema del apartado anterior, y que usa de forma brillante la PriorityClass y la preemption de 06-05.

La idea

Desplegamos pods que no hacen nada —duermen— pero reservan CPU y memoria, con una prioridad negativa. Efectos:

  1. El CA cuenta esos requests como ocupación, así que mantiene nodos arrancados para alojarlos.
  2. Cuando llega un pod real (prioridad 0 o superior) y no hay sitio, el planificador desaloja instantáneamente un pod de relleno y coloca el real en su hueco. La preemption tarda segundos, no minutos.
  3. Los pods de relleno desalojados pasan a Pending, lo que dispara al CA para arrancar nodos nuevos... que rellenarán el colchón para la siguiente vez.

Es un colchón de capacidad autorregenerativo. Compras capacidad ociosa a cambio de tiempo de respuesta.

sequenceDiagram
    participant HPA
    participant Sched as Planificador
    participant Relleno as Pods de relleno<br/>(prioridad -10)
    participant CA as Cluster Autoscaler

    Note over Relleno: Estado normal: 3 pods de relleno<br/>ocupando 3 nucleos en los nodos
    HPA->>Sched: Crear 10 pods de api-reservas (prioridad 0)
    Sched->>Sched: No hay CPU libre
    Sched->>Relleno: PREEMPTION: desalojar 3 pods de relleno
    Note over Sched: En 2-5 segundos hay hueco
    Sched->>Sched: Planificar pods de api-reservas
    Relleno->>CA: 3 pods de relleno en Pending
    CA->>CA: Pedir nodos nuevos (3-4 min)
    Note over Relleno: El colchon se regenera solo

Implementación completa

Paso 1: la PriorityClass negativa.

# k8s/base/priorityclass-relleno.yaml
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: relleno-capacidad
# CLAVE: valor NEGATIVO. Cualquier pod normal (prioridad 0 por defecto) tiene
# mas prioridad que estos, asi que los desalojara sin dudarlo.
value: -10
# No es la clase por defecto: solo la usan los pods que la piden explicitamente.
globalDefault: false
# preemptionPolicy por defecto es PreemptLowerPriority, pero estos pods no
# deben desalojar a NADIE: son los ultimos de la fila.
preemptionPolicy: Never
description: >
  Pods de relleno que reservan capacidad para absorber puntas de trafico.
  Son desalojados instantaneamente por cualquier pod real. Ver 09-03.

Dos detalles importantes:

  • value: -10 garantiza que cualquier pod sin priorityClassName (prioridad 0) los supere.
  • preemptionPolicy: Never hace que estos pods, al quedarse Pending, no intenten desalojar a otros. Sería absurdo que el colchón echase a un pod real.

Paso 2: el Deployment de relleno.

# k8s/entornos/pro/deployment-relleno-capacidad.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: relleno-capacidad
  namespace: rutas-norte-pro
  labels:
    app: relleno-capacidad
    app.kubernetes.io/part-of: rutas-norte
spec:
  # 6 pods de relleno. Cada uno reserva 1 nucleo y 1 GiB.
  # Colchon total: 6 nucleos y 6 GiB, equivalente a casi dos nodos.
  # Suficiente para absorber 11 replicas de api-reservas al instante.
  replicas: 6
  selector:
    matchLabels:
      app: relleno-capacidad
  template:
    metadata:
      labels:
        app: relleno-capacidad
        app.kubernetes.io/part-of: rutas-norte
      annotations:
        # Que el CA pueda retirar sin problema los nodos que solo tengan relleno.
        cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
    spec:
      priorityClassName: relleno-capacidad

      # Terminacion inmediata: no hay nada que guardar, no esperamos ni un segundo.
      # Esto es lo que hace que la preemption sea casi instantanea.
      terminationGracePeriodSeconds: 0

      # Repartir el colchon entre nodos: de nada sirve tener 6 nucleos libres
      # todos en el mismo nodo si los pods nuevos necesitan repartirse.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: relleno-capacidad

      containers:
        - name: pausa
          # Imagen minima oficial: un proceso que solo duerme. ~700 KB.
          image: registry.k8s.io/pause:3.9
          resources:
            requests:
              cpu: "1"
              memory: 1Gi
            limits:
              cpu: "1"
              memory: 1Gi
          # No consume NADA en la practica: solo reserva el hueco ante el
          # planificador. La CPU real usada es 0m.

La imagen pause es la elección canónica: es la misma que Kubernetes usa internamente para el contenedor de infraestructura de cada pod, pesa menos de un megabyte, está ya en la caché de todos los nodos y su único trabajo es dormir.

Paso 3: comprobar que funciona.

# Estado inicial: 6 pods de relleno repartidos
kubectl get pods -n rutas-norte-pro -l app=relleno-capacidad -o wide
NAME                                 READY   STATUS    NODE
relleno-capacidad-5d8f7c9b4-2xkjp    1/1     Running   ip-10-0-1-42
relleno-capacidad-5d8f7c9b4-4mnqz    1/1     Running   ip-10-0-1-88
relleno-capacidad-5d8f7c9b4-7wrtv    1/1     Running   ip-10-0-2-19
relleno-capacidad-5d8f7c9b4-9hbcx    1/1     Running   ip-10-0-2-73
relleno-capacidad-5d8f7c9b4-kp3ln    1/1     Running   ip-10-0-3-47
relleno-capacidad-5d8f7c9b4-tv6ws    1/1     Running   ip-10-0-3-91

Ahora forzamos una punta:

kubectl scale deployment api-reservas -n rutas-norte-pro --replicas=20
kubectl get pods -n rutas-norte-pro -l app=relleno-capacidad --watch
NAME                                 READY   STATUS        AGE
relleno-capacidad-5d8f7c9b4-2xkjp    1/1     Terminating   4h
relleno-capacidad-5d8f7c9b4-4mnqz    1/1     Terminating   4h
relleno-capacidad-5d8f7c9b4-7wrtv    0/1     Pending       3s
relleno-capacidad-5d8f7c9b4-9hbcx    0/1     Pending       3s

Y en los eventos de los pods de api-reservas:

Events:
  Type     Reason      Age   From               Message
  ----     ------      ----  ----               -------
  Normal   Preempted   4s    default-scheduler  Preempted by pod
                                                relleno-capacidad-5d8f7c9b4-2xkjp
                                                on node ip-10-0-1-42
  Normal   Scheduled   3s    default-scheduler  Successfully assigned
                                                rutas-norte-pro/api-reservas-... to ip-10-0-1-42

Tres segundos desde la creación del pod hasta su planificación, en lugar de tres minutos y medio. Esa es la diferencia entre absorber la apertura de la venta y caerse.

El coste

Nada es gratis:

Colchon: 6 nucleos y 6 GiB reservados permanentemente.
Equivale a ~1,7 nodos de 4 nucleos.

Coste estimado de un nodo (4 nucleos, 16 GiB, bajo demanda): 95 EUR/mes.
Coste del colchon: 1,7 x 95 = 162 EUR/mes = 1.940 EUR/ano.

Beneficio: absorber instantaneamente 11 replicas de api-reservas.

¿Merece la pena? En Rutas Norte, la respuesta es clara: cinco minutos de caída en la apertura del puente de mayo cuestan mucho más de 1.940 euros en billetes no vendidos. Pero es una decisión de negocio, no técnica, y debe tomarse con números encima de la mesa.

Optimización evidente: el colchón no tiene por qué ser constante. Un CronJob (06-03) que escale el Deployment de relleno de 2 a 6 réplicas los viernes por la tarde y lo devuelva a 2 los lunes reduce el coste anual a una fracción:

# k8s/entornos/pro/cronjob-ajustar-relleno.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: ampliar-relleno-fin-de-semana
  namespace: rutas-norte-pro
spec:
  schedule: "0 16 * * 5"       # Viernes a las 16:00
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: operador-escalado
          restartPolicy: OnFailure
          containers:
            - name: kubectl
              image: bitnami/kubectl:1.30
              command:
                - kubectl
                - scale
                - deployment/relleno-capacidad
                - --replicas=6
                - -n
                - rutas-norte-pro

Y su pareja el lunes a las 6:00 con --replicas=2. El ServiceAccount operador-escalado necesita un Role con permiso sobre deployments/scale, exactamente como vimos en 08-01.

  1. Karpenter: la alternativa moderna

El Cluster Autoscaler tiene una limitación de diseño: trabaja con grupos de nodos predefinidos. Alguien tiene que decidir de antemano qué tipos de máquina existen, crear un grupo por cada uno, y mantenerlos.

Karpenter invierte el planteamiento: mira los pods pendientes, calcula qué máquina sería la mejor para ellos, y la arranca directamente, sin grupos.

Cómo funciona

En lugar de grupos de nodos, defines restricciones de qué tipos de máquina son aceptables:

# NodePool: el espacio de decisiones que Karpenter puede explorar
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: rutas-norte-general
spec:
  template:
    metadata:
      labels:
        entorno: pro
        app.kubernetes.io/part-of: rutas-norte
    spec:
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]     # Prefiere spot; cae a on-demand
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]           # Familias de computo, general y memoria
        - key: karpenter.k8s.aws/instance-size
          operator: NotIn
          values: ["nano", "micro", "small"]
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: rutas-norte

  # CONSOLIDACION: la funcion estrella. Karpenter reevalua continuamente si los
  # pods actuales cabrian en menos nodos, o en nodos mas baratos, y migra.
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m
    budgets:
      - nodes: "20%"           # Como mucho, tocar el 20 % de los nodos a la vez

  limits:
    cpu: "200"                 # Techo global de seguridad
    memory: 800Gi

Con esta definición, cuando aparecen 15 pods de api-reservas pendientes, Karpenter calcula que la mejor máquina para alojarlos es, pongamos, una c6i.4xlarge spot en la zona B, y la arranca. Sin que nadie haya creado nunca un grupo de nodos c6i.4xlarge.

La consolidación

Es la diferencia más práctica en el día a día. Karpenter revisa continuamente si el conjunto de pods actual cabría en una configuración de nodos más barata, y si es así, migra los pods y sustituye los nodos.

El escenario que resuelve: tras el puente de mayo, el CA deja cinco nodos con un pod cada uno, porque ninguno baja del umbral de utilización de forma que permita vaciarlo. Karpenter ve que esos cinco pods cabrían en un solo nodo, arranca ese nodo, migra los pods y elimina los cinco.

Es exactamente el problema que mencionamos al final del apartado 6, resuelto de raíz.

Comparación

Aspecto Cluster Autoscaler Karpenter
Unidad de trabajo Grupos de nodos predefinidos Nodos individuales
Elección del tipo de máquina Fija por grupo Calculada según los pods pendientes
Configuración previa Un grupo por tipo/zona Un NodePool con restricciones
Tiempo de aprovisionamiento 3-6 min 1-2 min (habla directamente con la API de EC2)
Consolidación No (solo reducción por utilización) Sí, continua
Optimización de coste Limitada (expander price) Nativa: elige el tipo más barato que sirva
Gestión de spot Grupos separados + expander priority Nativa, con caída automática a bajo demanda
Soporte de proveedores Muchos (AWS, Azure, GCP, OpenStack, Cluster API...) AWS maduro; Azure disponible; otros en desarrollo
Madurez Muy alta, años de producción Alta en AWS, más reciente
Complejidad operativa Media (mantener grupos) Baja tras la configuración inicial
Predecibilidad Alta: sabes qué máquinas van a salir Menor: puede elegir tipos inesperados

Cuándo elegir cada uno:

  • Cluster Autoscaler si estás fuera de AWS, si tienes requisitos regulatorios sobre qué tipos de máquina son admisibles, si tu proveedor ya te lo da configurado y funciona, o si valoras la predecibilidad por encima del ahorro.
  • Karpenter si estás en AWS, si el coste es una prioridad real, si tu carga es heterogénea (pods pequeños y pods grandes conviviendo), y si te compensa la reducción de trabajo operativo.

Para Rutas Norte, con cuatro nodos y una carga homogénea, el CA es más que suficiente. Si la plataforma creciera a cincuenta nodos con cargas variadas, Karpenter sería la decisión evidente. Volveremos a este tipo de decisiones en 10-06 y 11-06.

Una nota importante que la comparativa no debe ocultar: Karpenter tampoco arregla la punta de cuarenta segundos. Uno o dos minutos siguen siendo demasiado. El sobreaprovisionamiento sigue haciendo falta.

  1. Cómo encajan los tres escaladores

Ya tenemos las tres piezas. Veamos el flujo completo en orden temporal.

flowchart TD
    T0["t=0 s<br/>Sube el trafico"] --> HPA["t=15 s<br/>HPA: la CPU media supera el objetivo<br/>Calcula replicas deseadas"]
    HPA --> DEP["t=16 s<br/>Deployment crea pods nuevos"]
    DEP --> SCHED{"t=17 s<br/>El planificador<br/>encuentra sitio?"}

    SCHED -->|Si| RUN["t=20-50 s<br/>Pod Running y Ready<br/>FIN: absorbido"]

    SCHED -->|No| PRE{"Hay pods de<br/>relleno que desalojar?"}
    PRE -->|Si| PREEMPT["t=20 s<br/>PREEMPTION<br/>Pod real colocado en 3 s"]
    PREEMPT --> RUN2["t=50 s<br/>Pod Running<br/>FIN: absorbido con colchon"]
    PREEMPT --> PEND2["Los pods de relleno<br/>pasan a Pending"]

    PRE -->|No| PEND["Pod en Pending<br/>evento FailedScheduling"]
    PEND2 --> CA
    PEND --> CA["t=30 s<br/>Cluster Autoscaler detecta<br/>los pods no planificables"]

    CA --> SIM["Simula: que grupo de nodos<br/>permitiria planificarlos?<br/>Aplica el expander"]
    SIM --> REQ["t=40 s<br/>Peticion al proveedor de nube"]
    REQ --> BOOT["t=40 s a 3 min<br/>Arranque, union al cluster,<br/>CNI y DaemonSets"]
    BOOT --> READY["t=3 min<br/>Nodo Ready"]
    READY --> SCHED2["t=3 min 5 s<br/>Se planifican los pods pendientes"]
    SCHED2 --> PULL["t=3 a 4 min<br/>Descarga de imagen y arranque"]
    PULL --> RUN3["t=4 min<br/>Pod Running<br/>FIN: absorbido con nodo nuevo"]

    style RUN fill:#cfc,stroke:#393
    style RUN2 fill:#cfc,stroke:#393
    style RUN3 fill:#ffd,stroke:#c90
    style PEND fill:#fdd,stroke:#c00

Las tres rutas y sus tiempos:

Ruta Cuándo ocurre Tiempo hasta servir tráfico
Cabe en los nodos actuales Hay capacidad libre 20-50 s
Preemption del colchón No cabe, pero hay pods de relleno 30-60 s
Nodo nuevo No cabe y no hay colchón 3-6 min

La conclusión estratégica es directa: el colchón de sobreaprovisionamiento no es un lujo, es lo que convierte un incidente de cinco minutos en uno de treinta segundos.

Y aún queda una ruta mejor, que no aparece en el diagrama porque no es reactiva: escalar antes de que llegue el tráfico. Si a las 9:30 ya tienes 25 réplicas y ocho nodos porque un disparador cron lo ordenó, a las 10:00 no hay ningún incidente. Eso es 09-04.

Un resumen de los tres niveles

Nivel Qué cambia Disparador Latencia Componente
1 Número de pods Métrica de CPU/negocio 15-60 s HPA (09-01) / KEDA (09-04)
2 Tamaño de cada pod Histórico de consumo Horas-días VPA (09-02)
3 Número de nodos Pods no planificables 3-6 min Cluster Autoscaler / Karpenter

Los tres son necesarios y ninguno sustituye a otro. El HPA sin CA tiene un techo duro. El CA sin HPA nunca se dispara. El VPA los hace a ambos más precisos, porque los requests correctos son la base tanto del cálculo del HPA como de la simulación del CA.

  1. Qué se puede practicar en minikube

Momento de honestidad: el Cluster Autoscaler no se puede probar de verdad en minikube. No hay proveedor de nube que arranque máquinas. El perfil rutas-norte tiene los nodos que le pediste al crearlo, y esos son todos.

Lo que SÍ se puede practicar

1. Producir el síntoma y aprender a leerlo.

# Un cluster de 2 nodos pequenos
minikube start -p rutas-norte --nodes=2 --cpus=2 --memory=4096

# Desplegar algo que no quepa
kubectl -n rutas-norte-dev create deployment ocupador \
  --image=registry.k8s.io/pause:3.9 --replicas=10

kubectl -n rutas-norte-dev set resources deployment/ocupador \
  --requests=cpu=800m,memory=512Mi

kubectl get pods -n rutas-norte-dev
NAME                        READY   STATUS    RESTARTS   AGE
ocupador-6f8d7c9b4-2mkjp    1/1     Running   0          15s
ocupador-6f8d7c9b4-4nqzx    1/1     Running   0          15s
ocupador-6f8d7c9b4-7wtrv    0/1     Pending   0          15s
ocupador-6f8d7c9b4-9bhcx    0/1     Pending   0          15s
...
kubectl describe pod -n rutas-norte-dev -l app=ocupador | grep -A5 Events

Verás exactamente el FailedScheduling con Insufficient cpu. Es el mismo mensaje que en producción.

2. El patrón de sobreaprovisionamiento con preemption. Esto funciona perfectamente, porque la preemption es una función del planificador, no del proveedor de nube.

# 1. Crear la PriorityClass negativa
kubectl apply -f k8s/base/priorityclass-relleno.yaml

# 2. Desplegar el relleno (ajustado al tamano de minikube)
kubectl -n rutas-norte-dev create deployment relleno \
  --image=registry.k8s.io/pause:3.9 --replicas=2
kubectl -n rutas-norte-dev set resources deployment/relleno \
  --requests=cpu=500m,memory=256Mi
kubectl -n rutas-norte-dev patch deployment relleno \
  -p '{"spec":{"template":{"spec":{"priorityClassName":"relleno-capacidad","terminationGracePeriodSeconds":0}}}}'

# 3. Verificar que el relleno ocupa
kubectl get pods -n rutas-norte-dev -o wide

# 4. Crear un pod real que no cabria sin desalojar el relleno
kubectl -n rutas-norte-dev run pod-importante \
  --image=registry.k8s.io/pause:3.9 \
  --overrides='{"spec":{"containers":[{"name":"pod-importante","image":"registry.k8s.io/pause:3.9","resources":{"requests":{"cpu":"900m"}}}]}}'

# 5. Observar la preemption en directo
kubectl get events -n rutas-norte-dev --sort-by=.lastTimestamp | tail -20
LAST SEEN   TYPE     REASON      OBJECT                      MESSAGE
3s          Normal   Preempted   pod/relleno-7d8c9f4b6-2xkjp Preempted by pod
                                                             rutas-norte-dev/pod-importante on node rutas-norte-m02
2s          Normal   Scheduled   pod/pod-importante          Successfully assigned to rutas-norte-m02
1s          Normal   Scheduled   pod/relleno-7d8c9f4b6-9wqzn FailedScheduling

Ver el Preempted con tus propios ojos y medir que ocurre en segundos es la parte más valiosa del ejercicio, y se practica sin nube.

3. Añadir y quitar nodos a mano, simulando lo que haría el CA.

# Anadir un nodo al perfil (equivalente a lo que hace el CA al ampliar)
minikube -p rutas-norte node add

# Ver como los pods Pending se planifican solos
kubectl get pods -n rutas-norte-dev --watch

# Retirar un nodo (equivalente a la reduccion, incluido el drenaje)
kubectl cordon rutas-norte-m03
kubectl drain rutas-norte-m03 --ignore-daemonsets --delete-emptydir-data
minikube -p rutas-norte node delete rutas-norte-m03

Esa secuencia cordondrain → eliminar es exactamente lo que hace el CA internamente. Practicarla a mano enseña más sobre la reducción de nodos que leer cualquier documentación, y es la base del procedimiento de mantenimiento que veremos en 09-05.

4. Practicar la anotación safe-to-evict y ver cómo bloquea un drenaje.

kubectl annotate pod pod-importante -n rutas-norte-dev \
  cluster-autoscaler.kubernetes.io/safe-to-evict=false

# Nota: esta anotacion la respeta el CA, no kubectl drain. Para ver el bloqueo
# real de un drenaje hace falta un PDB, que es lo que veremos en 09-05.

Lo que NO se puede practicar

No practicable Alternativa
Ampliación automática de nodos minikube node add a mano
Expanders y elección de grupo Estudiar la documentación y los logs de un clúster real
Reducción automática de nodos cordon + drain + node delete a mano
El ConfigMap cluster-autoscaler-status No existe sin CA instalado
Karpenter Requiere AWS

Para practicar el CA de verdad hacen falta: un clúster gestionado (10-06) con el autoescalado activado, o kind con el proveedor de pruebas del CA, o un entorno de nube personal. Recomendación práctica: una cuenta de pruebas de un proveedor durante una tarde, con un clúster pequeño y maxSize bajo, cuesta unos pocos euros y enseña muchísimo.

  1. El plan de capacidad de Rutas Norte y su coste

Cerramos con los números concretos. Este es el trabajo que hay que hacer antes de un puente de mayo, y que no se hizo el año pasado.

Paso 1: la demanda máxima

De los maxReplicas de 09-01 y los requests recalibrados con el VPA de 09-02:

Componente maxReplicas CPU/pod Mem/pod CPU máx Mem máx
api-reservas (api) 30 412m 800Mi 12,36 23,4 GiB
api-reservas (exportador) 30 6m 21Mi 0,18 0,6 GiB
tienda-web 15 70m 64Mi 1,05 0,9 GiB
worker-notificaciones 20 120m 410Mi 2,40 8,0 GiB
postgres-reservas 1 2000m 4Gi 2,00 4,0 GiB
redis-cache 1 300m 2Gi 0,30 2,0 GiB
Ingress controller 4 200m 256Mi 0,80 1,0 GiB
DaemonSets (por nodo) 450m 512Mi (var.) (var.)
Subtotal aplicación 19,09 39,9 GiB

Paso 2: traducir a nodos

Nodo general: 4 nucleos, 16 GiB.
Asignable tras reservas del kubelet y del sistema: ~3,5 nucleos, ~14 GiB.
DaemonSets por nodo: 450m y 512Mi.
Capacidad util por nodo: 3,05 nucleos y 13,5 GiB.

Nodos por CPU:     19,09 / 3,05 = 6,26  ->  7 nodos
Nodos por memoria: 39,9  / 13,5 = 2,96  ->  3 nodos

Limita la CPU: hacen falta 7 nodos generales en el pico.

Ademas: postgres-reservas y redis-cache viven en el grupo "datos" con taint.
  postgres: 2 nucleos, 4 GiB.  redis: 0,3 nucleos, 2 GiB.
  Total: 2,3 nucleos y 6 GiB.  Caben en 1 nodo de 8 nucleos, pero mantenemos
  2 por disponibilidad (uno por zona).

Colchon de sobreaprovisionamiento: 6 nucleos = 2 nodos adicionales.

TOTAL EN EL PICO: 9 nodos generales + 2 nodos de datos = 11 nodos.

Paso 3: configurar los grupos

rutas-norte-general-a   min=1  max=4    (era max=8, ajustamos: 3 zonas x 4 = 12 > 9)
rutas-norte-general-b   min=1  max=4
rutas-norte-general-c   min=1  max=4
rutas-norte-datos       min=2  max=3

Capacidad maxima total: 12 nodos generales + 3 de datos.
Margen sobre lo calculado: 33 %.

El margen del 33 % no es capricho: cubre errores de estimación, un despliegue en curso durante el pico (que duplica temporalmente los pods de un componente), y la pérdida de una zona completa.

Paso 4: los costes

Precios ficticios pero de orden de magnitud realista:

Concepto Cantidad Coste unitario/mes Total/mes
Nodos generales base (3, uno por zona) 3 95 € 285 €
Nodos de datos (2) 2 190 € 380 €
Colchón de sobreaprovisionamiento permanente 2 95 € 190 €
Subtotal permanente 855 €
Nodos extra durante el puente (6 nodos × 3 días) 6×3 días 3,17 €/día 57 €
Total el mes del puente 912 €

Y la comparación que justifica todo el módulo:

Estrategia Coste anual Capacidad en el pico Riesgo
Fijo dimensionado para el martes (situación de partida) 8.000 € Insuficiente La plataforma se cae. Caída de 2023
Fijo dimensionado para el pico 20.500 € Suficiente Ninguno, pero se pagan 12.500 € de capacidad ociosa 362 días al año
Autoescalado + colchón permanente 10.260 € + 57 € Suficiente Bajo
Autoescalado + colchón solo en temporada alta 8.550 € + 57 € Suficiente Bajo

La última fila es el objetivo: prácticamente el mismo coste que el dimensionado fijo insuficiente, con la capacidad del dimensionado para el pico. Ese es todo el valor del módulo 9 resumido en una línea.

Y un recordatorio de honestidad intelectual: el colchón reducido fuera de temporada solo funciona si el CronJob que lo amplía se ejecuta de verdad. Ponle una alerta: si el viernes a las 16:05 el Deployment de relleno no tiene 6 réplicas, alguien tiene que enterarse.

Errores Comunes y Consejos

Error 1: creer que el HPA solo puede llevarte hasta donde hay capacidad, y no darse cuenta. El síntoma es silencioso: kubectl get hpa muestra 30 réplicas y todo parece bien. Solo kubectl get pods revela que 15 están Pending. Alerta obligatoria sobre pods pendientes:

sum(kube_pod_status_phase{phase="Pending", namespace="rutas-norte-pro"}) > 0

Error 2: maxReplicas del HPA incoherente con el maxSize de los grupos de nodos. Un maxReplicas: 100 con grupos que topan en 8 nodos es una mentira: nunca llegarás a 100 réplicas. Calcula siempre Σ(maxReplicas × requests) y compáralo con Σ(maxSize × capacidad asignable). Documenta ese cálculo junto a los manifiestos.

Error 3: pods sin requests en un clúster con CA. Un pod sin requests es invisible para la simulación del CA: el autoescalador cree que un nodo tiene sitio de sobra cuando en realidad está saturado de pods que consumen sin declarar nada. El LimitRange del módulo 3 es la defensa: fuerza un defaultRequest a todo lo que se cree.

Error 4: esperar que el CA salve una punta repentina. Tres a seis minutos. Si tu tráfico sube en cuarenta segundos, el CA llega tarde. Sobreaprovisionamiento (apartado 10) y escalado anticipado (09-04). No hay tercera vía.

Error 5: PDB imposibles que anclan nodos para siempre. Un minAvailable igual al número de réplicas hace que ningún pod pueda desalojarse nunca, y el nodo que los aloje jamás se retirará. Estarás pagando por nodos infrautilizados sin saber por qué. Lo desarrollamos en 09-05, pero el diagnóstico está en los logs del CA: pdb-blocked.

Error 6: un solo grupo de nodos para todo. Sin separar los datos de la aplicación, una avalancha de pods de api-reservas puede aterrizar en el mismo nodo que postgres-reservas y competir por su CPU. Taints y grupos separados (06-05).

Error 7: nodos heterogéneos dentro de un mismo grupo. El CA usa una plantilla por grupo. Si el grupo mezcla máquinas de 2 y de 8 núcleos, la simulación miente y las decisiones son erráticas. Un tipo de máquina por grupo, siempre.

Consejo 1: --balance-similar-node-groups si tienes un grupo por zona. Sin ella, el CA puede amontonar todos los nodos nuevos en una zona y dejarte expuesto a una caída de zona. Es una línea de configuración con impacto directo en la disponibilidad.

Consejo 2: monitoriza cluster_autoscaler_unschedulable_pods_count. El CA expone métricas Prometheus. Las que importan:

# Pods que el CA no consigue colocar
cluster_autoscaler_unschedulable_pods_count > 0

# Nodos por grupo, para ver la ocupación de los grupos
cluster_autoscaler_nodes_count

# Errores de ampliación (sin capacidad en el proveedor, cuota agotada)
rate(cluster_autoscaler_failed_scale_ups_total[15m]) > 0

Ese tercer indicador es especialmente valioso: un failed_scale_up significa que el proveedor te ha dicho que no. Cuota agotada, tipo de instancia sin disponibilidad en la zona, límite de la cuenta. Son problemas que solo se descubren cuando ya es tarde, salvo que los vigiles.

Consejo 3: ensaya el escalado antes del pico. Una semana antes del puente, lanza una prueba de carga contra rutas-norte-pre (09-06) y cronometra cuánto tarda el clúster en alcanzar la capacidad máxima. Ese número, medido y no estimado, es lo que te dice si el colchón es suficiente.

Consejo 4: sube el maxSize de los grupos antes de la temporada alta y bájalo después. Es un cambio de un minuto que evita el max node group size reached en el peor momento. Ponlo en la lista de comprobación previa al evento.

Consejo 5: guarda los logs del CA. Son la única fuente que explica por qué no se escaló o por qué un nodo no se retiró. Sin ellos, el análisis posterior de un incidente de capacidad es adivinar. EFK (07-05).

Consejo 6: revisa el umbral de reducción si tienes muchos DaemonSets. Con Fluentd, Falco, el CNI, el exportador de nodo y quizá un proxy de malla de servicio, la utilización base de un nodo vacío puede rondar el 25 %. Con el umbral por defecto del 50 %, la reducción casi nunca se dispara. Bajarlo al 35 % puede ahorrar nodos enteros.

Ejercicios

Ejercicio 1: calcular la capacidad y detectar el error

El equipo de Rutas Norte ha configurado esto para el puente de mayo:

HPA:

Componente minReplicas maxReplicas requests.cpu requests.memory
api-reservas 4 40 520m 820Mi
tienda-web 3 20 70m 64Mi
worker-notificaciones 2 25 120m 410Mi

Componentes fijos: postgres-reservas (1 réplica, 2 núcleos, 4Gi), redis-cache (1 réplica, 300m, 2Gi), Ingress (4 réplicas, 200m, 256Mi).

Grupos de nodos:

rutas-norte-general-a   min=1  max=3   4 nucleos / 16 GiB
rutas-norte-general-b   min=1  max=3   4 nucleos / 16 GiB
rutas-norte-datos       min=1  max=2   8 nucleos / 32 GiB   taint rol=datos

DaemonSets: 450m y 512Mi por nodo. Reserva del sistema: 500m y 2Gi por nodo.

ResourceQuota del namespace: requests.cpu: 40, requests.memory: 80Gi.

Calcula: (a) la CPU y memoria máximas que puede demandar la aplicación; (b) la capacidad asignable máxima real de los grupos; (c) si la configuración aguanta el pico; (d) el porcentaje de maxReplicas de api-reservas que es alcanzable en la práctica; (e) qué corregirías y en qué orden.

Ejercicio 2: diagnosticar por qué no se retira un nodo

Tras el puente de mayo, el clúster sigue con 9 nodos aunque el tráfico volvió a la normalidad hace 5 horas. La factura preocupa. Esto es lo que ves:

kubectl get configmap cluster-autoscaler-status -n kube-system -o yaml | grep -A4 "ScaleDown"
      ScaleDown:   NoCandidates (candidates=0)
kubectl describe nodes | grep -A6 "Allocated resources"
Node ip-10-0-1-42:  cpu 2890m (82%)  memory 7Gi (50%)
Node ip-10-0-1-88:  cpu 1210m (34%)  memory 3Gi (21%)
Node ip-10-0-2-19:  cpu 2650m (75%)  memory 6Gi (43%)
Node ip-10-0-2-73:  cpu  980m (28%)  memory 2Gi (14%)
Node ip-10-0-3-47:  cpu 1450m (41%)  memory 4Gi (28%)
Node ip-10-0-3-91:  cpu 2100m (60%)  memory 5Gi (36%)
Node ip-10-0-1-15:  cpu  760m (21%)  memory 2Gi (14%)
Node ip-10-0-2-88:  cpu 1890m (54%)  memory 5Gi (36%)
Node ip-10-0-3-22:  cpu  890m (25%)  memory 2Gi (14%)
kubectl logs -n kube-system deployment/cluster-autoscaler --tail=50 | grep -i "not suitable"
scale_down.go: Node ip-10-0-1-88 is not suitable for removal:
    pod rutas-norte-pro/informes-ocupacion-28912440-x7kqp has
    cluster-autoscaler.kubernetes.io/safe-to-evict annotation set to false
scale_down.go: Node ip-10-0-2-73 is not suitable for removal:
    pod rutas-norte-pro/redis-cache-0 is not replicated and has local storage
scale_down.go: Node ip-10-0-1-15 is not suitable for removal:
    pdb-blocked: not enough pod disruption budget to move api-reservas-7c9d4f8b6d-mn2vp
scale_down.go: Node ip-10-0-3-22 is not suitable for removal:
    pod rutas-norte-pro/generador-carga-5f8d9c-2wxkp is not replicated

Analiza cada nodo bloqueado, di si el bloqueo es legítimo o un error, y propón la corrección para cada caso. Calcula cuántos nodos se podrían retirar tras las correcciones y el ahorro mensual (95 €/nodo/mes).

Ejercicio 3: diseñar la estrategia para una campaña relámpago

Rutas Norte lanza una campaña de Black Friday distinta a todo lo anterior:

  • Duración: 2 horas exactas, de 20:00 a 22:00 de un viernes.
  • Tráfico esperado: x25 respecto a un día normal (mucho más que el puente de mayo).
  • El pico llega en 20 segundos: se anuncia por radio y todo el mundo entra a la vez.
  • Los billetes son limitados: si la plataforma no responde en los primeros 10 minutos, la campaña ha fracasado.
  • Presupuesto: hasta 400 € para esa noche.
  • Se conoce con tres semanas de antelación.

Diseña la estrategia completa de capacidad. ¿Qué combinación de HPA, CA, sobreaprovisionamiento y escalado programado usarías? ¿Cuántos nodos, cuánto colchón, con qué antelación? ¿Cuánto costaría? Escribe los manifiestos clave y una lista de comprobación con horarios concretos para esa noche.


Soluciones

Solución 1

(a) Demanda máxima de la aplicación:

Componente Réplicas máx CPU/pod CPU total Mem/pod Mem total
api-reservas 40 520m 20,80 820Mi 32,03 GiB
tienda-web 20 70m 1,40 64Mi 1,25 GiB
worker-notificaciones 25 120m 3,00 410Mi 10,01 GiB
Ingress 4 200m 0,80 256Mi 1,00 GiB
Subtotal general 26,00 44,29 GiB
postgres-reservas 1 2000m 2,00 4Gi 4,00 GiB
redis-cache 1 300m 0,30 2Gi 2,00 GiB
Subtotal datos 2,30 6,00 GiB

(b) Capacidad asignable máxima real:

Nodo general (4 nucleos, 16 GiB):
  Reserva del sistema:  500m, 2 GiB
  DaemonSets:           450m, 512Mi
  Disponible para pods: 4 - 0,5 - 0,45 = 3,05 nucleos
                        16 - 2 - 0,5 = 13,5 GiB

Grupos generales: a(max=3) + b(max=3) = 6 nodos
  CPU:     6 x 3,05 = 18,30 nucleos
  Memoria: 6 x 13,5 = 81,00 GiB

Nodo de datos (8 nucleos, 32 GiB):
  Disponible: 8 - 0,5 - 0,45 = 7,05 nucleos
              32 - 2 - 0,5 = 29,5 GiB

Grupo datos (max=2): 2 nodos
  CPU:     14,10 nucleos
  Memoria: 59,00 GiB

(c) ¿Aguanta el pico? NO.

GENERAL:
  Necesario: 26,00 nucleos
  Disponible: 18,30 nucleos
  DEFICIT: 7,70 nucleos (30 % de lo necesario)

  Memoria: necesaria 44,29 GiB, disponible 81 GiB. Sobra.
  -> LA CPU ES EL LIMITE.

DATOS:
  Necesario: 2,30 nucleos y 6 GiB
  Disponible: 14,10 nucleos y 59 GiB
  -> Sobra muchisimo. El grupo de datos esta MUY sobredimensionado.

Y hay un segundo problema que muchos pasarían por alto: la ResourceQuota.

Demanda total de requests.cpu: 26,00 + 2,30 = 28,30
Cuota: 40. Cabe (71 % de uso).

Demanda total de requests.memory: 44,29 + 6,00 = 50,29 GiB
Cuota: 80 GiB. Cabe (63 % de uso).

La cuota NO es el problema aqui, pero conviene notar que durante un rolling
update de api-reservas coexisten pods viejos y nuevos, lo que puede anadir
hasta un 25 % mas: 28,30 x 1,25 = 35,4 nucleos. Al 88 % de la cuota. Justo.

(d) Porcentaje de maxReplicas de api-reservas alcanzable:

CPU disponible en el grupo general: 18,30 nucleos.
Consumo de los demas componentes generales (a su maximo):
  tienda-web 1,40 + worker 3,00 + ingress 0,80 = 5,20 nucleos

CPU disponible para api-reservas: 18,30 - 5,20 = 13,10 nucleos
Replicas alcanzables: 13,10 / 0,52 = 25,19  ->  25 replicas

25 de 40 = 62,5 %

El maxReplicas: 40 es ficción: la realidad son 25 réplicas. Y peor: cuando el HPA pida las réplicas 26 a 40, se quedarán en Pending y nadie se enterará salvo que haya una alerta.

(e) Correcciones, por orden de prioridad:

Prioridad 1 (imprescindible): subir el maxSize de los grupos generales.

Necesario: 26,00 nucleos / 3,05 por nodo = 8,52  ->  9 nodos
Con un tercer grupo por zona (recomendable para 09-05): 3 grupos x max=4 = 12 nodos
  Capacidad: 12 x 3,05 = 36,6 nucleos. Margen del 41 % sobre lo necesario.
rutas-norte-general-a   min=1  max=4
rutas-norte-general-b   min=1  max=4
rutas-norte-general-c   min=1  max=4     <- grupo nuevo, tercera zona

Prioridad 2: reducir el maxSize del grupo de datos.

El grupo de datos necesita 2,30 nucleos y 6 GiB. Un solo nodo de 8 nucleos
sobra. Con max=2 mantenemos disponibilidad (uno por zona) pero no hay razon
para permitir mas. Se queda en max=2, que ya es correcto.

PERO: el tipo de maquina esta sobredimensionado. Un nodo de 4 nucleos y 16 GiB
bastaria para postgres (2 nucleos, 4 GiB) y redis (0,3, 2 GiB). Cambiar el tipo
de 8/32 a 4/16 ahorraria ~95 EUR/mes por nodo, 190 EUR/mes en total.

Matiz: postgres se beneficia de memoria para shared_buffers y cache de paginas.
Antes de reducir, medir con el VPA (09-02) si 16 GiB bastan. Con requests de
4 GiB y un nodo de 16, hay 10 GiB de cache de sistema disponible: probablemente si.

Prioridad 3: revisar el maxReplicas: 40 de api-reservas.

Con 12 nodos generales caben 25 réplicas... espera, recalculemos con la nueva capacidad:

Capacidad general nueva: 36,6 nucleos
Menos los otros componentes: 36,6 - 5,20 = 31,4 nucleos
Replicas de api-reservas alcanzables: 31,4 / 0,52 = 60

Ahora si caben las 40. El maxReplicas: 40 es alcanzable.

Prioridad 4: añadir el colchón de sobreaprovisionamiento. Sin él, las réplicas 5 a 40 tardarán entre 3 y 6 minutos en aparecer. Con 6 pods de relleno de 1 núcleo, las primeras 11 réplicas son instantáneas.

Prioridad 5: la alerta de pods pendientes. Sin ella, todo este cálculo se puede equivocar y nadie lo sabrá hasta que sea tarde.

Solución 2

Análisis nodo por nodo:

Nodo Utilización Estado Motivo del bloqueo ¿Legítimo?
ip-10-0-1-42 82 % Ocupado Por encima del umbral Sí, no es candidato
ip-10-0-1-88 34 % Bloqueado safe-to-evict: false en informes-ocupacion Depende
ip-10-0-2-19 75 % Ocupado Por encima del umbral
ip-10-0-2-73 28 % Bloqueado redis-cache-0 sin réplica y con almacenamiento local Sí, legítimo
ip-10-0-3-47 41 % Candidato Debería retirarse
ip-10-0-3-91 60 % Ocupado Por encima del umbral
ip-10-0-1-15 21 % Bloqueado PDB de api-reservas NO, es un error
ip-10-0-2-88 54 % Ocupado Por encima del umbral
ip-10-0-3-22 25 % Bloqueado generador-carga sin controlador NO, es basura

Caso 1: ip-10-0-1-88 — informes-ocupacion con safe-to-evict: false.

Legitimidad: condicional. Si el Job está ejecutándose ahora mismo, el bloqueo es correcto: matarlo perdería el trabajo. Pero el puente terminó hace 5 horas y informes-ocupacion es un CronJob nocturno.

# ¿Sigue vivo el Job?
kubectl get jobs -n rutas-norte-pro
kubectl get pods -n rutas-norte-pro -l app=informes-ocupacion

Si aparece como Running desde hace horas, es un Job colgado: probablemente falló y se quedó atascado. Corrección:

# 1. Investigar por que sigue vivo
kubectl logs -n rutas-norte-pro informes-ocupacion-28912440-x7kqp --tail=50

# 2. Si esta colgado, eliminarlo
kubectl delete job informes-ocupacion-28912440 -n rutas-norte-pro

Y prevención estructural en el CronJob:

spec:
  jobTemplate:
    spec:
      # Si a las 2 horas no ha terminado, se corta. Evita jobs zombis que
      # anclan nodos indefinidamente.
      activeDeadlineSeconds: 7200
      ttlSecondsAfterFinished: 3600

Nodo recuperable: SÍ, tras limpiar el Job.

Caso 2: ip-10-0-2-73 — redis-cache-0.

Legitimidad: sí, plenamente. redis-cache es un StatefulSet de una réplica con volumen. El CA no puede moverlo con seguridad, y desde luego no debe hacerlo por su cuenta: reiniciar la caché descarga toda la carga sobre postgres-reservas.

Corrección: ninguna a corto plazo. A medio plazo, las opciones son:

  • Anclar redis-cache al grupo de nodos de datos con afinidad y taint (06-05), para que no ocupe un nodo general.
  • Aceptarlo: un nodo dedicado a la caché no es un desperdicio si está bien dimensionado.

La mejor corrección es la primera: redis-cache no debería estar en un nodo general. Es una decisión de arquitectura que ya deberíamos haber tomado.

Nodo recuperable: NO directamente, pero sí tras mover redis-cache al grupo de datos.

Caso 3: ip-10-0-1-15 — PDB bloqueando api-reservas.

Legitimidad: no, es un error de configuración. api-reservas es un Deployment con muchas réplicas: mover un pod debería ser trivial. Que el PDB lo impida significa que está mal.

kubectl get pdb -n rutas-norte-pro api-reservas -o yaml

Sospecha probable:

spec:
  minAvailable: 30        # <-- Puesto durante el puente, cuando habia 30 replicas

Tras el puente, el HPA ha bajado a 4-6 réplicas. Con minAvailable: 30 y 6 réplicas vivas, ningún pod puede desalojarse jamás: ya se está por debajo del mínimo.

Corrección:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  # PORCENTAJE, no numero absoluto. Se adapta solo al escalado del HPA.
  # Con 6 replicas permite desalojar 1; con 30, permite desalojar 6.
  maxUnavailable: 20%
  selector:
    matchLabels:
      app: api-reservas

Esta es la lección central de 09-05, adelantada: con HPA, los PDB en número absoluto son una trampa. El número de réplicas cambia solo, y un mínimo absoluto que era razonable en el pico se vuelve imposible en el valle.

Nodo recuperable: SÍ, inmediatamente tras corregir el PDB.

Caso 4: ip-10-0-3-22 — generador-carga sin controlador.

Legitimidad: no, es basura. Es el pod de prueba de carga del apartado 12 de 09-01, creado con kubectl run sin controlador y olvidado. Lleva días ahí anclando un nodo entero.

kubectl delete pod generador-carga-5f8d9c-2wxkp -n rutas-norte-pro

Prevención:

  • Usa siempre --rm con kubectl run para pruebas.
  • Una política de Kyverno (08-03) que rechace pods sin ownerReferences en rutas-norte-pro.
  • Una revisión periódica de pods huérfanos:
kubectl get pods -n rutas-norte-pro -o json | \
  python3 -c "import sys,json; [print(p['metadata']['name']) for p in json.load(sys.stdin)['items'] if not p['metadata'].get('ownerReferences')]"

Nodo recuperable: SÍ, inmediatamente.

Resumen y ahorro:

Nodo Acción ¿Se retira?
ip-10-0-1-88 Eliminar el Job colgado
ip-10-0-2-73 Mover redis-cache al grupo de datos (medio plazo) Sí, a medio plazo
ip-10-0-3-47 Ninguna: es candidato, solo necesita tiempo
ip-10-0-1-15 Corregir el PDB a maxUnavailable: 20%
ip-10-0-3-22 Borrar el pod huérfano
Nodos retirables a corto plazo: 4 (ip-10-0-1-88, -3-47, -1-15, -3-22)
Nodos retirables a medio plazo: 1 mas (ip-10-0-2-73)

Ahorro inmediato:    4 x 95 EUR = 380 EUR/mes = 4.560 EUR/ano
Ahorro a medio plazo: 5 x 95 EUR = 475 EUR/mes = 5.700 EUR/ano

Comprobación tras las correcciones:

# Esperar el scale-down-unneeded-time (10 min por defecto) y verificar
watch -n 30 'kubectl get configmap cluster-autoscaler-status -n kube-system \
  -o jsonpath="{.data.status}" | grep -A2 ScaleDown'

Debería pasar de NoCandidates a CandidatesPresent y luego a InProgress.

Lección de fondo del ejercicio: los nodos zombis nunca son culpa del Cluster Autoscaler. Siempre son consecuencia de algo mal configurado en las cargas: un PDB imposible, un Job colgado, un pod huérfano, un componente con estado en el sitio equivocado. El CA es el mensajero; los logs son el mensaje.

Solución 3

Análisis del escenario. Este caso es cualitativamente distinto al puente de mayo:

Factor Puente de mayo Black Friday
Duración 3 días 2 horas
Multiplicador x10 x25
Velocidad de subida Minutos 20 segundos
Antelación Conocida (calendario) 3 semanas
Tolerancia al fallo Baja Nula: 10 minutos y se acabó

La conclusión clave: el escalado reactivo NO sirve aquí. Ni el HPA (15 s de detección + 30 s de arranque) ni el CA (3-6 min) llegan a tiempo para una subida de 20 segundos. Toda la capacidad tiene que estar ya arrancada y caliente antes de las 20:00.

Estrategia: preescalado total.

Paso 1: calcular la capacidad necesaria.

Trafico normal de un viernes noche: ~250 rps.
Trafico esperado: 250 x 25 = 6.250 rps.

De la prueba de carga (09-06) sabemos: cada replica de api-reservas sostiene
~85 rps con latencia p95 aceptable.

Replicas necesarias: 6.250 / 85 = 73,5  ->  con margen del 20 %: 88 replicas.

CPU: 88 x 520m = 45,76 nucleos.
Memoria: 88 x 820Mi = 70,4 GiB.

tienda-web: sirve estatico, escala mejor. 6.250 rps / 400 rps por replica = 16
  -> con margen: 20 replicas. 20 x 70m = 1,4 nucleos.

worker-notificaciones: la cola se llena pero se puede vaciar DESPUES de las
  22:00. NO hace falta escalarlo durante la campana. Se queda en 4 replicas y
  se escala a 25 a partir de las 22:00, cuando ya sobra capacidad.
  Decision deliberada: prioridad absoluta a la venta.

Ingress: 8 replicas (el doble de lo habitual). 1,6 nucleos.

TOTAL en el pico: 45,76 + 1,4 + 0,48 + 1,6 = 49,24 nucleos
                  70,4 + 1,25 + 1,6 + 2 = 75,25 GiB

Paso 2: traducir a nodos.

Capacidad util por nodo general (4 nucleos): 3,05 nucleos, 13,5 GiB.

Por CPU:     49,24 / 3,05 = 16,1  ->  17 nodos
Por memoria: 75,25 / 13,5 = 5,6   ->  6 nodos

LIMITA LA CPU: 17 nodos generales.

Alternativa: nodos mas grandes. Con nodos de 8 nucleos / 32 GiB:
  Util por nodo: 8 - 0,5 - 0,45 = 7,05 nucleos
  Nodos necesarios: 49,24 / 7,05 = 6,98  ->  7 nodos

7 nodos grandes en lugar de 17 pequenos:
  - Menos nodos que arrancar = menos tiempo total de aprovisionamiento
  - Menos DaemonSets replicados (17 x 450m = 7,65 nucleos frente a 7 x 450m = 3,15)
  - Peor granularidad y peor tolerancia a fallos

DECISION: nodos de 8 nucleos para esa noche. El ahorro de DaemonSets (4,5
nucleos) es decisivo, y como toda la capacidad se arranca ANTES, la peor
granularidad no importa.

Paso 3: los manifiestos.

# k8s/entornos/pro/campana/hpa-api-reservas-blackfriday.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  annotations:
    rutasnorte.example/campana: "black-friday-2026"
    rutasnorte.example/revertir-antes-de: "2026-11-28T23:00:00Z"
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reservas
  # minReplicas ELEVADO A 88. Esta es la clave de toda la estrategia:
  # la capacidad esta ARRANCADA Y CALIENTE antes de las 20:00, no se espera
  # a que el HPA reaccione. Con una subida de 20 segundos, cualquier
  # mecanismo reactivo llega tarde.
  minReplicas: 88
  maxReplicas: 110          # Margen del 25 % por si la estimacion se queda corta
  metrics:
    - type: ContainerResource
      containerResource:
        name: cpu
        container: api
        target:
          type: Utilization
          averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - type: Percent
          value: 100
          periodSeconds: 15
        - type: Pods
          value: 15
          periodSeconds: 15
    scaleDown:
      # DESACTIVADO durante la campana. Ni una sola replica menos entre las
      # 19:00 y las 22:30, pase lo que pase con las metricas. Un valle de
      # 30 segundos no debe destruir capacidad que costo 20 minutos levantar.
      selectPolicy: Disabled
# k8s/entornos/pro/campana/deployment-relleno-blackfriday.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: relleno-capacidad
  namespace: rutas-norte-pro
spec:
  # Colchon ampliado a 10 pods de 1 nucleo. Absorbe instantaneamente las
  # replicas 89 a 108 si la estimacion se queda corta.
  replicas: 10
  selector:
    matchLabels:
      app: relleno-capacidad
  template:
    metadata:
      labels:
        app: relleno-capacidad
      annotations:
        cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
    spec:
      priorityClassName: relleno-capacidad
      terminationGracePeriodSeconds: 0
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: relleno-capacidad
      containers:
        - name: pausa
          image: registry.k8s.io/pause:3.9
          resources:
            requests: {cpu: "1", memory: 1Gi}
            limits: {cpu: "1", memory: 1Gi}

Grupos de nodos para esa noche:

rutas-norte-bf-a   min=3  max=4   8 nucleos / 32 GiB   zona A
rutas-norte-bf-b   min=3  max=4   8 nucleos / 32 GiB   zona B
rutas-norte-bf-c   min=2  max=4   8 nucleos / 32 GiB   zona C

Total minimo garantizado: 8 nodos (ya arrancados, no dependen del CA)
Total maximo: 12 nodos

Fíjate en el detalle importante: el min de los grupos se sube a 3, no se confía en el CA. Poniendo el mínimo alto, el proveedor arranca las máquinas y el CA no puede retirarlas. La capacidad está garantizada por construcción, no por reacción.

Paso 4: el coste.

Nodo de 8 nucleos / 32 GiB: ~0,26 EUR/hora bajo demanda.

Ventana de capacidad ampliada: 18:00 a 23:00 = 5 horas.
Nodos extra sobre la base (base: 3 nodos de 4 nucleos):
  8 nodos de 8 nucleos x 5 horas x 0,26 = 10,40 EUR

Trafico de salida adicional estimado: ~15 EUR
Margen para incidencias (nodos hasta el maximo de 12): +4 nodos x 5 h x 0,26 = 5,20 EUR

TOTAL ESTIMADO: ~31 EUR

PRESUPUESTO: 400 EUR.
Margen: 369 EUR (92 %).

El resultado sorprende y es la enseñanza del ejercicio: dimensionar generosamente para dos horas es baratísimo. El coste de la infraestructura en la nube es proporcional al tiempo, y dos horas de sobredimensionamiento cuestan menos que un café por nodo. Con un presupuesto de 400 €, no hay ninguna excusa para escatimar capacidad esa noche.

Con el margen sobrante se pueden permitir mejoras:

  • Subir a 12 nodos desde el principio en lugar de 8: +5,20 €.
  • Duplicar las réplicas de redis-cache en modo réplica de lectura: +2 €.
  • Mantener la capacidad hasta las 2:00 para vaciar la cola de notificaciones sin prisa: +15 €.

Todo eso cabe holgadamente.

Paso 5: la lista de comprobación de la noche.

Hora Acción Responsable Verificación
3 semanas antes Prueba de carga en rutas-norte-pre a 6.250 rps (09-06) Plataforma Medir rps por réplica real
2 semanas antes Verificar cuotas del proveedor: ¿permite 12 nodos de 8 núcleos? Plataforma Ticket de ampliación si no
1 semana antes Ensayo general: aplicar los manifiestos en pre y medir el tiempo de arranque completo Plataforma Cronómetro
1 semana antes Revisar PDB: maxUnavailable en porcentaje, no absoluto Plataforma kubectl get pdb
3 días antes Congelar despliegues. Ningún cambio de código hasta el lunes Todo el equipo Bloqueo en CI
17:00 Subir el min de los grupos de nodos a 3/3/2 Plataforma kubectl get nodes = 8
17:30 Verificar que los 8 nodos están Ready y con las imágenes precargadas Plataforma kubectl get nodes
18:00 Aplicar el HPA de campaña (minReplicas: 88) Plataforma kubectl get hpa
18:00 Ampliar el relleno a 10 réplicas Plataforma kubectl get pods -l app=relleno
18:30 Verificar 88 pods Running y Ready, no solo creados Plataforma kubectl get pods --field-selector status.phase=Running | wc -l
18:45 Prueba de humo: 100 reservas reales de prueba end-to-end QA Todas OK
19:00 Verificar Grafana: latencia p95 estable, cero errores, cero Pending Plataforma Paneles
19:30 Precalentar cachés: consultar las 50 rutas más vendidas Plataforma redis-cli DBSIZE
19:45 Sala de guerra abierta. Todo el equipo conectado Todos
19:55 Última verificación. kubectl get pods --all-namespaces | grep -v Running Plataforma Vacío
20:00 APERTURA. Vigilar: latencia p95, tasa de error, pods Pending, conexiones a PostgreSQL Todos Paneles
20:00-22:00 Vigilancia continua. Regla: no tocar nada salvo incidente confirmado Todos
22:00 Cierre de la campaña
22:15 Escalar worker-notificaciones a 25 réplicas para vaciar la cola Plataforma Longitud de cola
23:00 Restaurar el HPA normal (minReplicas: 4) Plataforma kubectl apply del manifiesto base
23:30 Bajar el min de los grupos de nodos. El CA retirará el resto Plataforma kubectl get nodes
Día siguiente Análisis: rps reales, latencias, comparación con la estimación Todos Informe

Notas críticas sobre el plan:

  1. La verificación de las 18:30 es la más importante. 88 pods creados no es lo mismo que 88 pods Ready. Si a las 18:30 hay 20 en Pending, hay dos horas para arreglarlo. Si te enteras a las 20:01, no hay nada que hacer.

  2. El scaleDown: Disabled es innegociable. Sin él, un valle de un minuto entre las 20:15 y las 20:16 podría destruir 30 réplicas que tardarían minutos en volver.

  3. worker-notificaciones no se escala durante la campaña. Es una decisión deliberada: los correos de confirmación pueden esperar dos horas, la venta no. Toda la capacidad va a la venta. Esta es la clase de compromiso explícito que distingue un plan de capacidad de una lista de deseos.

  4. La regla de "no tocar nada" durante la campaña. Casi todos los incidentes graves en eventos de este tipo los causa alguien intentando arreglar algo bajo presión. Si los paneles están verdes, se mira y no se toca.

  5. El precalentamiento de cachés a las 19:30 ahorra que los primeros 5.000 usuarios paguen el coste de las consultas frías a PostgreSQL. Es media hora de trabajo con un impacto enorme en los primeros minutos.

Conclusión

El autoescalado de clúster es el nivel que sostiene a los otros dos. Sin él, el HorizontalPodAutoscaler tiene un techo duro e invisible: pide réplicas, el Deployment las crea, y se quedan en Pending mientras los paneles muestran números que no se corresponden con la realidad.

Lo esencial:

  • El síntoma es siempre el mismo: pods en Pending con FailedScheduling e Insufficient cpu. Reconocerlo y alertar sobre él es lo primero.
  • El Cluster Autoscaler observa pods no planificables, simula qué grupo de nodos los alojaría, y elige con un expander. least-waste es el más razonable en general.
  • Ampliar es fácil; reducir es donde están los problemas. Umbral de utilización, tiempo de espera, y una lista de bloqueadores que hay que conocer: pods sin controlador, almacenamiento local, PDB restrictivos, pods del sistema, y la anotación safe-to-evict: "false".
  • Diagnosticar es leer tres sitios: el ConfigMap cluster-autoscaler-status, los eventos NotTriggerScaleUp sobre los pods pendientes, y los logs del CA con sus not suitable for removal.
  • Un nodo tarda entre 3 y 6 minutos en pasar de la petición a servir tráfico. Eso no salva una punta que sube en cuarenta segundos, por bien configurado que esté todo.
  • El sobreaprovisionamiento con pods de prioridad negativa convierte tres minutos y medio en tres segundos, a cambio de pagar capacidad ociosa. Es el uso más elegante de la PriorityClass y la preemption de 06-05, y se autorregenera.
  • Karpenter elige la máquina en función de los pods pendientes en lugar de trabajar con grupos fijos, y consolida continuamente. Mejor en AWS y con cargas heterogéneas; el CA sigue siendo la opción sólida y predecible en el resto de casos.
  • Los tres escaladores encajan en cascada: HPA crea pods → si no caben, Pending → el CA añade nodos. Y el colchón corta el camino largo.
  • El plan de capacidad de Rutas Norte cuesta prácticamente lo mismo que el dimensionado fijo insuficiente del año pasado, pero con la capacidad del dimensionado para el pico. Ese es todo el valor del módulo.

Pero seguimos teniendo un problema de fondo que ninguna de las tres lecciones ha resuelto. Todo lo que hemos construido es reactivo: espera a que la CPU suba para actuar. Y hay dos casos donde eso simplemente no funciona.

El primero es worker-notificaciones. Durante el puente de mayo puede tener cuarenta mil correos esperando en la cola y la CPU al 20 %, porque su trabajo es esperar respuestas del servidor de correo, no calcular. Un HPA por CPU vería ese 20 % contra un objetivo del 70 % y concluiría que sobran réplicas: escalaría hacia abajo justo cuando más consumidores hacen falta.

El segundo es el propio momento de apertura de la venta. Sabemos que ocurre a las 10:00 en punto. Es información que tenemos con meses de antelación. Y sin embargo, todo nuestro sistema espera a las 10:00:15 para descubrir, por la CPU, algo que ya sabíamos en marzo.

En la próxima lección, Escalado por Eventos y Métricas Personalizadas con KEDA, resolvemos los dos. Veremos cómo el HPA puede consumir métricas de negocio a través de la API de agregación, qué es KEDA y por qué no sustituye al HPA sino que lo alimenta, cómo escalar worker-notificaciones por la longitud real de su cola —con escalado a cero incluido—, cómo escalar api-reservas por peticiones por segundo usando las métricas que instrumentamos en 07-03, y cómo programar un disparador que precaliente toda la plataforma media hora antes de que se abra la venta del puente de mayo.

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