La lección anterior terminó con dos problemas que ninguna de las tres capas de escalado resuelve. El primero: worker-notificaciones puede tener cuarenta mil correos esperando en la cola con 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 escalaría hacia abajo justo cuando más consumidores hacen falta. El segundo: sabemos con meses de antelación que la venta del puente de mayo abre a las 10:00 en punto, y sin embargo todo nuestro sistema espera a las 10:00:15 para descubrir por la CPU algo que ya sabíamos en marzo.
Los dos problemas tienen la misma raíz: la CPU es una señal indirecta y tardía de lo que realmente le importa al negocio. Lo que importa no es cuánto calcula un pod, sino cuánto trabajo pendiente hay, cuántas peticiones llegan, y cuándo va a llegar la avalancha.
Esta lección enseña a escalar por señales de negocio. Veremos cómo el HorizontalPodAutoscaler puede consumir métricas personalizadas y externas a través de la API de agregación, qué es KEDA y por qué no sustituye al HPA sino que lo alimenta, el objeto ScaledObject campo a campo, el catálogo de escaladores disponibles, y la implementación completa en Rutas Norte: la cola de correos, las peticiones por segundo con PromQL, el escalado a cero, y el disparador cron que precalienta la plataforma antes de la apertura de la venta.
Contenido
- Por qué la CPU es una mala señal
- Métricas personalizadas y externas en el HPA
- La API de agregación y los adaptadores de métricas
- Qué es KEDA y cuál es su arquitectura
- La idea clave: KEDA no sustituye al HPA, lo alimenta
- El objeto
ScaledObjectcampo a campo - El catálogo de escaladores
TriggerAuthentication: credenciales para los disparadoresScaledJob: un trabajo por mensaje- Rutas Norte:
worker-notificacionespor longitud de cola - Rutas Norte:
api-reservaspor peticiones por segundo - Rutas Norte: el disparador cron del puente de mayo
- El escalado a cero y sus implicaciones
- Depurar un
ScaledObjectque no escala - Errores comunes y consejos
- Ejercicios
- Conclusión
- Por qué la CPU es una mala señal
Empecemos por entender bien el problema, porque la solución solo tiene sentido cuando el problema está claro.
El caso de worker-notificaciones
worker-notificaciones consume mensajes de una cola de correos de confirmación y los envía a través de un servidor SMTP externo. Su ciclo por mensaje:
1. Leer un mensaje de la cola ~2 ms de CPU
2. Consultar los datos de la reserva ~5 ms de CPU + 8 ms esperando a PostgreSQL
3. Renderizar la plantilla del correo ~12 ms de CPU
4. Conectar con el servidor SMTP y enviar ~3 ms de CPU + 450 ms ESPERANDO
5. Confirmar el mensaje en la cola ~1 ms de CPU
Total: ~23 ms de CPU y ~458 ms de espera.
Proporcion CPU/tiempo total: 23 / 481 = 4,8 %El 95 % del tiempo el proceso está bloqueado esperando a la red. No consume CPU: espera.
Consecuencia directa: con un requests.cpu de 300m y un objetivo de HPA del 70 % (210m), un worker que procesa mensajes a tope consume unos 60m. El HPA calcularía:
Con cuarenta mil correos en la cola, el HPA quiere reducir a una réplica. Es exactamente el comportamiento contrario al necesario, y no es un fallo del HPA: es que le estamos preguntando la pregunta equivocada.
La pregunta correcta
La pregunta que importa no es «¿cuánta CPU consumen mis workers?» sino «¿cuánto trabajo pendiente hay?». Y esa pregunta tiene una respuesta directa: la longitud de la cola.
Cola: 40.000 mensajes.
Un worker procesa ~2 mensajes/segundo (limitado por el SMTP).
Objetivo: vaciar la cola en 20 minutos = 1.200 segundos.
Mensajes por segundo necesarios: 40.000 / 1.200 = 33,3
Workers necesarios: 33,3 / 2 = 17 workers.Diecisiete workers, no uno. Y ese cálculo se puede expresar como una regla de escalado trivial: «un worker por cada 500 mensajes en cola» (40.000 / 500 = 80... ajustemos: un worker por cada 2.500 mensajes daría 16). El número exacto se calibra midiendo, pero la forma de la regla es evidente y no tiene nada que ver con la CPU.
Otros casos donde la CPU miente
| Carga | Por qué la CPU miente | Señal correcta |
|---|---|---|
| Consumidores de cola | Trabajo dominado por espera de red | Longitud de la cola |
| APIs con muchas llamadas externas | El tiempo se va esperando a terceros | Peticiones por segundo, conexiones activas |
| Procesamiento de ficheros | La CPU depende del tamaño, no del número | Ficheros pendientes en el almacén |
| Servicios con caché muy efectiva | CPU baja pero latencia alta si la caché falla | Tasa de aciertos, latencia p95 |
| Cargas con límite de tasa externo | La CPU está artificialmente baja | Peticiones en cola de espera |
| WebSockets / conexiones persistentes | Muchas conexiones inactivas consumen memoria, no CPU | Número de conexiones |
Un patrón general: la CPU es buena señal cuando el trabajo del pod es calcular, y mala señal cuando el trabajo es coordinar, esperar o mantener estado de conexión.
Y el problema del tiempo
Incluso con la métrica correcta, todo escalado reactivo comparte una limitación: actúa después. La secuencia es siempre la misma: llega la carga → se degrada el servicio → se detecta → se escala → se recupera. Entre el segundo y el quinto paso hay usuarios sufriendo.
Para eventos predecibles —la apertura de la venta a las 10:00, el cierre contable del día 1, la campaña de Black Friday a las 20:00— esperar a que la métrica suba es absurdo. Es información que ya tenemos. Lo racional es escalar antes.
Esa capacidad se llama escalado programado, y es una de las cosas que KEDA aporta de fábrica.
- Métricas personalizadas y externas en el HPA
Antes de KEDA, veamos qué puede hacer el HPA por sí mismo. Recordemos los tipos de métrica de 09-01:
| Tipo | API que la sirve | Qué mide |
|---|---|---|
Resource / ContainerResource |
metrics.k8s.io |
CPU y memoria de los pods |
Pods |
custom.metrics.k8s.io |
Métrica emitida por los pods del destino, promediada |
Object |
custom.metrics.k8s.io |
Métrica asociada a otro objeto de Kubernetes |
External |
external.metrics.k8s.io |
Algo de fuera del clúster |
El HPA sabe consumir los cuatro tipos. No hace falta ningún componente adicional para que el HPA entienda una métrica externa: su código ya lo contempla.
Lo que falta es quién sirve esas APIs. metrics.k8s.io la sirve metrics-server (07-02). Pero custom.metrics.k8s.io y external.metrics.k8s.io no las sirve nadie por defecto. Si escribes un HPA con type: External en un clúster limpio, obtendrás:
Conditions:
Type Status Reason Message
---- ------ ------ -------
ScalingActive False FailedGetExternalMetric unable to get external metric
rutas-norte-pro/longitud_cola_correos/nil:
no custom metrics API (external.metrics.k8s.io/v1beta1)
registeredEse es el hueco que llena un adaptador de métricas.
La diferencia entre Pods, Object y External
Merece la pena tenerla clara porque determina cómo se calculan las réplicas.
Pods: la métrica la emite cada pod del destino y el HPA la promedia entre todos.
- type: Pods
pods:
metric:
name: peticiones_por_segundo
target:
type: AverageValue
averageValue: "85"Fórmula: techo(replicasActuales × mediaEntrePods / objetivo). Es la misma que la CPU, con otra magnitud.
Object: la métrica pertenece a otro objeto del clúster (un Ingress, un Service). Es un valor único, no una media.
- type: Object
object:
describedObject:
apiVersion: networking.k8s.io/v1
kind: Ingress
name: rutas-norte-publico
metric:
name: peticiones_por_segundo
target:
type: Value
value: "5000"Con type: Value el HPA compara el valor bruto con el objetivo. Con type: AverageValue divide el valor entre las réplicas actuales antes de comparar.
External: la métrica viene de fuera del clúster. Una cola de RabbitMQ, un topic de Kafka, una métrica de facturación.
- type: External
external:
metric:
name: longitud_cola_correos
selector:
matchLabels:
cola: notificaciones-confirmacion
target:
type: AverageValue
averageValue: "2500"Para colas, siempre AverageValue. El significado es «cada réplica se hace cargo de 2.500 mensajes», y así el número de réplicas crece linealmente con la cola:
| Mensajes en cola | Réplicas deseadas |
|---|---|
| 1.000 | techo(1000/2500) = 1 |
| 10.000 | techo(10000/2500) = 4 |
| 40.000 | techo(40000/2500) = 16 |
| 100.000 | techo(100000/2500) = 40 (limitado por maxReplicas) |
Con type: Value el comportamiento sería completamente distinto y casi siempre indeseado: compararía 40.000 con 2.500 y multiplicaría las réplicas actuales por 16 en cada ciclo, lo que produce oscilaciones violentas.
- La API de agregación y los adaptadores de métricas
Entender esta pieza es lo que convierte a KEDA de magia en ingeniería.
La capa de agregación
Kubernetes permite extender su API delegando ciertas rutas en servicios que corren dentro del clúster. El mecanismo es el objeto APIService:
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True 41d
v1beta1.external.metrics.k8s.io keda/keda-operator-metrics-apiserver True 12dCuando alguien (el HPA, o tú con kubectl) pide /apis/external.metrics.k8s.io/v1beta1/..., el servidor de API no responde él: reenvía la petición al Service registrado. Ese servicio devuelve los datos en el formato que la API define, y el servidor de API los pasa al cliente como si fueran suyos.
flowchart TD
HPA[Controlador HPA] -->|GET /apis/external.metrics.k8s.io/...| API[Servidor de API<br/>kube-apiserver]
API -->|capa de agregacion| ADAPT[Adaptador de metricas<br/>registrado como APIService]
ADAPT -->|consulta nativa| SRC
subgraph SRC["Fuente real de datos"]
PROM[Prometheus]
RMQ[RabbitMQ]
KAFKA[Kafka]
PG[PostgreSQL]
end
ADAPT -->|devuelve en formato<br/>ExternalMetricValueList| API
API -->|respuesta| HPA
style ADAPT fill:#cde,stroke:#369,stroke-width:2px
Solo puede haber un servicio registrado por cada API. Es decir, un único proveedor de external.metrics.k8s.io en todo el clúster. Esto es importante: si ya tienes el adaptador de Prometheus registrado ahí y luego instalas KEDA, uno de los dos se quedará sin registrar. Lo veremos en los errores comunes.
El adaptador de Prometheus
La opción clásica antes de KEDA es prometheus-adapter. Traduce consultas PromQL en métricas de la API de Kubernetes mediante reglas de configuración:
# Fragmento de la configuracion de prometheus-adapter
rules:
- seriesQuery: 'peticiones_http_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^peticiones_http_total$"
as: "peticiones_por_segundo"
metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'Y una vez configurado, la métrica está disponible:
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods/*/peticiones_por_segundo" \
| python3 -m json.toolFunciona, pero tiene inconvenientes que explican por qué KEDA se ha impuesto:
Inconveniente de prometheus-adapter |
Cómo lo resuelve KEDA |
|---|---|
| Configuración en un ConfigMap global, difícil de razonar | Cada ScaledObject lleva su consulta |
| Cambiar una regla requiere reiniciar el adaptador | Cambio en caliente |
| Solo habla con Prometheus | Más de 70 fuentes distintas |
| No permite escalar a cero | Escalado a cero nativo |
| Sin disparadores temporales | Escalador cron incluido |
| Sin gestión de credenciales | TriggerAuthentication |
- Qué es KEDA y cuál es su arquitectura
KEDA (Kubernetes Event-Driven Autoscaling) es un proyecto graduado de la CNCF que añade al clúster la capacidad de escalar cargas en función de eventos y métricas externas de prácticamente cualquier fuente.
Instalación
# Con Helm (lo veremos a fondo en 10-03)
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
# Verificacion
kubectl get pods -n kedaNAME READY STATUS RESTARTS AGE
keda-admission-webhooks-6d8f7c9b4d-x2klm 1/1 Running 0 2m
keda-operator-7d9c5f8b6b-4mnpq 1/1 Running 0 2m
keda-operator-metrics-apiserver-5f7b8c9d6a-9wrtz 1/1 Running 0 2mY los CRDs que registra:
clustertriggerauthentications.keda.sh 2026-04-20T11:32:14Z
scaledjobs.keda.sh 2026-04-20T11:32:14Z
scaledobjects.keda.sh 2026-04-20T11:32:14Z
triggerauthentications.keda.sh 2026-04-20T11:32:14ZLos tres componentes
1. El operador (keda-operator).
Es el controlador principal. Vigila los objetos ScaledObject y ScaledJob y, cuando encuentra uno:
- Crea y gestiona un HorizontalPodAutoscaler que apunta al mismo destino.
- Traduce los
triggersdelScaledObjecten métricasExternaldel HPA. - Gestiona el escalado a cero activando y desactivando el destino (esto el HPA no sabe hacerlo por sí solo).
- Actualiza el estado del
ScaledObject.
2. El servidor de métricas (keda-operator-metrics-apiserver).
Se registra como proveedor de external.metrics.k8s.io en la capa de agregación. Cuando el HPA pregunta por el valor de una métrica, este componente:
- Identifica a qué
ScaledObjecty a qué disparador corresponde. - Consulta la fuente real (Prometheus, RabbitMQ, Kafka...) usando el escalador correspondiente.
- Devuelve el valor en el formato que el HPA espera.
3. El webhook de admisión (keda-admission-webhooks).
Valida los ScaledObject antes de aceptarlos: comprueba que el destino existe, que no hay dos ScaledObject sobre la misma carga, que el destino no tiene ya un HPA propio. Evita configuraciones incoherentes antes de que causen problemas.
El diagrama completo
flowchart TD
SO[ScaledObject<br/>rutas-norte-pro]
OP[keda-operator]
HPA[HorizontalPodAutoscaler<br/>keda-hpa-worker-notificaciones<br/>GENERADO por KEDA]
MS[keda-operator-metrics-apiserver]
API[Servidor de API<br/>capa de agregacion]
DEP[Deployment<br/>worker-notificaciones]
subgraph FUENTES["Fuentes externas"]
RMQ[RabbitMQ<br/>cola de correos]
PROM[Prometheus]
CRON[Reloj interno]
end
SO -->|1. observa| OP
OP -->|2. CREA Y MANTIENE| HPA
OP -->|3. activa/desactiva<br/>escalado 0 a 1| DEP
HPA -->|4. pregunta el valor| API
API -->|5. delega| MS
MS -->|6. consulta| RMQ
MS -->|6. consulta| PROM
MS -->|6. consulta| CRON
MS -->|7. devuelve valor| API
API -->|8. valor| HPA
HPA -->|9. PATCH /scale<br/>de 1 en adelante| DEP
style OP fill:#cde,stroke:#369
style HPA fill:#ffd,stroke:#c90,stroke-width:2px
style MS fill:#cfc,stroke:#393
- La idea clave: KEDA no sustituye al HPA, lo alimenta
Esta es la idea que más malentendidos genera y la que hay que fijar bien.
KEDA no es un autoescalador alternativo. KEDA crea un HorizontalPodAutoscaler normal y corriente, y le da de comer métricas.
Compruébalo tú mismo. Tras crear un ScaledObject:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
keda-hpa-worker-notificaciones Deployment/worker-notificaciones 1847/2500 (avg) 1 25 8Ahí está: un HPA con el prefijo keda-hpa-, gestionado por el operador de KEDA. Y su contenido:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: keda-hpa-worker-notificaciones
namespace: rutas-norte-pro
labels:
app.kubernetes.io/managed-by: keda-operator
scaledobject.keda.sh/name: worker-notificaciones
ownerReferences:
- apiVersion: keda.sh/v1alpha1
kind: ScaledObject
name: worker-notificaciones
controller: true
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: worker-notificaciones
minReplicas: 1
maxReplicas: 25
metrics:
- type: External
external:
metric:
name: s0-rabbitmq-notificaciones-confirmacion
selector:
matchLabels:
scaledobject.keda.sh/name: worker-notificaciones
target:
type: AverageValue
averageValue: "2500"Es exactamente el HPA de 09-01, con una métrica External cuyo nombre lo ha generado KEDA (s0 = disparador 0). Todo lo que aprendimos sobre el HPA sigue aplicando: la fórmula, la banda de tolerancia, el behavior, la regla de que gana la métrica que pide más réplicas.
Consecuencias prácticas de este diseño
| Consecuencia | Detalle |
|---|---|
| No gestiones el HPA a mano | Es propiedad del ScaledObject (ownerReferences). Si lo editas, KEDA lo revierte; si lo borras, lo recrea |
| La carga no puede tener otro HPA | Un solo HPA por destino. Si ya tienes uno propio, hay que borrarlo |
El behavior se configura desde el ScaledObject |
A través de advanced.horizontalPodAutoscalerConfig |
Al borrar el ScaledObject, desaparece el HPA |
Por el ownerReference. El Deployment se queda con las réplicas que tuviera |
| Se depura como un HPA | kubectl describe hpa keda-hpa-... sigue siendo la herramienta |
La excepción: el escalado a cero
Hay una cosa que el HPA no sabe hacer y que KEDA gestiona directamente: pasar de 0 a 1 réplica y de 1 a 0.
El HPA no puede escalar a cero (salvo con una feature gate opcional poco usada), y aunque pudiera, tendría un problema irresoluble: con cero pods no hay métricas de pod, así que no sabría cuándo volver a arrancar.
KEDA lo resuelve porque consulta la fuente externa directamente, no a través de los pods. Con cero réplicas de worker-notificaciones, KEDA sigue preguntándole a RabbitMQ cuántos mensajes hay. Cuando la cola pasa de 0 a 1 mensaje, el operador escala el Deployment de 0 a 1 y a partir de ahí deja que el HPA tome el mando.
flowchart LR
Z["0 replicas<br/>servicio apagado"] -->|"KEDA: hay actividad<br/>(operador)"| U["1 replica"]
U -->|"HPA: la metrica sube"| M["2 a 25 replicas"]
M -->|"HPA: la metrica baja"| U
U -->|"KEDA: cooldownPeriod sin actividad<br/>(operador)"| Z
style Z fill:#eee,stroke:#999
style U fill:#cfc,stroke:#393
style M fill:#cde,stroke:#369
El tramo 0↔1 lo gobierna el operador de KEDA; el tramo 1↔N lo gobierna el HPA. Esta división explica muchos comportamientos: por ejemplo, que el cooldownPeriod solo afecte a la bajada a cero, y que la ventana de estabilización del behavior gobierne el resto.
- El objeto
ScaledObject campo a campo
ScaledObject campo a campoapiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-notificaciones
namespace: rutas-norte-pro
spec:
# A QUE carga se aplica.
scaleTargetRef:
apiVersion: apps/v1 # Opcional, por defecto apps/v1
kind: Deployment # Opcional, por defecto Deployment
name: worker-notificaciones
envSourceContainerName: worker # De que contenedor leer variables de entorno
# CADA CUANTO consulta KEDA la fuente externa.
pollingInterval: 15
# CUANTO espera sin actividad antes de bajar a CERO.
cooldownPeriod: 300
# Replicas cuando NO hay actividad. 0 = escalado a cero.
minReplicaCount: 0
maxReplicaCount: 25
# Replicas en estado "inactivo" DISTINTO de cero.
idleReplicaCount: 0
# Que hacer si la fuente de metricas NO RESPONDE.
fallback:
failureThreshold: 3
replicas: 6
# Configuracion avanzada.
advanced:
restoreToOriginalReplicaCount: false
horizontalPodAutoscalerConfig:
name: hpa-worker-notificaciones
behavior:
scaleUp: {}
scaleDown: {}
# QUE observa. Es una lista: puede haber varios.
triggers:
- type: rabbitmq
metadata:
queueName: notificaciones-confirmacion
mode: QueueLength
value: "2500"
authenticationRef:
name: rabbitmq-credencialesLos campos, uno a uno
scaleTargetRef — la carga destino. Admite Deployment, StatefulSet y cualquier recurso con subrecurso /scale, incluidos CRDs de terceros (Argo Rollouts, por ejemplo). El campo envSourceContainerName indica de qué contenedor leer variables de entorno cuando un disparador las referencia.
pollingInterval (defecto: 30 s) — cada cuánto consulta KEDA la fuente externa. Es un compromiso:
| Valor | Ventaja | Inconveniente |
|---|---|---|
| 5-10 s | Reacción rápida | Muchas consultas a la fuente; puede sobrecargarla |
| 15-30 s | Equilibrado. Recomendado | — |
| 60 s+ | Poca carga sobre la fuente | Reacción lenta |
Nota importante: el pollingInterval solo gobierna las consultas de KEDA para el escalado a cero. Una vez hay al menos una réplica, el HPA consulta las métricas con su propio ciclo de 15 segundos a través del servidor de métricas de KEDA. Así que un pollingInterval de 60 s no hace que el escalado de 1 a 25 sea lento; solo hace lenta la activación desde cero.
cooldownPeriod (defecto: 300 s) — tiempo sin actividad antes de bajar a cero. Solo aplica a la transición 1→0. La bajada de 25 a 1 la gobierna la ventana de estabilización del HPA.
minReplicaCount (defecto: 0) — réplicas mínimas. Con 0, se activa el escalado a cero.
maxReplicaCount (defecto: 100) — techo. Igual que el maxReplicas del HPA, y con las mismas advertencias de capacidad de 09-03.
idleReplicaCount — un matiz sutil pero útil. Permite un estado «inactivo» con más de cero réplicas:
minReplicaCount: 3 # Cuando hay actividad, minimo 3
idleReplicaCount: 1 # Cuando NO hay actividad, baja a 1Significa: sin actividad, 1 réplica (para que la primera petición no espere un arranque en frío); con actividad, mínimo 3. Solo es válido si idleReplicaCount < minReplicaCount. Es un punto medio excelente entre el escalado a cero y mantener capacidad.
fallback — qué hacer si la fuente de métricas falla:
fallback:
failureThreshold: 3 # Tras 3 consultas fallidas consecutivas...
replicas: 6 # ...fijar 6 replicas y mantenerlasEste campo es fundamental en producción. Sin él, si RabbitMQ deja de responder, el HPA se queda con la métrica en <unknown> y no escala: el número de réplicas se congela donde estuviera. Con fallback, KEDA fija un número seguro y conocido.
El valor de replicas debe ser suficiente para el tráfico habitual: no el mínimo ni el máximo, sino un número con el que la plataforma funcione mientras se arregla la fuente de métricas.
Limitación a conocer: el fallback solo funciona con disparadores cuyo objetivo sea AverageValue, y no aplica cuando minReplicaCount es 0 con la carga escalada a cero.
advanced.restoreToOriginalReplicaCount (defecto: false) — qué pasa al borrar el ScaledObject. Con false, el Deployment se queda con las réplicas que tuviera en ese momento. Con true, vuelve al número que tenía antes de que KEDA lo gestionara. Ponlo a true si quieres que borrar el ScaledObject sea reversible sin sorpresas.
advanced.horizontalPodAutoscalerConfig — la puerta al behavior de 09-01:
advanced:
horizontalPodAutoscalerConfig:
name: hpa-worker-notificaciones # Nombre personalizado del HPA generado
behavior:
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
selectPolicy: Min
policies:
- type: Percent
value: 25
periodSeconds: 60Todo lo aprendido en 09-01 sobre el behavior sigue siendo válido, y se configura aquí. Es la prueba más clara de que KEDA alimenta al HPA en lugar de sustituirlo.
triggers — la lista de fuentes. Con varios disparadores, gana el que pide más réplicas, exactamente igual que con varias métricas en un HPA. Esta regla es lo que hace posible combinar «escala por cola» con «escala por hora» de forma natural.
- El catálogo de escaladores
KEDA trae más de setenta escaladores. Estos son los que se usan de verdad:
| Escalador | type |
Métrica que observa | Caso de uso típico |
|---|---|---|---|
| Prometheus | prometheus |
Resultado de una consulta PromQL | El más versátil. Cualquier métrica que ya tengas |
| RabbitMQ | rabbitmq |
Longitud de cola o tasa de publicación | Consumidores de cola |
| Kafka | kafka |
Lag del grupo de consumidores | Procesamiento de flujos |
| AWS SQS | aws-sqs-queue |
Mensajes visibles + en vuelo | Colas en AWS |
| Azure Service Bus | azure-servicebus |
Mensajes activos | Colas en Azure |
| Google Pub/Sub | gcp-pubsub |
Mensajes sin confirmar | Colas en GCP |
| PostgreSQL | postgresql |
Resultado de una consulta SQL | Trabajos pendientes en una tabla |
| Redis | redis |
Longitud de una lista o stream | Colas ligeras |
| Cron | cron |
La hora del día | Escalado programado |
| CPU | cpu |
Utilización de CPU | Igual que el HPA clásico |
| Memoria | memory |
Utilización de memoria | Igual que el HPA clásico |
| MongoDB | mongodb |
Resultado de una consulta | Documentos pendientes |
| Elasticsearch | elasticsearch |
Resultado de una consulta | Documentos por procesar |
| Métrica externa genérica | external |
Un gRPC propio | Fuentes a medida |
Un detalle que sorprende: KEDA también trae escaladores de CPU y memoria. ¿Por qué, si el HPA ya lo hace? Porque permite combinar CPU con métricas externas en un mismo ScaledObject, y porque KEDA gestiona el HPA por ti. Es común ver:
triggers:
- type: rabbitmq # Señal principal: la cola
metadata: {...}
- type: cpu # Red de seguridad: si la CPU se dispara, escalar igual
metricType: Utilization
metadata:
value: "80"Con la regla de «gana el que pide más», ese segundo disparador es una salvaguarda gratuita: si la cola está vacía pero los pods están al 90 % de CPU por alguna razón imprevista, se escala igualmente.
Ejemplos de configuración de los escaladores clave
Prometheus:
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
query: |
sum(rate(api_reservas_peticiones_total{namespace="rutas-norte-pro"}[2m]))
threshold: "85"
# Que valor usar si la consulta no devuelve nada (serie vacia)
ignoreNullValues: "true"
unsafeSsl: "false"RabbitMQ:
- type: rabbitmq
metadata:
protocol: amqp
queueName: notificaciones-confirmacion
mode: QueueLength # QueueLength o MessageRate
value: "2500"
# activationValue: por debajo de esto, se considera INACTIVO (para el 0)
activationValue: "10"
authenticationRef:
name: rabbitmq-credencialesCron:
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 9 * * *" # A las 9:30
end: "0 14 * * *" # Hasta las 14:00
desiredReplicas: "25"PostgreSQL:
- type: postgresql
metadata:
query: "SELECT COUNT(*) FROM tareas_pendientes WHERE estado = 'pendiente'"
targetQueryValue: "50"
activationTargetQueryValue: "1"
authenticationRef:
name: postgres-credencialesactivationValue: la línea entre cero y uno
Un concepto propio de KEDA que conviene entender. Cada escalador distingue dos umbrales:
value/threshold: el objetivo para el cálculo de réplicas (lo usa el HPA).activationValue: el umbral por debajo del cual la carga se considera inactiva y puede bajar a cero.
metadata:
value: "2500" # 1 replica por cada 2500 mensajes
activationValue: "10" # Con menos de 10 mensajes, se considera inactivoLa razón de que sean dos números distintos: sin activationValue, cualquier valor mayor que cero activaría la carga. Con una cola que siempre tiene 2 o 3 mensajes residuales, el escalado a cero nunca ocurriría. El activationValue define el ruido de fondo aceptable.
TriggerAuthentication: credenciales para los disparadores
TriggerAuthentication: credenciales para los disparadoresLos disparadores necesitan hablar con sistemas externos, y eso requiere credenciales. Meterlas en el ScaledObject en claro sería un desastre de seguridad, después de todo el módulo 8.
KEDA lo resuelve con TriggerAuthentication:
# 1. El Secret con las credenciales (modulo 3 y 08-05)
apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-credenciales
namespace: rutas-norte-pro
type: Opaque
stringData:
# Cadena de conexion completa con usuario y contrasena.
host: "amqp://keda_lector:[email protected]:5672/"
---
# 2. El TriggerAuthentication que la referencia
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: rabbitmq-credenciales
namespace: rutas-norte-pro
spec:
secretTargetRef:
- parameter: host # Nombre del parametro que espera el escalador
name: rabbitmq-credenciales
key: host # Clave dentro del SecretY en el ScaledObject:
triggers:
- type: rabbitmq
metadata:
queueName: notificaciones-confirmacion
mode: QueueLength
value: "2500"
authenticationRef:
name: rabbitmq-credencialesOtras fuentes de credenciales
TriggerAuthentication admite más que Secrets:
| Fuente | Campo | Uso |
|---|---|---|
| Secret | secretTargetRef |
El caso general |
| Variable de entorno del pod destino | env |
Reutilizar la config que ya tiene la aplicación |
| Identidad del proveedor de nube | podIdentity |
AWS IRSA, Azure Workload Identity, GCP |
| HashiCorp Vault | hashiCorpVault |
Gestión centralizada de secretos |
| Azure Key Vault | azureKeyVault |
Ídem en Azure |
Ejemplo con identidad de nube, que es la forma correcta en producción porque elimina el secreto de largo plazo:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: aws-sqs-identidad
namespace: rutas-norte-pro
spec:
podIdentity:
provider: aws
roleArn: arn:aws:iam::000000000000:role/rutas-norte-keda-sqsConecta directamente con el principio de mínimo privilegio de 08-01: el usuario que KEDA usa para leer la cola solo necesita permiso de lectura de metadatos, no de consumir mensajes. En RabbitMQ, un usuario con permiso de read sobre la cola y nada más.
ClusterTriggerAuthentication
La variante de ámbito de clúster, para credenciales compartidas entre namespaces:
apiVersion: keda.sh/v1alpha1
kind: ClusterTriggerAuthentication
metadata:
name: prometheus-lectura # Sin namespace
spec:
secretTargetRef:
- parameter: bearerToken
name: prometheus-token
key: tokenSe referencia con kind: ClusterTriggerAuthentication en el authenticationRef. Útil para Prometheus, que es único y lo consultan todos los namespaces.
ScaledJob: un trabajo por mensaje
ScaledJob: un trabajo por mensajeScaledObject escala un Deployment: pods de larga vida que consumen en bucle. ScaledJob es distinto: crea un Job (06-03) por cada unidad de trabajo.
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: procesador-facturas
namespace: rutas-norte-pro
spec:
jobTargetRef:
parallelism: 1
completions: 1
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: procesador
image: registry.rutasnorte.example/procesador-facturas:2.1.0
resources:
requests: {cpu: 500m, memory: 512Mi}
limits: {cpu: "2", memory: 2Gi}
pollingInterval: 30
maxReplicaCount: 20
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 10
# Como calcular cuantos Jobs crear a partir del valor de la metrica.
scalingStrategy:
strategy: "default"
triggers:
- type: rabbitmq
metadata:
queueName: facturas-pendientes
mode: QueueLength
value: "1" # UN Job por mensaje
authenticationRef:
name: rabbitmq-credencialesCuándo usar cada uno
| Criterio | ScaledObject (Deployment) |
ScaledJob (Job por mensaje) |
|---|---|---|
| Duración de la tarea | Corta (ms a segundos) | Larga (minutos a horas) |
| Coste de arranque | Se amortiza: el pod vive mucho | Se paga por cada tarea |
| Aislamiento entre tareas | Comparten proceso | Total: un pod por tarea |
| Recursos por tarea | Iguales para todos | Se pueden ajustar por tipo de trabajo |
| Si una tarea falla | Puede afectar al pod entero | Solo falla ese Job |
| Reintentos | Los gestiona la aplicación | backoffLimit de Kubernetes |
| Fugas de memoria | Se acumulan | Imposibles: el pod muere al acabar |
| Ejemplo en Rutas Norte | worker-notificaciones (450 ms/correo) |
Generación de un informe PDF de 20 min |
La regla: si la tarea dura menos que el arranque del pod, ScaledObject; si dura mucho más, ScaledJob.
Para worker-notificaciones, cada correo tarda 450 ms y arrancar un pod tarda 15 segundos. Un ScaledJob gastaría treinta veces más tiempo arrancando que trabajando. ScaledObject, sin duda.
Pero imaginemos que Rutas Norte añade la generación de facturas en PDF para empresas: cada factura tarda 12 minutos, consume 2 GiB de memoria de pico, y ocasionalmente falla por un PDF corrupto. Ahí ScaledJob brilla: aislamiento total, reintentos gestionados por Kubernetes, memoria liberada al terminar.
- Rutas Norte:
worker-notificaciones por longitud de cola
worker-notificaciones por longitud de colaVamos con la implementación completa, el caso central de la lección.
Situación
- Fuera de temporada, la cola de correos está prácticamente vacía. Se envían unos 200 correos al día, a ráfagas.
- Durante el puente de mayo, la cola acumula 40.000 correos en dos horas.
- Cada worker procesa ~2 correos/segundo (limitado por el SMTP).
- Los correos de confirmación pueden tolerar unos minutos de retraso, pero no horas.
El manifiesto
# k8s/entornos/pro/scaledobject-worker-notificaciones.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-notificaciones
namespace: rutas-norte-pro
labels:
app: worker-notificaciones
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: worker-notificaciones
# Consulta a RabbitMQ cada 15 s. Suficientemente rapido para que la
# reactivacion desde cero sea casi inmediata, y suficientemente lento para
# no machacar la API de gestion de RabbitMQ.
pollingInterval: 15
# 10 minutos sin actividad antes de apagar del todo. Es generoso a proposito:
# las reservas llegan a rafagas, y apagar y encender cada dos minutos costaria
# mas (en arranques en frio) que mantener un pod.
cooldownPeriod: 600
# ESCALADO A CERO. Fuera de temporada no hay razon para mantener workers
# encendidos las 24 horas esperando un correo cada varios minutos.
minReplicaCount: 0
# Techo: 25 workers x 2 correos/s = 50 correos/s = 3.000 correos/minuto.
# Los 40.000 correos del puente se vacian en ~14 minutos. Suficiente.
# Ademas: 25 x 120m = 3 nucleos, que caben en el plan de capacidad de 09-03.
maxReplicaCount: 25
# Si RabbitMQ deja de responder, no nos quedamos congelados: fijamos 6
# replicas, que es el caudal de un dia normal con margen. Mejor un numero
# seguro conocido que un numero congelado desconocido.
fallback:
failureThreshold: 3
replicas: 6
advanced:
# Al borrar el ScaledObject, devolver el Deployment a sus replicas originales.
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-worker-notificaciones
behavior:
scaleUp:
# Sin espera: si hay cola, hay trabajo pendiente AHORA.
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
# 5 minutos de calma. Menos conservador que api-reservas porque
# arrancar un worker es barato (no tiene cache que calentar) y
# porque no hay ningun usuario esperando una respuesta.
stabilizationWindowSeconds: 300
selectPolicy: Min
policies:
- type: Percent
value: 30
periodSeconds: 60
triggers:
# DISPARADOR PRINCIPAL: la longitud de la cola.
- type: rabbitmq
metadata:
protocol: amqp
queueName: notificaciones-confirmacion
mode: QueueLength
# 1 worker por cada 2.500 mensajes en cola.
#
# Calculo: queremos vaciar 40.000 mensajes en ~15 minutos.
# 40.000 / 900 s = 44,4 mensajes/s necesarios
# 44,4 / 2 (por worker) = 22,2 workers
# 40.000 / 22,2 = 1.800 mensajes por worker
# Redondeamos a 2.500 para no llegar al techo con margen justo:
# 40.000 / 2.500 = 16 workers -> vacia en ~21 minutos. Aceptable.
value: "2500"
# Por debajo de 10 mensajes, se considera inactivo y puede bajar a cero.
# No ponemos 0 porque siempre hay 1-2 mensajes residuales en transito.
activationValue: "10"
authenticationRef:
name: rabbitmq-credenciales
# DISPARADOR SECUNDARIO: red de seguridad por CPU.
# Si por alguna razon imprevista los workers se saturan de CPU (una
# plantilla de correo patologica, por ejemplo) escalamos igual, aunque
# la cola este corta. Gana el disparador que pide MAS replicas.
- type: cpu
metricType: Utilization
metadata:
value: "80"Verificación
kubectl apply -f k8s/entornos/pro/scaledobject-worker-notificaciones.yaml
kubectl get scaledobject -n rutas-norte-proNAME SCALETARGETKIND SCALETARGETNAME MIN MAX TRIGGERS AUTHENTICATION READY ACTIVE FALLBACK AGE
worker-notificaciones apps/v1.Deployment worker-notificaciones 0 25 rabbitmq rabbitmq-credenciales True False False 34sColumnas clave:
| Columna | Significado |
|---|---|
READY |
KEDA ha configurado todo correctamente |
ACTIVE |
Hay actividad ahora mismo (por encima del activationValue) |
FALLBACK |
Está usando el fallback porque la fuente falla |
ACTIVE: False con la cola vacía significa que el Deployment está a cero réplicas. Confirmemos:
La reactivación en vivo
Abrimos dos terminales. En el primero, observamos:
En el segundo, metemos mensajes en la cola:
# Simular 30.000 correos de confirmacion (apertura de la venta)
kubectl exec -n rutas-norte-pro rabbitmq-0 -- \
rabbitmqadmin publish routing_key=notificaciones-confirmacion \
payload='{"reserva":"RN-2026-054321","destinatario":"[email protected]"}' \
--count=30000Y en el primer terminal:
NAME READY UP-TO-DATE AVAILABLE AGE
worker-notificaciones 0/0 0 0 89d
worker-notificaciones 0/1 0 0 89d <- KEDA activa: 0 -> 1
worker-notificaciones 1/1 1 1 89d
worker-notificaciones 1/6 1 1 89d <- El HPA toma el mando
worker-notificaciones 6/6 6 6 89d
worker-notificaciones 6/12 6 6 89d
worker-notificaciones 12/12 12 12 89d
worker-notificaciones 12/12 12 12 89d <- Estable: 30000/2500 = 12Se ve perfectamente la división de responsabilidades: el salto 0→1 lo hace el operador de KEDA en cuanto detecta actividad; a partir de ahí es el HPA aplicando su fórmula y su behavior.
Y el HPA generado confirma la aritmética:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
hpa-worker-notificaciones Deployment/worker-notificaciones 2483/2500 (avg) 1 25 122483/2500 (avg): hay 12 réplicas y 29.800 mensajes; 29.800/12 = 2.483 mensajes por réplica, contra un objetivo de 2.500. El sistema está en equilibrio.
Cuando la cola se vacía:
worker-notificaciones 12/12 12 12 89d
worker-notificaciones 12/8 12 12 89d <- HPA baja (tras estabilizacion)
worker-notificaciones 8/3 8 8 89d
worker-notificaciones 3/1 3 3 89d
worker-notificaciones 1/1 1 1 89d <- Se queda en 1 (minimo del HPA)
...pasan 10 minutos de cooldownPeriod...
worker-notificaciones 0/0 0 0 89d <- KEDA apaga: 1 -> 0
- Rutas Norte:
api-reservas por peticiones por segundo
api-reservas por peticiones por segundoAhora sustituimos el HPA por CPU de api-reservas por uno basado en las métricas Prometheus que instrumentamos en 07-03.
Por qué cambiar
La CPU no es una señal mala para api-reservas —es una API que sí calcula— pero es indirecta. Las peticiones por segundo son mejor porque:
- Es la magnitud del negocio. «Cada réplica atiende 85 peticiones por segundo» es una frase que entiende cualquiera en la empresa.
- No depende del
requests. Un cambio delrequests.cpu(recomendado por el VPA de 09-02) no altera el comportamiento del escalado. - Es más rápida. La CPU sube cuando el trabajo ya está entrando; las peticiones se cuentan al llegar.
- Elimina el conflicto con el VPA. Como vimos en 09-02, con el HPA por métrica de negocio, el VPA puede gobernar CPU y memoria sin bucle.
Las métricas instrumentadas
De 07-03, api-reservas expone en /metricas:
# HELP api_reservas_peticiones_total Total de peticiones HTTP atendidas
# TYPE api_reservas_peticiones_total counter
api_reservas_peticiones_total{metodo="GET",ruta="/rutas",codigo="200"} 1847293
api_reservas_peticiones_total{metodo="POST",ruta="/reservas",codigo="201"} 92841
# HELP api_reservas_duracion_segundos Duracion de las peticiones
# TYPE api_reservas_duracion_segundos histogram
api_reservas_duracion_segundos_bucket{ruta="/reservas",le="0.1"} 78201
api_reservas_duracion_segundos_bucket{ruta="/reservas",le="0.5"} 91043
...
# HELP api_reservas_confirmadas_total Reservas confirmadas
# TYPE api_reservas_confirmadas_total counter
api_reservas_confirmadas_total 92841El manifiesto
# k8s/entornos/pro/scaledobject-api-reservas.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-reservas
namespace: rutas-norte-pro
labels:
app: api-reservas
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-reservas
pollingInterval: 15
cooldownPeriod: 300
# NO escalamos a cero. api-reservas es la API que sostiene la venta: un
# arranque en frio delante de un usuario que quiere comprar es inaceptable.
# 4 replicas es el suelo de disponibilidad acordado (09-01, y 09-05).
minReplicaCount: 4
maxReplicaCount: 30
fallback:
failureThreshold: 3
# Si Prometheus cae, fijamos 12 replicas: el triple del minimo. Es un
# numero conservador que aguanta un dia normal con holgura y nos da tiempo
# a arreglar Prometheus sin riesgo de caida.
replicas: 12
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-api-reservas
behavior:
# El MISMO behavior asimetrico de 09-01: subir en cero segundos,
# bajar tras diez minutos de calma sostenida.
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 6
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 20
periodSeconds: 120
- type: Pods
value: 3
periodSeconds: 120
triggers:
# DISPARADOR PRINCIPAL: peticiones por segundo, medidas en Prometheus.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
# Nombre con el que aparecera la metrica en el HPA generado.
metricName: api_reservas_peticiones_por_segundo
query: |
sum(
rate(
api_reservas_peticiones_total{
namespace="rutas-norte-pro",
app="api-reservas"
}[2m]
)
)
# 85 peticiones por segundo y replica.
#
# Calculo (de la prueba de carga de 09-06): una replica de api-reservas
# con 412m de CPU sostiene ~120 rps antes de que la latencia p95 supere
# los 300 ms. Dejamos un 30 % de margen para absorber el tiempo de
# arranque de las replicas nuevas: 120 x 0,7 = 84 -> 85 rps.
threshold: "85"
activationThreshold: "5"
ignoreNullValues: "true"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-lectura
# RED DE SEGURIDAD 1: CPU. Si por lo que sea las peticiones son mucho mas
# caras de lo habitual (una consulta patologica, un cliente abusivo), la
# CPU lo detectara aunque el numero de peticiones sea normal.
- type: cpu
metricType: Utilization
metadata:
value: "70"La consulta PromQL, explicada
Desmenuzada de dentro afuera:
-
api_reservas_peticiones_total{...}— selecciona el contador de peticiones, filtrado por namespace y aplicación. Devuelve una serie por pod y por combinación de etiquetas (método, ruta, código). -
rate(...[2m])— convierte el contador acumulado en peticiones por segundo, promediando sobre los últimos 2 minutos. Es la función correcta para contadores: maneja los reinicios de pod (cuando el contador vuelve a cero) sin producir picos falsos.¿Por qué 2 minutos? Es un compromiso. Con
[1m]la señal es más reactiva pero muy ruidosa (y con unscrape_intervalde 30 s solo habría 2 muestras, lo que hace elratepoco fiable). Con[5m]la señal es suave pero llega tarde. Regla: la ventana delratedebe ser al menos 4 veces el intervalo de recolección. Con recolección cada 30 s,[2m]es el mínimo razonable. -
sum(...)— suma todas las series. Sin él tendríamos una serie por pod y por ruta; KEDA necesita un único número escalar.
Es imprescindible que la consulta devuelva un solo valor. Si devuelve varias series, KEDA registra un error. Prueba siempre la consulta en la interfaz de Prometheus antes de ponerla en un ScaledObject.
Comprobación de la aritmética
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
hpa-api-reservas Deployment/api-reservas 1247/85 (avg), 34%/70% 4 30 15Hay dos métricas. La primera dice 1247/85 (avg)... y aquí hay un detalle importante que confunde a mucha gente.
KEDA usa AverageValue para los disparadores de Prometheus. Eso significa que el HPA divide el valor total entre las réplicas:
Valor total de la consulta: 1.247 rps (todas las replicas juntas)
Replicas actuales: 15
Valor por replica: 1.247 / 15 = 83,1 rps
Objetivo: 85 rps
ratio = 83,1 / 85 = 0,978 -> dentro de la banda de tolerancia. Estable.La visualización 1247/85 puede despistar porque muestra el total contra el objetivo por réplica. Lo que compara internamente es 83,1 contra 85.
Y la segunda métrica, la CPU: 34%/70%. Pediría bajar réplicas, pero gana la que pide más, así que manda la de peticiones por segundo.
Migrar del HPA anterior
Importante: no pueden coexistir dos HPA sobre el mismo Deployment. El HPA manual de 09-01 hay que borrarlo antes.
# 1. Borrar el HPA manual
kubectl delete hpa api-reservas -n rutas-norte-pro
# 2. Crear el ScaledObject
kubectl apply -f k8s/entornos/pro/scaledobject-api-reservas.yaml
# 3. Verificar que KEDA ha creado el suyo
kubectl get hpa -n rutas-norte-proSi te saltas el paso 1, el webhook de admisión de KEDA rechaza el ScaledObject:
Error from server: admission webhook "vscaledobject.kb.io" denied the request:
the workload 'api-reservas' of type 'apps/v1.Deployment' is already managed by
the hpa 'api-reservas'Es un mensaje claro y una validación muy bienvenida: sin ella, tendrías dos HPA peleándose por el mismo replicas.
- Rutas Norte: el disparador cron del puente de mayo
Y ahora la pieza que resuelve el segundo problema: escalar antes de que llegue el tráfico.
El problema, con cronómetro
09:59:50 4 replicas de api-reservas. Trafico normal: 180 rps.
10:00:00 Se abre la venta. El trafico salta a 2.400 rps en 40 segundos.
10:00:15 Prometheus recolecta. El rate[2m] aun refleja el promedio de
los ultimos 2 minutos, que incluye 1m50s de calma: ~400 rps.
10:00:15 KEDA/HPA: 400/4 = 100 rps por replica. Ratio 1,18. Pide 5 replicas.
<- MUY POR DEBAJO de lo necesario. El rate[2m] SUAVIZA el pico.
10:00:45 El rate ya refleja mejor la realidad: ~1.500 rps. Pide 18 replicas.
10:01:15 Los primeros pods nuevos estan Ready.
10:02:30 Se alcanzan las 28 replicas necesarias.
DEGRADACION: dos minutos y medio en el momento de mas valor del ano.Fíjate en el efecto perverso de la ventana de 2 minutos del rate: suaviza exactamente el pico que queremos detectar. Es inevitable: sin esa ventana la señal sería inutilizablemente ruidosa. Es una limitación intrínseca de cualquier escalado reactivo basado en tasas.
La solución: el disparador cron
# k8s/entornos/pro/scaledobject-api-reservas.yaml (fragmento anadido)
triggers:
- type: prometheus
# ... (el disparador principal de arriba, sin cambios)
- type: cpu
# ... (la red de seguridad, sin cambios)
# DISPARADOR DE PRECALENTAMIENTO: el puente de mayo.
#
# La venta abre a las 10:00 del 25 de abril. A las 9:30 ya queremos
# 25 replicas ARRANCADAS Y CALIENTES: pool de conexiones a PostgreSQL
# establecido, cache de rutas poblada, JIT de Node.js calentado.
#
# Recuerda la regla del HPA: GANA EL DISPARADOR QUE PIDE MAS REPLICAS.
# Entre las 9:30 y las 14:00, el suelo son 25 replicas. Si el trafico
# real pide mas, el disparador de Prometheus sube hasta 30.
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 9 25 4 *" # 25 de abril a las 9:30
end: "0 14 25 4 *" # 25 de abril a las 14:00
desiredReplicas: "25"Sintaxis de start y end: cron estándar de cinco campos (minuto hora día mes día-de-la-semana). 30 9 25 4 * = a las 9:30 del día 25 de abril, cualquier día de la semana.
El timezone es obligatorio y crítico. Sin él, KEDA usa UTC, y en horario de verano español eso son dos horas de diferencia: tus 25 réplicas aparecerían a las 11:30, hora y media después de la apertura. Usa siempre el nombre IANA de la zona (Europe/Madrid), nunca un desplazamiento fijo, para que los cambios de horario se gestionen solos.
Un patrón más mantenible: cron semanal
Fechas fijas hay que actualizarlas cada año. Para un patrón recurrente:
# Precalentamiento diario de la franja de mas venta.
# De lunes a domingo, de 9:30 a 13:00, suelo de 10 replicas.
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 9 * * *"
end: "0 13 * * *"
desiredReplicas: "10"
# Refuerzo de fin de semana: viernes tarde y sabado manana concentran
# las reservas de escapada.
- type: cron
metadata:
timezone: Europe/Madrid
start: "0 16 * * 5" # Viernes a las 16:00
end: "0 22 * * 6" # Sabado a las 22:00
desiredReplicas: "15"Con varios disparadores cron solapados, gana el que pide más. Un viernes a las 17:00 estarían activos el diario (10) y el de fin de semana (15): el suelo es 15.
Escenario completo con todos los disparadores
Veamos qué pasa el 25 de abril con los cuatro disparadores activos:
| Hora | Cron puente | Cron diario | Prometheus | CPU | Réplicas | Quién manda |
|---|---|---|---|---|---|---|
| 08:00 | — | — | 3 (180 rps) | 2 | 4 | minReplicaCount |
| 09:29 | — | — | 3 | 2 | 4 | minReplicaCount |
| 09:31 | 25 | 10 | 3 | 2 | 25 | Cron puente |
| 09:59 | 25 | 10 | 3 | 2 | 25 | Cron puente |
| 10:01 | 25 | 10 | 5 | 8 | 25 | Cron puente (¡el pico ya está cubierto!) |
| 10:05 | 25 | 10 | 29 | 22 | 29 | Prometheus |
| 11:30 | 25 | 10 | 21 | 16 | 25 | Cron puente |
| 13:05 | 25 | — | 12 | 9 | 25 | Cron puente |
| 14:01 | — | — | 9 | 7 | 9 | Prometheus (con estabilización de 10 min) |
A las 10:01, con el tráfico ya disparado, el disparador de Prometheus solo pide 5 réplicas (por el suavizado del rate) pero ya tenemos 25 gracias al cron. Cero degradación. El pico se absorbe sin que nadie lo note.
Y a las 10:05, cuando el rate ya refleja la realidad y pide 29, el sistema sube esas 4 réplicas adicionales con toda tranquilidad.
Esta es la diferencia entre reaccionar y anticiparse. El cron no reemplaza al escalado reactivo: le quita la responsabilidad del momento crítico.
El mismo patrón para el clúster
El cron escala pods, pero recuerda 09-03: los pods necesitan nodos. 25 réplicas de api-reservas requieren nodos que tardan 3-6 minutos en arrancar.
Solución coherente: escalar también el colchón de sobreaprovisionamiento antes:
# k8s/entornos/pro/scaledobject-relleno-capacidad.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: relleno-capacidad
namespace: rutas-norte-pro
spec:
scaleTargetRef:
name: relleno-capacidad
minReplicaCount: 2
maxReplicaCount: 10
cooldownPeriod: 300
triggers:
# Ampliar el colchon UNA HORA ANTES que las replicas de api-reservas.
# Asi el Cluster Autoscaler tiene tiempo de arrancar los nodos, y a las
# 9:30, cuando el cron de api-reservas pida 25 replicas, hay sitio.
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 8 25 4 *" # 8:30, una hora antes
end: "0 15 25 4 *"
desiredReplicas: "10"La cadena temporal completa:
08:30 El cron amplia el relleno de 2 a 10 pods.
8 pods de relleno pasan a Pending.
08:31 El Cluster Autoscaler detecta los pendientes, pide 3 nodos.
08:35 Los nodos estan Ready. El relleno se coloca.
CAPACIDAD LISTA Y PAGADA, pero ocupada por pods que no hacen nada.
09:30 El cron de api-reservas pide 25 replicas.
El planificador DESALOJA los pods de relleno (prioridad -10).
09:30 Las 25 replicas se colocan en SEGUNDOS. No hace falta esperar nodos.
09:31 Los pods de relleno pasan a Pending -> el CA arranca mas nodos ->
el colchon se regenera para lo que venga.
09:32 25 replicas Ready y calientes.
10:00 APERTURA. Cero degradacion.Aquí se ven las cuatro piezas del módulo trabajando juntas: el escalado programado (KEDA), el sobreaprovisionamiento con preemption (09-03), el autoescalado de nodos (09-03) y el HPA reactivo (09-01) como red de seguridad.
- El escalado a cero y sus implicaciones
El escalado a cero es la función más llamativa de KEDA, y la que más hay que pensar antes de usar.
Qué significa
Con minReplicaCount: 0, cuando no hay actividad durante el cooldownPeriod, KEDA lleva el Deployment a cero réplicas. No hay pods. No hay contenedores. El consumo de CPU y memoria es literalmente cero.
El coste: el arranque en frío
La contrapartida es inevitable. Desde que aparece trabajo hasta que se procesa:
t=0 Llega un mensaje a la cola.
t=0-15 KEDA lo detecta en su siguiente ciclo (pollingInterval).
t=15 KEDA escala el Deployment de 0 a 1.
t=16 El planificador coloca el pod. Si NO hay sitio -> +3 min (09-03).
t=17 El kubelet descarga la imagen. Si esta cacheada, ~2 s; si no, 20-60 s.
t=20 El contenedor arranca. Node.js: ~15 s hasta estar listo.
t=35 Conexion a PostgreSQL y a RabbitMQ establecida.
t=36 Se procesa el primer mensaje.
RETRASO TOTAL: entre 35 segundos y 4 minutos.Qué cargas lo toleran y cuáles no
| Carga | ¿Escalar a cero? | Motivo |
|---|---|---|
worker-notificaciones |
Sí | Nadie espera. Un correo 40 s más tarde da igual |
informes-ocupacion |
No aplica | Ya es un CronJob: solo existe cuando se ejecuta |
| Procesador de trabajos por lotes | Sí | Ídem: sin usuario esperando |
| Entornos de desarrollo y pruebas | Sí, muy recomendable | Ahorro enorme fuera del horario laboral |
api-reservas |
No | Un usuario esperando 40 s para ver rutas se va a la competencia |
tienda-web |
No | Es la puerta de entrada |
postgres-reservas |
Jamás | Base de datos con estado |
redis-cache |
No | Arrancar vacía descarga todo sobre PostgreSQL |
La regla: si hay un ser humano esperando una respuesta, no escales a cero.
El caso de rutas-norte-dev: el ahorro más rentable
El entorno de desarrollo se usa de lunes a viernes, de 8:00 a 19:00. Son 55 horas de 168 semanales: el 67 % del tiempo está encendido sin que nadie lo use.
# k8s/entornos/dev/scaledobject-apagado-nocturno.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-reservas-horario
namespace: rutas-norte-dev
labels:
app: api-reservas
entorno: dev
spec:
scaleTargetRef:
name: api-reservas
# Fuera del horario laboral: CERO. El entorno se apaga solo.
minReplicaCount: 0
maxReplicaCount: 3
cooldownPeriod: 300
triggers:
# De lunes a viernes, de 7:45 a 19:30: 1 replica encendida.
# 7:45 para que este caliente cuando llegue el equipo a las 8:00.
- type: cron
metadata:
timezone: Europe/Madrid
start: "45 7 * * 1-5"
end: "30 19 * * 1-5"
desiredReplicas: "1"Ahorro estimado: el entorno de desarrollo completo (5 componentes, ~4 núcleos y 8 GiB) apagado 113 horas semanales. Aproximadamente el 67 % del coste del entorno, que en un clúster gestionado puede ser un nodo entero.
Y hay un beneficio adicional que no es económico: fuerza al equipo a que los servicios arranquen rápido y sin intervención manual. Un entorno que se apaga y enciende cada día detecta enseguida las dependencias de arranque mal resueltas.
El patrón idleReplicaCount: lo mejor de los dos mundos
Para cargas que no toleran el arranque en frío pero tampoco justifican mantener capacidad plena:
spec:
# Cuando HAY actividad: minimo 4 replicas.
minReplicaCount: 4
# Cuando NO hay actividad: 1 replica encendida.
idleReplicaCount: 1Esa réplica única mantiene la conexión a la base de datos, la caché caliente y el proceso listo. La primera petición se atiende en milisegundos, y el HPA sube a 4 en el siguiente ciclo. Es el compromiso correcto para la mayoría de servicios con usuarios, y muchos equipos lo prefieren al cero absoluto.
La activación desde cero: un detalle importante
Con cero réplicas, KEDA no puede usar métricas de pod. Por eso los disparadores de CPU y memoria no sirven para activar desde cero: si no hay pods, no hay CPU que medir.
Consecuencia práctica: un ScaledObject con minReplicaCount: 0 necesita al menos un disparador que consulte una fuente externa (cola, Prometheus, cron, base de datos). Si solo tiene disparadores de CPU y memoria, se quedará en cero para siempre.
Es un error frecuente y el mensaje de KEDA no siempre lo deja claro. Ténlo presente.
- Depurar un
ScaledObject que no escala
ScaledObject que no escalaProcedimiento sistemático, del más general al más específico.
Paso 1: el estado del ScaledObject
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX READY ACTIVE FALLBACK AGE
worker-notificaciones apps/v1.Deployment worker-notificaciones 0 25 False False False 4mREADY: False es el primer problema. Significa que KEDA no ha podido configurarlo.
Status:
Conditions:
Type: Ready
Status: False
Reason: ScalerFailed
Message: error creating scaler for trigger 0: error parsing rabbitmq metadata:
no host setting given
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning ScalerFailed 4m keda-operator error creating scaler for trigger 0El mensaje señala el problema exacto: falta el host del disparador de RabbitMQ, es decir, el TriggerAuthentication no está bien.
Tabla de diagnóstico por condición
READY |
ACTIVE |
Síntoma | Causa probable |
|---|---|---|---|
False |
False |
No hace nada | Error de configuración del disparador o del TriggerAuthentication |
True |
False |
Se queda en minReplicaCount |
La métrica está por debajo del activationThreshold |
True |
True |
Escala, pero mal | El threshold está mal calibrado |
True |
True |
FALLBACK: True |
La fuente de métricas no responde |
Unknown |
— | Sin estado | El operador de KEDA no está funcionando |
Paso 2: el HPA generado
Recuerda: KEDA alimenta a un HPA. Si el HPA no ve la métrica, no escala.
Conditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True SucceededGetScale the HPA controller was able to get the target's current scale
ScalingActive False FailedGetExternalMetric unable to get external metric
rutas-norte-pro/s0-rabbitmq-notificaciones-confirmacion/&LabelSelector{...}:
unable to fetch metrics from external metrics API:
rpc error: code = Unknown desc = error inspecting rabbitMQ:
Object Not FoundObject Not Found de RabbitMQ: la cola no existe con ese nombre. Un error tipográfico en queueName, o la cola aún no se ha creado.
Paso 3: consultar la métrica directamente
Puentea el HPA y pregunta a la API de agregación:
kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/s0-rabbitmq-notificaciones-confirmacion?labelSelector=scaledobject.keda.sh%2Fname%3Dworker-notificaciones" \
| python3 -m json.tool{
"kind": "ExternalMetricValueList",
"apiVersion": "external.metrics.k8s.io/v1beta1",
"items": [
{
"metricName": "s0-rabbitmq-notificaciones-confirmacion",
"metricLabels": null,
"timestamp": "2026-05-01T10:14:32Z",
"value": "29800"
}
]
}Si esto devuelve un valor correcto, el problema está entre el HPA y la API, no en KEDA ni en la fuente. Si devuelve error, el problema está en el escalador o en la fuente.
Paso 4: los logs del operador
ERROR scale_handler error getting scale decision {"scaledObject.Namespace": "rutas-norte-pro",
"scaledObject.Name": "worker-notificaciones",
"error": "dial tcp 10.96.42.17:5672: connect: connection refused"}connection refused: KEDA no puede conectar con RabbitMQ. Causas: el Service no existe, la NetworkPolicy lo bloquea (04-06), o RabbitMQ está caído.
Y del servidor de métricas:
Paso 5: verificar la conectividad de red
Un caso muy frecuente tras el módulo 8: las NetworkPolicies bloquean a KEDA.
# ¿Puede KEDA llegar a RabbitMQ?
kubectl run prueba-conectividad --rm -it --restart=Never \
-n keda --image=busybox:1.36 -- \
wget -qO- --timeout=5 http://rabbitmq.rutas-norte-pro.svc.cluster.local:15672/api/overviewSi falla y RabbitMQ funciona, revisa las NetworkPolicies del namespace rutas-norte-pro. KEDA vive en el namespace keda y necesita permiso explícito de entrada:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: permitir-keda-a-rabbitmq
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: rabbitmq
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: keda
ports:
- protocol: TCP
port: 5672
- protocol: TCP
port: 15672Este es el error número uno tras implantar las NetworkPolicies de 04-06 y 08-04. Todo funcionaba, se añaden las políticas de seguridad, y de repente KEDA deja de escalar sin que nadie relacione las dos cosas.
Paso 6: probar la consulta PromQL a mano
Para disparadores de Prometheus:
Y en la interfaz web, ejecuta la consulta exacta del ScaledObject. Comprueba:
- ¿Devuelve algún dato? Si está vacía, las etiquetas del selector no coinciden.
- ¿Devuelve UN SOLO valor? Si devuelve varias series, falta un
sum(). - ¿El valor tiene sentido? Un
rateque da 0,003 en lugar de 1.247 sugiere que faltan unidades o que la ventana está mal.
Lista de comprobación rápida
| Comprobación | Comando |
|---|---|
| ¿KEDA está vivo? | kubectl get pods -n keda |
| ¿La API externa está registrada? | kubectl get apiservices v1beta1.external.metrics.k8s.io |
¿El ScaledObject está READY? |
kubectl get scaledobject -n rutas-norte-pro |
| ¿El HPA ve la métrica? | kubectl describe hpa keda-hpa-... |
| ¿La métrica se puede consultar? | kubectl get --raw "/apis/external.metrics.k8s.io/..." |
| ¿Hay errores en el operador? | kubectl logs -n keda deployment/keda-operator |
| ¿Hay conectividad de red? | Pod de prueba desde el namespace keda |
| ¿La consulta PromQL es correcta? | Interfaz de Prometheus |
| ¿Hay otro HPA en conflicto? | kubectl get hpa -n rutas-norte-pro |
| ¿Hay sitio para los pods nuevos? | kubectl get pods --field-selector status.phase=Pending |
Esa última comprobación conecta con 09-03 y merece énfasis: un ScaledObject puede estar funcionando perfectamente y aun así no ver más pods corriendo, porque los pods pedidos están en Pending. El ScaledObject diría ACTIVE: True y el HPA mostraría 20 réplicas, mientras solo 8 están realmente sirviendo. Mira siempre los pods, no solo el escalador.
Errores Comunes y Consejos
Error 1: type: Value en lugar de AverageValue para una cola. Con Value, el HPA compara el valor bruto con el objetivo y las réplicas oscilan violentamente. Para colas, siempre AverageValue: el significado es «cada réplica se hace cargo de N unidades».
Error 2: la consulta PromQL devuelve varias series. KEDA necesita un escalar. Sin sum() (o avg(), max(), según el caso), el disparador falla. Prueba siempre la consulta en Prometheus antes de ponerla en un manifiesto.
Error 3: escalar a cero un servicio con usuarios delante. El arranque en frío son 35 segundos como mínimo. Nadie espera 35 segundos por una página de billetes. Usa idleReplicaCount: 1 si quieres ahorrar sin ese coste.
Error 4: dejar un HPA manual sobre el mismo Deployment. El webhook de KEDA lo detecta y rechaza el ScaledObject con un mensaje claro. Borra el HPA antiguo primero.
Error 5: olvidar el timezone en el disparador cron. KEDA usa UTC por defecto. En horario de verano español, tus 25 réplicas aparecen dos horas tarde. Usa siempre el nombre IANA (Europe/Madrid), nunca un desplazamiento fijo.
Error 6: NetworkPolicies que bloquean a KEDA. El operador vive en el namespace keda y necesita permiso explícito para llegar a RabbitMQ, Prometheus o la base de datos. Es el error más frecuente tras implantar las políticas de 04-06 y 08-04, y el más difícil de relacionar con su causa.
Error 7: no configurar el fallback. Si la fuente de métricas cae, el HPA se queda con la métrica en <unknown> y las réplicas congeladas donde estuvieran. Con fallback, fijas un número seguro y conocido mientras arreglas la fuente.
Error 8: minReplicaCount: 0 con solo disparadores de CPU o memoria. Con cero pods no hay métricas de pod, así que nunca se reactivará. El escalado a cero necesita al menos un disparador de fuente externa.
Consejo 1: empieza con minReplicaCount igual a tu número actual. Despliega el ScaledObject, observa una semana qué métricas reporta y qué habría hecho, y solo entonces abre el rango. Igual que el consejo del HPA en 09-01: la observación previa es gratis y evita sustos.
Consejo 2: calibra el threshold con datos, no con intuición. El número debe salir de una prueba de carga (09-06): ¿cuántas peticiones por segundo sostiene una réplica antes de que la latencia p95 supere el objetivo? Ese número, menos un 30 % de margen para el tiempo de arranque, es tu threshold.
Consejo 3: combina disparadores. La señal principal (cola, peticiones) más una red de seguridad de CPU más un cron de precalentamiento. Con la regla de «gana el que pide más», cada uno cubre un fallo de los otros y ninguno estorba.
Consejo 4: monitoriza el propio KEDA. El operador expone métricas Prometheus:
# Errores del escalador: si sube, KEDA no puede consultar la fuente
sum(rate(keda_scaler_errors_total[5m])) by (scaledObject, scaler) > 0
# Valor de la metrica que KEDA esta reportando
keda_scaler_metrics_value{scaledObject="worker-notificaciones"}
# ScaledObjects en fallback: senal de que una fuente esta caida
keda_scaled_object_errors_total > 0El primero es la alerta imprescindible: si KEDA no puede leer la métrica, tu escalado no existe, aunque todo parezca normal en kubectl get deployment.
Consejo 5: en Grafana, superpón la métrica de negocio y las réplicas. Ver la longitud de la cola y el número de workers en el mismo gráfico hace evidente de un vistazo si el threshold está bien calibrado: la cola debe subir, las réplicas seguirla, y la cola bajar. Si la cola sube y las réplicas no, el threshold es demasiado alto.
Consejo 6: documenta el cálculo del threshold en el manifiesto. Un comentario que explique de dónde sale el 2.500 convierte un número mágico en una decisión revisable. El siguiente que lo lea sabrá si puede tocarlo y con qué criterio.
Consejo 7: usa ClusterTriggerAuthentication para Prometheus. Es único en el clúster y lo consultan todos los namespaces. Un solo objeto en lugar de uno por namespace.
Ejercicios
Ejercicio 1: calcular el threshold de una cola
Rutas Norte añade worker-facturas, que genera las facturas en PDF de las reservas y las sube a un almacén de objetos. Datos medidos:
- Cada factura tarda 8 segundos en generarse (2 s de CPU, 6 s esperando al almacén).
- Un worker procesa las facturas de una en una (no hay concurrencia interna).
- Durante el puente de mayo se generan 12.000 facturas en las 4 horas siguientes al cierre de la venta.
- Objetivo de negocio: ninguna factura debe tardar más de 30 minutos desde la reserva.
- Cada worker consume
requests.cpu: 250myrequests.memory: 512Mi. - La ResourceQuota del namespace tiene 8 núcleos libres en ese momento.
Calcula: (a) las facturas por segundo que genera un worker; (b) los workers necesarios para cumplir el objetivo de 30 minutos; (c) si caben en la cuota; (d) el threshold (mensajes por réplica) que produce ese número de workers en el pico; (e) escribe el ScaledObject completo, decidiendo además si usarías ScaledObject o ScaledJob y por qué.
Ejercicio 2: diagnosticar un ScaledObject mudo
worker-notificaciones no escala. La cola de RabbitMQ tiene 18.000 mensajes desde hace veinte minutos y sigue habiendo 1 réplica. Esto es lo que ves:
NAME SCALETARGETKIND MIN MAX READY ACTIVE FALLBACK AGE
worker-notificaciones apps/v1.Deployment 0 25 True True True 3hConditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True ReadyForNewScale recommended size matches current size
ScalingActive True ValidMetricFound the HPA was able to successfully calculate a replica count
ScalingLimited False DesiredWithinRange the desired count is within the acceptable range
Metrics:
( current / target )
"s0-rabbitmq-notificaciones-confirmacion" (target average value): 6 / 2500
Events: <none>ERROR scale_handler error getting metric for scaler {"scaledObject.Name": "worker-notificaciones",
"scaler": "rabbitmqScaler",
"error": "Get \"https://rabbitmq.rutas-norte-pro.svc.cluster.local:15671/api/queues/%2F/notificaciones-confirmacion\":
dial tcp 10.96.42.17:15671: i/o timeout"}Explica exactamente qué está pasando, por qué el HPA dice que todo está bien, por qué el valor de la métrica es 6, qué hay que corregir y cómo evitar que vuelva a pasar sin que nadie se entere.
Ejercicio 3: diseñar la estrategia de escalado de un componente nuevo
Rutas Norte lanza motor-precios, un servicio que recalcula los precios dinámicos de todas las rutas en función de la ocupación. Perfil:
- Es un servicio HTTP interno que consulta
api-reservas. No es público. - Recibe peticiones de
api-reservascada vez que alguien busca rutas: entre 50 y 2.500 peticiones por segundo. - Cada petición tarda 40 ms y consume bastante CPU (cálculo matemático puro).
- Una réplica sostiene ~180 peticiones por segundo antes de que la latencia p95 supere los 80 ms.
- Además, cada noche a las 3:00 recalcula la matriz de precios completa: es un proceso de 45 minutos que consume 4 núcleos y que hoy se ejecuta en el mismo servicio (lo que degrada las respuestas HTTP durante esos 45 minutos).
- La latencia de este servicio entra directamente en la latencia de
api-reservas: si tarda, la búsqueda de rutas tarda. - Está instrumentado con Prometheus: expone
motor_precios_peticiones_totalymotor_precios_duracion_segundos.
Diseña la estrategia completa. ¿Un ScaledObject o varios? ¿Qué disparadores? ¿Escalado a cero? ¿Qué harías con el proceso nocturno? Escribe los manifiestos y justifica cada decisión.
Soluciones
Solución 1
(a) Facturas por segundo de un worker:
Cada factura: 8 segundos, procesadas de una en una.
Facturas por segundo por worker = 1 / 8 = 0,125 facturas/s(b) Workers necesarios:
El objetivo es que ninguna factura espere más de 30 minutos. El caso peor es la factura que entra la última en el momento de mayor acumulación.
12.000 facturas en 4 horas = 12.000 / 14.400 s = 0,833 facturas/s de entrada.
Si el sistema procesara justo a la velocidad de entrada, la cola no crecería
pero tampoco se vaciaría, y una factura esperaria... depende de la cola
acumulada. Necesitamos procesar MAS RAPIDO que la entrada.
Enfoque por el objetivo de 30 minutos:
En 30 minutos (1.800 s) entran 0,833 x 1.800 = 1.500 facturas.
Para que ninguna espere mas de 30 min, hay que procesar 1.500 facturas
en menos de 1.800 s: capacidad >= 1.500 / 1.800 = 0,833 facturas/s.
Con margen del 100 % (para absorber rafagas, que la entrada no es uniforme):
Capacidad objetivo: 1,67 facturas/s
Workers = 1,67 / 0,125 = 13,3 -> 14 workersComprobación con el peor caso realista: si las facturas llegan concentradas (la mitad en la primera hora):
Primera hora: 6.000 facturas = 1,67 facturas/s de entrada.
Con 14 workers: 14 x 0,125 = 1,75 facturas/s de capacidad.
Capacidad > entrada: la cola no crece de forma sostenida. Correcto.
Acumulacion maxima estimada: ~500 facturas.
Tiempo de vaciado de esas 500: 500 / 1,75 = 286 s = 4,8 minutos.
MUY por debajo de los 30 minutos. Sobrado.14 workers es la respuesta, con margen amplio.
(c) ¿Caben en la cuota?
14 workers x 250m = 3,5 nucleos
Cuota libre: 8 nucleos.
Caben. Y hay margen para subir el maxReplicaCount a 25:
25 x 250m = 6,25 nucleos < 8. Tambien cabe.
Memoria: 14 x 512Mi = 7 GiB. Sin problema.Ponemos maxReplicaCount: 20 (14 con un 40 % de margen), que consume 5 núcleos y deja holgura.
(d) El threshold:
En el pico, ¿cuantos mensajes hay en cola?
La acumulacion maxima estimada es ~500 facturas (calculado arriba). Pero
si el escalado tarda en arrancar, la acumulacion inicial es mayor: en los
primeros 2 minutos con 1 sola replica, entran 1,67 x 120 = 200 facturas
y se procesan 0,125 x 120 = 15. Acumulacion: ~185.
Queremos que con ~500 mensajes en cola haya 14 workers:
threshold = 500 / 14 = 35,7 -> redondeamos a 35
Verificacion con otros valores de cola:
100 mensajes -> techo(100/35) = 3 workers
300 mensajes -> techo(300/35) = 9 workers
500 mensajes -> techo(500/35) = 15 workers (objetivo alcanzado)
700 mensajes -> techo(700/35) = 20 workers (techo)threshold: "35"
(e) ¿ScaledObject o ScaledJob?
Aplicamos la regla del apartado 9:
Duracion de la tarea: 8 segundos.
Tiempo de arranque de un pod: ~15 segundos.
8 < 15: la tarea dura MENOS que el arranque.
-> ScaledObject. Un ScaledJob gastaria mas tiempo arrancando que trabajando.Con ScaledJob, generar 12.000 facturas supondría crear 12.000 pods, con 15 segundos de arranque cada uno: 50 horas de tiempo de cómputo desperdiciado solo en arrancar. Absurdo.
Manifiesto completo:
# k8s/entornos/pro/scaledobject-worker-facturas.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-facturas
namespace: rutas-norte-pro
labels:
app: worker-facturas
app.kubernetes.io/part-of: rutas-norte
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: worker-facturas
pollingInterval: 20
# 15 minutos sin actividad antes de apagar. Generoso porque las facturas
# llegan a rafagas tras los cierres de venta, y apagar entre rafagas
# costaria mas en arranques en frio que mantener un pod.
cooldownPeriod: 900
# ESCALADO A CERO. Nadie espera una factura en tiempo real: el objetivo
# son 30 minutos, y el arranque en frio son 35 segundos. Sobra margen.
# Fuera de temporada se generan pocas facturas al dia.
minReplicaCount: 0
# 20 workers = 5 nucleos, dentro de los 8 libres de la cuota.
# 20 x 0,125 = 2,5 facturas/s = 9.000 facturas/hora. Muy por encima
# de las 3.000/hora del pico del puente.
maxReplicaCount: 20
fallback:
failureThreshold: 3
# Si el almacen de la cola no responde, 5 workers: suficiente para el
# caudal habitual mientras se investiga.
replicas: 5
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-worker-facturas
behavior:
scaleUp:
# Sin espera: si hay cola, hay trabajo pendiente.
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
# 10 minutos: las facturas llegan a rafagas tras cada cierre de
# venta, y no queremos destruir workers entre rafaga y rafaga.
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 30
periodSeconds: 120
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: facturas-pendientes
mode: QueueLength
# 1 worker por cada 35 facturas en cola.
#
# Calculo: un worker procesa 1 factura cada 8 s = 0,125 facturas/s.
# La entrada en el pico del puente es 0,833 facturas/s (12.000 en 4 h).
# Con margen del 100 % para rafagas: capacidad objetivo 1,67 facturas/s.
# Workers necesarios: 1,67 / 0,125 = 14.
# Acumulacion de cola esperada en el pico: ~500 mensajes.
# threshold = 500 / 14 = 35.
#
# Objetivo de negocio: ninguna factura espera mas de 30 minutos.
# Con 14 workers, el tiempo de vaciado de 500 facturas es ~5 minutos.
value: "35"
# Por debajo de 3 facturas, se considera inactivo. Permite el
# escalado a cero sin que 1-2 mensajes residuales lo impidan.
activationValue: "3"
authenticationRef:
name: rabbitmq-credenciales
# Red de seguridad: si el almacen de objetos se ralentiza y las facturas
# tardan 30 s en lugar de 8, la cola crecera y el disparador principal
# ya lo detectara. Pero si el problema es de CPU (un PDF patologico),
# este disparador lo cubre.
- type: cpu
metricType: Utilization
metadata:
value: "80"Solución 2
Qué está pasando: KEDA no puede leer la cola y está usando el fallback.
Las tres piezas de evidencia encajan así:
Pieza 1: FALLBACK: True en el ScaledObject. Esta es la señal principal y la que revela todo. Significa que KEDA ha superado el failureThreshold de consultas fallidas y está devolviendo un valor artificial.
Pieza 2: el HPA dice que todo está bien. Y tiene razón desde su punto de vista: está recibiendo un valor de la API externa (6), lo compara con su objetivo (2500), calcula techo(1 × 6/2500) = 1 réplica, y concluye que el tamaño actual coincide con el recomendado. El HPA no sabe que ese 6 es un valor inventado.
Esto es importante y es la trampa del ejercicio: ScalingActive: True no significa que la métrica sea correcta, solo que se pudo obtener un número.
Pieza 3: los logs revelan la causa raíz.
Get "https://rabbitmq.rutas-norte-pro.svc.cluster.local:15671/api/queues/..."
dial tcp 10.96.42.17:15671: i/o timeoutDos observaciones sobre esta línea:
- Puerto 15671 y protocolo HTTPS. El puerto de gestión de RabbitMQ es 15672 en HTTP y 15671 en HTTPS. KEDA está intentando HTTPS.
i/o timeout, noconnection refused. La diferencia es diagnóstica:connection refusedsignifica que llegó y algo dijo que no;i/o timeoutsignifica que el paquete se perdió sin respuesta, que es la firma característica de un cortafuegos o una NetworkPolicy.
Por qué el valor de la métrica es 6:
El fallback está configurado con replicas: 6. Cuando KEDA entra en modo fallback, no devuelve el número de réplicas directamente: devuelve un valor de métrica calculado para que el HPA llegue a ese número de réplicas.
KEDA quiere que el HPA calcule 6 replicas.
El HPA calcula: techo(replicasActuales x valor / threshold)
Pero como el HPA usa AverageValue, la mecanica del fallback de KEDA es
reportar un valor tal que el resultado sea las replicas del fallback.
En la salida vemos "6 / 2500", que es el valor de fallback reportado.
El HPA con 1 replica calcula techo(1 x 6/2500) = techo(0,0024) = 1.Y aquí está el segundo hallazgo del ejercicio: el fallback no está funcionando como se esperaba. El HPA se ha quedado en 1 réplica, no en 6.
La razón es una limitación documentada de KEDA: el fallback no funciona correctamente cuando minReplicaCount es 0 y la carga estaba escalada a cero o cerca. Con minReplicaCount: 0 y el mecanismo de activación involucrado, el comportamiento del fallback no es el esperado.
Qué hay que corregir:
Corrección 1 (inmediata): restablecer la conectividad.
# 1. ¿Existe el Service y responde?
kubectl get svc rabbitmq -n rutas-norte-pro
kubectl get endpoints rabbitmq -n rutas-norte-pro
# 2. ¿Hay una NetworkPolicy nueva?
kubectl get networkpolicy -n rutas-norte-pro
kubectl describe networkpolicy -n rutas-norte-pro
# 3. Probar la conectividad DESDE el namespace de KEDA
kubectl run prueba --rm -it --restart=Never -n keda --image=busybox:1.36 -- \
wget -qO- --timeout=5 http://rabbitmq.rutas-norte-pro.svc.cluster.local:15672/api/overviewLa causa más probable, dado el i/o timeout: una NetworkPolicy de denegación por defecto aplicada en rutas-norte-pro (módulo 4 y 08-04) que no contempla el tráfico procedente del namespace keda.
# k8s/entornos/pro/networkpolicy-keda-rabbitmq.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: permitir-keda-a-rabbitmq
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: rabbitmq
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: keda
ports:
- protocol: TCP
port: 15672 # API de gestion HTTP
- protocol: TCP
port: 5672 # AMQPCorrección 2: revisar el puerto y el protocolo del disparador.
# Si RabbitMQ no tiene TLS configurado en el puerto de gestion, el
# TriggerAuthentication debe apuntar a http://...:15672, no https://...:15671
stringData:
host: "amqp://keda_lector:[email protected]:5672/"Corrección 3: hacer robusto el fallback.
Como minReplicaCount: 0 compromete el fallback, la opción segura para una carga crítica en temporada alta es no escalar a cero:
spec:
# Durante la temporada alta, no escalamos a cero. El fallback funciona
# de forma fiable y el arranque en frio no penaliza en el peor momento.
minReplicaCount: 2
idleReplicaCount: 1 # Fuera de actividad, 1 replica encendida
fallback:
failureThreshold: 3
replicas: 8Cómo evitar que vuelva a pasar sin que nadie se entere:
Este es el punto más importante del ejercicio. El sistema falló de forma silenciosa durante veinte minutos. Todo parecía correcto en las comprobaciones habituales.
Alertas necesarias en Alertmanager (07-04):
# 1. LA MAS IMPORTANTE: KEDA en modo fallback.
# Si esto se dispara, el escalado NO esta funcionando aunque todo parezca normal.
keda_scaled_object_errors_total > 0
# 2. Errores del escalador: KEDA no puede consultar la fuente.
sum(rate(keda_scaler_errors_total[5m])) by (scaledObject, scaler) > 0
# 3. Alerta de negocio: la cola crece sin que crezcan los workers.
# Esta es la que habria detectado el problema en 2 minutos.
(
rabbitmq_queue_messages{queue="notificaciones-confirmacion"} > 5000
)
and
(
kube_deployment_status_replicas{deployment="worker-notificaciones"} < 5
)
# 4. Antiguedad del mensaje mas viejo en la cola: el indicador de negocio real.
rabbitmq_queue_head_message_timestamp_seconds > 600La alerta 3 es especialmente valiosa porque no depende de que KEDA funcione: mira el resultado (cola grande, pocos workers) en lugar del mecanismo. Es el principio de alertar sobre síntomas y no sobre causas del módulo 7.
Y una comprobación estructural: incluir a KEDA en las pruebas de las NetworkPolicies. Cada vez que se añada una política de denegación por defecto en un namespace, verificar explícitamente que KEDA sigue pudiendo consultar sus fuentes. Es fácil de olvidar porque KEDA no es «tráfico de la aplicación».
Solución 3
Análisis: son dos cargas distintas metidas en un mismo servicio.
La primera observación, y la más importante del ejercicio, es de arquitectura, no de escalado:
| Carga | Perfil | Necesidad |
|---|---|---|
| API HTTP síncrona | 50-2.500 rps, 40 ms/petición, latencia crítica | Muchas réplicas pequeñas, escalado rápido |
| Recálculo nocturno | 45 minutos, 4 núcleos, una vez al día | Un pod grande, sin usuarios |
Meterlas en el mismo Deployment es un error de diseño, y el enunciado ya lo señala: el proceso nocturno «degrada las respuestas HTTP durante esos 45 minutos». Ninguna configuración de KEDA arregla eso.
Decisión 1: separar las dos cargas.
motor-precios -> Deployment + ScaledObject (API HTTP)
recalculo-precios -> CronJob (proceso nocturno)El proceso nocturno pasa a ser un CronJob (06-03) con sus propios recursos, aislado del servicio HTTP. Esto:
- Elimina la degradación de 45 minutos cada noche.
- Permite dimensionar cada carga por separado.
- Permite que el VPA en modo
Initial(09-02) ajuste el CronJob con el tiempo. - Hace que un fallo del recálculo no afecte al servicio HTTP.
Decisión 2: ¿escalado a cero para la API? NO.
motor-precios está en el camino crítico de cada búsqueda de rutas. Su latencia entra directamente en la de api-reservas. Un arranque en frío de 35 segundos con un usuario esperando es inaceptable.
Además, hay un efecto de segundo orden importante: si motor-precios está a cero y llega una petición, api-reservas se queda esperando. Con muchas peticiones simultáneas, api-reservas agota su pool de conexiones salientes y se degrada en cascada. El escalado a cero de un servicio interno síncrono puede tumbar a quien lo llama.
Decisión 3: la métrica. Peticiones por segundo, no CPU.
Aunque el trabajo de motor-precios es cálculo puro (donde la CPU sí correlaciona bien), las peticiones por segundo siguen siendo mejor señal porque:
- Es la magnitud que conocemos: 180 rps por réplica, medido.
- Se anticipa: se cuentan al llegar, antes de consumir CPU.
- No depende del
requests, así que el VPA puede trabajar libremente.
Pero añadimos CPU como red de seguridad, porque en un servicio de cálculo puro una consulta patológica (una ruta con muchas combinaciones) podría consumir mucha CPU con pocas peticiones.
Y añadimos un tercer disparador que casi nadie considera: la latencia. Es la métrica que realmente le importa al negocio.
Los manifiestos:
# k8s/entornos/pro/scaledobject-motor-precios.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: motor-precios
namespace: rutas-norte-pro
labels:
app: motor-precios
app.kubernetes.io/part-of: rutas-norte
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: motor-precios
pollingInterval: 15
cooldownPeriod: 300
# NO escalamos a cero. Esta en el camino critico de cada busqueda de rutas:
# su latencia entra directamente en la de api-reservas. Un arranque en frio
# de 35 s con usuarios esperando es inaceptable, y ademas agotaria el pool
# de conexiones salientes de api-reservas: degradacion en cascada.
#
# 3 replicas de suelo: 3 x 180 = 540 rps, muy por encima del minimo de 50.
# El suelo lo marca la DISPONIBILIDAD (09-05), no la capacidad.
minReplicaCount: 3
# Techo: 2.500 rps / 180 rps por replica = 13,9 -> 14.
# Con margen del 40 %: 20 replicas.
maxReplicaCount: 20
fallback:
failureThreshold: 3
# Si Prometheus cae, 8 replicas = 1.440 rps de capacidad. Cubre el
# trafico habitual con holgura mientras se arregla.
replicas: 8
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-motor-precios
behavior:
scaleUp:
# Sin espera. Es un servicio interno con latencia critica: cualquier
# retraso se propaga a la busqueda de rutas del usuario final.
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
# 10 minutos. El trafico de busquedas es muy irregular (rafagas de
# usuarios comparando rutas) y no queremos destruir capacidad entre
# rafaga y rafaga.
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 20
periodSeconds: 120
triggers:
# DISPARADOR PRINCIPAL: peticiones por segundo.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
metricName: motor_precios_peticiones_por_segundo
query: |
sum(
rate(
motor_precios_peticiones_total{
namespace="rutas-norte-pro",
app="motor-precios"
}[2m]
)
)
# 125 rps por replica.
#
# Calculo: una replica sostiene 180 rps antes de que la latencia p95
# supere los 80 ms. Aplicamos un margen del 30 % para cubrir el tiempo
# de arranque de las replicas nuevas: 180 x 0,7 = 126 -> 125.
#
# Con 2.500 rps: techo(2500/125) = 20 replicas. Justo el maxReplicaCount.
threshold: "125"
activationThreshold: "10"
ignoreNullValues: "true"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-lectura
# DISPARADOR SECUNDARIO: la LATENCIA, que es lo que importa al negocio.
#
# Si el p95 supera los 60 ms (75 % del objetivo de 80 ms), escalamos
# aunque las peticiones por segundo esten dentro de lo normal. Esto cubre
# el caso de peticiones anormalmente CARAS: rutas con muchas combinaciones,
# o una degradacion de una dependencia.
#
# NOTA IMPORTANTE: escalar por latencia es un arma de doble filo. Si la
# latencia sube por una causa que NO se resuelve con mas replicas (por
# ejemplo, PostgreSQL saturado), escalar empeora las cosas (09-01, el
# cuello de botella real). Por eso el umbral es conservador y el
# maxReplicaCount esta acotado.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
metricName: motor_precios_latencia_p95
query: |
histogram_quantile(0.95,
sum(rate(motor_precios_duracion_segundos_bucket{
namespace="rutas-norte-pro"
}[2m])) by (le)
) * 1000
threshold: "60"
activationThreshold: "1"
ignoreNullValues: "true"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-lectura
# RED DE SEGURIDAD: CPU.
- type: cpu
metricType: Utilization
metadata:
value: "70"Y el proceso nocturno, separado:
# k8s/entornos/pro/cronjob-recalculo-precios.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: recalculo-precios
namespace: rutas-norte-pro
labels:
app: recalculo-precios
app.kubernetes.io/part-of: rutas-norte
spec:
schedule: "0 3 * * *"
timeZone: "Europe/Madrid" # CronJob soporta timeZone desde 1.27
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
# Si a las 2 horas no ha terminado, cortar. Evita que un proceso colgado
# ancle un nodo (09-03) y se solape con el trafico de la manana.
activeDeadlineSeconds: 7200
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: recalculo-precios
annotations:
# NO desalojar durante la ejecucion: perderiamos 45 minutos de
# trabajo. El Cluster Autoscaler respetara esta anotacion (09-03).
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
spec:
restartPolicy: OnFailure
containers:
- name: recalculo
image: registry.rutasnorte.example/motor-precios:4.2.0
command: ["node", "recalcular-matriz.js"]
resources:
requests:
cpu: "4"
memory: 4Gi
limits:
cpu: "4"
memory: 4Gi
# requests == limits -> QoS Guaranteed. Se ejecuta a las 3:00
# con el cluster vacio: no le quitamos capacidad a nadie y
# garantizamos que no lo desalojen por presion de memoria.Y su VPA, aplicando lo de 09-02:
# k8s/entornos/pro/vpa-recalculo-precios.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: recalculo-precios
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: batch/v1
kind: CronJob
name: recalculo-precios
updatePolicy:
# Initial: cada ejecucion nueva nace con los recursos aprendidos. Nunca
# desalojamos un Job a mitad de camino.
updateMode: "Initial"
resourcePolicy:
containerPolicies:
- containerName: recalculo
minAllowed:
cpu: "2"
memory: 2Gi
maxAllowed:
cpu: "8"
memory: 8Gi
controlledValues: RequestsAndLimitsResumen de decisiones y su justificación:
| Decisión | Elección | Justificación |
|---|---|---|
¿Uno o varios ScaledObject? |
Uno, más un CronJob separado | Son dos cargas con perfiles opuestos; mezclarlas degrada la API |
| ¿Escalado a cero? | No (minReplicaCount: 3) |
Camino crítico síncrono; el arranque en frío degradaría a api-reservas en cascada |
| Métrica principal | Peticiones por segundo | Magnitud medida y conocida (180 rps/réplica); no depende del requests |
threshold |
125 rps | 180 medido × 0,7 de margen para el arranque |
| Métrica secundaria | Latencia p95 | Es el objetivo de negocio real; detecta peticiones anormalmente caras |
| Red de seguridad | CPU al 70 % | Cubre casos imprevistos; con «gana el que pide más», nunca estorba |
maxReplicaCount |
20 | 2.500 rps / 125 = 20; acotado para no enmascarar problemas ajenos |
fallback |
8 réplicas | 1.440 rps de capacidad: cubre el tráfico habitual mientras se arregla Prometheus |
| Proceso nocturno | CronJob con QoS Guaranteed |
Aislamiento total; recursos dedicados; se ejecuta con el clúster vacío |
| VPA del CronJob | Modo Initial |
Aprende de ejecuciones anteriores sin desalojar a mitad |
Advertencia final que merece constar: escalar por latencia es útil pero peligroso. Si la latencia sube porque una dependencia está saturada (PostgreSQL, por ejemplo), añadir réplicas de motor-precios empeora la situación: más réplicas, más conexiones, más presión sobre la dependencia. Es exactamente el error de 09-01 de escalar la capa equivocada. Por eso el umbral es conservador (60 ms de 80) y el techo está acotado en 20. Y por eso hace falta una alerta que distinga las dos situaciones:
# Latencia alta CON las replicas al maximo: el problema NO se resuelve escalando
(
histogram_quantile(0.95, sum(rate(motor_precios_duracion_segundos_bucket[5m])) by (le)) * 1000 > 80
)
and
(
kube_deployment_status_replicas{deployment="motor-precios"} >= 20
)Conclusión
El escalado por eventos convierte una señal indirecta y tardía —el consumo de CPU— en la señal que de verdad describe el trabajo pendiente: la longitud de una cola, las peticiones por segundo, o simplemente la hora del día.
Lo esencial:
- La CPU es buena señal cuando el trabajo del pod es calcular, y mala cuando es coordinar, esperar o mantener conexiones.
worker-notificacionespasa el 95 % del tiempo esperando al servidor SMTP: un HPA por CPU habría reducido réplicas con cuarenta mil correos en cola. - El HPA ya sabe consumir métricas personalizadas y externas (
Pods,Object,External). Lo que falta es quién sirva esas APIs, y ahí entra un adaptador registrado en la capa de agregación. - KEDA no sustituye al HPA: crea uno y lo alimenta. Todo lo de 09-01 sigue vigente, incluido el
behavior, que se configura desdeadvanced.horizontalPodAutoscalerConfig. El único tramo que gobierna KEDA directamente es el 0↔1. - El
ScaledObjectdefine el destino, el intervalo de consulta, el rango de réplicas, elfallbacky los disparadores. Con varios disparadores, gana el que pide más réplicas: combina la señal principal, una red de seguridad de CPU y un cron de precalentamiento. TriggerAuthenticationmantiene las credenciales fuera del manifiesto y permite usar identidades de nube, coherente con el mínimo privilegio de 08-01.ScaledJobcrea un Job por unidad de trabajo. Úsalo cuando la tarea dure mucho más que el arranque de un pod; en caso contrario,ScaledObject.- El disparador cron es lo que rompe la limitación del escalado reactivo. Para un evento predecible como la apertura de la venta, esperar a que la métrica suba es absurdo: el
rate[2m]además suaviza precisamente el pico que queremos detectar. Escalar a las 9:30 elimina la degradación de las 10:00 por completo. - El escalado a cero es potentísimo para trabajos por lotes y entornos de desarrollo, e inaceptable donde hay un ser humano esperando.
idleReplicaCountes el punto medio que sirve para casi todo lo demás. - La depuración sigue una cadena:
ScaledObject→ HPA generado → API de agregación → logs del operador → conectividad de red → la consulta en la fuente. Y las NetworkPolicies que bloquean a KEDA son el error número uno tras implantar las políticas del módulo 8.
Rutas Norte tiene ahora worker-notificaciones escalando por la longitud real de su cola con apagado completo fuera de temporada, api-reservas escalando por peticiones por segundo con las métricas que instrumentamos en 07-03, y un disparador cron que arranca y calienta 25 réplicas media hora antes de que se abra la venta del puente de mayo, con el colchón de nodos ampliado una hora antes que eso.
La plataforma sube cuando hace falta y antes de que haga falta. Pero todo el módulo se ha ocupado hasta ahora de crecer, y hay una pregunta que no hemos hecho: ¿qué pasa cuando algo se rompe o cuando alguien tiene que tocar el clúster?
El próximo martes toca actualizar la versión de Kubernetes de los nodos. Alguien ejecutará kubectl drain sobre un nodo para vaciarlo. Si en ese nodo están tres de las cuatro réplicas de api-reservas —porque el planificador las puso ahí y nadie le dijo que no lo hiciera—, ese drenaje se llevará por delante el 75 % de la capacidad de la API en un instante. Y si el nodo que se cae no lo drena nadie, sino que se apaga solo por un fallo de hardware en la zona de disponibilidad B a las tres de la madrugada, el resultado será el mismo pero sin previo aviso.
En la próxima lección, Alta Disponibilidad: PodDisruptionBudgets y Topología, veremos la distinción entre las interrupciones que puedes controlar y las que no, cómo el PodDisruptionBudget protege de las primeras (y cómo un PDB mal puesto bloquea el mantenimiento para siempre, como ya intuimos en 09-03), cómo repartir las réplicas entre nodos y zonas con topologySpreadConstraints, y el procedimiento completo de mantenimiento de un nodo de principio a fin.
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
