Autoescalado de Next.js en Kubernetes: HPA y KEDA

Ajustar el autoescalado de una app Next.js en Kubernetes: requests de CPU realistas, HPA, KEDA, PodDisruptionBudget y costes medidos en OVHcloud.

Primer plano de servidores apilados en un rack dentro de un centro de datos, con luces verdes encendidas

Respuesta corta: para que una aplicación Next.js escale correctamente en Kubernetes, el HPA no es más que la última pieza. Primero hay que fijar unas requests de CPU medidas (normalmente entre 100 y 250 m por pod para renderizado de servidor ligero), una limit de memoria sin limit de CPU, probes readiness/startup separadas, un terminationGracePeriodSeconds coherente con el drenaje del ingress y un PodDisruptionBudget. Solo después llega el HorizontalPodAutoscaler sobre CPU al 60-70 %, complementado con KEDA cuando la carga es una cola de mensajes o un tráfico planificado. Sin este trabajo previo, el HPA oscila, duplica la factura y no corrige nada.

Con frecuencia nos encontramos con clústeres en los que el HPA existe desde hace meses sin haber servido nunca para nada. O muestra <unknown>/70% porque los pods no tienen requests, o sube a maxReplicas en cuanto se compila una imagen y baja veinte minutos después, o escala mientras los usuarios reciben errores 502 porque los pods antiguos se eliminan antes de retirarse del backend del ingress. Este artículo describe el ajuste completo, en el orden en el que hay que hacerlo.

Primer plano de servidores apilados en un rack dentro de un centro de datos, con luces verdes encendidas

Por qué el HPA no funciona mientras las requests estén mal

El HorizontalPodAutoscaler en modo Resource no razona en CPU absoluta. Calcula una proporción: consumo observado dividido por la requests declarada del contenedor. La documentación de Kubernetes sobre el HPA es explícita: sin resources.requests.cpu, la métrica de utilización no está disponible y el controlador no decide nada.

Las consecuencias prácticas se resumen en tres casos que se ven por todas partes:

SituaciónEfecto en el HPA
Sin requests.cputargetCPUUtilizationPercentage inservible, HPA inerte
requests.cpu demasiado alta (1 CPU «por si acaso»)utilización real del 8 %, el HPA nunca escala y cada pod reserva un núcleo en el nodo
requests.cpu demasiado baja (10 m)utilización del 400 %, HPA en maxReplicas de forma permanente

El primer paso es, por tanto, una medición, no una estimación. En un despliegue existente, con metrics-server instalado:

# consumo instantáneo por pod
kubectl top pod -n prod -l app=web --containers

# a lo largo de un periodo, si tienes Prometheus
# rate(container_cpu_usage_seconds_total{namespace="prod",container="web"}[5m])

Para una aplicación Next.js con output: 'standalone' que sirve mayoritariamente páginas prerenderizadas y algunas rutas dinámicas, medimos habitualmente entre 40 y 120 m de CPU por pod en reposo con tráfico moderado, con picos breves de 400-600 m en renderizado de servidor o revalidación. De ahí el ajuste que aplicamos por defecto:

resources:
  requests:
    cpu: 150m
    memory: 256Mi
  limits:
    memory: 512Mi

Dos decisiones merecen una justificación.

Sin limits.cpu. La CPU es un recurso comprimible: por encima de la requests, el pod toma lo que haya libre en el nodo. Poner una limit activa el throttling del CFS (cpu.max en cgroup v2) y, en un runtime Node.js monohilo que hace picos cortos, el throttling se traduce directamente en latencia p95. El fenómeno está documentado desde hace años y la corrección del burst del CFS no está habilitada en todas partes. Dejamos la CPU sin techo y protegemos el nodo mediante las requests de las demás cargas.

Una limits.memory, en cambio, sí es necesaria. La memoria es incomprimible: sin techo, una fuga en un pod lleva al nodo a presión de memoria y desencadena desalojos en otros sitios. Con Node.js 22, recuerda alinear el heap: NODE_OPTIONS=--max-old-space-size=384 en un contenedor limitado a 512 Mi evita el OOMKill brutal a favor de un error de JavaScript legible.

Comprobar la clase de QoS

kubectl get pod -n prod -l app=web \
  -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass

Con requests.memory igual a limits.memory pero sin limits.cpu, obtienes Burstable. Es el equilibrio correcto: el pod no es el primer candidato al desalojo, y no reserva un núcleo entero.

Probes: la condición para que el escalado no rompa nada

Un HPA que crea tres pods mientras el ingress envía tráfico a pods que todavía no están listos no mejora nada. El trío de probes que hay que distinguir:

startupProbe:
  httpGet: { path: /api/health, port: 3000 }
  periodSeconds: 2
  failureThreshold: 30      # 60 s para arrancar, luego fallo
readinessProbe:
  httpGet: { path: /api/health, port: 3000 }
  periodSeconds: 5
  failureThreshold: 2
livenessProbe:
  httpGet: { path: /api/health, port: 3000 }
  periodSeconds: 10
  failureThreshold: 3

La startupProbe es la que más se olvida. Suspende liveness y readiness durante el arranque, lo que evita el círculo vicioso clásico: un nodo cargado, un arranque lento, una livenessProbe que mata el pod, un reinicio y una carga aún mayor. Un servidor Next.js en modo standalone arranca normalmente en 1 a 3 segundos, pero un pod que espera su volumen o una conexión a PostgreSQL puede superar los 20 segundos.

En cuanto a la ruta de salud, manténla trivial y sin dependencias externas:

// app/api/health/route.ts
export const dynamic = 'force-dynamic'

export function GET() {
  return new Response('ok', {
    status: 200,
    headers: { 'cache-control': 'no-store' },
  })
}

Una readinessProbe que consulta la base de datos convierte un incidente de PostgreSQL en una indisponibilidad total del front: todos los pods pasan a NotReady al mismo tiempo y el ingress se queda sin ningún backend. Si quieres comprobar la base de datos, hazlo en una ruta distinta consumida por la monitorización, no por el kubelet. El mismo razonamiento se aplica a las bases de datos que describimos en nuestra comparativa PostgreSQL en Kubernetes o base de datos gestionada.

El apagado limpio, a menudo la verdadera causa de los 502

Cuando el HPA reduce el número de réplicas, o cuando un rolling update reemplaza los pods, Kubernetes envía SIGTERM y retira la IP del pod de los Endpoints: ambas cosas en paralelo, no en orden. El ingress (NGINX, Traefik, Kong) puede seguir enrutando hacia el pod durante unos segundos.

terminationGracePeriodSeconds: 40
lifecycle:
  preStop:
    exec:
      command: ["sleep", "10"]

El preStop de 10 segundos da tiempo a la propagación de los Endpoints. En el lado de la aplicación, hay que cerrar el servidor HTTP al recibir SIGTERM en lugar de dejar que el proceso muera en medio de una petición:

// server.ts (custom server) o wrapper alrededor de server.js standalone
const shutdown = () => {
  server.close(() => process.exit(0))
  setTimeout(() => process.exit(1), 25_000).unref()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)

Si despliegas con el pipeline descrito en nuestro artículo sobre CI/CD con GitLab para Next.js en Kubernetes, estos campos viven en el mismo chart de Helm que el HPA: deben evolucionar juntos.

Pasillo de racks de servidores en un centro de datos iluminado en azul, con la etiqueta «NETWORK-2» y luces verdes

El manifiesto HPA que desplegamos

La API autoscaling/v2 es estable desde Kubernetes 1.23 y aporta el campo decisivo: behavior.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
        - type: Pods
          value: 4
          periodSeconds: 30
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60

Los parámetros que importan:

  • minReplicas: 2 y nunca 1. Con una sola réplica, cualquier desalojo, cualquier drenaje de nodo o cualquier actualización corta el servicio. Dos réplicas en dos nodos distintos son el mínimo para un sitio en producción.
  • averageUtilization: 65 en lugar del valor por defecto de 80. El tiempo de reacción completo —recolección de métricas (15 s por defecto en metrics-server), bucle del HPA (15 s), planificación, arranque del pod— se cuenta en decenas de segundos. Al 80 % de la requests, el margen se consume antes de que lleguen los nuevos pods.
  • scaleUp.stabilizationWindowSeconds: 0: subir rápido. scaleDown a 300 s: bajar despacio. La asimetría es lo esencial del ajuste: es ella la que elimina el vaivén (flapping) que se ve en los HPA dejados por defecto.

Para comprobar que todo está conectado:

kubectl get hpa web -n prod
# NAME  REFERENCE        TARGETS         MINPODS  MAXPODS  REPLICAS
# web   Deployment/web   cpu: 41%/65%    2        10       3

kubectl describe hpa web -n prod | tail -20

Si TARGETS muestra <unknown>/65%, el problema está más arriba: metrics-server ausente, o requests.cpu que falta. Vuelve a la primera sección.

El PodDisruptionBudget, inseparable

Sin PDB, un kubectl drain durante una actualización de nodo puede evacuar todas las réplicas al mismo tiempo.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web
  namespace: prod
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: web

Con minReplicas: 2, minAvailable: 1 es suficiente. Por encima de 4 réplicas, pasamos a maxUnavailable: 25%. Añade también un topologySpreadConstraints sobre kubernetes.io/hostname con whenUnsatisfiable: ScheduleAnyway, para que las réplicas no acaben todas en el mismo nodo.

Cuando la CPU ya no basta: KEDA

La CPU es un buen indicador indirecto para el renderizado de servidor. Es inútil para tres cargas habituales.

Los workers de cola. Un worker que consume una cola de Redis o RabbitMQ se pasa el tiempo esperando: su CPU se queda al 5 % mientras 8.000 mensajes se acumulan. KEDA (proyecto graduado de la CNCF desde agosto de 2023) resuelve exactamente este caso exponiendo scalers externos.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker-emails
  namespace: prod
spec:
  scaleTargetRef:
    name: worker-emails
  minReplicaCount: 0
  maxReplicaCount: 20
  pollingInterval: 15
  cooldownPeriod: 120
  triggers:
    - type: redis
      metadata:
        addressFromEnv: REDIS_ADDRESS
        listName: queue:emails
        listLength: "50"

Un pod por cada tramo de 50 mensajes en espera, y minReplicaCount: 0 —KEDA sabe bajar a cero, algo que el HPA por sí solo no hace—. En la práctica, KEDA crea y gobierna un HPA por debajo; tú solo gestionas el ScaledObject.

El tráfico previsible. Una web de pyme cuya audiencia es francesa y europea no recibe casi nada entre la 1 y las 6 de la mañana. El scaler cron evita pagar réplicas inútiles:

  triggers:
    - type: cron
      metadata:
        timezone: Europe/Paris
        start: "45 7 * * 1-5"
        end: "30 20 * * 1-5"
        desiredReplicas: "4"

Las métricas de negocio. Peticiones por segundo, longitud de la cola HTTP, latencia p95: el scaler prometheus conecta cualquier consulta PromQL al autoescalado. A menudo es más pertinente que la CPU para Next.js, donde una petición intensiva en E/S (llamada a una API, base de datos) bloquea un slot sin consumir ciclos.

    - type: prometheus
      metadata:
        serverAddress: http://prometheus-server.monitoring:80
        query: sum(rate(http_requests_total{service="web"}[2m]))
        threshold: "60"

Regla que aplicamos: un solo autoescalador por Deployment. Un HPA y un ScaledObject que apuntan al mismo objetivo se pelean y producen ciclos de creación/eliminación. Si pasas a KEDA, elimina el HPA que habías escrito a mano.

Servidor en rack iluminado en azul con luces verdes y naranjas en un centro de datos

Lo que cuesta realmente

El autoescalado horizontal solo reduce la factura si los nodos acompañan. Si no, estarás pagando permanentemente la capacidad del pico. Dos palancas:

  • Autoescalado de nodos. OVHcloud Managed Kubernetes y Scaleway Kapsule ofrecen ambos pools de tamaño variable, con un mínimo y un máximo por pool. El tiempo de incorporación de un nodo se cuenta en minutos, no en segundos: mantén un margen de pods ya calientes.
  • Pods «pause» de sobreaprovisionamiento. Un Deployment de pods con prioridad negativa que reservan sitio y son expulsados en cuanto llega un pod real. Coste: una fracción de nodo, a cambio de ganar varios minutos en la subida de carga.

Orden de magnitud en una configuración que explotamos: tres nodos de 4 vCPU / 8 GB en pool fijo, más un pool elástico de 0 a 3 nodos idénticos. Con requests de 150 m, un nodo de 4 vCPU alberga teóricamente una veintena de pods web, en la práctica una docena una vez descontados el sistema, el agente CSI y la monitorización. Las tarifas públicas de OVHcloud y de Scaleway cambian: consulta las tablas del día en sus páginas de ofertas gestionadas, y cuenta el almacenamiento y el ancho de banda de salida por separado. Detallamos este enfoque en nuestra página de servicios de infraestructura.

La trampa de facturación más frecuente sigue siendo la misma: un maxReplicas a 50 puesto «por si acaso», un bucle de revalidación que se desmadra y una noche con 40 réplicas en un pool elástico. Fija maxReplicas en lo que tolere tu presupuesto, no en lo que pueda absorber el clúster, y pon una alerta sobre kube_horizontalpodautoscaler_status_current_replicas cerca del máximo.

Preguntas frecuentes

¿Hay que poner un límite de CPU en un pod Next.js?

No en la gran mayoría de los casos. El throttling del CFS degrada la latencia p95 en un runtime Node.js que hace picos cortos, y la requests basta para garantizar la equidad entre pods. La excepción: un clúster compartido entre varios clientes donde un ResourceQuota o una política interna impone una limit. En ese caso, ponla al menos 4 veces por encima de la requests y vigila container_cpu_cfs_throttled_seconds_total.

¿Por qué mi HPA muestra <unknown>?

Tres causas, por orden de frecuencia: metrics-server no está instalado o no responde (kubectl top node falla), el contenedor no tiene resources.requests.cpu, o el scaleTargetRef apunta a un recurso que no existe. kubectl describe hpa da el motivo exacto en las condiciones ScalingActive y AbleToScale.

¿HPA o VPA?

El VerticalPodAutoscaler ajusta las requests de un pod; el HPA ajusta el número de pods. Los dos sobre la misma métrica entran en conflicto. Para un front web sin estado, el HPA es la herramienta adecuada. El VPA resulta útil solo en modo recommender, para medir las requests correctas sin aplicar nada automáticamente: así es como lo usamos durante las dos primeras semanas de un nuevo despliegue.

¿Y la caché ISR de Next.js cuando los pods se multiplican?

Ese es el verdadero punto de atención. En modo standalone, la caché de revalidación incremental se almacena en el sistema de archivos del pod: diez réplicas, diez cachés, diez regeneraciones de la misma página. Hace falta una caché compartida mediante un CacheHandler personalizado (Redis, S3) o aceptar la duplicación. Sin eso, el autoescalado multiplica la carga sobre la base de datos en lugar de repartirla.

¿Cómo probar que el autoescalado funciona?

Con un generador de carga, no en producción un viernes. k6, vegeta o hey lanzados desde un pod del clúster, sobre una ruta dinámica representativa, durante 10 minutos, y un kubectl get hpa -w en paralelo. Validas tres cosas: que el HPA escala, que los nuevos pods pasan a Ready en el tiempo esperado y que el scale-down no genera errores en el cliente. El mismo protocolo sirve para verificar las restauraciones, como en nuestra guía sobre copias de seguridad de Kubernetes con Velero.

En resumen

  • El HPA solo funciona si resources.requests.cpu está medida, no estimada: de 100 a 250 m para un pod Next.js standalone típico.
  • Sin limits.cpu (throttling del CFS y latencia p95), pero con una limits.memory alineada con --max-old-space-size.
  • startupProbe independiente, ruta de salud sin dependencia de la base de datos, preStop: sleep 10 y terminationGracePeriodSeconds: 40 para eliminar los 502 en el scale-down.
  • HPA autoscaling/v2 al 65 % de utilización, minReplicas: 2, subida inmediata y bajada en 300 s mediante behavior.
  • PodDisruptionBudget y topologySpreadConstraints siempre, porque si no un drenaje de nodo anula todo el beneficio.
  • KEDA para las colas (escalado a cero), el tráfico planificado (cron) y las métricas de Prometheus, pero un solo autoescalador por Deployment.
  • Prever el autoescalado de los nodos y un maxReplicas ajustado al presupuesto, no a la capacidad del clúster.
  • En el lado de Next.js, una caché ISR compartida es imprescindible en cuanto el número de réplicas varía.

¿Preparas un pico de tráfico o detectas errores 502 en cada despliegue? Cuéntanos cómo es tu clúster a través de la página de contacto: auditamos las requests, las probes y el HPA antes de tocar la infraestructura.