Pre-escalado Predictivo en Kubernetes: Cómo Ganarle 60 Segundos a un Pico de Breaking News

Sala de redacción con monitor CMS mostrando banner rojo ÚLTIMO MOMENTO y cluster de Kubernetes escalando en tiempo real, conectados por línea dorada de webhook

Pre-escalar un cluster de Kubernetes de forma reactiva nunca funciona para el tráfico de breaking news. Cuando el Cluster Autoscaler detecta que el CPU se dispara, la home ya está sirviendo 503s. El cluster reacciona en minutos; el tráfico llega en segundos. La única salida es escalar antes de que el tráfico aparezca — y para eso necesitás una señal que la capa de métricas no te puede dar.

Los sitios de noticias tienen un patrón de tráfico brutalmente bimodal: la mayor parte del tiempo es predecible y modesto, y cuando ocurre un evento externo grande se multiplica 20x o 50x en minutos. La reacción típica de la industria es overprovisioning permanente — dejar prendida 24/7 la capacidad para el peor pico imaginable. Es carísimo y, peor, igual no alcanza: aun con mucha capacidad ociosa, el pico genera hot spots, conexiones saturadas, y servicios stateful que no escalan tan rápido como los stateless.

Pero hay una asimetría temporal que casi nadie aprovecha: cuando un editor marca una nota como breaking news en el CMS interno, la nota no se publica instantáneamente al público. Pasa por revisión legal, edición final, aprobación del editor jefe — un proceso que típicamente toma entre 30 y 90 segundos. Esa ventana es oro: el tiempo suficiente para pre-escalar la infra antes de que el tráfico exista.

En este post mostramos el patrón completo: webhook desde el CMS, Lambda detector, KEDA con trigger externo, y un NodePool dedicado de Karpenter que provisiona en 30-60 segundos en lugar de los 3-5 minutos del Cluster Autoscaler.

El Problema con el Modelo Reactivo

Antes de entrar en el patrón, vale la pena ver por qué el approach clásico (overprovisionar + Cluster Autoscaler) no funciona. Esta es la tabla típica de un cluster de sitio de noticias overprovisionado — los números son ilustrativos pero reflejan lo que vemos una y otra vez en auditorías:

Métrica Valor típico
Utilización promedio de CPU 8-15%
Utilización promedio de memoria 20-30%
Tiempo de reacción del Cluster Autoscaler 3-5 minutos
Tiempo de warm-up de los pods nuevos 20-60 segundos
Multiplicador de tráfico durante breaking news 20x-50x
Tiempo entre publicación y pico de tráfico 10-30 segundos

Las dos últimas filas matan al modelo reactivo. Incluso si la infra base absorbiera un 5x (que ya implica overprovisioning absurdo), un 20x le pasa por encima. Y aunque Cluster Autoscaler eventualmente provisione los nodos, llegan 2-3 minutos tarde — cuando el pico ya rompió todo. La pregunta correcta no es "¿cómo hacemos que el Autoscaler reaccione más rápido?". Es: ¿cómo enteramos al cluster antes de que el tráfico exista?

Fase 1: Hook al CMS y Lambda Detector

El primer eslabón es un webhook en el CMS editorial. La mayoría de los CMS modernos (WordPress VIP, Arc Publishing, Strapi, los propietarios de las editoriales grandes) soportan webhooks de cambio de estado. Agregamos uno que dispara cada vez que una nota pasa al estado breaking_news o recibe un flag de prioridad alta. El webhook apunta a una Lambda que valida la firma HMAC, aplica filtros mínimos (anti-duplicados, throttling), y emite un evento de pre-scale a DynamoDB — la fuente de verdad que después consume KEDA.

# breaking_news_detector/handler.py
import hashlib
import hmac
import json
import os
import time
import boto3

WEBHOOK_SECRET = os.environ["CMS_WEBHOOK_SECRET"]
TABLE_NAME = os.environ["PRESCALE_TABLE"]
MIN_INTERVAL_SECONDS = 60  # anti-duplicate window

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(TABLE_NAME)


def verify_signature(body: bytes, signature: str) -> bool:
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


def should_prescale(story: dict) -> bool:
    if story.get("status") != "breaking_news":
        return False
    if story.get("priority", "normal") not in ("high", "critical"):
        return False
    return True


def recently_fired(story_id: str) -> bool:
    resp = table.get_item(Key={"story_id": story_id})
    item = resp.get("Item")
    if not item:
        return False
    return (time.time() - item["ts"]) < MIN_INTERVAL_SECONDS


def emit_prescale_event(story: dict) -> None:
    table.put_item(
        Item={
            "story_id": story["id"],
            "ts": int(time.time()),
            "ttl": int(time.time()) + 900,  # 15 min TTL
            "priority": story["priority"],
            "estimated_traffic_multiplier": story.get("multiplier", 20),
            "source": "cms_editorial",
        }
    )


def lambda_handler(event, context):
    body = event["body"].encode() if isinstance(event["body"], str) else event["body"]
    signature = event["headers"].get("x-cms-signature", "")

    if not verify_signature(body, signature):
        return {"statusCode": 401, "body": "invalid signature"}

    story = json.loads(body)

    if not should_prescale(story):
        return {"statusCode": 200, "body": "no prescale needed"}

    if recently_fired(story["id"]):
        return {"statusCode": 200, "body": "already fired"}

    emit_prescale_event(story)
    return {"statusCode": 202, "body": "prescale event emitted"}

Decisiones clave:

  • HMAC primero, todo lo demás después. El endpoint es público; un atacante que descubra la URL podría dispararte pre-scales fake cada 10 segundos y dejarte la factura en llamas. La verificación de firma no es negociable.
  • Dedupe con TTL corto. El mismo story_id puede disparar varios webhooks (cambio de título, edición final, aprobación). Ignoramos eventos del mismo story dentro de los 60 segundos.
  • TTL de 15 minutos en DynamoDB. Los items se auto-eliminan, la tabla no crece.
  • No escalás acá. La Lambda no habla con Kubernetes ni con Karpenter — solo emite el evento. Eso la mantiene simple, rápida (<50ms p99), y testeable.

Fase 2: KEDA con Trigger Externo

KEDA (Kubernetes Event-Driven Autoscaling) es lo que traduce "hay un evento en DynamoDB" a "escala estos Deployments ya". Configuramos un ScaledObject por servicio crítico, cada uno con un trigger de DynamoDB que apunta a la tabla prescale_events.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: public-cms-prescale
  namespace: news-frontend
spec:
  scaleTargetRef:
    name: public-cms
  pollingInterval: 5          # segundos
  cooldownPeriod: 300         # 5 min antes de escalar para abajo
  minReplicaCount: 6
  maxReplicaCount: 120
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 0
          policies:
            - type: Percent
              value: 300
              periodSeconds: 15
        scaleDown:
          stabilizationWindowSeconds: 300
          policies:
            - type: Percent
              value: 10
              periodSeconds: 60
  triggers:
    - type: aws-dynamodb
      metadata:
        tableName: prescale_events
        awsRegion: us-east-1
        keyConditionExpression: "source = :source"
        expressionAttributeValues: '{":source": {"S": "cms_editorial"}}'
        targetValue: "1"
        activationTargetValue: "0"
        identityOwner: operator
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observability.svc:9090
        metricName: http_requests_per_second
        query: sum(rate(nginx_ingress_controller_requests{service="public-cms"}[1m]))
        threshold: "500"

Dos triggers por diseño:

  1. El trigger de DynamoDB es la señal predictiva. Apenas aparece un item en la tabla, KEDA empuja el Deployment a su target. pollingInterval: 5 significa que en el peor caso KEDA detecta el evento 5 segundos después de que la Lambda lo escribió.
  2. El trigger de Prometheus es la red de seguridad. Si el webhook falla (red, firma mal rotada, editor que publicó sin marcar como breaking), el trigger clásico de RPS sigue funcionando. Nunca confíes solo en la señal predictiva.

El behavior.scaleUp es crítico. Por default el HPA escala gradualmente — 300% por 15s le dice "triplicá la cantidad de pods cada 15 segundos". Para breaking news querés agresividad; el tiempo perdido en scale-up gradual se paga en 503s. El scaleDown, en cambio, es conservador: 10% cada 60 segundos con stabilization window de 5 minutos. Los picos tienen colas largas (la gente sigue entrando 10-20 minutos después), y bajar la infra demasiado rápido te expone a un segundo pico cuando el tema vuelve a trending.

Para trabajos pesados: ScaledJob

Algunos servicios no son Deployments sino jobs — por ejemplo, regenerar los static assets de la home o warm-up del caché de búsqueda. Para esos usamos ScaledJob:

apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
  name: homepage-cache-warmer
  namespace: news-frontend
spec:
  jobTargetRef:
    parallelism: 8
    completions: 8
    backoffLimit: 2
    template:
      spec:
        restartPolicy: Never
        containers:
          - name: warmer
            image: registry.internal/cache-warmer:1.4.2
            args: ["--target=homepage", "--variants=all"]
            resources:
              requests:
                cpu: "500m"
                memory: "512Mi"
  pollingInterval: 5
  maxReplicaCount: 8
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  triggers:
    - type: aws-dynamodb
      metadata:
        tableName: prescale_events
        awsRegion: us-east-1
        keyConditionExpression: "source = :source"
        expressionAttributeValues: '{":source": {"S": "cms_editorial"}}'
        targetValue: "1"

Esto dispara 8 jobs paralelos apenas aparece el evento. Para el momento en que la nota se publica, el caché ya está caliente y las primeras requests golpean al CDN, no al origin.

Fase 3: Karpenter con NodePool Dedicado

KEDA escala los Deployments, pero si los nodos no existen los pods se quedan Pending. La clave es tener un NodePool dedicado al tráfico de breaking news, separado del NodePool general del cluster:

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: breaking-news-burst
spec:
  template:
    metadata:
      labels:
        workload-tier: breaking-news
    spec:
      taints:
        - key: workload-tier
          value: breaking-news
          effect: NoSchedule
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand", "spot"]
        - key: node.kubernetes.io/instance-type
          operator: In
          values:
            - c6i.2xlarge
            - c6i.4xlarge
            - c6a.2xlarge
            - c6a.4xlarge
            - c7i.2xlarge
            - c7i.4xlarge
            - m6i.2xlarge
            - m6i.4xlarge
            - m6a.2xlarge
            - m6a.4xlarge
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a", "us-east-1b", "us-east-1c"]
      nodeClassRef:
        name: default
      expireAfter: 2h
  limits:
    cpu: "1000"
    memory: 2000Gi
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 180s
    budgets:
      - nodes: "10%"

Decisiones clave:

  • Taints + labels. El NodePool está tainted con workload-tier=breaking-news:NoSchedule. Solo los Deployments críticos (con la tolerancia y el nodeSelector) aterrizan acá. El resto del cluster ni se entera de que este pool existe.
  • Familias CPU-dense. c6i, c6a, c7i, m6i, m6a — todas CPU-first. Los sitios de noticias bajo carga son CPU-bound por rendering HTML, SSR y TLS termination. Evitamos familias t (burstable, impredecibles) y r (memoria innecesaria para este workload).
  • consolidationPolicy: WhenEmpty, no WhenUnderutilized. Durante un pico, querés que los nodos se queden aunque estén subutilizados un rato. WhenUnderutilized consolidaría agresivamente y generaría churn durante el pico.
  • expireAfter: 2h. Después de un pico, Karpenter recicla los nodos aunque KEDA todavía mantenga pods arriba por la cooldown. Evita que nodos Spot viejos con interrupciones pendientes queden dando vueltas.
  • disruption.budgets: 10%. Nunca más del 10% de los nodos del pool pueden estar siendo reemplazados al mismo tiempo.

Los Deployments críticos llevan esta configuración para aterrizar en el pool correcto:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: public-cms
  namespace: news-frontend
spec:
  template:
    spec:
      tolerations:
        - key: workload-tier
          operator: Equal
          value: breaking-news
          effect: NoSchedule
      nodeSelector:
        workload-tier: breaking-news
      terminationGracePeriodSeconds: 60
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: public-cms
      containers:
        - name: public-cms
          image: registry.internal/public-cms:2.18.0
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 2
            periodSeconds: 2
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8080
            failureThreshold: 15
            periodSeconds: 2
          resources:
            requests:
              cpu: "1500m"
              memory: "1Gi"
            limits:
              cpu: "3000m"
              memory: "2Gi"

Los probes están tuneados para warm-up rápido: startupProbe da hasta 30 segundos para arrancar (15 * 2s), y el readinessProbe declara listo al pod apenas pasa 3 chequeos seguidos (6 segundos en el mejor caso). Con Karpenter provisionando nodos en 30-60 segundos, el flujo completo desde "Lambda recibe el webhook" hasta "los pods nuevos están serving traffic" queda en 60-90 segundos — cómodo dentro de la ventana editorial.

Fase 4: Observabilidad del Patrón

Un patrón predictivo que no medís es un patrón que no podés debuggear. Instrumentamos todo el flujo con Prometheus para validar que el pre-escalado efectivamente llega antes que el tráfico. Las cuatro métricas clave:

# 1. Tiempo desde que el webhook llegó hasta que los pods nuevos están ready
histogram_quantile(0.95,
  sum(rate(prescale_webhook_to_ready_seconds_bucket[10m])) by (le, service)
)

# 2. Cuántos pods extra levantó el patrón vs baseline
sum(kube_deployment_status_replicas{deployment="public-cms"})
  - on() group_left
sum(kube_deployment_spec_replicas_baseline{deployment="public-cms"})

# 3. RPS real vs capacidad pre-escalada (debería ser <70% en el pico)
sum(rate(nginx_ingress_controller_requests{service="public-cms"}[1m]))
/
(sum(kube_deployment_status_replicas{deployment="public-cms"}) * 200)

# 4. Pods Pending durante un pico (debería ser 0 o casi 0)
sum(kube_pod_status_phase{phase="Pending", namespace="news-frontend"})

La primera es la más importante: si el p95 de prescale_webhook_to_ready_seconds sube por encima de 90 segundos, el patrón rompe su contrato. El p95 típico con esta configuración está entre 50 y 75 segundos, y el p99 rara vez pasa de 100. Sumamos un alert básico:

- alert: PrescalingMissingWindow
  expr: |
    histogram_quantile(0.95,
      sum(rate(prescale_webhook_to_ready_seconds_bucket[10m])) by (le)
    ) > 90
  for: 10m
  labels:
    severity: critical
  annotations:
    summary: "Pre-scaling is slower than editorial review window"
    description: "p95 end-to-end prescaling time exceeded 90s for 10+ minutes. Breaking news traffic may hit unprescaled infra."

Order of Magnitude: Reactivo vs Predictivo

Esta es la comparación que nos importa. Los números son ilustrativos para un cluster de sitio de noticias bajo carga de breaking news, no de un cliente específico:

Métrica Reactivo + overprovisioning Predictivo + KEDA + Karpenter
Utilización promedio fuera de picos 8-15% 40-60%
Tiempo de reacción a un pico 3-5 min 30-90 seg
Capacidad de absorber picos 20x Parcial (hot spots) Completa
Nodos baseline necesarios Alto (para peor caso) Bajo (utilización real)
Costo relativo mensual 1x 0.1x - 0.3x
Downtime esperado en breaking news Minutos de 503s Cero

La reducción de costos más grande no viene del pre-escalado en sí — viene de poder bajar el baseline sin miedo. Cuando sabés que podés pasar de 6 pods a 120 pods en 60 segundos con garantías, dejás de correr 80 pods todo el tiempo "por las dudas". Para clusters que vivían con 8-15% de utilización promedio, una reducción de un orden de magnitud en el gasto de compute es realista sin comprometer disponibilidad durante picos.

Qué Haríamos Diferente

El patrón funciona, pero tiene tradeoffs honestos que vale la pena admitir:

El acoplamiento al CMS es un riesgo operacional. Si el equipo editorial cambia el CMS, si alguien refactorea los estados internos, si la firma HMAC se rota mal — el patrón se rompe silenciosamente. Poner el fallback de Prometheus es obligatorio, no opcional. El día que el webhook no dispare, querés que el sistema siga funcionando aunque peor.

Los editores pueden disparar eventos fantasma. Un editor que marca como breaking news una nota que después decide no publicar te cuesta 30-60 segundos de compute levantado al pedo. En volúmenes bajos es irrelevante; si tu redacción marca breaking news 40 veces por día, empezás a pagar ruido. Vale la pena considerar un segundo filtro — por ejemplo, solo disparar si el flag viene acompañado de una estimación de tráfico alta, o solo para notas con un tag específico.

El cold start del CDN sigue siendo un problema. Pre-escalar el origin está bien, pero si el CDN tiene caché frío para la nota nueva, igual vas a ver una ráfaga de requests al origin durante los primeros 10-20 segundos. Vale la pena combinar el pre-escalado con un warm-up proactivo del CDN — golpear la URL del artículo desde varias regiones apenas aparece el evento, para que CloudFront/Fastly tenga la respuesta cacheada antes de que lleguen los usuarios reales.

Los servicios stateful siguen siendo el cuello de botella. Este patrón funciona impecable para stateless — API, frontend, workers. Para Postgres, Redis, Elasticsearch, no hay pre-escalado mágico. Si tu DB no aguanta un 20x, ningún hook al CMS te va a salvar. El patrón reduce el problema a "la DB y el caché son el bottleneck", que es un problema más limpio y más atacable que "todo el stack es el bottleneck".

Medir la ventana editorial real. Asumimos 30-90 segundos de ventana porque es el rango típico, pero en algunas redacciones ese número es 15 segundos y en otras es 3 minutos. Antes de prometer SLAs basados en este patrón, hay que medir cuánto tarde realmente el flujo editorial en el CMS específico, idealmente con histograma por horario del día — porque a las 3 AM con un solo editor de guardia, la ventana se acorta.


¿Tu infra colapsa cuando una nota explota? ¿Estás overprovisionando el cluster porque los picos son imposibles de predecir? Conocemos este patrón y lo hemos implementado en producción. Hablemos y te mostramos cómo adaptarlo a tu stack editorial.

Preguntas frecuentes

¿Por qué Cluster Autoscaler no alcanza para picos de breaking news?

Tres razones: (1) es lento — necesita que las métricas crucen un umbral, pedir nuevos nodos a EC2, esperar que los nodos se unan al cluster, y recién ahí schedulear pods. Total: 3-5 minutos. (2) Reacciona, no predice — cuando el CPU cruza el 70%, ya estás dropeando requests. (3) Los node groups pre-definidos limitan la flexibilidad de tipos de instancia y la capacidad de Spot. Para tráfico bursty de noticias necesitás escalar ANTES del pico y tener todo listo en 30-60 segundos, que es territorio de Karpenter, no de CA.

¿Funciona con plataformas distintas de Arc Publishing o WordPress VIP?

Sí — cualquier CMS que dispare webhooks de cambio de estado funciona. Lo vimos andando en Arc Publishing, WordPress VIP, Strapi, Contentful, Sanity, Ghost, y CMS propietarios a medida. El patrón también se generaliza a dominios que no son news: ventas de tickets (webhook en 'empieza la presale'), lanzamientos de producto (webhook en 'empieza el flash sale'), streaming deportivo (webhook en 'arranca el partido'). Cualquier cosa con una señal predecible que llegue antes del pico de tráfico.

¿Qué pasa si el webhook dispara pero la nota no se publica?

Gastaste 30-90 segundos de capacidad pre-escalada al pedo — típicamente entre $0.50 y $2.00 según el tamaño del cluster. A volumen bajo no importa. Si tu redacción marca breaking news 40 veces al día y solo publica 10, sí importa — en ese caso agregá un segundo filtro en la Lambda: que dispare solo si el flag viene con atributo de prioridad alta, o solo para notas con un tag editorial específico. Medí la tasa de eventos fantasma y tuneá desde ahí.

¿Cómo aseguramos el endpoint del webhook?

La validación de firma HMAC no es opcional. El endpoint es público — cualquiera que descubra la URL podría disparar eventos falsos cada 10 segundos y prender fuego tu factura de AWS. La Lambda verifica cada request contra un HMAC calculado con un secreto compartido. También rate-limiteá el endpoint (API Gateway o CloudFront) y agregá deduplicación (ignorar eventos para el mismo story_id dentro de una ventana de 60 segundos).

¿Los servicios stateful (bases de datos, caches) escalan igual?

No — los servicios stateful son el cuello de botella. Este patrón funciona limpio para servicios stateless (APIs, frontend, workers). Para PostgreSQL, Redis, Elasticsearch no hay pre-escalado mágico — tenés que dimensionarlos para el pico de antemano, o usar read replicas / modos de cluster. Lo bueno es que el patrón reduce el problema a 'la DB y el cache son el bottleneck', que es mucho más limpio y tratable que 'todo el stack es el bottleneck'.

¿Podés usar este patrón para otros workloads bursty (ventas de tickets, lanzamientos, eventos deportivos)?

Sí — el patrón es general. Necesitás tres cosas: (1) una señal predecible upstream que llegue antes del tráfico (editor marca nota, scheduler de flash sale dispara, webhook de 'empieza el partido'), (2) tiempo suficiente (30+ segundos para que Karpenter provisione nodos), (3) servicios stateless en el path crítico que efectivamente puedan escalar. Si tenés las tres, el mismo patrón CMS-webhook → Lambda → DynamoDB → KEDA → Karpenter aplica. Lo usamos para picos de apuestas deportivas (webhook en 'kickoff'), flash sales de e-commerce, y respuestas a anuncios de rate limits de APIs.