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

  1. Por qué la CPU es una mala señal
  2. Métricas personalizadas y externas en el HPA
  3. La API de agregación y los adaptadores de métricas
  4. Qué es KEDA y cuál es su arquitectura
  5. La idea clave: KEDA no sustituye al HPA, lo alimenta
  6. El objeto ScaledObject campo a campo
  7. El catálogo de escaladores
  8. TriggerAuthentication: credenciales para los disparadores
  9. ScaledJob: un trabajo por mensaje
  10. Rutas Norte: worker-notificaciones por longitud de cola
  11. Rutas Norte: api-reservas por peticiones por segundo
  12. Rutas Norte: el disparador cron del puente de mayo
  13. El escalado a cero y sus implicaciones
  14. Depurar un ScaledObject que no escala
  15. Errores comunes y consejos
  16. Ejercicios
  17. Conclusión

  1. 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:

ratio = 60 / 210 = 0,286
replicasDeseadas = techo(2 x 0,286) = 1

El HPA quiere BAJAR a 1 replica.

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.

  1. 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)
                         registered

Ese 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.

  1. 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:

kubectl get apiservices | grep metrics
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        12d

Cuando 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.tool

Funciona, 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

  1. 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 keda
NAME                                              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          2m

Y los CRDs que registra:

kubectl get crd | grep keda
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:14Z

Los 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 triggers del ScaledObject en métricas External del 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é ScaledObject y 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

  1. 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:

kubectl get hpa -n rutas-norte-pro
NAME                                  REFERENCE                          TARGETS        MINPODS  MAXPODS  REPLICAS
keda-hpa-worker-notificaciones        Deployment/worker-notificaciones   1847/2500 (avg)  1        25       8

Ahí está: un HPA con el prefijo keda-hpa-, gestionado por el operador de KEDA. Y su contenido:

kubectl get hpa keda-hpa-worker-notificaciones -n rutas-norte-pro -o yaml
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.

  1. El objeto ScaledObject campo a campo

apiVersion: 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-credenciales

Los 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 1

Significa: 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 mantenerlas

Este 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: 60

Todo 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.

  1. 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-credenciales

Cron:

- 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-credenciales

activationValue: 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 inactivo

La 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.

  1. TriggerAuthentication: credenciales para los disparadores

Los 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 Secret

Y en el ScaledObject:

triggers:
  - type: rabbitmq
    metadata:
      queueName: notificaciones-confirmacion
      mode: QueueLength
      value: "2500"
    authenticationRef:
      name: rabbitmq-credenciales

Otras 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-sqs

Conecta 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: token

Se referencia con kind: ClusterTriggerAuthentication en el authenticationRef. Útil para Prometheus, que es único y lo consultan todos los namespaces.

  1. ScaledJob: un trabajo por mensaje

ScaledObject 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-credenciales

Cuá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.

  1. Rutas Norte: worker-notificaciones por longitud de cola

Vamos 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-pro
NAME                    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     34s

Columnas 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:

kubectl get deployment worker-notificaciones -n rutas-norte-pro
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
worker-notificaciones   0/0     0            0           89d

La reactivación en vivo

Abrimos dos terminales. En el primero, observamos:

kubectl get deployment worker-notificaciones -n rutas-norte-pro --watch

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=30000

Y 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 = 12

Se 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:

kubectl get hpa hpa-worker-notificaciones -n rutas-norte-pro
NAME                        REFERENCE                          TARGETS            MINPODS  MAXPODS  REPLICAS
hpa-worker-notificaciones   Deployment/worker-notificaciones   2483/2500 (avg)    1        25       12

2483/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

  1. Rutas Norte: api-reservas por peticiones por segundo

Ahora 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:

  1. Es la magnitud del negocio. «Cada réplica atiende 85 peticiones por segundo» es una frase que entiende cualquiera en la empresa.
  2. No depende del requests. Un cambio del requests.cpu (recomendado por el VPA de 09-02) no altera el comportamiento del escalado.
  3. Es más rápida. La CPU sube cuando el trabajo ya está entrando; las peticiones se cuentan al llegar.
  4. 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 92841

El 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

sum(
  rate(
    api_reservas_peticiones_total{
      namespace="rutas-norte-pro",
      app="api-reservas"
    }[2m]
  )
)

Desmenuzada de dentro afuera:

  1. 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).

  2. 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 un scrape_interval de 30 s solo habría 2 muestras, lo que hace el rate poco fiable). Con [5m] la señal es suave pero llega tarde. Regla: la ventana del rate debe ser al menos 4 veces el intervalo de recolección. Con recolección cada 30 s, [2m] es el mínimo razonable.

  3. 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

kubectl get hpa hpa-api-reservas -n rutas-norte-pro
NAME               REFERENCE                 TARGETS                   MINPODS  MAXPODS  REPLICAS
hpa-api-reservas   Deployment/api-reservas   1247/85 (avg), 34%/70%    4        30       15

Hay 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-pro

Si 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.

  1. 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.

  1. 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 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 Í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: 1

Esa 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.

  1. Depurar un ScaledObject que no escala

Procedimiento sistemático, del más general al más específico.

Paso 1: el estado del ScaledObject

kubectl get scaledobject -n rutas-norte-pro
NAME                    SCALETARGETKIND      SCALETARGETNAME         MIN  MAX  READY  ACTIVE  FALLBACK  AGE
worker-notificaciones   apps/v1.Deployment   worker-notificaciones   0    25   False  False   False     4m

READY: False es el primer problema. Significa que KEDA no ha podido configurarlo.

kubectl describe scaledobject worker-notificaciones -n rutas-norte-pro
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 0

El 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.

kubectl describe hpa keda-hpa-worker-notificaciones -n rutas-norte-pro
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 Found

Object 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

kubectl logs -n keda deployment/keda-operator --tail=100 | grep -i "worker-notificaciones"
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:

kubectl logs -n keda deployment/keda-operator-metrics-apiserver --tail=50

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/overview

Si 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: 15672

Este 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:

kubectl port-forward -n monitorizacion svc/prometheus-operated 9090:9090

Y en la interfaz web, ejecuta la consulta exacta del ScaledObject. Comprueba:

  1. ¿Devuelve algún dato? Si está vacía, las etiquetas del selector no coinciden.
  2. ¿Devuelve UN SOLO valor? Si devuelve varias series, falta un sum().
  3. ¿El valor tiene sentido? Un rate que 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 > 0

El 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: 250m y requests.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:

kubectl get scaledobject -n rutas-norte-pro
NAME                    SCALETARGETKIND      MIN  MAX  READY  ACTIVE  FALLBACK  AGE
worker-notificaciones   apps/v1.Deployment   0    25   True   True    True      3h
kubectl describe hpa keda-hpa-worker-notificaciones -n rutas-norte-pro
Conditions:
  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>
kubectl logs -n keda deployment/keda-operator --tail=20 | grep worker
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-reservas cada 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_total y motor_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 workers

Comprobació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 timeout

Dos observaciones sobre esta línea:

  1. Puerto 15671 y protocolo HTTPS. El puerto de gestión de RabbitMQ es 15672 en HTTP y 15671 en HTTPS. KEDA está intentando HTTPS.
  2. i/o timeout, no connection refused. La diferencia es diagnóstica: connection refused significa que llegó y algo dijo que no; i/o timeout significa 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/overview

La 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       # AMQP

Correcció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: 8

Có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 > 600

La 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: RequestsAndLimits

Resumen 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-notificaciones pasa 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 desde advanced.horizontalPodAutoscalerConfig. El único tramo que gobierna KEDA directamente es el 0↔1.
  • El ScaledObject define el destino, el intervalo de consulta, el rango de réplicas, el fallback y 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.
  • TriggerAuthentication mantiene las credenciales fuera del manifiesto y permite usar identidades de nube, coherente con el mínimo privilegio de 08-01.
  • ScaledJob crea 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. idleReplicaCount es 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

Módulo 2: Componentes Principales de Kubernetes

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

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

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

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

© Copyright 2026. Todos los derechos reservados