Email copiado — support@tuurt.com
Cargando experiencia
kubernetes · 31 de agosto de 2026 · 7 min

Horizontal Pod Autoscaler con métricas personalizadas: por qué el CPU no siempre es la señal correcta para escalar

El CPU bajo no significa poco trabajo: puede significar que el pod está esperando una respuesta externa mientras la cola crece. Cómo conectar Prometheus Adapter al HPA para escalar sobre la señal correcta.

Por Equipo Tuurt

Horizontal Pod Autoscaler con métricas personalizadas: por qué el CPU no siempre es la señal correcta para escalar

El Horizontal Pod Autoscaler por defecto escala en función de CPU o memoria, leídas a través de metrics-server. Para un servicio que procesa cada request de forma síncrona y cuyo trabajo es mayormente cómputo, esa señal es razonable: más tráfico significa más CPU consumida, y escalar en CPU escala en la dirección correcta. El problema aparece cuando el cuello de botella real del servicio no tiene relación directa con cuánta CPU consume cada pod.

Nos topamos con esto en un worker que consume mensajes de una cola y llama a un servicio de terceros por cada mensaje. El worker pasa la mayor parte del tiempo esperando la respuesta de ese servicio externo, no calculando nada. Cuando el proveedor externo se pone lento, la cola empieza a acumular mensajes sin procesar, pero cada pod individual sigue reportando un uso de CPU bajo porque literalmente está esperando, no trabajando. El HPA, mirando solo CPU, no tenía ninguna razón para escalar. El resultado era una cola creciendo sin control mientras el autoscaler se quedaba tranquilo viendo un gráfico de CPU plano.

Por qué metrics-server no alcanza

metrics-server expone únicamente CPU y memoria, agregadas desde kubelet vía cAdvisor. No tiene forma de saber cuántos mensajes hay en una cola de RabbitMQ, cuál es la latencia p99 de un endpoint downstream, ni cuántas conexiones activas mantiene un pod. Esas señales existen, pero viven en otro lado: en Prometheus, si la aplicación expone un endpoint /metrics, o en el propio sistema de colas.

El HPA, sin embargo, no habla directamente con Prometheus. Habla con tres APIs de Kubernetes: metrics.k8s.io para recursos (lo que cubre metrics-server), custom.metrics.k8s.io para métricas asociadas a objetos de Kubernetes (pods, servicios), y external.metrics.k8s.io para métricas que no están atadas a ningún objeto del clúster, como el tamaño de una cola externa. Prometheus Adapter es el componente que traduce consultas PromQL en respuestas que esas dos últimas APIs pueden devolver, para que el HPA las consuma como si fueran una métrica de recurso más.

La arquitectura completa

El flujo queda así: la aplicación expone métricas en formato Prometheus (o un exporter las expone por ella, como el exporter de RabbitMQ), Prometheus las scrapea con su intervalo normal, Prometheus Adapter consulta Prometheus con una regla de PromQL cada vez que el HPA pregunta por esa métrica, y el HPA decide cuántas réplicas hacer falta en base a esa respuesta.

Esto significa que hay dos piezas nuevas que mantener: la configuración de reglas de Prometheus Adapter, que define qué consulta PromQL corresponde a cada nombre de métrica que el HPA va a pedir, y el propio manifiesto de HPA, que ya no apunta a cpu sino a una métrica external o custom con ese nombre.

Configurando Prometheus Adapter

La configuración vive en un ConfigMap que Prometheus Adapter lee al iniciar. Para exponer el largo de una cola de RabbitMQ como métrica externa:

apiVersion: v1
kind: ConfigMap
metadata:
  name: adapter-config
  namespace: monitoring
data:
  config.yaml: |
    rules: []
    externalRules:
      - seriesQuery: 'rabbitmq_queue_messages_ready{queue="pedidos-pendientes"}'
        resources:
          overrides:
            namespace:
              resource: "namespace"
        name:
          matches: "rabbitmq_queue_messages_ready"
          as: "pedidos_pendientes_en_cola"
        metricsQuery: 'avg(rabbitmq_queue_messages_ready{queue="pedidos-pendientes"})'

El campo metricsQuery es lo que de verdad importa: es la consulta PromQL que Prometheus Adapter ejecuta cada vez que el HPA pide esta métrica. Usamos avg en lugar del valor crudo a propósito, y esa no fue la primera versión que probamos.

Lo que no funcionó: queue depth sin suavizar

La primera versión de esta regla usaba el valor instantáneo de rabbitmq_queue_messages_ready sin ningún agregado. El HPA consultaba esa métrica cada 15 segundos -el intervalo de sincronización por defecto del controlador- y escalaba hacia arriba o hacia abajo según ese valor puntual. El resultado fue un autoscaler nervioso: la cola subía y bajaba en ráfagas cortas a medida que llegaban lotes de mensajes y se procesaban rápido, y el HPA reaccionaba a cada ráfaga agregando réplicas que quince segundos después volvían a sobrar.

Esto generó scale-flapping: pods creándose y destruyéndose en ciclos de menos de dos minutos, cada uno con su tiempo de arranque desperdiciado porque para cuando el pod nuevo estaba listo para consumir mensajes, la ráfaga que lo motivó ya se había drenado con los workers existentes. Dos cambios lo resolvieron. El primero fue agregar avg() a la consulta para suavizar la lectura puntual, aunque en este caso con un solo valor de serie el impacto real vino del segundo cambio: configurar behavior.scaleUp.stabilizationWindowSeconds y behavior.scaleDown.stabilizationWindowSeconds en el propio HPA, que obliga al controlador a mirar una ventana de decisiones recientes en vez de reaccionar a la última lectura aislada.

El manifiesto de HPA

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: worker-pedidos
  namespace: pedidos
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker-pedidos
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: External
      external:
        metric:
          name: pedidos_pendientes_en_cola
          selector:
            matchLabels:
              namespace: pedidos
        target:
          type: AverageValue
          averageValue: "30"
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60

La asimetría entre scaleUp y scaleDown es deliberada. Escalar hacia arriba rápido es barato: un pod de más procesando poco no cuesta tanto como una cola creciendo sin control. Escalar hacia abajo rápido, en cambio, es el patrón que generaba el flapping que describimos arriba, así que le dejamos una ventana de cinco minutos antes de considerar una reducción de réplicas, dándole tiempo a la cola real de asentarse en un nivel estable antes de decidir que ya no hacen falta esos workers.

El target.type: AverageValue con valor 30 significa que el HPA intenta mantener el promedio de mensajes pendientes por réplica en 30: si hay 90 mensajes en cola con 3 réplicas activas, el HPA calcula que haría falta una réplica más para volver a ese promedio. Es el mismo mecanismo de cálculo que usa para CPU, solo que la métrica de entrada viene de Prometheus Adapter en lugar de metrics-server.

Cuándo usar Pods y cuándo External

Prometheus Adapter también permite exponer métricas como custom.metrics.k8s.io en vez de external.metrics.k8s.io, bajo el tipo Pods en el HPA. La diferencia práctica es a qué está atada la métrica: un tipo Pods tiene sentido cuando el valor viene de cada pod individual -conexiones activas por pod, tamaño de una cola interna en memoria de cada instancia- y Kubernetes promedia esos valores entre las réplicas activas automáticamente. Un tipo External tiene sentido cuando la métrica no pertenece a ningún pod en particular, como el largo de una cola compartida en RabbitMQ a la que escriben productores externos al clúster. Intentar forzar el largo de una cola compartida como métrica Pods no funciona bien: no hay ninguna forma natural de dividir ese número entre los pods, porque el número no les pertenece a ellos individualmente.

Qué vigilar después de activarlo

Dos cosas que solo se notan en producción. La primera es la latencia de la cadena completa: Prometheus Adapter no cachea agresivamente por defecto, así que cada consulta del HPA dispara una consulta real a Prometheus, y si esa consulta es costosa -rangos largos, demasiadas series- el tiempo de respuesta de Prometheus Adapter se vuelve parte del ciclo de reconciliación del HPA. Mantuvimos las consultas simples, sobre una sola serie, justamente para que esto no se convierta en un problema.

La segunda es qué pasa cuando Prometheus está caído o la métrica no tiene datos recientes: el HPA no escala sobre una métrica que no puede leer, y en la práctica se queda en el número de réplicas que tenía. Para un worker de cola esto es tolerable -en el peor caso, no escala más durante la ventana en la que Prometheus no responde-, pero vale la pena saberlo antes de decidir escalar el cluster de Prometheus con la misma urgencia que cualquier otro componente crítico del camino de autoscaling.

kubernetes hpa prometheus devops
← Volver al blog