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.

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ón | Efecto en el HPA |
|---|---|
Sin requests.cpu | targetCPUUtilizationPercentage 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.

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: 2y 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: 65en lugar del valor por defecto de 80. El tiempo de reacción completo —recolección de métricas (15 s por defecto enmetrics-server), bucle del HPA (15 s), planificación, arranque del pod— se cuenta en decenas de segundos. Al 80 % de larequests, el margen se consume antes de que lleguen los nuevos pods.scaleUp.stabilizationWindowSeconds: 0: subir rápido.scaleDowna 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
ScaledObjectque 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.

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.cpuestá 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 unalimits.memoryalineada con--max-old-space-size. startupProbeindependiente, ruta de salud sin dependencia de la base de datos,preStop: sleep 10yterminationGracePeriodSeconds: 40para eliminar los 502 en el scale-down.- HPA
autoscaling/v2al 65 % de utilización,minReplicas: 2, subida inmediata y bajada en 300 s mediantebehavior. PodDisruptionBudgetytopologySpreadConstraintssiempre, 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
maxReplicasajustado 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.


