Cerramos el módulo 8 con una plataforma segura y auditable, pero con una confesión incómoda: los componentes de Rutas Norte siguen teniendo un número de réplicas fijo, escrito a mano. api-reservas tiene replicas: 4 y tienda-web tiene replicas: 3 porque alguien, hace meses, midió un martes cualquiera y le pareció suficiente. Ese número no sabe nada del puente de mayo. El año pasado la plataforma se cayó a los once minutos de abrir la venta, y la respuesta del equipo fue un kubectl scale a la carrera desde un portátil, en el andén de una estación, con el tren saliendo.

Esta lección elimina esa carrera. El HorizontalPodAutoscaler (HPA) es el objeto de Kubernetes que ajusta automáticamente el número de réplicas de un Deployment en función de métricas observadas. No es magia: es un controlador más, con su bucle de reconciliación —el mismo que estudiamos en el módulo 1—, una fórmula aritmética muy concreta y un puñado de parámetros que hay que entender bien para que el resultado sea estabilidad y no un péndulo de pods arrancando y muriendo.

Vamos a ver qué cargas se pueden escalar horizontalmente y cuáles no, la API autoscaling/v2 completa, la fórmula exacta con la que se calculan las réplicas deseadas, el bloque behavior que gobierna la velocidad de subida y bajada, y los dos manifiestos definitivos de api-reservas y tienda-web. Terminaremos con un conflicto que muerde a casi todos los equipos la primera vez: el HPA y el campo replicas del YAML versionado peleándose por el mismo número.

Contenido

  1. Escalar hacia fuera frente a escalar hacia arriba
  2. Qué cargas admiten escalado horizontal y cuáles no
  3. Anatomía del HorizontalPodAutoscaler en autoscaling/v2
  4. Los cuatro tipos de métrica: Resource, Pods, Object y External
  5. La fórmula exacta del controlador, paso a paso
  6. La banda de tolerancia y el bailoteo de réplicas
  7. Requisitos ineludibles: requests y metrics-server
  8. Diagnóstico del temido <unknown>
  9. El bloque behavior: políticas, periodos y ventana de estabilización
  10. Varias métricas a la vez: gana la que pide más
  11. Los HPA de Rutas Norte, manifiestos completos
  12. Simulación de una punta de carga y observación en vivo
  13. El conflicto entre el HPA y el campo replicas
  14. Qué no escalar con HPA y el cuello de botella real
  15. Errores comunes y consejos
  16. Ejercicios
  17. Conclusión

  1. Escalar hacia fuera frente a escalar hacia arriba

Hay exactamente dos formas de darle más capacidad a un servicio, y conviene tener los nombres claros porque el resto del módulo se apoya en ellos.

Escalar hacia arriba (scale up, escalado vertical) es darle más recursos a cada instancia: subir el requests/limits de CPU de 200m a 800m, la memoria de 256Mi a 1Gi. El número de pods no cambia; cada pod es más grande. En Kubernetes esto implica recrear el pod, porque los recursos de un contenedor forman parte de su especificación inmutable (con el matiz del redimensionado en caliente que veremos en 09-02).

Escalar hacia fuera (scale out, escalado horizontal) es poner más instancias del mismo tamaño: pasar de 4 pods de api-reservas a 12 pods idénticos. Cada pod sigue siendo igual de grande; hay más. En Kubernetes esto es cambiar un número entero: el replicas del Deployment.

Aspecto Escalado vertical (hacia arriba) Escalado horizontal (hacia fuera)
Qué cambia Tamaño de cada pod (requests/limits) Número de pods
Techo El nodo más grande disponible Prácticamente ilimitado (con nodos)
Requiere reiniciar el pod Sí (salvo redimensionado en caliente) No, los pods existentes no se tocan
Requisito de la aplicación Que sepa aprovechar más CPU/RAM Que no tenga estado local ni afinidad de sesión
Tolerancia a fallos No mejora: sigue habiendo un punto único Mejora: la carga se reparte entre más instancias
Coste de granularidad Salta a escalones grandes Fino: un pod cada vez
Objeto de Kubernetes VPA (09-02) o edición manual HPA (esta lección), KEDA (09-04)
Velocidad de reacción Lenta (recreación del pod) Rápida (segundos, si la imagen está cacheada)

La regla práctica: el escalado vertical resuelve el problema de una instancia mal dimensionada; el escalado horizontal resuelve el problema del volumen de tráfico. No son alternativas, son ejes distintos. Rutas Norte necesita los dos: acertar con el tamaño de cada pod (09-02) y multiplicar el número de pods cuando llega el puente de mayo (esta lección).

Un detalle importante que muchos pasan por alto: el escalado horizontal también mejora la disponibilidad, mientras que el vertical no. Doce pods de api-reservas repartidos entre nodos sobreviven a la caída de un nodo; un único pod gigantesco, no. Esta idea la retomaremos a fondo en 09-05.

  1. Qué cargas admiten escalado horizontal y cuáles no

El escalado horizontal solo funciona si cualquier réplica puede atender cualquier petición. Esto se llama ser sin estado (stateless), y es una propiedad de la aplicación, no de Kubernetes. Kubernetes te dejará escalar cualquier cosa; que el resultado sea correcto depende de cómo esté escrita la aplicación.

Repasemos los componentes de Rutas Norte:

Componente ¿Escalable horizontalmente? Motivo
tienda-web (nginx) , sin reservas Sirve activos estáticos y hace de proxy. No guarda nada entre peticiones.
api-reservas (Node.js) API REST sin estado: la sesión va en un token firmado, el estado en PostgreSQL y redis-cache.
worker-notificaciones , pero la CPU no es la señal correcta Consume de una cola; añadir consumidores acelera el vaciado. La métrica útil es la longitud de la cola (09-04).
redis-cache No con HPA Es una caché con estado particionado. Añadir réplicas no reparte la carga sin sharding explícito.
postgres-reservas Rotundamente no Base de datos primaria. Añadir pods no crea más bases de datos: crea réplicas que se pelean por el mismo volumen o quedan huérfanas.
informes-ocupacion (CronJob) No aplica Su paralelismo se controla con parallelism en el Job (06-03), no con HPA.

El caso de postgres-reservas merece un párrafo. Es un StatefulSet con un volumen persistente por réplica. Si pusieras un HPA sobre él y el HPA decidiera pasar de 1 a 5 réplicas, Kubernetes crearía cinco pods postgres-reservas-0 a postgres-reservas-4, cada uno con su propio PVC vacío, cada uno arrancando una base de datos independiente y vacía. El Service repartiría las peticiones entre las cinco. El resultado sería una corrupción silenciosa de datos: unas reservas irían a una base de datos y otras a otra. Escalar una base de datos relacional es un problema de arquitectura de datos (réplicas de lectura, particionado, un operador que sepa hacerlo — 06-07), no un problema de contar pods.

Regla que conviene memorizar: si al añadir una réplica el sistema no responde más peticiones correctas por segundo, el HPA no es tu herramienta.

  1. Anatomía del HorizontalPodAutoscaler en autoscaling/v2

El HPA es un objeto de la API como cualquier otro. Su versión estable y actual es autoscaling/v2; las versiones v2beta1 y v2beta2 están eliminadas desde hace varias versiones y autoscaling/v1 solo permite CPU (el servidor la sigue sirviendo por compatibilidad, pero no la uses: pierdes behavior y todas las métricas que no sean CPU).

Este es el esqueleto completo, con comentarios sobre cada campo:

apiVersion: autoscaling/v2          # Versión estable. NUNCA autoscaling/v1 en material nuevo.
kind: HorizontalPodAutoscaler
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  # A QUÉ objeto le cambia el número de réplicas.
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment              # También vale StatefulSet o cualquier recurso con subrecurso /scale
    name: api-reservas            # Debe existir en el MISMO namespace que el HPA

  minReplicas: 4                  # Suelo. El HPA nunca bajará de aquí.
  maxReplicas: 30                 # Techo. El HPA nunca subirá de aquí, pase lo que pase.

  # QUÉ mira para decidir. Es una LISTA: puede haber varias.
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65  # Objetivo: 65 % del requests de CPU, de media entre pods

  # CÓMO de rápido sube y baja. Opcional pero casi siempre necesario.
  behavior:
    scaleUp: {}
    scaleDown: {}

Tres puntos de partida que conviene fijar antes de seguir:

  • scaleTargetRef apunta a un objeto que expone el subrecurso /scale. Deployments, ReplicaSets y StatefulSets lo exponen. Un DaemonSet no, porque su número de pods lo determina el número de nodos (06-02), y no tendría sentido.
  • minReplicas y maxReplicas son barreras duras. El maxReplicas es tu red de seguridad económica y operativa: si un error de la aplicación dispara la CPU al 100 % sin que haya tráfico real, el HPA intentará escalar sin fin; el techo lo impide. Ponlo siempre, y ponlo pensando en qué pasaría si se alcanzase.
  • minReplicas puede ser 0 desde Kubernetes 1.30 si la feature gate HPAScaleToZero está activa, pero eso requiere una métrica externa o de objeto y no está habilitado por defecto en la mayoría de clústeres. El escalado a cero de verdad lo haremos con KEDA en 09-04.

El bucle del controlador

El HPA lo gestiona el horizontal-pod-autoscaler que vive dentro del kube-controller-manager. Su ciclo por defecto es de 15 segundos (--horizontal-pod-autoscaler-sync-period). En cada ciclo:

flowchart TD
    A[Cada 15 s] --> B[Lee el HPA y el objeto destino]
    B --> C[Consulta las metricas<br/>metrics.k8s.io o APIs de agregacion]
    C --> D{Metricas disponibles?}
    D -- No --> E[TARGETS = unknown<br/>no escala, emite evento]
    D -- Si --> F[Aplica la formula<br/>replicas deseadas]
    F --> G{Dentro de la<br/>banda de tolerancia?}
    G -- Si --> H[No hace nada]
    G -- No --> I[Aplica behavior:<br/>estabilizacion y politicas]
    I --> J[Acota entre min y maxReplicas]
    J --> K[PATCH al subrecurso /scale]
    K --> L[El Deployment ajusta su ReplicaSet]

Nótese el final del flujo: el HPA no crea pods. Modifica el campo replicas del Deployment mediante el subrecurso /scale, y a partir de ahí el controlador de Deployments y el de ReplicaSets hacen su trabajo habitual (módulo 2). Es reconciliación encadenada, exactamente el patrón que vimos en 01-02.

  1. Los cuatro tipos de métrica: Resource, Pods, Object y External

El bloque metrics acepta cuatro tipos. Dos los usarás hoy; los otros dos se definen aquí y se explotan en 09-04.

Tipo De dónde salen los datos Qué mide Ámbito Se usa en
Resource metrics-server (metrics.k8s.io) CPU o memoria de los pods del destino Por pod, promediado 09-01
ContainerResource metrics-server CPU/memoria de un contenedor concreto del pod Por contenedor 09-01
Pods API de métricas personalizadas (custom.metrics.k8s.io) Cualquier métrica emitida por los pods del destino Por pod, promediado 09-04
Object API de métricas personalizadas Una métrica asociada a otro objeto de Kubernetes (un Ingress, un Service) Valor único 09-04
External API de métricas externas (external.metrics.k8s.io) Algo de fuera del clúster: longitud de una cola, mensajes en un topic Valor único o dividido entre pods 09-04

Resource con Utilization

Es la forma más común. Expresa el objetivo como porcentaje del requests del contenedor:

metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 65

Esto significa: «mantén el consumo medio de CPU de los pods de api-reservas en torno al 65 % de lo que cada uno tiene reservado en requests.cpu». Si requests.cpu es 500m, el objetivo son 325m por pod.

Es fundamental entender que Utilization se calcula sobre requests, no sobre limits ni sobre la CPU del nodo. Un pod puede mostrar una utilización del 180 % si consume 900m con un requests de 500m; eso es perfectamente posible porque el requests es una reserva mínima, no un tope (módulo 3).

Resource con AverageValue

Expresa el objetivo en unidades absolutas, ignorando el requests:

metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: AverageValue
        averageValue: 700Mi

«Mantén el consumo medio de memoria por pod en 700Mi.»

¿Cuándo usar cada uno?

Utilization AverageValue
Se expresa en Porcentaje del requests Unidades (m de CPU, Mi de memoria)
Requiere requests declarado Sí, obligatorio No
Se rompe si cambias el requests Sí: el objetivo real se mueve solo No
Legibilidad para el equipo Alta («al 65 % de lo suyo») Media
Recomendado para CPU, el caso general Memoria, o cuando el requests cambia a menudo (VPA)

Un aviso sobre memoria como métrica de HPA: casi nunca es buena idea. Muchos entornos de ejecución (la JVM, el recolector de basura de Node.js, PostgreSQL) no devuelven la memoria al sistema aunque ya no la necesiten. El consumo sube y se queda arriba. Un HPA por memoria escalaría hacia fuera y nunca volvería a bajar, porque la memoria de los pods viejos jamás baja del objetivo. Usa CPU, o mejor aún, una métrica de negocio (09-04).

ContainerResource

Variante de Resource que mira un contenedor concreto en lugar de sumar todos los del pod. Es muy útil en Rutas Norte, donde varios pods llevan sidecars:

metrics:
  - type: ContainerResource
    containerResource:
      name: cpu
      container: api                # Solo el contenedor "api", no el sidecar de metricas
      target:
        type: Utilization
        averageUtilization: 65

Sin esto, un sidecar que consume 50m constantes distorsiona el porcentaje del pod entero, y en pods pequeños la distorsión es grande. Si tu destino tiene sidecars, prefiere ContainerResource.

Pods, Object y External (definición)

Los definimos ahora para que reconozcas la sintaxis, pero su uso real llega en 09-04.

# Pods: una metrica que emiten los propios pods del destino, promediada entre ellos.
- type: Pods
  pods:
    metric:
      name: peticiones_por_segundo
    target:
      type: AverageValue
      averageValue: "80"          # 80 peticiones por segundo y pod

# Object: una metrica asociada a OTRO objeto del cluster.
- type: Object
  object:
    describedObject:
      apiVersion: networking.k8s.io/v1
      kind: Ingress
      name: rutas-norte-publico
    metric:
      name: peticiones_por_segundo
    target:
      type: Value
      value: "2000"               # Valor TOTAL del objeto, no por pod

# External: algo de fuera del cluster.
- type: External
  external:
    metric:
      name: longitud_cola_correos
      selector:
        matchLabels:
          cola: notificaciones-confirmacion
    target:
      type: AverageValue
      averageValue: "500"         # 500 mensajes pendientes por pod

Diferencia clave entre Value y AverageValue en Object y External: con Value el HPA compara el valor bruto con el objetivo; con AverageValue divide el valor entre el número de réplicas antes de comparar. Para una cola siempre quieres AverageValue («cada worker se hace cargo de 500 mensajes»), porque es lo que hace que el número de réplicas crezca con la cola.

Los tipos Pods, Object y External no funcionan de fábrica: necesitan un adaptador de métricas registrado en la capa de agregación de la API. Sin él, el HPA reportará <unknown> eternamente. Eso es exactamente lo que resuelve KEDA en 09-04.

  1. La fórmula exacta del controlador, paso a paso

Aquí está el corazón de la lección. Todo el comportamiento del HPA sale de una única expresión:

replicasDeseadas = techo( replicasActuales × ( valorActual / valorObjetivo ) )

Donde techo() es el redondeo hacia arriba al entero siguiente. Vamos a aplicarla a api-reservas con números reales.

Situación de partida

api-reservas está desplegado con estos recursos (los fijamos en el módulo 3):

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: "1"
    memory: 1Gi

Y su HPA tiene averageUtilization: 65, minReplicas: 4, maxReplicas: 30.

Objetivo absoluto por pod: 65 % de 500m = 325m.

Caso 1: martes por la tarde, todo tranquilo

Hay 4 réplicas. kubectl top pods muestra:

NAME                            CPU(cores)   MEMORY(bytes)
api-reservas-7c9d4f8b6d-2mk8p   140m         310Mi
api-reservas-7c9d4f8b6d-5xqzn   155m         298Mi
api-reservas-7c9d4f8b6d-9jw4t   132m         305Mi
api-reservas-7c9d4f8b6d-hb7rc   149m         301Mi

Media: (140 + 155 + 132 + 149) / 4 = 144m.

replicasDeseadas = techo( 4 × (144 / 325) )
                 = techo( 4 × 0,443 )
                 = techo( 1,772 )
                 = 2

La fórmula pide 2 réplicas. Pero minReplicas es 4, así que se queda en 4. Correcto: no queremos bajar de 4 en producción por disponibilidad.

Caso 2: apertura de la venta del puente de mayo

Sigue habiendo 4 réplicas, pero el tráfico se dispara:

NAME                            CPU(cores)   MEMORY(bytes)
api-reservas-7c9d4f8b6d-2mk8p   910m         680Mi
api-reservas-7c9d4f8b6d-5xqzn   940m         702Mi
api-reservas-7c9d4f8b6d-9jw4t   895m         671Mi
api-reservas-7c9d4f8b6d-hb7rc   925m         694Mi

Media: (910 + 940 + 895 + 925) / 4 = 917,5m. Ojo: están rozando su limit de 1000m, o sea, están sufriendo throttling (módulo 3).

replicasDeseadas = techo( 4 × (917,5 / 325) )
                 = techo( 4 × 2,823 )
                 = techo( 11,29 )
                 = 12

El HPA quiere 12 réplicas. Está por debajo de maxReplicas: 30, así que se aplica (sujeto al behavior, que veremos en el apartado 9).

Observa la elegancia de la fórmula: multiplica el número actual por el factor de exceso. Si vas al triple del objetivo con 4 pods, pide 12 pods. Es una regla de tres que asume implícitamente que la carga se reparte por igual entre las réplicas, lo cual es cierto si el Service hace balanceo razonable y las peticiones son homogéneas.

Caso 3: pasa la punta

Ahora hay 12 réplicas y la media baja a 120m:

replicasDeseadas = techo( 12 × (120 / 325) )
                 = techo( 12 × 0,369 )
                 = techo( 4,43 )
                 = 5

Pide 5 réplicas. Bajará de 12 a 5, pero no de golpe: la ventana de estabilización y las políticas de scaleDown controlan el ritmo (apartado 9).

Detalles finos de la fórmula que casi nadie conoce

Estos matices explican comportamientos que de otro modo parecen erráticos:

  1. Pods sin métricas. Si un pod no tiene métricas todavía (acaba de arrancar), el HPA lo excluye del promedio pero lo cuenta de forma conservadora: al escalar hacia arriba asume consumo 0 para ese pod (para no sobrestimar), y al escalar hacia abajo asume consumo igual al objetivo (para no infraestimar). El resultado es que el HPA es prudente en ambos sentidos mientras hay pods arrancando.

  2. Pods no listos. Los pods que no han pasado su sonda readinessProbe se excluyen del cálculo al escalar hacia arriba. Esto evita el clásico bucle infernal: pods nuevos que aún no reciben tráfico, con CPU alta por el arranque, harían creer al HPA que hace falta escalar aún más.

  3. --horizontal-pod-autoscaler-initial-readiness-delay (30 s por defecto): durante los primeros 30 segundos de vida de un pod, sus métricas se ignoran por completo. La justificación es clara: el arranque de Node.js consume mucha CPU compilando y cargando módulos, y no queremos que ese pico contamine la decisión.

  4. --horizontal-pod-autoscaler-cpu-initialization-period (5 min por defecto): un margen adicional para métricas de CPU de pods recién listos.

La consecuencia práctica de los puntos 3 y 4 es que el HPA tiene una latencia de arranque de al menos medio minuto por decisión, y por eso el sobreaprovisionamiento de 09-03 y el precalentamiento por cron de 09-04 tienen sentido.

  1. La banda de tolerancia y el bailoteo de réplicas

Si el HPA aplicase la fórmula literalmente cada 15 segundos, el número de réplicas oscilaría sin parar. Con 4 pods a 320m y un objetivo de 325m la fórmula da techo(4 × 0,984) = 4; con 330m da techo(4 × 1,015) = 5. Una fluctuación de 10m provocaría un pod arriba y abajo indefinidamente. A eso se le llama bailoteo o thrashing, y es caro: cada arranque consume CPU, calienta cachés desde cero y ensucia las métricas.

Kubernetes lo evita con una banda de tolerancia: si el cociente valorActual / valorObjetivo está entre 0,9 y 1,1 (es decir, dentro de un ±10 %), el HPA no hace nada.

ratio = valorActual / valorObjetivo

|ratio - 1| <= 0,10   →   no se escala
|ratio - 1|  > 0,10   →   se aplica la formula

Con nuestro objetivo de 325m, la zona muerta es:

Consumo medio ratio ¿Actúa el HPA?
280m 0,86 Sí, escala hacia abajo
300m 0,92 No, dentro de la banda
325m 1,00 No
355m 1,09 No, dentro de la banda
380m 1,17 Sí, escala hacia arriba

Este umbral es global del clúster (--horizontal-pod-autoscaler-tolerance), no configurable por HPA... hasta hace poco. Desde Kubernetes 1.33 existe la feature gate HPAConfigurableTolerance, que permite fijar la tolerancia por métrica dentro del behavior:

behavior:
  scaleUp:
    tolerance: 0.05     # Mas sensible al subir: reacciona con un 5 % de exceso
  scaleDown:
    tolerance: 0.15     # Mas perezoso al bajar

Como feature gate opcional, no la des por hecha: comprueba la versión y la configuración de tu clúster antes de usarla en producción. En el clúster de minikube del curso trabajaremos con la tolerancia por defecto del 10 %.

  1. Requisitos ineludibles: requests y metrics-server

Dos requisitos, y ninguno es negociable.

Requisito 1: metrics-server instalado

El HPA con métricas de tipo Resource lee de la API metrics.k8s.io, que sirve metrics-server. Lo instalamos en 07-02 con el addon de minikube. Verifícalo:

# ¿Esta registrada la API de metricas?
kubectl get apiservices v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        41d

La columna AVAILABLE debe decir True. Si dice False (MissingEndpoints) o similar, el HPA no funcionará.

# ¿Devuelve datos?
kubectl top pods -n rutas-norte-pro -l app=api-reservas

Si kubectl top funciona, el HPA tendrá datos. Si no, no hay HPA que valga. Es la primera comprobación siempre.

En minikube, si aún no lo tienes:

minikube -p rutas-norte addons enable metrics-server
# Tarda entre 30 y 60 segundos en empezar a servir datos.

Requisito 2: requests declarados en el contenedor

Esta es la trampa más frecuente. El objetivo Utilization se calcula como porcentaje del requests. Si un contenedor no declara requests.cpu, el HPA no tiene denominador, y su métrica queda como <unknown> para siempre. No hay mensaje de error espectacular; simplemente no escala.

# Fragmento del Deployment de api-reservas. SIN ESTO NO HAY HPA.
spec:
  template:
    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:1.14.2
          resources:
            requests:
              cpu: 500m           # <-- El HPA NECESITA este valor
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 1Gi

Y ojo con los sidecars: si el pod tiene un contenedor sin requests.cpu, la métrica del pod entero queda inválida para el HPA cuando usas type: Resource. En Rutas Norte, api-reservas lleva un sidecar exportador de métricas; si ese sidecar no declara requests, el HPA de todo el Deployment se queda ciego. Dos soluciones:

  1. Declarar requests también en el sidecar (recomendado, y además necesario para la QoS del módulo 3).
  2. Usar type: ContainerResource apuntando solo al contenedor api.

Aplicamos las dos en Rutas Norte: cinturón y tirantes.

  1. Diagnóstico del temido <unknown>

Tarde o temprano verás esto:

kubectl get hpa -n rutas-norte-pro
NAME           REFERENCE                 TARGETS              MINPODS  MAXPODS  REPLICAS  AGE
api-reservas   Deployment/api-reservas   <unknown>/65%        4        30       4         3m12s

<unknown> significa: «el HPA no ha podido obtener el valor actual de esta métrica». No escala, y punto. El diagnóstico se hace siempre en el mismo orden:

kubectl describe hpa api-reservas -n rutas-norte-pro

Fíjate en el bloque Conditions y en los eventos del final. Las causas, ordenadas por frecuencia:

Síntoma en describe Causa Solución
FailedGetResourceMetric ... unable to get metrics for resource cpu: no metrics returned from resource metrics API metrics-server no está o no responde Instalar/reparar metrics-server (07-02)
missing request for cpu on container X Un contenedor del pod no declara requests.cpu Añadir requests a todos los contenedores, sidecars incluidos
ScalingActive=False, reason: FailedGetScale El scaleTargetRef apunta a un objeto que no existe o está mal escrito Corregir name/kind/apiVersion
did not receive metrics for any ready pods Todos los pods llevan menos de 30 s listos, o ninguno pasa la readiness Esperar; revisar sondas (07-01)
<unknown> solo en métricas Pods/External No hay adaptador de métricas personalizadas registrado Instalar el adaptador o KEDA (09-04)

Ejemplo de salida real con el error de requests faltante:

Conditions:
  Type           Status  Reason                   Message
  ----           ------  ------                   -------
  AbleToScale    True    SucceededGetScale        the HPA controller was able to get the target's current scale
  ScalingActive  False   FailedGetResourceMetric  the HPA was unable to compute the replica count:
                                                  failed to get cpu utilization: missing request for cpu
                                                  in container exportador-metricas of Pod api-reservas-7c9d4f8b6d-2mk8p
Events:
  Type     Reason                        Age                 From                       Message
  ----     ------                        ----                ----                       -------
  Warning  FailedGetResourceMetric       12s (x8 over 2m)    horizontal-pod-autoscaler  missing request for cpu
  Warning  FailedComputeMetricsReplicas  12s (x8 over 2m)    horizontal-pod-autoscaler  invalid metrics (1 invalid out of 1)

El mensaje señala con nombre y apellidos el contenedor culpable: exportador-metricas. Añadirle requests.cpu resuelve el problema en el siguiente ciclo de 15 segundos.

Un truco de diagnóstico rápido: consulta directamente la API de métricas para ver qué está viendo el HPA.

kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods" \
  | python3 -m json.tool | head -40

Si esa llamada devuelve datos y el HPA sigue en <unknown>, el problema es de requests, no de metrics-server.

  1. El bloque behavior: políticas, periodos y ventana de estabilización

Hasta ahora hemos visto cuántas réplicas quiere el HPA. El bloque behavior decide a qué velocidad llega a ese número. Es la diferencia entre un autoescalado que funciona y uno que da miedo.

Estructura

behavior:
  scaleUp:
    stabilizationWindowSeconds: 0
    selectPolicy: Max
    policies:
      - type: Percent
        value: 100
        periodSeconds: 30
      - type: Pods
        value: 4
        periodSeconds: 30
  scaleDown:
    stabilizationWindowSeconds: 600
    selectPolicy: Min
    policies:
      - type: Percent
        value: 20
        periodSeconds: 120

Campo a campo:

policies — cada política limita cuánto puede cambiar el número de réplicas en una ventana de periodSeconds:

  • type: Percent con value: 100 y periodSeconds: 30 significa «en cualquier ventana de 30 segundos puedes como mucho duplicar el número de réplicas» (aumentar un 100 % sobre la base).
  • type: Pods con value: 4 y periodSeconds: 30 significa «en cualquier ventana de 30 segundos puedes añadir como mucho 4 pods».

selectPolicy — cuando hay varias políticas, decide cuál manda:

Valor Efecto Cuándo usarlo
Max (defecto en scaleUp) Permite el cambio más agresivo de todas las políticas Subir rápido
Min (defecto en scaleDown) Permite el cambio más conservador Bajar despacio
Disabled Desactiva el escalado en esa dirección Congelar bajadas durante una campaña

Ejemplo del efecto de selectPolicy: Max en scaleUp con nuestras políticas y 4 réplicas actuales:

  • Política de porcentaje: 100 % de 4 = permite añadir 4 → 8 réplicas.
  • Política de pods: permite añadir 4 → 8 réplicas.
  • Max toma el mayor: 8 réplicas.

Con 20 réplicas actuales:

  • Porcentaje: 100 % de 20 = permite añadir 20 → 40 réplicas.
  • Pods: permite añadir 4 → 24 réplicas.
  • Max toma 40 (limitado luego por maxReplicas: 30 → 30).

Y con selectPolicy: Min habría elegido 24. Esto ilustra por qué Max al subir tiene sentido: cuando ya tienes muchas réplicas, el porcentaje te da margen para crecer rápido.

stabilizationWindowSeconds — es el parámetro más importante y el peor entendido. Funciona así: el HPA guarda un histórico de las recomendaciones de los últimos N segundos y usa el valor más conservador de esa ventana.

  • En scaleUp, «más conservador» significa el mínimo de las recomendaciones recientes. Con una ventana de 60 s, si en el último minuto el HPA recomendó 12, 8 y 14 réplicas, escalará a 8. Efecto: ignora picos aislados.
  • En scaleDown, «más conservador» significa el máximo de las recomendaciones recientes. Con una ventana de 600 s, si en los últimos 10 minutos recomendó 12, 5 y 6, mantendrá 12. Efecto: no baja hasta que la calma se sostiene diez minutos enteros.

Valores por defecto si no defines behavior:

scaleUp scaleDown
stabilizationWindowSeconds 0 300 (5 minutos)
policies 100 % cada 15 s y 4 pods cada 15 s 100 % cada 15 s
selectPolicy Max Min

Es decir, por defecto el HPA puede duplicarse cada 15 segundos y puede irse a minReplicas de golpe tras 5 minutos de calma. Lo primero suele estar bien; lo segundo es demasiado brusco para Rutas Norte.

La configuración razonada de Rutas Norte: subir rápido, bajar despacio

Nuestra política es deliberadamente asimétrica, y conviene entender por qué:

Subir rápido. El coste de escalar de más durante unos minutos es unos céntimos de cómputo. El coste de escalar de menos durante unos minutos es que la venta del puente de mayo se cae y Rutas Norte pierde miles de euros en billetes no vendidos, más la reputación. Los costes son radicalmente asimétricos, y por tanto la respuesta debe serlo. stabilizationWindowSeconds: 0 en scaleUp: en cuanto la carga sube, se actúa.

Bajar despacio. El tráfico de una web de billetes es irregular por naturaleza: una punta a las 10:00, un valle a las 10:03, otra punta a las 10:05. Si bajásemos rápido, estaríamos destruyendo pods que hay que volver a crear treinta segundos después, con el coste de arranque en frío (conexiones a PostgreSQL, caché de rutas vacía) y una latencia peor para el usuario. stabilizationWindowSeconds: 600 en scaleDown: no bajamos hasta que la calma dura diez minutos. Y aun entonces, bajamos un 20 % cada dos minutos, no de golpe.

La formulación mental: el autoescalado hacia arriba es un mecanismo de disponibilidad; el autoescalado hacia abajo es un mecanismo de coste. La disponibilidad es urgente; el ahorro puede esperar diez minutos.

Congelar el escalado hacia abajo durante una campaña

Un uso avanzado y muy práctico de selectPolicy: Disabled. Durante los tres días del puente de mayo, el equipo de Rutas Norte puede decidir que no quiere ninguna reducción de réplicas, ni siquiera lenta:

behavior:
  scaleDown:
    selectPolicy: Disabled       # Durante el puente: nunca bajar. Solo subir.

Se aplica el viernes por la mañana y se revierte el lunes. Es un interruptor de emergencia legítimo y mucho más seguro que borrar el HPA.

  1. Varias métricas a la vez: gana la que pide más

El campo metrics es una lista. Cuando hay varias, el HPA calcula la fórmula para cada una por separado y luego aplica una regla muy simple:

Se queda con el número de réplicas MÁS ALTO de todos los cálculos.

Es un OR de necesidad: basta con que una sola métrica pida más réplicas para que se escale. Nunca se promedian ni se combinan.

Ejemplo con dos métricas en api-reservas (5 réplicas actuales):

Métrica Objetivo Valor actual Réplicas que pide
CPU (Utilization) 65 % 42 % techo(5 × 0,646) = 4
Peticiones por segundo (Pods) 80 rps/pod 190 rps/pod techo(5 × 2,375) = 12
Decisión final 12

La CPU dice que sobran pods; las peticiones por segundo dicen que faltan muchos. Gana 12. Esta asimetría es intencionada y correcta: el HPA prefiere equivocarse por exceso de capacidad que por defecto.

Consecuencia práctica que hay que tener presente: si una de las métricas se queda en <unknown>, el HPA no puede garantizar que no haga falta escalar por ella. Su comportamiento en ese caso es conservador: si la métrica válida pide subir, sube; si pide bajar, no baja, porque no sabe si la métrica rota justificaría mantener las réplicas. Esto explica el desconcierto habitual de «mi HPA tiene una métrica en <unknown> y se ha quedado clavado arriba».

  1. Los HPA de Rutas Norte, manifiestos completos

Vamos a escribir los dos manifiestos definitivos. Van en k8s/entornos/pro/, porque los valores de minReplicas difieren por entorno.

HPA de api-reservas

# k8s/entornos/pro/hpa-api-reservas.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reservas

  minReplicas: 4          # Suelo de disponibilidad: 4 replicas repartidas entre zonas (09-05)
  maxReplicas: 30         # Techo: 30 x 500m = 15 nucleos de requests. Cabe en el plan de capacidad.

  metrics:
    # Metrica principal: CPU del contenedor "api", excluyendo el sidecar de metricas.
    - type: ContainerResource
      containerResource:
        name: cpu
        container: api
        target:
          type: Utilization
          averageUtilization: 65
          # 65 % de 500m = 325m por pod. Deja un 35 % de margen para absorber
          # el tiempo que tardan en arrancar las replicas nuevas.

  behavior:
    scaleUp:
      # Reaccion inmediata: cero espera. La disponibilidad manda.
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        # Duplicar el numero de replicas cada 30 s...
        - type: Percent
          value: 100
          periodSeconds: 30
        # ...o anadir 6 pods cada 30 s, lo que permita mas.
        # Con pocas replicas manda la politica de pods; con muchas, la de porcentaje.
        - type: Pods
          value: 6
          periodSeconds: 30

    scaleDown:
      # Diez minutos de calma sostenida antes de empezar a bajar.
      stabilizationWindowSeconds: 600
      selectPolicy: Min
      policies:
        # Como mucho, quitar el 20 % de las replicas cada 2 minutos.
        - type: Percent
          value: 20
          periodSeconds: 120
        # Y nunca mas de 3 pods cada 2 minutos. Con selectPolicy: Min manda la mas suave.
        - type: Pods
          value: 3
          periodSeconds: 120

Un cálculo que justifica el maxReplicas: 30: cada pod reserva 500m de CPU y 512Mi de memoria. Treinta pods son 15 núcleos y 15 GiB solo de api-reservas. Sumando tienda-web, worker-notificaciones y las bases de datos, eso define el tamaño del clúster que necesitaremos en 09-03. El maxReplicas no es un número que se pone al azar: es un compromiso de capacidad.

Otro cálculo, sobre la velocidad de subida. Partiendo de 4 réplicas con Max entre «duplicar» y «+6 pods»:

Momento Réplicas Política que manda
t = 0 s 4
t = 30 s 10 +6 pods (duplicar daría 8)
t = 60 s 20 duplicar (+6 daría 16)
t = 90 s 30 duplicar daría 40, maxReplicas corta en 30

De 4 a 30 réplicas en 90 segundos. Sumando unos 20-30 segundos de arranque de cada pod de Node.js, la plataforma pasa de su capacidad de martes a su capacidad máxima en unos dos minutos. Eso es lo que hay que comparar contra la velocidad a la que llega el tráfico.

HPA de tienda-web

tienda-web es nginx sirviendo activos estáticos. Su perfil es distinto: consume poquísima CPU por petición, arranca en un segundo y su límite real es el ancho de banda y el número de conexiones, no el cómputo.

# k8s/entornos/pro/hpa-tienda-web.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tienda-web

  minReplicas: 3
  maxReplicas: 15

  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          # Umbral MAS BAJO que el de la API: nginx arranca en ~1 s y consume poco,
          # asi que podemos permitirnos escalar antes sin coste apreciable.
          # Ademas es la puerta de entrada: si se satura, no entra nadie.
          averageUtilization: 50

  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - type: Percent
          value: 100
          periodSeconds: 15     # Mas agresivo aun: nginx arranca en un segundo
        - type: Pods
          value: 4
          periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300   # 5 min: menos conservador que la API,
                                        # porque nginx no tiene arranque en frio caro
      selectPolicy: Min
      policies:
        - type: Percent
          value: 25
          periodSeconds: 60

Comparación razonada de ambos HPA:

Parámetro api-reservas tienda-web Motivo de la diferencia
Objetivo de CPU 65 % 50 % nginx es barato de escalar; la API no
minReplicas 4 3 Disponibilidad mínima acordada por componente
maxReplicas 30 15 La API es el componente caro en CPU
periodSeconds al subir 30 s 15 s nginx arranca en 1 s; Node.js en 20-30 s
Estabilización al bajar 600 s 300 s La API tiene arranque en frío costoso (pool de conexiones, caché)
Tipo de métrica ContainerResource Resource La API tiene sidecar de métricas; nginx no

Aplicación

kubectl apply -f k8s/entornos/pro/hpa-api-reservas.yaml
kubectl apply -f k8s/entornos/pro/hpa-tienda-web.yaml

kubectl get hpa -n rutas-norte-pro
NAME           REFERENCE                 TARGETS       MINPODS  MAXPODS  REPLICAS  AGE
api-reservas   Deployment/api-reservas   28%/65%       4        30       4         22s
tienda-web     Deployment/tienda-web     11%/50%       3        15       3         19s

La columna TARGETS muestra valorActual/objetivo. Si ves números en lugar de <unknown>, todo está bien conectado.

  1. Simulación de una punta de carga y observación en vivo

Vamos a provocar una punta en el entorno de desarrollo y ver el HPA trabajar. Usaremos el perfil de minikube del curso.

Preparar el terreno

minikube -p rutas-norte addons enable metrics-server
kubectl -n rutas-norte-dev apply -f k8s/entornos/dev/hpa-api-reservas.yaml

Para la demostración, un HPA de desarrollo con umbrales bajos para que reaccione rápido:

# k8s/entornos/dev/hpa-api-reservas.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-reservas
  namespace: rutas-norte-dev
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reservas
  minReplicas: 1
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 40     # Umbral bajo: escala pronto, se ve mejor la demo
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 3
          periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 120   # Corto para la demo; en pro seria 600

Terminal 1: observar

kubectl get hpa api-reservas -n rutas-norte-dev --watch

Terminal 2: generar carga

Un pod efímero que martillea el endpoint de búsqueda de rutas en bucle:

kubectl -n rutas-norte-dev run generador-carga \
  --image=busybox:1.36 \
  --restart=Never \
  --rm -it \
  -- /bin/sh -c \
  'while true; do wget -q -O- http://api-reservas/rutas?origen=BIL\&destino=SDR > /dev/null; done'

Explicación del comando:

  • --rm -it crea el pod, se conecta a él y lo borra al salir con Ctrl+C.
  • http://api-reservas usa el DNS interno del clúster (04-03): resuelve al Service del mismo namespace.
  • El bucle infinito de wget genera peticiones tan rápido como pueda.

Para una punta más seria, varias réplicas del generador:

kubectl -n rutas-norte-dev create deployment generador-carga \
  --image=busybox:1.36 --replicas=6 \
  -- /bin/sh -c 'while true; do wget -q -O- http://api-reservas/rutas > /dev/null; done'

Lo que se ve en el terminal 1

NAME           REFERENCE                 TARGETS    MINPODS  MAXPODS  REPLICAS  AGE
api-reservas   Deployment/api-reservas   3%/40%     1        10       1         5m
api-reservas   Deployment/api-reservas   3%/40%     1        10       1         5m15s
api-reservas   Deployment/api-reservas   187%/40%   1        10       1         5m30s
api-reservas   Deployment/api-reservas   187%/40%   1        10       4         5m30s
api-reservas   Deployment/api-reservas   142%/40%   1        10       4         5m45s
api-reservas   Deployment/api-reservas   142%/40%   1        10       7         5m45s
api-reservas   Deployment/api-reservas   94%/40%    1        10       7         6m
api-reservas   Deployment/api-reservas   94%/40%    1        10       10        6m
api-reservas   Deployment/api-reservas   61%/40%    1        10       10        6m30s
api-reservas   Deployment/api-reservas   38%/40%    1        10       10        7m

Lee la secuencia con la fórmula en la mano:

  1. 3%/40% con 1 réplica: techo(1 × 0,075) = 1. Nada que hacer.
  2. 187%/40% con 1 réplica: techo(1 × 4,675) = 5. Pero la política Pods: 3 cada 15s limita a 4. Ahí está el behavior en acción.
  3. 142%/40% con 4 réplicas: techo(4 × 3,55) = 15. La política limita a 4+3 = 7.
  4. 94%/40% con 7 réplicas: techo(7 × 2,35) = 17. Limitado a 10 por maxReplicas.
  5. 38%/40% con 10 réplicas: ratio 0,95, dentro de la banda de tolerancia. Estable. Objetivo alcanzado.

Ahora detén el generador (Ctrl+C o borrando el Deployment) y observa la bajada:

api-reservas   Deployment/api-reservas   2%/40%     1        10       10        9m
api-reservas   Deployment/api-reservas   2%/40%     1        10       10        10m
api-reservas   Deployment/api-reservas   2%/40%     1        10       1         11m

Fíjate: la CPU cae inmediatamente al 2 %, pero las réplicas se quedan en 10 durante dos minutos completos. Esa es la stabilizationWindowSeconds: 120. Cumplida la ventana, baja a 1 de golpe (en esta demo no definimos políticas de scaleDown, así que aplica la de por defecto: 100 % cada 15 s).

Los eventos del escalado

kubectl describe hpa api-reservas -n rutas-norte-dev
Events:
  Type    Reason             Age    From                       Message
  ----    ------             ----   ----                       -------
  Normal  SuccessfulRescale  5m30s  horizontal-pod-autoscaler  New size: 4;  reason: cpu resource utilization (percentage of request) above target
  Normal  SuccessfulRescale  5m15s  horizontal-pod-autoscaler  New size: 7;  reason: cpu resource utilization (percentage of request) above target
  Normal  SuccessfulRescale  5m     horizontal-pod-autoscaler  New size: 10; reason: cpu resource utilization (percentage of request) above target
  Normal  SuccessfulRescale  30s    horizontal-pod-autoscaler  New size: 1;  reason: All metrics below target

Estos eventos son oro puro para el análisis posterior de un incidente: te dicen exactamente cuándo escaló, a cuánto y por qué. Recuerda que los eventos caducan (una hora por defecto); para el historial largo hace falta el registro centralizado del módulo 7.

  1. El conflicto entre el HPA y el campo replicas

Este es el error que muerde a todos los equipos exactamente una vez, y conviene que no sea en el puente de mayo.

El problema

El Deployment de api-reservas que llevamos escribiendo desde el módulo 2 dice:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
spec:
  replicas: 4        # <-- Este campo
  ...

Y ahora un HPA también gobierna ese mismo campo. Tenemos dos autoridades sobre el mismo número.

Mientras nadie aplique el YAML, no pasa nada: el HPA modifica el campo en el servidor y el objeto vive feliz con 22 réplicas. El problema aparece cuando alguien vuelve a aplicar el manifiesto.

Escenario real:

10:00  El HPA escala api-reservas a 22 replicas. La venta va bien.
10:14  Un companero corrige una etiqueta en el Deployment y ejecuta:
       kubectl apply -f k8s/base/deployment-api-reservas.yaml
10:14  El manifiesto dice replicas: 4. La API lo acepta.
       22 pods -> 4 pods, INSTANTANEAMENTE.
10:14  La plataforma se cae.
10:14  El HPA (siguiente ciclo, hasta 15 s despues) detecta CPU al 400 %
       y vuelve a escalar. Pero ya hay 15-90 segundos de caida.

Quince segundos de caída total en el pico de venta del año. Todo por un campo que no debería estar ahí.

Y es peor con GitOps: si Argo CD (10-05) tiene el repositorio como fuente de verdad y detecta que el clúster tiene 22 réplicas cuando el Git dice 4, marcará el recurso como OutOfSync y, con sincronización automática, lo «corregirá» a 4. Cada vez. Tendrás un tira y afloja permanente entre el HPA y el operador de GitOps, con la plataforma oscilando entre 4 y 22 réplicas indefinidamente.

La solución: quitar replicas del manifiesto

# k8s/base/deployment-api-reservas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
spec:
  # SIN campo "replicas": lo gobierna el HorizontalPodAutoscaler api-reservas.
  # Ver k8s/entornos/pro/hpa-api-reservas.yaml
  # Si lo devuelves aqui, cada "kubectl apply" tirara la plataforma al suelo
  # durante el pico de trafico. No lo hagas.
  selector:
    matchLabels:
      app: api-reservas
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/part-of: rutas-norte
    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:1.14.2
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 1Gi

Al omitir replicas, el valor por defecto de la API es 1, pero solo en la creación inicial. En aplicaciones posteriores, el campo simplemente no se toca: kubectl apply compara con la anotación kubectl.kubernetes.io/last-applied-configuration, ve que replicas no estaba antes ni está ahora, y lo deja como está. El HPA sigue mandando.

Una consecuencia a tener en cuenta: la primera vez que crees el Deployment sin replicas, arrancará con 1 pod hasta que el HPA lo lleve a minReplicas en el siguiente ciclo (menos de 15 segundos). Es aceptable en un despliegue nuevo; si te molesta, crea el HPA primero.

Ese comentario explícito en el YAML no es decorativo: es documentación en el lugar donde alguien tomará la decisión equivocada. Escríbelo.

Alternativas y sus matices

Enfoque Cómo Cuándo usarlo
Omitir replicas Borrarlo del YAML Recomendado. Simple, funciona con apply y con GitOps
Server-Side Apply con propiedad compartida kubectl apply --server-side; el HPA es propietario del campo Bueno, pero requiere que todo el flujo use SSA
ignoreDifferences en Argo CD Configurar Argo CD para ignorar /spec/replicas Necesario si por alguna razón no puedes quitar el campo
Kustomize sin patch de réplicas No usar replicas: en los overlays Complementa a la primera opción (10-04)

Ojo con una trampa de Server-Side Apply: si aplicas con SSA y el campo replicas está en tu manifiesto, tomarás la propiedad del campo y el HPA recibirá un conflicto. La API te lo dirá con un error explícito de conflicto de propietarios, lo cual es mejor que el fallo silencioso de kubectl apply clásico, pero sigue siendo un fallo.

Verificar quién manda

kubectl get deployment api-reservas -n rutas-norte-pro \
  -o jsonpath='{.metadata.managedFields[*].manager}{"\n"}'
kubectl-client-side-apply kube-controller-manager

Si ves kube-controller-manager entre los gestores, el HPA está escribiendo el campo. Es la confirmación de que la cadena funciona.

  1. Qué no escalar con HPA y el cuello de botella real

Terminamos con la lección más valiosa de todas, y la que más dinero ahorra.

El error de escalar la capa equivocada

Imagina esta secuencia en el puente de mayo:

  1. Llega la avalancha de tráfico.
  2. El HPA de api-reservas escala de 4 a 30 réplicas en dos minutos. Perfecto.
  3. Las 30 réplicas abren, cada una, su pool de 20 conexiones a postgres-reservas. Total: 600 conexiones.
  4. postgres-reservas tiene max_connections = 200.
  5. Las 400 conexiones sobrantes son rechazadas. Los pods de api-reservas devuelven error 500.
  6. La CPU de api-reservas sube (los reintentos y el manejo de errores consumen), así que el HPA quiere escalar más. maxReplicas lo corta en 30.
  7. La plataforma está caída con 30 réplicas donde antes se caía con 4. Ha costado más dinero y no ha servido de nada.

Este patrón tiene nombre: desplazar el cuello de botella sin resolverlo. El HPA no lo detecta porque el HPA solo sabe de CPU: no sabe que la base de datos está saturada.

flowchart LR
    A[Trafico x10] --> B[tienda-web<br/>HPA: 3 a 15]
    B --> C[api-reservas<br/>HPA: 4 a 30]
    C --> D[postgres-reservas<br/>1 replica<br/>max_connections=200]
    D -.->|CUELLO DE BOTELLA| E[Errores 500]
    C --> F[redis-cache<br/>1 replica]
    style D fill:#f88,stroke:#900,stroke-width:3px
    style E fill:#f88,stroke:#900

La capa que no escala determina la capacidad de todo el sistema. Es la ley del eslabón más débil aplicada a la arquitectura.

Qué hay que hacer en cambio

Con postgres-reservas como límite, las palancas reales son:

Palanca Qué hace Dónde se trata
Limitar el pool por réplica Con 30 réplicas × 6 conexiones = 180 < 200 09-06
Poner un pool compartido (PgBouncer) Multiplexa miles de conexiones de aplicación sobre pocas de base de datos 09-06
Cachear en redis-cache La consulta de disponibilidad no llega a PostgreSQL 09-06
Réplicas de lectura Las búsquedas van a la réplica; solo las reservas al primario 06-07 (operador)
Escalar verticalmente PostgreSQL Más CPU/RAM y disco más rápido 09-02 y 09-06

Ninguna de ellas es un HPA. La lección: antes de poner un HPA, identifica el recurso escaso. Si no es la CPU de los pods que vas a escalar, el HPA no te va a ayudar.

Lista de comprobación antes de poner un HPA

Pregúntate:

  1. ¿La aplicación es realmente sin estado? ¿Puede cualquier réplica atender cualquier petición sin memoria local ni afinidad de sesión?
  2. ¿La CPU es el recurso escaso? ¿O es la base de datos, el disco, la red, un servicio externo o un bloqueo global?
  3. ¿La carga se reparte por igual? Si un endpoint concentra el 90 % del coste y un solo cliente lo llama, añadir réplicas no ayuda.
  4. ¿Cuánto tarda un pod nuevo en ser útil? Si tarda tres minutos, el HPA llegará tarde a las puntas rápidas. Habrá que trabajar el arranque (09-06) o precalentar (09-04).
  5. ¿Hay sitio en el clúster para las réplicas nuevas? Si no, el HPA solo generará pods Pending (09-03).
  6. ¿El servicio de abajo aguanta N veces más carga? El pool de conexiones, la cuota de la API externa, el límite de tasa.

Solo si las seis respuestas son satisfactorias, el HPA hará lo que esperas.

Errores Comunes y Consejos

Error 1: dejar el campo replicas en el manifiesto. Ya lo hemos desarrollado en el apartado 13, pero merece repetirse porque es el error número uno y sus consecuencias son inmediatas y visibles. Quítalo y deja un comentario en su lugar.

Error 2: escalar por memoria. Los entornos de ejecución con recolector de basura no devuelven memoria al sistema. El HPA sube y no vuelve a bajar nunca. Usa CPU, o una métrica de negocio (09-04). Si de verdad necesitas memoria, usa AverageValue en lugar de Utilization y ten expectativas realistas.

Error 3: poner el objetivo de utilización demasiado alto. Un averageUtilization: 90 parece eficiente, pero deja solo un 10 % de margen. Cuando el HPA detecta la subida, los pods nuevos tardan 30 segundos en estar listos, y durante esos 30 segundos los pods existentes están al 130 % y sufriendo throttling. El objetivo debe dejar hueco para el tiempo de arranque. Entre 50 % y 70 % es el rango sano; cuanto más lento arranque tu aplicación, más bajo el objetivo.

Error 4: maxReplicas sin plan de capacidad. Un maxReplicas: 200 en un clúster de tres nodos no te da 200 réplicas: te da un montón de pods Pending y una falsa sensación de seguridad. Calcula maxReplicas × requests y compáralo con la capacidad real (09-03).

Error 5: olvidar los requests en los sidecars. Un solo contenedor sin requests.cpu deja ciego al HPA de todo el Deployment. En Rutas Norte, el exportador de métricas de api-reservas es el sospechoso habitual.

Error 6: no comprobar que los pods nuevos son útiles. Un pod que arranca y pasa la readiness pero tiene el pool de conexiones vacío y la caché fría atiende peor que uno caliente. Durante los primeros segundos, escalar puede empeorar la latencia media. Ajusta las sondas (07-01) para que un pod solo se declare listo cuando de verdad lo esté.

Consejo 1: pon primero el HPA en modo observación. Créalo con minReplicas igual a maxReplicas (por ejemplo, ambos a 4). El HPA no podrá escalar, pero calculará y publicará las métricas en kubectl get hpa. Déjalo una semana, mira qué habría hecho, y solo entonces abre el rango. Es gratis y evita sustos.

Consejo 2: alerta cuando el HPA esté pegado al techo. Si api-reservas lleva media hora en 30 réplicas, el maxReplicas está limitando la capacidad y nadie se entera. Una alerta en Alertmanager (07-04):

kube_horizontalpodautoscaler_status_current_replicas
  >= kube_horizontalpodautoscaler_spec_max_replicas

Consejo 3: registra un panel con réplicas y métrica juntas. En Grafana, superponer «réplicas actuales» y «CPU media» en el mismo gráfico hace evidente si el HPA reacciona bien o va por detrás. Es el diagnóstico visual más útil que existe para un HPA.

Consejo 4: prueba el HPA antes de necesitarlo. Un ensayo de carga contra rutas-norte-pre una semana antes del puente de mayo cuesta una tarde y evita la caída del año. Lo haremos formalmente con k6 en 09-06.

Consejo 5: los eventos del HPA caducan. Una hora por defecto. Si quieres el histórico del incidente, necesitas EFK (07-05) o las métricas de kube-state-metrics en Prometheus.

Ejercicios

Ejercicio 1: aplicar la fórmula

tienda-web está desplegado con requests.cpu: 200m y un HPA con averageUtilization: 50, minReplicas: 3, maxReplicas: 15. En este momento hay 6 réplicas y kubectl top da:

tienda-web-6f7d9c4b58-2wq4m   210m
tienda-web-6f7d9c4b58-4kjnx   198m
tienda-web-6f7d9c4b58-7hgpz   225m
tienda-web-6f7d9c4b58-9mzbc   187m
tienda-web-6f7d9c4b58-kx2vt   204m
tienda-web-6f7d9c4b58-tn8fr   216m

Calcula: (a) el objetivo absoluto por pod; (b) el consumo medio; (c) el ratio y si está dentro de la banda de tolerancia; (d) las réplicas deseadas; (e) qué hará el HPA con este behavior:

behavior:
  scaleUp:
    stabilizationWindowSeconds: 0
    selectPolicy: Max
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
      - type: Pods
        value: 4
        periodSeconds: 15

Ejercicio 2: diagnosticar un HPA mudo

Un compañero ha desplegado worker-notificaciones con este HPA y se queja de que nunca escala:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: worker-notificaciones
  namespace: rutas-norte-pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker-notificaciones
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

El Deployment es:

spec:
  template:
    spec:
      containers:
        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:2.3.0
          resources:
            limits:
              cpu: 500m
              memory: 512Mi
        - name: exportador-metricas
          image: registry.rutasnorte.example/exportador:1.2.0

kubectl get hpa muestra <unknown>/70%. Identifica todos los problemas, escribe los comandos de diagnóstico que ejecutarías y las correcciones. Además, explica por qué incluso arreglando todo esto este HPA seguirá siendo una mala idea para este componente concreto.

Ejercicio 3: diseñar un behavior para un escenario nuevo

Rutas Norte lanza panel-conductores, una aplicación web interna que usan los 40 conductores de la flota. Perfil de carga:

  • De lunes a viernes, de 5:30 a 7:00, todos los conductores entran para consultar su ruta del día: la carga pasa de cero a su máximo en quince minutos.
  • El resto del día hay uso esporádico, muy bajo.
  • Por la noche no hay nadie.
  • La aplicación es Node.js y tarda unos 25 segundos en estar lista.
  • Es interna: una caída de dos minutos es molesta pero no cuesta dinero.
  • El equipo tiene presión de costes: el clúster es compartido y hay ResourceQuota.

Escribe el HPA completo (autoscaling/v2) razonando cada decisión: minReplicas, maxReplicas, métrica, objetivo, y todo el behavior. Compara al menos tres decisiones con las del HPA de api-reservas y explica por qué difieren.


Soluciones

Solución 1

(a) Objetivo absoluto por pod:

50 % de 200m = 100m por pod

(b) Consumo medio:

(210 + 198 + 225 + 187 + 204 + 216) / 6 = 1240 / 6 = 206,67m

(c) Ratio y banda de tolerancia:

ratio = 206,67 / 100 = 2,067
|2,067 - 1| = 1,067 > 0,10   ->  FUERA de la banda. El HPA actua.

(d) Réplicas deseadas:

replicasDeseadas = techo( 6 × 2,067 ) = techo( 12,4 ) = 13

13 réplicas, por debajo de maxReplicas: 15.

(e) Qué hace el behavior:

Réplicas actuales: 6.

  • Política Percent 100 % / 15 s: permite añadir el 100 % de 6 = 6 → hasta 12 réplicas.
  • Política Pods 4 / 15 s: permite añadir 4 → hasta 10 réplicas.
  • selectPolicy: Max toma el techo más alto: 12.

El HPA quiere 13 pero solo puede llegar a 12 en este paso. Escalará a 12 réplicas ahora.

En el siguiente ciclo (15 s después), con 12 réplicas y suponiendo que la carga total no cambia, el consumo medio bajará a unos 103m por pod. El nuevo ratio sería 1,03, dentro de la banda de tolerancia, así que se quedará en 12 y no llegará nunca a 13. Este es un comportamiento habitual y correcto: la tolerancia absorbe el último salto.

Observación adicional: los pods están al 103 % de su requests (206m contra 200m). No están sufriendo throttling porque su limit es mayor, pero están consumiendo más de lo reservado, lo que significa que dependen de la CPU sobrante del nodo. Es una señal de que el requests de tienda-web está infradimensionado y de que el VPA de 09-02 tendría algo que decir.

Solución 2

Hay cuatro problemas, uno detrás de otro.

Problema 1: el contenedor worker no declara requests.cpu.

Solo tiene limits. Sin requests, el HPA no tiene denominador para el porcentaje.

Matiz importante que muchos desconocen: cuando declaras limits sin requests, Kubernetes rellena automáticamente requests con el valor de limits. Así que este contenedor sí acaba teniendo requests.cpu: 500m. Como efecto secundario, la QoS del pod sería Guaranteed si todos los contenedores estuvieran así (módulo 3). Pero...

Problema 2: el contenedor exportador-metricas no declara ningún recurso.

Este sí es fatal. Sin limits ni requests, no hay relleno automático, y el HPA no puede calcular la utilización del pod. Este es el que produce el <unknown>. Además hace que la QoS del pod sea Burstable en lugar de Guaranteed.

Problema 3: no se sabe si metrics-server está disponible.

Hay que verificarlo antes que nada.

Problema 4 (de diseño, y el más importante): la CPU es la señal equivocada para este componente.

Comandos de diagnóstico, en orden:

# 1. ¿Funciona metrics-server?
kubectl top pods -n rutas-norte-pro -l app=worker-notificaciones

# 2. ¿Qué dice el HPA exactamente?
kubectl describe hpa worker-notificaciones -n rutas-norte-pro

# 3. ¿Qué recursos declara cada contenedor?
kubectl get deployment worker-notificaciones -n rutas-norte-pro \
  -o jsonpath='{range .spec.template.spec.containers[*]}{.name}{": "}{.resources}{"\n"}{end}'

La salida del paso 3 delataría el problema:

worker: {"limits":{"cpu":"500m","memory":"512Mi"}}
exportador-metricas: {}

Corrección del Deployment:

spec:
  template:
    spec:
      containers:
        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:2.3.0
          resources:
            requests:
              cpu: 300m          # Explicito, no dependemos del relleno automatico
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
        - name: exportador-metricas
          image: registry.rutasnorte.example/exportador:1.2.0
          resources:
            requests:
              cpu: 20m           # <-- LA CORRECCION CLAVE
              memory: 32Mi
            limits:
              cpu: 50m
              memory: 64Mi

Y, defensivamente, cambiar el HPA a ContainerResource para que el sidecar no distorsione el porcentaje:

  metrics:
    - type: ContainerResource
      containerResource:
        name: cpu
        container: worker
        target:
          type: Utilization
          averageUtilization: 70

Por qué sigue siendo mala idea aunque funcione:

worker-notificaciones consume mensajes de una cola de correos. Su trabajo por mensaje es sobre todo espera de entrada/salida: hablar con el servidor SMTP, esperar la respuesta de red. Eso consume muy poca CPU.

En el puente de mayo puede haber cuarenta mil correos esperando en la cola y el worker estar al 20 % de CPU, porque está bloqueado esperando al SMTP la mayor parte del tiempo. El HPA por CPU vería 20 % contra un objetivo de 70 % y concluiría que sobran réplicas: escalaría hacia abajo justo cuando más hacen falta consumidores.

La métrica correcta es la longitud de la cola: «quiero un worker por cada 500 mensajes pendientes». Eso no lo puede leer el HPA de fábrica; hace falta una métrica externa, y esa es exactamente la razón de ser de KEDA en 09-04.

Solución 3

# k8s/entornos/pro/hpa-panel-conductores.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: panel-conductores
  namespace: rutas-norte-pro
  labels:
    app: panel-conductores
    app.kubernetes.io/part-of: rutas-norte
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: panel-conductores

  # minReplicas: 1. No es un servicio que genere ingresos y por la noche no hay
  # nadie. Una replica basta para el uso esporadico del resto del dia y para
  # que la primera peticion de las 5:30 no encuentre el servicio apagado.
  # No ponemos 2 porque hay presion de costes y una caida de 2 min es asumible.
  minReplicas: 1

  # maxReplicas: 6. Son 40 conductores concurrentes como maximo. Con 6 replicas
  # tocan a menos de 7 usuarios por pod: sobra. Un techo bajo protege la
  # ResourceQuota del namespace compartido.
  maxReplicas: 6

  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          # 50 %, no 65-70 %. La aplicacion tarda 25 s en estar lista, asi que
          # necesitamos margen para cubrir ese arranque. Ademas la subida de las
          # 5:30 es abrupta: mejor ir por delante.
          averageUtilization: 50

  behavior:
    scaleUp:
      # Cero espera. La ventana critica dura 90 minutos al dia y toda la carga
      # llega en los primeros 15. Cualquier retraso se come la ventana entera.
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        # periodSeconds 30, no 15: la aplicacion tarda 25 s en estar lista.
        # Con 15 s escalariamos otra vez ANTES de que los pods anteriores
        # empezasen a absorber carga, y sobreescalariamos.
        - type: Percent
          value: 100
          periodSeconds: 30
        - type: Pods
          value: 2
          periodSeconds: 30
        # Con maxReplicas 6, "+2 pods" cubre bien el rango 1->3->6.

    scaleDown:
      # 300 s (5 min), no 600. El pico dura 90 minutos y luego no vuelve hasta
      # el dia siguiente: no hay puntas intermitentes que justifiquen esperar
      # diez minutos, y la presion de costes pide liberar recursos pronto.
      stabilizationWindowSeconds: 300
      selectPolicy: Min
      policies:
        # Bajada suave: un pod cada 3 minutos. De 6 a 1 en 15 minutos.
        # Suficiente para no dejar tirado a un rezagado de las 7:05.
        - type: Pods
          value: 1
          periodSeconds: 180

Comparación razonada con el HPA de api-reservas:

Decisión api-reservas panel-conductores Por qué difieren
minReplicas 4 1 La API genera ingresos y necesita disponibilidad multizona; el panel es interno, con presión de costes y una caída tolerable
maxReplicas 30 6 El tráfico de la API es público e impredecible (x10 en el puente); el panel tiene un techo conocido de 40 usuarios
Objetivo de CPU 65 % 50 % Ambas son Node.js, pero el panel sufre una subida más abrupta (de cero al máximo en 15 min) y necesita más margen para cubrir los 25 s de arranque
periodSeconds al subir 30 s 30 s Igual: ambas son Node.js con arranque de 20-30 s. Coincidencia justificada por la misma causa técnica
Estabilización al bajar 600 s 300 s El tráfico de la API es intermitente durante horas; el del panel es un único bloque diario que no vuelve
Política de bajada 20 % / 120 s 1 pod / 180 s Con solo 6 réplicas, un porcentaje del 20 % daría fracciones inútiles; en números pequeños se razona mejor en pods absolutos

Consideración adicional que merece nota: este perfil de carga —cero por la noche, avalancha a hora fija— es el candidato perfecto para un disparador cron que precaliente el servicio a las 5:15, en lugar de esperar a que la CPU suba y reaccionar tarde. Eso ya no lo hace el HPA de fábrica: es KEDA, y lo veremos en 09-04. Un HPA reactivo siempre va por detrás de una carga que sube en escalón; solo una señal anticipada resuelve ese caso de verdad.

Conclusión

El HorizontalPodAutoscaler convierte el número de réplicas de un dato fijo escrito a mano en una consecuencia observada del tráfico real. Es la diferencia entre dimensionar para un martes cualquiera y sobrevivir al puente de mayo sin que nadie tenga que abrir el portátil en el andén de una estación.

Lo esencial que te llevas de esta lección:

  • Escalar hacia fuera y escalar hacia arriba son ejes distintos. El horizontal resuelve el volumen de tráfico y además mejora la disponibilidad; el vertical resuelve el dimensionado de cada instancia. Rutas Norte necesita los dos.
  • Solo se escala horizontalmente lo que es realmente sin estado. api-reservas y tienda-web sí; postgres-reservas nunca, y el intento produciría corrupción de datos, no capacidad.
  • La fórmula es una regla de tres: techo(replicasActuales × valorActual / valorObjetivo), con una banda de tolerancia del ±10 % que evita el bailoteo.
  • Sin requests en todos los contenedores y sin metrics-server no hay HPA. El <unknown> de la columna TARGETS casi siempre apunta a un sidecar olvidado.
  • El behavior es donde se decide si el autoescalado funciona. La asimetría de Rutas Norte —subir en cero segundos, bajar tras diez minutos de calma— refleja una asimetría real de costes: perder ventas es mucho más caro que sobrar capacidad un rato.
  • Con varias métricas, gana la que pide más réplicas. Un OR de necesidad, deliberadamente conservador.
  • El campo replicas debe desaparecer del manifiesto versionado. Si no, un kubectl apply inocente tirará la plataforma en el pico de venta, y con GitOps será una guerra permanente (10-05).
  • Antes de poner un HPA, encuentra el recurso escaso. Escalar la capa web cuando el límite es la base de datos multiplica el coste sin añadir un solo billete vendido.

Rutas Norte tiene ya api-reservas y tienda-web respondiendo al tráfico por su cuenta. Pero seguimos arrastrando un problema del que hemos hablado varias veces sin resolver del todo: el HPA se apoya en el requests de CPU como referencia de todo su cálculo, y ese requests sigue siendo un número que pusimos a ojo. En 07-02 lo recalibramos comparando con el consumo real, pero lo hicimos a mano, una vez, mirando kubectl top durante una tarde. Si el requests está mal, el porcentaje del HPA miente, y con él todas sus decisiones.

En la próxima lección, Autoescalado Vertical de Pods, veremos el componente que resuelve exactamente ese problema: el VPA observa el consumo real durante días, calcula percentiles y te dice con datos qué requests y qué limits deberían tener tus contenedores. Descubriremos también por qué el VPA y el HPA no pueden gobernar la misma métrica sin pelearse, y cuál es el flujo de trabajo que usan los equipos serios: el VPA como asesor, no como piloto automático.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados