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
- El techo duro: cuando el HPA pide y no hay sitio
- El síntoma exacto:
PendingyFailedScheduling - Qué es el Cluster Autoscaler y dónde vive
- Cómo decide ampliar: simulación y expanders
- Grupos de nodos y su relación con el proveedor
- La reducción de nodos: la parte delicada
- Los motivos por los que un nodo no se puede vaciar
- Diagnóstico: el ConfigMap de estado y los eventos
- Los tiempos reales: cuánto tarda un nodo de verdad
- Sobreaprovisionamiento con pods de relleno
- Karpenter: la alternativa moderna
- Cómo encajan los tres escaladores
- Qué se puede practicar en minikube
- El plan de capacidad de Rutas Norte y su coste
- Errores comunes y consejos
- Ejercicios
- Conclusión
- 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.
- El síntoma exacto:
Pending y FailedScheduling
Pending y FailedSchedulingAprendamos a reconocerlo con precisión, porque es el disparador de todo lo demás.
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 91sPending 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.
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 reachedDos 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 | Sí |
Insufficient memory |
No hay memoria asignable suficiente | Sí |
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:
Y para ver la ocupación real de los nodos:
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.
- 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.
- 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
- Lista los pods no planificables. Los que llevan al menos
--max-pod-provisioning-timeenPendingconFailedScheduling. - Agrupa pods equivalentes. Los pods del mismo controlador con los mismos requisitos se tratan como un grupo, para no simular veinte veces lo mismo.
- 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.
- 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.
- Elige entre los grupos viables usando el
expander. - 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-norteLa 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 disponibleEl 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.
- 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:NoScheduleTres 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 sí 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.
- 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:
- ¿Está por debajo del umbral de utilización? Por defecto,
--scale-down-utilization-threshold=0.5: la suma derequestsde sus pods es menor del 50 % de su capacidad asignable. - ¿Lleva así el tiempo suficiente?
--scale-down-unneeded-time=10m: diez minutos consecutivos por debajo del umbral. - ¿Se puede vaciar? Simula si todos sus pods cabrían en otros nodos existentes. Si no caben, no se toca.
- ¿Hay algún pod que impida el desalojo? La lista del apartado 7.
- 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).
- 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-ocupaciones 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-reservasCon 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.
- Diagnóstico: el ConfigMap de estado y los eventos
El CA publica su estado interno en un ConfigMap. Es el primer sitio donde mirar.
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=.lastTimestampLAST 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:
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 falseLos 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.
- 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:
- Sobreaprovisionamiento: tener capacidad ya arrancada y esperando (apartado 10).
- Escalado anticipado: escalar antes del pico usando un disparador temporal, no reactivo. Es el disparador cron de KEDA que veremos en 09-04.
- 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:
- El CA cuenta esos
requestscomo ocupación, así que mantiene nodos arrancados para alojarlos. - 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.
- 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: -10garantiza que cualquier pod sinpriorityClassName(prioridad 0) los supere.preemptionPolicy: Neverhace que estos pods, al quedarsePending, 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 wideNAME 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-91Ahora 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 --watchNAME 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 3sY 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-42Tres 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-proY 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.
- 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: 800GiCon 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.
- 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.
- 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-devNAME 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
...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 -20LAST 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 FailedSchedulingVer 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-m03Esa secuencia cordon → drain → 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.
- 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:
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]) > 0Ese 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=datosDaemonSets: 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:
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%)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 replicatedAnaliza 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 zonaPrioridad 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 | Sí |
| 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 | Sí |
| 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 | Sí |
| 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-ocupacionSi 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-proY 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: 3600Nodo 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-cacheal 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.
Sospecha probable:
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-reservasEsta 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.
Prevención:
- Usa siempre
--rmconkubectl runpara pruebas. - Una política de Kyverno (08-03) que rechace pods sin
ownerReferencesenrutas-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 | Sí |
| 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 | Sí |
| ip-10-0-1-15 | Corregir el PDB a maxUnavailable: 20% |
Sí |
| ip-10-0-3-22 | Borrar el pod huérfano | Sí |
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/anoComprobació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 GiBPaso 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 nodosFí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-cacheen 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:
-
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 enPending, hay dos horas para arreglarlo. Si te enteras a las 20:01, no hay nada que hacer. -
El
scaleDown: Disabledes 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. -
worker-notificacionesno 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. -
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.
-
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
PendingconFailedSchedulingeInsufficient 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-wastees 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 eventosNotTriggerScaleUpsobre los pods pendientes, y los logs del CA con susnot 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
PriorityClassy 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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
