HPA Kubernetes: por qué CPU funciona y memoria casi nunca

HPA Kubernetes: por qué CPU funciona y memoria casi nunca

Hay una configuración que aparece en prácticamente todos los clústeres de Kubernetes: un HorizontalPodAutoscaler con objetivo de 70% de CPU y 70% de memoria. Parece razonable. Sigue los ejemplos de la documentación oficial. Y en muchos casos, genera más problemas de los que resuelve — en silencio.

Los síntomas son predecibles: workloads que no hacen nada se escalan hacia arriba porque su footprint de memoria es naturalmente alto. APIs sensibles a la latencia escalan demasiado tarde porque el pico de CPU ya ha pasado cuando los nuevos pods están listos. Jobs batch oscilan entre escalar hacia arriba y hacia abajo durante operación normal. Y los equipos dedican horas a depurar comportamientos del autoescalado que deberían ser sencillos.

Este artículo explica por qué la configuración por defecto del HPA falla, en qué condiciones exactas el HPA basado en memoria tiene sentido (y cuándo no), y qué métricas alternativas — métricas custom, triggers basados en eventos y señales externas — producen un autoescalado que realmente refleja la demanda del workload.


Cómo decide el HPA escalar: la mecánica real

Antes de diagnosticar los problemas, conviene entender con precisión la mecánica. El HPA calcula el número deseado de réplicas con esta fórmula:

desiredReplicas = ceil(currentReplicas × (currentMetricValue / desiredMetricValue))

Para un objetivo de CPU del 70%, con 2 réplicas consumiendo actualmente una media del 140% de su CPU request, el HPA calcula ceil(2 × (140 / 70)) = 4 réplicas. Conceptualmente simple, pero con una dependencia crítica que la mayoría de configuraciones ignora: el valor de la métrica se expresa en relación al resource request, no al limit.

Esta distinción es fundamental para entender todos los modos de fallo que siguen. Si un contenedor tiene un CPU request de 100m y un limit de 2000m, y actualmente consume 80m, el HPA ve un 80% de utilización — aunque el contenedor esté usando solo el 4% de su techo permitido. Pon un threshold de HPA del 70% sobre un contenedor con CPU request de 100m y cualquier carga no trivial disparará el escalado de inmediato.

El controlador HPA sondea métricas cada 15 segundos por defecto (--horizontal-pod-autoscaler-sync-period). El escalado hacia arriba ocurre rápido — en uno a tres ciclos de sondeo cuando el threshold se supera de forma consistente. El escalado hacia abajo es deliberadamente lento: por defecto el controlador espera 5 minutos (--horizontal-pod-autoscaler-downscale-stabilization) antes de reducir réplicas, para evitar el thrashing. Esta asimetría importa cuando se depura oscilación.


HPA basado en CPU: cuándo funciona y cuándo no

CPU es un recurso compresible. Cuando un contenedor alcanza su limit de CPU, el kernel lo throttlea — el proceso se ralentiza pero no muere ni es desalojado. Esta propiedad convierte a CPU en un proxy razonable para la carga en muchos escenarios, pero no en todos.

Dónde funciona bien el HPA por CPU

Los workloads stateless de procesamiento de peticiones son el punto ideal para el HPA basado en CPU. Si tu servicio hace trabajo CPU-bound por petición — APIs REST que transforman datos, lógica de negocio computacionalmente intensiva, procesamiento de imágenes — entonces la utilización de CPU correlaciona bien con el volumen de peticiones. Más peticiones implica más CPU consumida, lo que hace que el HPA añada réplicas, distribuyendo la carga.

Los prerrequisitos clave para que el HPA por CPU funcione correctamente son:

CPU requests precisos. Ajusta los requests al consumo sostenido real del workload bajo carga normal, no a un placeholder bajo. Usa VPA en modo recomendación o datos históricos de Prometheus para dimensionar bien los requests antes de activar el HPA.

Ratio request/limit razonable. Un ratio de 1:4 o menos mantiene los thresholds en porcentaje significativos. Un contenedor con request 100m y limit 4000m convierte los thresholds porcentuales en algo casi inútil.

Consumo de CPU que sigue la carga de usuarios de forma lineal. Si tu servicio realiza trabajo pesado en background independiente de las peticiones entrantes, la utilización de CPU disparará el escalado independientemente de la demanda real.

Dónde falla el HPA por CPU

Servicios sensibles a la latencia con picos bruscos de tráfico. El HPA reacciona a la utilización media de CPU medida en la ventana de sondeo. Para un servicio que gestiona ráfagas de tráfico — una venta flash, un batch de llamadas API disparado por cron, un broadcast de notificaciones — cuando el controlador HPA detecta el pico, encola nuevos pods y esos pods superan los readiness checks, el pico puede haber terminado ya. El resultado: réplicas añadidas después de que el daño está hecho, con el coste añadido de un ciclo de scale-down posterior.

Workloads I/O-bound. Un servicio que pasa la mayor parte del tiempo esperando a consultas de base de datos, llamadas a APIs externas o lecturas de colas de mensajes mostrará CPU baja incluso bajo carga elevada. El HPA no añadirá réplicas mientras el servicio está degradado — ve CPUs ociosas mientras goroutines o threads están bloqueados esperando I/O.

Workloads con costes de cold-start elevados. Si una nueva réplica tarda 30-60 segundos en arrancar (cargando modelos de ML, estableciendo pools de conexiones, populando caches), las decisiones de escalado deben ocurrir antes — no en reacción al pico de CPU.


HPA basado en memoria: por qué casi siempre se rompe

Memory es un recurso incompresible. A diferencia de CPU — que puede throttlearse sin matar un proceso — cuando un contenedor agota su memory limit, el OOM killer lo termina. Esta propiedad única desencadena un conjunto de problemas fundamentales en el uso de memoria como trigger del HPA.

El problema central: la memoria no correlaciona naturalmente con la carga

Para la mayoría de servicios bien diseñados, el consumo de memoria es relativamente estable. Un servicio en Go asigna memoria al arrancar para sus estructuras de runtime, pools de conexiones y caches — y después mantiene aproximadamente ese footprint independientemente del tráfico. Una aplicación JVM asigna heap al arrancar y usa el garbage collector para gestionarlo. En ambos casos, el uso de memoria bajo 10 peticiones por segundo y bajo 10.000 peticiones por segundo puede ser prácticamente idéntico.

Esto significa que un HPA basado en memoria con un threshold del 70% hará una de dos cosas:

  • Nunca dispararse, porque el consumo de memoria del workload es estable y siempre está por debajo del threshold — dejando el HPA inútil.
  • Dispararse siempre, porque el consumo de memoria base del workload supera naturalmente el threshold — causando que el workload escale de forma permanente y nunca vuelva a bajar.

Ninguno de estos resultados corresponde a una necesidad real de escalado.

La trampa de la mala configuración de requests

Este es el modo de fallo más común y la causa más frecuente del «mi workload se escala sin motivo aparente». Considera un servicio Java que necesita 512Mi de heap para funcionar con normalidad. El equipo establece el memory request en 256Mi — demasiado conservador, ya sea para ahorrar costes o porque la estimación inicial era incorrecta. El servicio consume inmediatamente el 200% de su memory request con solo estar en ejecución. Un HPA con un objetivo de memoria del 70% escalará este workload hasta el máximo de réplicas a los pocos minutos del despliegue, y se quedará ahí para siempre.

La solución nunca es «ajustar el threshold del HPA». La solución es dimensionar correctamente el memory request. Pero esto revela el problema más profundo: el HPA basado en memoria es extremadamente sensible a la precisión de tus resource requests, y la mayoría de equipos no tienen requests precisos — especialmente en workloads nuevos o tras cambios de código que alteran el footprint de memoria.

Comportamiento de memoria en JVM y Go

Los workloads JVM son especialmente problemáticos. Por defecto, la JVM asigna heap hasta un máximo (-Xmx) y después lo retiene — no libera heap de vuelta al SO de forma agresiva, ni siquiera después del garbage collection. Un servicio JVM que gestiona una petición por hora mostrará prácticamente el mismo footprint de memoria que uno gestionando miles de peticiones por minuto. Además, el garbage collector de la JVM introduce picos de memoria durante los ciclos de recolección que no están relacionados con la carga.

En entornos JVM contenerizados, también hay que tener en cuenta el flag de awareness del memory limit del contenedor (-XX:+UseContainerSupport, activado por defecto desde JDK 11), que afecta a cómo la JVM calcula su techo de heap en relación con el container limit. Sin el tuning adecuado, la JVM puede asignar un heap que ocupa el 80-90% del memory limit del contenedor — disparando de inmediato cualquier HPA basado en memoria.

Los workloads en Go se comportan de forma diferente pero también mal con el HPA de memoria. El garbage collector de Go está diseñado para mantener baja latencia más que uso mínimo de memoria. El runtime puede retener memoria por encima de lo estrictamente necesario, y el footprint de memoria puede variar según los parámetros de tuning del GC (GOGC, GOMEMLIMIT) de maneras que no correlacionan con la carga de peticiones entrantes.

Cuándo el HPA de memoria sí tiene sentido

Hay casos concretos donde el HPA basado en memoria tiene justificación:

Workloads donde el consumo de memoria realmente sigue la carga de forma lineal. Algunos pipelines de procesamiento de datos, caches en memoria que crecen con el volumen de peticiones, o aplicaciones de streaming que buferizan datos proporcionalmente al throughput. Si puedes demostrar mediante métricas que memoria y carga tienen una correlación lineal fuerte, el HPA de memoria es defendible.

Como válvula de seguridad junto al HPA por CPU. Usar memoria como métrica secundaria (no primaria) para protegerse ante memory leaks o allocaciones descontroladas en un servicio que normalmente escala por CPU. En este caso, establece el threshold de memoria alto — 85-90% — de forma que solo se active en escenarios de sobreconsumo real.

Servicios de caché donde no es deseable el desalojo. Si un servicio usa memoria como caché de rendimiento y quieres escalar antes de que la presión de memoria cause desalojo de caché, la utilización de memoria puede ser un trigger útil — siempre que los requests estén correctamente dimensionados.

Fuera de estos casos específicos, eliminar memoria del spec del HPA y apoyarse en las señales que se describen más adelante producirá mejor comportamiento en prácticamente todos los escenarios.


Dimensionar correctamente los requests antes de añadir HPA

Ninguna estrategia de HPA funciona correctamente sin resource requests precisos. Antes de añadir cualquier autoescalador — CPU, memoria o métricas custom — ejecuta tu workload bajo carga representativa y mide el consumo real. La forma más sencilla es usar VPA en modo recomendación:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: my-service-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-service
  updatePolicy:
    updateMode: "Off"   # Solo recomendación — no aplicar automáticamente

Tras 24-48 horas de tráfico, comprueba las recomendaciones del VPA:

kubectl describe vpa my-service-vpa

Los valores lowerBound, target y upperBound te dan una base fundamentada en datos para establecer los requests. Establece tus requests en o cerca del valor target del VPA antes de configurar el HPA. Este único paso elimina la causa más común de mal comportamiento del HPA.

Restricción crítica: VPA y HPA no pueden gestionar la misma métrica de recurso simultáneamente. Si el VPA está configurado para actualizar automáticamente CPU o memoria, y el HPA también escala sobre esas métricas, los dos controladores se pelearán entre sí. La combinación segura es: HPA sobre CPU/memoria con VPA en modo solo-recomendación, o HPA sobre métricas custom con VPA sobre CPU/memoria en modo automático.


Mejores señales: qué usar en lugar de memoria (y cuándo ir más allá de CPU)

El cambio fundamental es pasar de métricas de consumo de recursos (que describen el pasado) a métricas de demanda (que describen lo que el workload está siendo pedido hacer ahora mismo o será pedido en segundos).

Peticiones por segundo (RPS)

Para servicios HTTP, las peticiones por segundo por réplica suelen ser el proxy más preciso para la carga. A diferencia de CPU, mide la demanda directamente — no un efecto secundario de la demanda. Un HPA que mantiene 500 RPS por réplica escalará de forma predecible a medida que el tráfico crece, independientemente de si el servicio es CPU-bound, memory-bound o I/O-bound.

RPS está disponible como métrica custom desde tu service mesh (Istio lo expone como istio_requests_total), desde tu ingress controller (NGINX expone tasas de peticiones vía Prometheus), o desde las propias métricas Prometheus de tu aplicación. Configurar el HPA sobre métricas custom requiere el Prometheus Adapter o una implementación compatible de la custom metrics API.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-service
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "500"

Profundidad de cola y consumer lag

Los consumidores de colas de mensajes deben escalar en función del consumer lag — el backlog esperando ser procesado. Esto mide directamente si los consumidores están al día o si la cola crece más rápido de lo que se puede procesar. KEDA proporciona scalers integrados para Kafka, RabbitMQ, SQS y muchos otros sistemas.

Este enfoque es superior a CPU para consumers de mensajería porque un consumer puede estar idle (CPU baja) mientras la cola acumula millones de mensajes. Sin el lag como señal de escalado, el HPA no haría nada — exactamente lo contrario de lo que necesitas.

Latencia (P99)

La latencia P99 por réplica señala eficazmente la sobrecarga cuando se aproxima a los umbrales de SLO, especialmente para APIs de cara al usuario. Si la latencia sube, hay un problema — independientemente de lo que diga la CPU. Usar latencia como señal de escalado es especialmente útil cuando el workload tiene comportamientos de rendimiento no lineales bajo carga.

Escalado planificado y predictivo

Para patrones predecibles (horario laboral, ciclos semanales, eventos programados), el Cron scaler de KEDA permite pre-escalar de forma proactiva antes de que la carga aumente. Esto elimina por completo el problema del cold-start en los picos de tráfico esperados: en lugar de reaccionar a la carga, te anticipas a ella.


Configuración de HPA para producción: lo que realmente importa

Mínimo de réplicas

Establece siempre minReplicas: 2. Un único pod es un single point of failure; si el HPA escala hacia abajo a 1 réplica y ese pod es terminado durante un scale-down, tu servicio tiene un instante de downtime. Dos réplicas mínimas también garantizan que el HPA tenga datos de consumo medio sobre los que calcular.

Ventanas de estabilización

La ventana de scale-down por defecto de 5 minutos suele provocar oscilación en muchos workloads. Auméntala para que coincida con los ciclos del workload. Esta configuración es clave en producción:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: my-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-service
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 600
      policies:
      - type: Percent
        value: 25
        periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
      - type: Percent
        value: 100
        periodSeconds: 30

Aquí se definen dos comportamientos distintos:

  • Scale-down conservador: ventana de estabilización de 10 minutos, máximo 25% de reducción por minuto. Evita que una caída temporal de carga destruya réplicas que volverán a necesitarse.
  • Scale-up agresivo: sin ventana de estabilización, hasta el doble de réplicas cada 30 segundos. El coste de escalar de más es menor que el de responder tarde.

Ajuste del threshold de CPU

Considera el tiempo de reacción al escalar. Un threshold del 70% suena razonable, pero implica que ya estás sobrecargado cuando el HPA comienza a reaccionar. Establece objetivos al 50-60% para mantener margen mientras arrancan los nuevos pods — especialmente si tu tiempo de cold-start es de más de unos pocos segundos.

PodDisruptionBudgets

Empareja siempre el HPA con un PodDisruptionBudget para evitar que el scale-down termine múltiples pods simultáneamente:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-service-pdb
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: my-service

Sin PDB, Kubernetes puede terminar varios pods al mismo tiempo durante el scale-down, causando degradación de servicio en el proceso de optimización de costes.


KEDA: escalado basado en eventos sin reemplazar el HPA

KEDA (Kubernetes Event-Driven Autoscaling) extiende el HPA sin reemplazarlo. En lugar de competir con él, KEDA implementa una custom metrics API que el HPA consume. No añades un nuevo controlador de escalado — añades una fuente de métricas más rica al sistema existente.

KEDA gestiona más de 60 scalers integrados para sistemas como Kafka, RabbitMQ, Azure Service Bus, AWS SQS, y también permite escalado basado en consultas Prometheus arbitrarias. Esto lo convierte en la herramienta de elección para:

  • Consumers de colas de mensajes: escala a cero cuando no hay mensajes, escala agresivamente cuando el lag crece.
  • Workloads event-driven: triggers basados en métricas externas que el HPA estándar no puede alcanzar.
  • Patrones de tráfico predecibles: el Cron scaler permite programar réplicas mínimas por franja horaria.

Un ejemplo de ScaledObject de KEDA para un consumer de Kafka:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-consumer-scaler
spec:
  scaleTargetRef:
    name: my-kafka-consumer
  minReplicaCount: 1
  maxReplicaCount: 30
  triggers:
  - type: kafka
    metadata:
      bootstrapServers: kafka-broker:9092
      consumerGroup: my-consumer-group
      topic: my-topic
      lagThreshold: "100"

Esto escala el consumer manteniendo un lag máximo de 100 mensajes por réplica. Cuando la cola crece, KEDA añade réplicas. Cuando está vacía, puede bajar a cero — algo imposible con el HPA estándar basado solo en CPU.


Tabla de decisión: qué señal usar según el workload

Tipo de workloadSeñal recomendadaHerramienta
API HTTP stateless (CPU-bound)CPU al 50-60%HPA
API HTTP stateless (I/O-bound)RPS por réplica o latencia P99HPA + custom metrics
Consumer de cola de mensajesConsumer lag / profundidad de colaKEDA
Event-driven (Kafka/SQS/RabbitMQ)Tasa de eventos o lagKEDA
Patrón de tráfico predeciblePlanificación horariaKEDA Cron
Riesgo de memory leakCPU primario + memoria al 85% secundarioHPA v2 multi-metric
Dimensionado previo al HPARecomendaciones históricas CPU/memoriaVPA en modo recomendación

Preguntas frecuentes sobre el autoescalado horizontal en Kubernetes

¿Qué ocurre si defino múltiples métricas en un HPA?

El HPA evalúa todas las métricas definidas y escala para satisfacer la más exigente. Si CPU dice 3 réplicas y RPS dice 5, el HPA usará 5. Nunca se toma el mínimo — siempre el máximo — lo que puede causar sorpresas si no se entiende esta semántica.

Mi workload se escala hacia arriba inmediatamente después del despliegue. ¿Por qué?

Esto casi siempre indica una mala configuración de requests. Usa kubectl top pods para ver el consumo real y compáralo con los requests definidos. Si el consumo supera el request, el HPA verá utilización superior al 100% y escalará agresivamente. La solución es ajustar los requests, no el threshold del HPA.

El HPA escala hacia abajo demasiado rápido y luego vuelve a escalar. ¿Cómo lo soluciono?

Aumenta stabilizationWindowSeconds en la sección scaleDown del behavior. También considera añadir una política de porcentaje (type: Percent) para limitar cuántas réplicas se eliminan por ciclo. El objetivo es que el scale-down sea conservador y el scale-up sea ágil.

¿Puedo usar HPA con StatefulSets?

Técnicamente sí, pero generalmente no deberías. Los StatefulSets implican identidad estable de pod, almacenamiento persistente y orden de escala — propiedades que chocan con el escalado dinámico del HPA. Para workloads stateful, el escalado manual suele ser más seguro.

Mi CPU request es de 50m. ¿Tiene sentido usar HPA por CPU?

Con requests muy bajos, las métricas en porcentaje se vuelven muy granulares — un cambio de 10m en consumo supone un 20% de cambio en utilización. El escalado se vuelve ruidoso. Considera usar métricas custom como RPS en su lugar, o al menos aumentar el request a un valor más representativo.

¿Cómo depuro el HPA cuando no se comporta como espero?

kubectl describe hpa <nombre>

Este comando muestra las métricas actuales, los eventos de escalado recientes y la razón por la que el HPA está o no está escalando. Es el primer sitio donde mirar. Para más detalle, los logs del controlador kube-controller-manager contienen información de depuración sobre las decisiones del HPA.


Conclusión

La configuración por defecto del HPA — CPU al 70%, memoria al 70% — falla de formas predecibles en la mayoría de workloads de producción. La memoria casi nunca es una señal de escalado útil porque no correlaciona con la carga en la mayoría de arquitecturas. CPU funciona bien para servicios CPU-bound con requests bien dimensionados, pero falla en escenarios I/O-bound, picos bruscos y cold-starts lentos.

El autoescalado horizontal en Kubernetes que realmente funciona en producción requiere tres cosas:

  1. Resource requests precisos — sin esto, cualquier estrategia de HPA es aleatoria.
  2. La señal correcta para el tipo de workload — CPU para servicios CPU-bound, RPS/latencia para I/O-bound, consumer lag para colas.
  3. Configuración de comportamiento explícita — ventanas de estabilización, políticas de porcentaje, PDBs.

El resto es ruido. Empieza por los requests, elige la señal adecuada para tu patrón de carga y configura el comportamiento de scale-down de forma conservadora. El autoescalado eficaz no es magia — es ingeniería aplicada con las métricas correctas.