La lección anterior terminó con una incomodidad. El HorizontalPodAutoscaler de api-reservas calcula todas sus decisiones como un porcentaje del requests.cpu, y ese requests.cpu: 500m es un número que alguien puso a ojo hace meses. En 07-02 lo recalibramos comparando con el consumo real, pero lo hicimos a mano, una tarde, mirando kubectl top y anotando en una hoja de cálculo. Si el requests está mal, el porcentaje del HPA miente y todas sus decisiones se apoyan en una referencia falsa.

Y el problema es más amplio que el HPA. Los requests y limits gobiernan la planificación (dónde cabe un pod), la clase de QoS (quién muere primero cuando falta memoria), el OOMKilled, el estrangulamiento de CPU, el consumo de la ResourceQuota del namespace y, en la nube, la factura. Todo eso descansa sobre números que en la mayoría de las organizaciones se escribieron una vez, copiando el manifiesto de otro servicio, y nadie ha vuelto a mirar.

El VerticalPodAutoscaler (VPA) existe para eso. No escala por carga: escala por conocimiento. Observa lo que tus contenedores consumen realmente durante días, calcula percentiles y te dice con datos qué deberían pedir. Esta lección cubre qué es, cómo funciona por dentro, cómo leer sus recomendaciones, por qué la mayoría de equipos serios lo usan como asesor y no como piloto automático, y por qué no puede convivir con el HPA sobre la misma métrica.

Contenido

  1. El problema real: nadie sabe qué pedir
  2. Qué es el VPA y qué no es
  3. No forma parte del núcleo: instalación
  4. Los tres componentes y cómo colaboran
  5. El objeto VerticalPodAutoscaler campo a campo
  6. Los modos de actualización: Off, Initial, Recreate y Auto
  7. El redimensionado en caliente y su estado actual
  8. resourcePolicy: acotar, excluir y controlar
  9. Leer una recomendación: target, lowerBound, upperBound, uncappedTarget
  10. El modo Off como asesor: el flujo de trabajo real
  11. La incompatibilidad clásica: VPA y HPA sobre la misma métrica
  12. Aplicación a Rutas Norte, componente a componente
  13. Interacción con LimitRange y ResourceQuota
  14. Los límites del VPA
  15. Errores comunes y consejos
  16. Ejercicios
  17. Conclusión

  1. El problema real: nadie sabe qué pedir

Pongamos números al problema. Estos son los requests que Rutas Norte lleva arrastrando desde el módulo 3, junto al consumo real medido durante dos semanas con Prometheus:

Contenedor requests.cpu CPU real (p95) requests.memory Memoria real (p95) Diagnóstico
api-reservas / api 500m 340m 512Mi 690Mi CPU sobrada, memoria corta
api-reservas / exportador 20m 4m 32Mi 18Mi Sobredimensionado x5
tienda-web / nginx 200m 55m 128Mi 64Mi Sobredimensionado x3
worker-notificaciones 300m 90m 256Mi 410Mi CPU sobrada, memoria corta
informes-ocupacion 500m 1750m 512Mi 1,4Gi Muy infradimensionado
postgres-reservas 2 1,2 4Gi 3,6Gi Razonable

Cada fila cuenta una historia distinta y cada una cuesta dinero o disponibilidad:

  • api-reservas con memoria corta. Pide 512Mi y usa 690Mi en el p95. Funciona porque el limit es 1Gi y hay memoria libre en los nodos... hasta que no la hay. El día que un nodo se llena, el kubelet desaloja pods, y un pod Burstable que consume por encima de su requests es de los primeros candidatos (módulo 3). Es una bomba de relojería.
  • tienda-web sobredimensionado tres veces. Con 15 réplicas en el pico son 3 núcleos reservados de los que se usan 0,8. Esos 2,2 núcleos están bloqueados para el planificador: ningún otro pod puede usarlos aunque estén ociosos. Se traduce en nodos de más.
  • informes-ocupacion muy infradimensionado. Pide 500m y necesita 1750m. Consecuencia: estrangulamiento brutal. Un informe que debería tardar 8 minutos tarda 40, y algunas noches no termina antes de la ventana de mantenimiento.
  • El exportador de métricas. 20m de requests para un proceso que usa 4m. Parece poco, pero multiplicado por todos los pods de la plataforma son núcleos enteros desperdiciados.

Ahora la pregunta incómoda: ¿cómo se corrige esto? La respuesta habitual —«miramos Grafana y ajustamos»— tiene tres problemas. Requiere que alguien se acuerde. Requiere que ese alguien sepa qué percentil mirar. Y hay que repetirlo cada vez que la aplicación cambia, lo que en la práctica significa nunca.

El VPA automatiza exactamente ese trabajo.

  1. Qué es el VPA y qué no es

El VerticalPodAutoscaler es un componente que:

  1. Observa el consumo histórico de CPU y memoria de los contenedores de un conjunto de pods.
  2. Calcula una recomendación de requests (y opcionalmente limits) basada en percentiles de ese histórico, con decaimiento exponencial para dar más peso a lo reciente.
  3. Opcionalmente aplica esa recomendación, recreando los pods con los nuevos valores.

Y muy importante, lo que no es:

El VPA... ...pero no
Ajusta el tamaño de cada pod Cambia el número de pods (eso es el HPA)
Responde a lo que la aplicación consume Responde a la carga entrante o a la latencia
Corrige un dimensionado equivocado Absorbe una punta de tráfico
Trabaja en escala de horas y días Reacciona en segundos

Esta distinción es la clave para entender la relación con el HPA. El HPA es un mecanismo de respuesta a la carga, con constante de tiempo de segundos. El VPA es un mecanismo de corrección del dimensionado, con constante de tiempo de días. No compiten en el mismo terreno... salvo cuando ambos miran la misma métrica, que es el conflicto del apartado 11.

Una forma útil de recordarlo: el HPA responde a la pregunta «¿cuántos?»; el VPA responde a la pregunta «¿de qué tamaño?».

  1. No forma parte del núcleo: instalación

A diferencia del HPA, que vive dentro del kube-controller-manager y está disponible en cualquier clúster, el VPA es un componente externo que hay que instalar. Vive en el repositorio kubernetes/autoscaler, junto al Cluster Autoscaler que veremos en 09-03.

Esto tiene consecuencias prácticas que conviene conocer antes de empezar:

  • Debes comprobar la compatibilidad de versiones entre el VPA y tu Kubernetes. La tabla de compatibilidad está en el repositorio; usar una versión desajustada produce errores oscuros.
  • Instala tres Deployments y un puñado de CRDs, RBAC y un webhook de admisión.
  • En Kubernetes gestionado (10-06), muchos proveedores lo ofrecen como complemento activable con un interruptor (GKE lo tiene integrado desde hace años). Si estás en la nube, mira primero si tu proveedor te lo da hecho.

Instalación con el script oficial

# 1. Clonar el repositorio del autoescalador en la rama que corresponda
git clone -b vpa-release-1.2 https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler

# 2. Ejecutar el instalador. Crea CRDs, RBAC, los tres deployments y el webhook.
./hack/vpa-up.sh

Requisito previo importante: el VPA necesita metrics-server (el mismo de 07-02) para el updater y el admission-controller, y su recomendador puede leer además de Prometheus si lo configuras. En minikube:

minikube -p rutas-norte addons enable metrics-server

Verificación

kubectl get pods -n kube-system -l app in (vpa-recommender,vpa-updater,vpa-admission-controller)
NAME                                        READY   STATUS    RESTARTS   AGE
vpa-admission-controller-6b8f9d7c4d-x2klm   1/1     Running   0          2m
vpa-recommender-7d9c5f8b6b-4mnpq            1/1     Running   0          2m
vpa-updater-5f7b8c9d6a-9wrtz                1/1     Running   0          2m

Y los CRDs registrados:

kubectl get crd | grep autoscaling.k8s.io
verticalpodautoscalers.autoscaling.k8s.io               2026-04-12T09:14:22Z
verticalpodautoscalercheckpoints.autoscaling.k8s.io     2026-04-12T09:14:22Z

El segundo CRD, VerticalPodAutoscalerCheckpoint, merece una nota: ahí el recomendador persiste el histograma de consumo cada pocos minutos. Sin él, un reinicio del recomendador perdería todo el histórico acumulado y habría que volver a empezar el periodo de observación. Cuando alguien te diga «reinicié el VPA y ahora las recomendaciones son raras», el checkpoint es lo primero que hay que mirar.

Para desinstalar:

./hack/vpa-down.sh

  1. Los tres componentes y cómo colaboran

El VPA no es un proceso, son tres, con responsabilidades bien separadas. Entender esta separación explica casi todo su comportamiento.

flowchart TD
    subgraph Fuentes["Fuentes de datos"]
        MS[metrics-server<br/>metrics.k8s.io]
        PR[Prometheus<br/>opcional, historico largo]
    end

    subgraph VPA["Componentes del VPA"]
        REC[Recomendador<br/>vpa-recommender]
        UPD[Actualizador<br/>vpa-updater]
        ADM[Controlador de admision<br/>vpa-admission-controller]
    end

    OBJ[(Objeto VPA<br/>status.recommendation)]
    CKP[(Checkpoint<br/>histograma persistido)]
    API[Servidor de API]
    POD[Pods de api-reservas]

    MS --> REC
    PR -.-> REC
    REC -->|escribe recomendacion| OBJ
    REC <-->|guarda y restaura| CKP
    OBJ -->|lee| UPD
    OBJ -->|lee| ADM
    UPD -->|API de desalojo<br/>solo si updateMode lo permite| API
    API -->|recrea el pod| POD
    POD -->|peticion de creacion| ADM
    ADM -->|MUTA los recursos<br/>antes de persistir| API

    style REC fill:#cde,stroke:#369
    style UPD fill:#fda,stroke:#960
    style ADM fill:#cfc,stroke:#393

El recomendador (vpa-recommender)

Es el cerebro. Su trabajo:

  • Lee el consumo de CPU y memoria de todos los pods que coinciden con algún targetRef de un objeto VPA.
  • Mantiene, por contenedor, un histograma con decaimiento exponencial: las muestras viejas pesan menos que las recientes. La semivida por defecto es de 24 horas, lo que significa que una muestra de hace un día vale la mitad que una de ahora.
  • Calcula percentiles sobre ese histograma y escribe la recomendación en status.recommendation del objeto VPA.
  • Persiste el histograma en un VerticalPodAutoscalerCheckpoint para sobrevivir a reinicios.

Los percentiles que usa por defecto:

Recurso Percentil para target Margen añadido
CPU p90 del histograma +15 % de seguridad
Memoria Máximo de las ventanas de 24 h de los últimos 8 días +15 % de seguridad

La asimetría es deliberada y muy sensata. Quedarse corto de CPU produce lentitud; quedarse corto de memoria produce OOMKilled. Por eso la memoria se calcula con el pico y la CPU con un percentil alto pero no extremo. Un contenedor puede sobrevivir perfectamente a picos de CPU por encima de su requests (se los presta el nodo si hay); a un pico de memoria por encima de su limit, no.

Detalle adicional: el recomendador observa los eventos OOMKilled y, cuando detecta uno, sube la recomendación de memoria de golpe (aproximadamente un 20 %) sin esperar a que el histograma se ajuste. Es una realimentación directa desde el fallo.

El recomendador funciona siempre, sea cual sea el updateMode. Incluso en modo Off está trabajando y publicando recomendaciones. Esto es precisamente lo que hace útil el modo asesor.

El actualizador (vpa-updater)

Es el brazo ejecutor. Cada minuto:

  • Compara los recursos actuales de cada pod con la recomendación vigente.
  • Si la diferencia supera un umbral (por defecto, si el valor actual está fuera del rango [lowerBound, upperBound]), decide que el pod hay que recrearlo.
  • Desaloja el pod usando la API de desalojo (eviction), la misma que usa kubectl drain. Esto es fundamental: el actualizador respeta los PodDisruptionBudgets (09-05). Si tu PDB no permite el desalojo, el VPA espera pacientemente.
  • No modifica el pod: lo mata. El Deployment lo recrea, y el nuevo pod pasa por el controlador de admisión.

Dos salvaguardas que evitan desastres:

  • No desaloja un pod si no han pasado al menos 12 horas desde su creación (evita ciclos de recreación).
  • Nunca desaloja todos los pods de un mismo controlador a la vez.

El actualizador solo actúa en los modos Recreate y Auto. En Off e Initial está inactivo.

El controlador de admisión (vpa-admission-controller)

Es un MutatingAdmissionWebhook registrado en la API. Cuando llega una petición de creación de pod:

  • Comprueba si ese pod coincide con algún VPA activo.
  • Si sí, reescribe los requests (y los limits, manteniendo la proporción) del pod antes de que se persista en etcd.
  • El pod nace ya con los valores recomendados. El planificador lo coloca con esos números.

Este es el componente que hace que la recreación funcione. Sin él, el actualizador mataría un pod y el Deployment lo recrearía con los valores originales del manifiesto: un bucle infinito de desalojos inútiles.

Una consecuencia importante y a menudo sorprendente: el VPA no modifica el Deployment. Si haces kubectl get deployment api-reservas -o yaml seguirás viendo requests.cpu: 500m. Los pods reales tendrán otros valores. La mutación ocurre en el nivel del pod, en admisión. Para ver los valores efectivos:

kubectl get pod api-reservas-7c9d4f8b6d-2mk8p -n rutas-norte-pro \
  -o jsonpath='{.spec.containers[*].resources}' | python3 -m json.tool

Un riesgo derivado: si el webhook de admisión está caído o mal configurado, según su failurePolicy puede bloquear la creación de todos los pods del clúster o dejar que pasen sin mutar. El instalador oficial lo pone en Ignore por defecto, lo cual es lo seguro.

  1. El objeto VerticalPodAutoscaler campo a campo

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  # A QUE recurso se aplica. Mismo formato que el scaleTargetRef del HPA.
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reservas

  # QUE hace con la recomendacion.
  updatePolicy:
    updateMode: "Off"                 # Off | Initial | Recreate | Auto
    minReplicas: 2                    # No desaloja si quedarian menos de N replicas

  # LIMITES de la recomendacion, por contenedor.
  resourcePolicy:
    containerPolicies:
      - containerName: api
        minAllowed:
          cpu: 200m
          memory: 256Mi
        maxAllowed:
          cpu: "2"
          memory: 2Gi
        controlledResources: ["cpu", "memory"]
        controlledValues: RequestsAndLimits
      - containerName: exportador-metricas
        mode: "Off"                   # Este contenedor queda excluido
Campo Tipo Qué hace
targetRef objeto Deployment, StatefulSet, DaemonSet, CronJob... cualquier controlador de pods
updatePolicy.updateMode cadena Qué hacer con la recomendación (apartado 6)
updatePolicy.minReplicas entero El actualizador no desaloja si el controlador tiene menos réplicas que esto. Por defecto 2
resourcePolicy.containerPolicies[].containerName cadena Nombre del contenedor, o "*" para todos
.minAllowed / .maxAllowed recursos Suelo y techo de la recomendación
.mode cadena Auto (defecto) o Off para excluir este contenedor
.controlledResources lista ["cpu"], ["memory"] o ambos
.controlledValues cadena RequestsAndLimits (defecto) o RequestsOnly

Nota sobre targetRef: a diferencia del HPA, no requiere que el objeto exponga el subrecurso /scale. Por eso el VPA sí puede aplicarse a un DaemonSet, mientras que el HPA no. Esto es útil de verdad: los agentes de Fluentd y de Falco que desplegamos en los módulos 7 y 8 son DaemonSets, y sus requests son igual de arbitrarios que los demás.

  1. Los modos de actualización: Off, Initial, Recreate y Auto

El updateMode es la decisión más importante del objeto.

Modo El recomendador Muta pods nuevos Desaloja pods vivos Riesgo Uso típico
Off No No Ninguno Asesoría. El más usado en producción
Initial No Bajo Aplicar en el siguiente despliegue, sin reinicios extra
Recreate Medio-alto Cargas que toleran reinicios
Auto Sí (hoy = Recreate) Medio-alto Igual que Recreate hoy

Off — el asesor

updatePolicy:
  updateMode: "Off"

El recomendador observa y publica en status.recommendation. Nada más. Ningún pod se toca, ningún manifiesto cambia. Es información pura.

Coste operativo: cero. Riesgo: cero. Valor: alto. Por eso es, con diferencia, el modo más usado en producción seria.

Initial — aplicar solo al crear

updatePolicy:
  updateMode: "Initial"

El controlador de admisión muta los pods cuando nacen por otra razón: un despliegue nuevo, un reinicio por caída de nodo, un escalado del HPA. El actualizador nunca desaloja nada.

Es un punto intermedio interesante: los valores se aplican de verdad, pero sin causar ni un solo reinicio adicional. La contrapartida es la lentitud: si api-reservas se despliega cada dos semanas, las recomendaciones tardan dos semanas en materializarse. Y crea un efecto desconcertante: dos pods del mismo Deployment, creados en momentos distintos, pueden tener recursos distintos.

Recreate — aplicar desalojando

updatePolicy:
  updateMode: "Recreate"
  minReplicas: 2

El actualizador desaloja activamente los pods cuyos recursos se han desviado. Es autoescalado vertical de verdad, con la consecuencia inevitable: cada ajuste es un reinicio del pod.

Para worker-notificaciones es aceptable (procesa mensajes de una cola, un reinicio pierde como mucho el mensaje en vuelo, que la cola reentrega). Para postgres-reservas es otra historia, y lo discutimos en el apartado 12.

minReplicas: 2 es la protección esencial: el actualizador no desalojará si el controlador tiene menos réplicas que ese número. Con una sola réplica, un desalojo es una caída completa del servicio.

Auto — hoy es Recreate

updatePolicy:
  updateMode: "Auto"

Semánticamente significa «usa el mejor mecanismo disponible». Hoy, en la práctica, Auto se comporta exactamente igual que Recreate en la mayoría de instalaciones. La intención declarada del proyecto es que, cuando el redimensionado en caliente sea estable, Auto lo use y deje de recrear pods.

Consejo operativo: usa Recreate explícitamente si eso es lo que quieres. Así, cuando el significado de Auto cambie con una actualización del VPA, tu comportamiento no cambiará por sorpresa.

  1. El redimensionado en caliente y su estado actual

Durante años, la limitación fundamental del escalado vertical en Kubernetes fue que los recursos de un contenedor eran inmutables: cambiarlos exigía recrear el pod.

Eso está cambiando. La funcionalidad se llama In-Place Pod Vertical Scaling (redimensionado vertical de pods en caliente), y permite modificar requests y limits de un contenedor en ejecución sin recrear el pod, mediante el subrecurso /resize.

Cómo funciona

Cada contenedor puede declarar una política de reinicio por recurso:

spec:
  containers:
    - name: api
      image: registry.rutasnorte.example/api-reservas:1.14.2
      resizePolicy:
        - resourceName: cpu
          restartPolicy: NotRequired      # La CPU se ajusta en caliente
        - resourceName: memory
          restartPolicy: RestartContainer # La memoria exige reiniciar el contenedor
      resources:
        requests:
          cpu: 500m
          memory: 512Mi
        limits:
          cpu: "1"
          memory: 1Gi

Y el cambio se aplica con:

kubectl patch pod api-reservas-7c9d4f8b6d-2mk8p -n rutas-norte-pro \
  --subresource resize \
  --patch '{"spec":{"containers":[{"name":"api","resources":{"requests":{"cpu":"800m"}}}]}}'

El estado del proceso queda reflejado en el pod:

kubectl get pod api-reservas-7c9d4f8b6d-2mk8p -n rutas-norte-pro -o yaml | grep -A5 resize
  status:
    resize: InProgress
    containerStatuses:
      - name: api
        allocatedResources:
          cpu: 500m
        resources:
          requests:
            cpu: 800m

Estado actual y advertencia

Esta es la parte importante para tomar decisiones:

  • La funcionalidad ha avanzado a beta en Kubernetes 1.33 y sigue madurando. En 1.30-1.32 es alpha y requiere activar la feature gate InPlacePodVerticalScaling en el servidor de API y en los kubelets.
  • Reducir la memoria en caliente no está resuelto de forma general: bajar el limit de memoria por debajo de lo que el proceso ya tiene mapeado puede provocar un OOMKilled inmediato. Por eso la política habitual para memoria es RestartContainer.
  • La integración del VPA con esta funcionalidad existe pero no está completa ni es el camino por defecto.
  • En Kubernetes gestionado (10-06), la feature gate puede no estar disponible: no controlas el plano de control.

Advertencia explícita: no construyas la estrategia de capacidad de producción sobre el redimensionado en caliente todavía. Es la dirección correcta y en un par de versiones será la forma natural de hacer las cosas, pero hoy:

  1. Puede no estar disponible en tu clúster.
  2. Su comportamiento con memoria tiene aristas.
  3. El VPA no la explota plenamente.

Conócela, pruébala en rutas-norte-dev, síguele la pista. En rutas-norte-pro, el flujo de trabajo del apartado 10.

  1. resourcePolicy: acotar, excluir y controlar

Dejar que un recomendador escriba números sin barandillas es una mala idea. El resourcePolicy es donde pones el criterio humano.

Acotar mínimos y máximos

resourcePolicy:
  containerPolicies:
    - containerName: api
      minAllowed:
        cpu: 200m           # Nunca por debajo: el arranque de Node.js necesita CPU
        memory: 256Mi       # Nunca por debajo: el heap base ya ocupa esto
      maxAllowed:
        cpu: "2"            # Nunca por encima: cabria en pocos nodos
        memory: 2Gi         # Nunca por encima: sospecharia de una fuga de memoria

Por qué cada barandilla:

minAllowed. El recomendador se basa en el consumo observado. Si un servicio pasa la noche sin tráfico, su p90 de CPU es casi cero, y sin minAllowed la recomendación bajaría a 15m. Cuando llega el tráfico de la mañana, el pod se ahoga arrancando. El minAllowed codifica «esto es lo mínimo para arrancar y responder decentemente, aunque las métricas digan otra cosa».

maxAllowed. Dos motivos. Primero, planificación: un pod de 8 núcleos solo cabe en nodos grandes, y si tus nodos son de 4 núcleos ese pod se queda Pending para siempre. Segundo, detección de anomalías: si la recomendación llega al techo, hay algo que investigar (una fuga de memoria, una consulta patológica). El maxAllowed convierte un problema silencioso en una señal.

Regla práctica para el maxAllowed: nunca por encima de lo que cabe en un nodo real, con margen para el sistema. En nodos de 4 núcleos y 16 GiB, un maxAllowed razonable ronda 3 núcleos y 12Gi.

Excluir un contenedor

resourcePolicy:
  containerPolicies:
    - containerName: exportador-metricas
      mode: "Off"

mode: "Off" a nivel de contenedor lo saca del VPA por completo: no se recomienda ni se muta. Cuándo hacerlo:

  • Sidecars con recursos ya bien conocidos y estables. Un exportador de Prometheus consume 5m y 20Mi hoy, mañana y siempre. No necesita un recomendador.
  • Sidecars de malla de servicio (el proxy de Istio, 08-04). Suelen tener su propia gestión de recursos y el VPA se pisa con ella.
  • Contenedores con requests deliberadamente altos por razones que las métricas no ven: reservar CPU para picos de latencia crítica, por ejemplo.

Ojo con un detalle: si excluyes todos los contenedores del pod, el VPA no hará nada, lo cual es correcto, pero el objeto sigue existiendo y confunde. Bórralo mejor.

Comodín para todos los contenedores

resourcePolicy:
  containerPolicies:
    - containerName: "*"
      minAllowed:
        cpu: 10m
        memory: 32Mi
      maxAllowed:
        cpu: "1"
        memory: 1Gi
    - containerName: api          # La regla especifica GANA sobre el comodin
      maxAllowed:
        cpu: "2"
        memory: 2Gi

controlledResources y controlledValues

- containerName: api
  controlledResources: ["memory"]     # Solo memoria; la CPU la deja en paz
  controlledValues: RequestsOnly      # Solo requests; no toca los limits

controlledResources es la pieza clave para convivir con el HPA: si el HPA escala por CPU, el VPA debe limitarse a la memoria (apartado 11).

controlledValues decide si el VPA toca también los limits:

Valor Comportamiento Consecuencia en QoS
RequestsAndLimits (defecto) Ajusta ambos, manteniendo la proporción original Preserva la QoS: si era Guaranteed, sigue siéndolo
RequestsOnly Solo ajusta requests, deja los limits como están Puede cambiar la QoS de Guaranteed a Burstable

El detalle de la proporción merece un ejemplo. Si el manifiesto tiene requests.cpu: 500m y limits.cpu: 1000m (proporción 1:2) y el VPA recomienda requests.cpu: 800m, con RequestsAndLimits pondrá limits.cpu: 1600m para mantener el 1:2. Es un comportamiento sensato pero que sorprende: el VPA puede subirte los limits por encima de lo que esperabas, y eso interactúa con la ResourceQuota del namespace (apartado 13).

  1. Leer una recomendación: target, lowerBound, upperBound, uncappedTarget

Aquí está el valor real del VPA en modo Off. Veamos una recomendación completa.

kubectl describe vpa api-reservas -n rutas-norte-pro
Name:         api-reservas
Namespace:    rutas-norte-pro
API Version:  autoscaling.k8s.io/v1
Kind:         VerticalPodAutoscaler
Spec:
  Target Ref:
    API Version:  apps/v1
    Kind:         Deployment
    Name:         api-reservas
  Update Policy:
    Update Mode:  Off
Status:
  Conditions:
    Type:                  RecommendationProvided
    Status:                True
    Last Transition Time:  2026-05-11T08:22:14Z
  Recommendation:
    Container Recommendations:
      Container Name:  api
      Lower Bound:
        Cpu:     287m
        Memory:  573Mi
      Target:
        Cpu:     412m
        Memory:  792Mi
      Uncapped Target:
        Cpu:     412m
        Memory:  792Mi
      Upper Bound:
        Cpu:     1140m
        Memory:  1489Mi
      Container Name:  exportador-metricas
      Lower Bound:
        Cpu:     4m
        Memory:  14Mi
      Target:
        Cpu:     6m
        Memory:  21Mi
      Uncapped Target:
        Cpu:     6m
        Memory:  21Mi
      Upper Bound:
        Cpu:     18m
        Memory:  48Mi

Qué significa cada campo

Campo Significado Qué hacer con él
target El valor recomendado. La estimación del recomendador de lo que el contenedor necesita Este es el que llevas al manifiesto
lowerBound Por debajo de esto, el contenedor está claramente falto de recursos Si tu valor actual está por debajo, actúa ya
upperBound Por encima de esto, estás desperdiciando de forma clara Si tu valor actual está por encima, estás pagando de más
uncappedTarget El target antes de aplicar minAllowed/maxAllowed Si difiere de target, tus barandillas están recortando

Los límites lowerBound y upperBound no son percentiles del consumo: son el intervalo de confianza del recomendador. Cuanto menos histórico tenga, más ancho será. Un VPA con dos horas de datos dará un upperBound enorme; con dos semanas, un intervalo estrecho. La anchura del intervalo es un indicador directo de cuánto puedes fiarte de la recomendación.

Y son también el criterio de actuación del actualizador: en modos Recreate/Auto, el pod se desaloja cuando su valor actual sale del rango [lowerBound, upperBound]. Dentro del rango, se deja en paz. Esta banda es el equivalente vertical de la banda de tolerancia del HPA.

Interpretación para api-reservas

Contrastemos con lo que tenemos en el manifiesto:

Recurso Actual lowerBound target upperBound Veredicto
CPU (api) 500m 287m 412m 1140m Dentro del rango, pero un 21 % de más. Ajuste opcional
Memoria (api) 512Mi 573Mi 792Mi 1489Mi Por debajo del lowerBound. Corregir ya
CPU (exportador) 20m 4m 6m 18m Por encima del upperBound. Sobredimensionado
Memoria (exportador) 32Mi 14Mi 21Mi 48Mi Dentro del rango, ajuste menor

La fila crítica es la memoria de api: 512Mi está por debajo del lowerBound de 573Mi. El VPA está diciendo, con datos de dos semanas, que ese contenedor tiene menos memoria reservada de la que necesita para funcionar con seguridad. Es exactamente el riesgo que intuíamos en el apartado 1, ahora cuantificado.

Y el exportador confirma la otra intuición: 20m reservados para algo que necesita 6m. Con 30 réplicas de api-reservas en el pico, son 420m de CPU reservados y nunca usados: casi medio núcleo bloqueado para el planificador.

uncappedTarget: la señal de que tus barandillas aprietan

Supongamos que ponemos maxAllowed.memory: 512Mi en el resourcePolicy. La recomendación quedaría:

      Target:
        Memory:  512Mi          <- Recortado por maxAllowed
      Uncapped Target:
        Memory:  792Mi          <- Lo que el recomendador queria de verdad

Cuando target y uncappedTarget difieren, tu maxAllowed (o minAllowed) está mordiendo. Y aquí hay que pensar dos veces, porque hay dos interpretaciones opuestas y ambas son plausibles:

  1. La barandilla está mal puesta. Fue un número conservador de hace meses y la aplicación ha crecido legítimamente. Súbela.
  2. La barandilla está haciendo su trabajo. Hay una fuga de memoria y el consumo crece sin parar. El maxAllowed es lo único que impide que ese pod se coma un nodo entero.

Distinguir entre las dos requiere mirar la tendencia en Grafana (07-04): si la memoria crece de forma monótona y nunca baja, es fuga; si se estabiliza en una meseta más alta, es crecimiento legítimo. Un uncappedTarget que se aleja del target mes a mes es una fuga de memoria hasta que se demuestre lo contrario.

Extraer la recomendación por línea de comandos

Para automatizar el flujo de trabajo:

kubectl get vpa api-reservas -n rutas-norte-pro \
  -o jsonpath='{range .status.recommendation.containerRecommendations[*]}{.containerName}{"\t"}{.target.cpu}{"\t"}{.target.memory}{"\n"}{end}'
api	412m	792Mi
exportador-metricas	6m	21Mi

Salida limpia, apta para meterla en un informe semanal o en un comentario automático de una pull request.

  1. El modo Off como asesor: el flujo de trabajo real

Aquí está la práctica que usan los equipos serios, y merece explicarse bien porque va a contracorriente de lo que sugiere el nombre «autoescalador».

La mayoría de organizaciones con Kubernetes en producción usan el VPA exclusivamente en modo Off. No como piloto automático, sino como asesor cuya recomendación revisa una persona antes de llegar a producción.

Por qué

Motivo Explicación
El manifiesto sigue siendo la verdad Con Recreate, el YAML dice 500m y el pod tiene 412m. Nadie sabe qué recursos tiene el sistema mirando el repositorio
Cada ajuste es un reinicio En modo activo, el VPA reinicia pods por su cuenta, en cualquier momento. Un reinicio no planificado en el puente de mayo es intolerable
Compatibilidad con GitOps Con Argo CD o Flux (10-05), el VPA activo crea una divergencia permanente entre Git y el clúster. El mismo problema del replicas de 09-01
Revisión humana Un cambio de recursos merece pasar por una revisión de código. «La memoria de api-reservas sube de 512Mi a 800Mi» es una decisión con consecuencias de coste y capacidad
Auditoría El registro de cambios de recursos queda en el historial de Git, no en logs de un controlador

El flujo de trabajo, paso a paso

flowchart TD
    A[1. Crear el VPA en modo Off] --> B[2. Esperar 2 semanas<br/>incluyendo un ciclo de negocio completo]
    B --> C[3. Extraer la recomendacion<br/>kubectl get vpa]
    C --> D{4. target dentro de<br/>las barandillas?}
    D -- No --> E[Investigar: fuga o<br/>crecimiento legitimo?]
    E --> C
    D -- Si --> F[5. Comparar con el manifiesto actual]
    F --> G{6. Diferencia > 20 %?}
    G -- No --> H[No tocar. Volver en un mes]
    G -- Si --> I[7. Pull request con los nuevos valores<br/>y la recomendacion en la descripcion]
    I --> J[8. Revision del equipo]
    J --> K[9. Merge, despliegue en pre, validacion]
    K --> L[10. Despliegue en pro]
    L --> M[11. Verificar en Grafana:<br/>menos throttling? menos OOM?]
    M --> B

Paso 2, dos semanas y por qué. El histograma del recomendador tiene semivida de 24 horas, así que con dos o tres días ya hay señal. Pero dos semanas cubren dos ciclos semanales completos, incluidos fines de semana. En Rutas Norte esto importa mucho: el tráfico de sábado no se parece al de miércoles. Y si hay un cierre mensual o un evento periódico, hay que cubrirlo también.

Paso 6, el umbral del 20 %. Cambiar los recursos de un Deployment implica desplegarlo. No merece la pena hacerlo por un 5 %. El umbral del 20 % es una convención razonable: por debajo, el ruido supera a la señal.

Paso 7, la pull request. Aquí está el corazón del método. El cambio queda documentado:

Titulo: Ajustar recursos de api-reservas segun recomendacion del VPA

Recomendacion del VPA (14 dias de observacion, 2026-04-27 a 2026-05-11):

  Contenedor           Actual        Recomendado    Cambio
  api / cpu            500m          412m           -17,6 %
  api / memoria        512Mi         792Mi          +54,7 %
  exportador / cpu     20m           6m             -70,0 %
  exportador / memoria 32Mi          21Mi           -34,4 %

Justificacion:
- La memoria de "api" estaba POR DEBAJO del lowerBound (573Mi). Riesgo real
  de desalojo bajo presion de memoria del nodo. Es la correccion importante.
- La CPU de "api" baja un 17,6 %. Ojo: esto afecta al HPA, que calcula sobre
  el requests. Ver nota mas abajo.
- El exportador estaba sobredimensionado x3. Con 30 replicas en el pico son
  420m de CPU liberados para el planificador.

Nota sobre el HPA: al bajar requests.cpu de 500m a 412m, el objetivo absoluto
del HPA pasa de 325m a 268m por pod. El HPA escalara ANTES que ahora con la
misma carga. Es el efecto deseado (los pods estaban sobredimensionados), pero
hay que vigilar el numero de replicas en el pico durante la primera semana.

Validado en rutas-norte-pre con la prueba de carga de k6 (09-06): sin
throttling, sin OOM, latencia p95 igual o mejor.

Esa nota sobre el HPA es exactamente el tipo de razonamiento que se pierde cuando dejas que un controlador cambie los números por su cuenta.

El VPA en modo Off para toda la plataforma

Una práctica muy rentable: un VPA en modo Off sobre todos los Deployments, StatefulSets y DaemonSets de la plataforma, aunque nunca los apliques automáticamente. El coste es un objeto por controlador y algo de memoria en el recomendador. El beneficio es tener, en cualquier momento, la respuesta a «¿está bien dimensionado esto?».

# Generar un VPA en modo Off para cada Deployment del namespace
for d in $(kubectl get deploy -n rutas-norte-pro -o name | cut -d/ -f2); do
  cat <<EOF | kubectl apply -f -
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: $d
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: $d
  updatePolicy:
    updateMode: "Off"
EOF
done

Y un informe semanal comparando recomendación con manifiesto. Ese informe, revisado cada lunes en quince minutos, es probablemente la actividad de mayor retorno por minuto invertido en la operación de un clúster.

  1. La incompatibilidad clásica: VPA y HPA sobre la misma métrica

Esta es la trampa que hay que conocer sí o sí.

El conflicto

El VPA y el HPA no pueden gobernar el mismo recurso (CPU o memoria) sobre la misma carga de trabajo. Si lo intentas, se retroalimentan de forma destructiva.

Veamos por qué, con api-reservas y números concretos. Supongamos HPA por CPU con averageUtilization: 65 y VPA en modo Recreate controlando CPU.

Estado inicial: 4 replicas, requests.cpu 500m, objetivo HPA 325m/pod.
Carga total: 1600m.

t=0    Consumo: 400m/pod (1600 / 4). Ratio 400/325 = 1,23.
       HPA -> techo(4 x 1,23) = 5 replicas.

t=1min 5 replicas, consumo 320m/pod. HPA satisfecho (ratio 0,98).
       Pero el VPA observa 320m de consumo y recomienda requests.cpu = 368m
       (320m x 1,15 de margen).

t=15m  El VPA aplica 368m. AHORA el objetivo del HPA es 65 % de 368m = 239m.
       Consumo real sigue en 320m. Ratio = 320/239 = 1,34.
       HPA -> techo(5 x 1,34) = 7 replicas.

t=16m  7 replicas, consumo 229m/pod. HPA satisfecho.
       El VPA observa 229m y recomienda requests.cpu = 263m.

t=30m  El VPA aplica 263m. Objetivo del HPA = 171m. Consumo 229m.
       Ratio 1,34. HPA -> 10 replicas.

t=31m  10 replicas, consumo 160m/pod. El VPA recomienda 184m...

El bucle es evidente: el HPA reduce el consumo por pod añadiendo réplicas; el VPA interpreta ese consumo menor como que los pods son grandes de más y baja el requests; al bajar el requests, el objetivo absoluto del HPA baja, y el HPA vuelve a escalar. La carga total no ha cambiado ni un vatio, pero hemos pasado de 4 réplicas de 500m a 10 réplicas de 263m, y el proceso continúa.

Cada iteración implica recrear pods (VPA) y crear/destruir pods (HPA). Es una espiral de inestabilidad que además hace muy difícil el diagnóstico, porque cada componente por separado parece estar comportándose bien.

flowchart LR
    A[Carga constante] --> B[HPA anade replicas]
    B --> C[Baja el consumo POR POD]
    C --> D[VPA baja el requests]
    D --> E[Baja el objetivo absoluto del HPA]
    E --> B
    style B fill:#fdd,stroke:#c00
    style D fill:#fdd,stroke:#c00

Las combinaciones válidas

HPA escala por VPA controla ¿Válido? Comentario
CPU CPU NO El bucle descrito arriba
Memoria Memoria NO Mismo bucle
CPU Solo memoria Combinación recomendada. Ejes independientes
Memoria Solo CPU Sí, técnicamente Pero escalar por memoria ya es mala idea (09-01)
Métrica personalizada / externa CPU y memoria La mejor combinación de todas
— (sin HPA) CPU y memoria El caso simple

La configuración recomendada para api-reservas, si quisieras VPA activo:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-reservas-memoria
  namespace: rutas-norte-pro
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reservas
  updatePolicy:
    updateMode: "Recreate"
    minReplicas: 2
  resourcePolicy:
    containerPolicies:
      - containerName: api
        # CLAVE: solo memoria. La CPU la gobierna el HPA de 09-01.
        controlledResources: ["memory"]
        minAllowed:
          memory: 512Mi
        maxAllowed:
          memory: 2Gi
      - containerName: exportador-metricas
        controlledResources: ["memory"]
        minAllowed:
          memory: 16Mi
        maxAllowed:
          memory: 128Mi

controlledResources: ["memory"] es la línea que hace que la convivencia sea posible. Sin ella, tienes el bucle.

La combinación óptima: HPA por métrica de negocio + VPA completo

La configuración más elegante aparece cuando el HPA escala por una métrica que no es CPU ni memoria: peticiones por segundo, longitud de una cola. Entonces:

  • El HPA decide cuántos pods, según la demanda del negocio.
  • El VPA decide de qué tamaño, según el consumo observado.
  • No hay realimentación: la métrica de negocio no depende del requests.

Es exactamente lo que habilita KEDA en 09-04. Cuando worker-notificaciones escale por longitud de cola, un VPA completo sobre él será perfectamente seguro y muy útil.

El VPA en modo Off es siempre seguro

Y esto conviene subrayarlo: el conflicto solo existe cuando el VPA actúa. En modo Off, el VPA no cambia nada, así que puede convivir con cualquier HPA sobre cualquier métrica sin el menor riesgo. Su recomendación de CPU será algo pesimista (medirá pods a los que el HPA ya ha aliviado la carga), pero seguirá siendo información válida.

Este es otro argumento a favor del modo Off: elimina toda una clase de problemas de un plumazo.

  1. Aplicación a Rutas Norte, componente a componente

Decisiones concretas y justificadas para cada pieza de la plataforma.

api-reservas y tienda-web: VPA en modo Off

Ambos tienen HPA por CPU desde 09-01. Ponemos VPA en modo Off, como asesores.

# k8s/entornos/pro/vpa-api-reservas.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-reservas
  updatePolicy:
    # MODO OFF. api-reservas tiene un HPA por CPU (09-01). Un VPA activo sobre
    # CPU provocaria el bucle de realimentacion. Y aunque limitasemos el VPA a
    # memoria, no queremos reinicios no planificados en el componente que
    # sostiene la venta. Aqui el VPA es un ASESOR: revisamos su recomendacion
    # cada lunes y la llevamos al manifiesto en una pull request.
    updateMode: "Off"
  resourcePolicy:
    containerPolicies:
      - containerName: api
        minAllowed:
          cpu: 200m
          memory: 256Mi
        maxAllowed:
          cpu: "2"
          memory: 2Gi
      - containerName: exportador-metricas
        minAllowed:
          cpu: 5m
          memory: 16Mi
        maxAllowed:
          cpu: 100m
          memory: 128Mi
# k8s/entornos/pro/vpa-tienda-web.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tienda-web
  updatePolicy:
    updateMode: "Off"
  resourcePolicy:
    containerPolicies:
      - containerName: nginx
        minAllowed:
          cpu: 50m
          memory: 64Mi
        maxAllowed:
          cpu: 500m
          memory: 512Mi

Con las mediciones del apartado 1, tienda-web es candidato claro a bajar de 200m a unos 70m de CPU. Con 15 réplicas en el pico, eso libera casi dos núcleos: menos nodos que arrancar (09-03) y menos factura.

worker-notificaciones: VPA en modo Recreate

Aquí sí lo activamos, y es el mejor candidato de la plataforma.

# k8s/entornos/pro/vpa-worker-notificaciones.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: worker-notificaciones
  namespace: rutas-norte-pro
  labels:
    app: worker-notificaciones
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker-notificaciones
  updatePolicy:
    # MODO RECREATE. Este componente consume mensajes de una cola: un reinicio
    # no pierde trabajo (la cola reentrega el mensaje en vuelo) y no hay ningun
    # usuario esperando una respuesta. Es la carga que MEJOR tolera el VPA activo.
    updateMode: "Recreate"
    # Nunca desalojar si quedasen menos de 2 replicas: la cola no puede quedarse
    # sin consumidores ni un minuto durante el puente de mayo.
    minReplicas: 2
  resourcePolicy:
    containerPolicies:
      - containerName: worker
        minAllowed:
          cpu: 50m
          memory: 128Mi
        maxAllowed:
          cpu: "1"
          memory: 1Gi
        # CPU y memoria, ambas: en 09-04 este componente pasara a escalar por
        # LONGITUD DE COLA con KEDA, que es una metrica externa. No hay conflicto
        # con la CPU, asi que el VPA puede gobernarla sin problema.
        controlledResources: ["cpu", "memory"]
      - containerName: exportador-metricas
        mode: "Off"

informes-ocupacion: VPA sobre el CronJob

El caso más rentable de todos. Recordemos: pide 500m y necesita 1750m. Se estrangula, tarda cinco veces más de lo debido y algunas noches no termina.

# k8s/entornos/pro/vpa-informes-ocupacion.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: informes-ocupacion
  namespace: rutas-norte-pro
  labels:
    app: informes-ocupacion
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: batch/v1
    kind: CronJob            # El VPA soporta CronJob directamente
    name: informes-ocupacion
  updatePolicy:
    # MODO INITIAL, no Recreate. Un Job es efimero: no tiene sentido desalojarlo
    # a mitad de ejecucion (perderiamos el trabajo hecho y empezaria de cero).
    # Con Initial, cada ejecucion NUEVA nace con los recursos correctos.
    updateMode: "Initial"
  resourcePolicy:
    containerPolicies:
      - containerName: generador
        minAllowed:
          cpu: 200m
          memory: 256Mi
        maxAllowed:
          cpu: "4"
          memory: 4Gi        # Techo generoso: es un proceso por lotes que se
                             # beneficia de todo lo que le des, y se ejecuta de
                             # madrugada cuando el cluster esta vacio
        controlledResources: ["cpu", "memory"]

Initial es la elección correcta para trabajos por lotes, y merece la pena entender por qué: un Job que se desaloja a los diez minutos de una ejecución de treinta pierde todo el trabajo y empieza de nuevo. Con Initial, cada ejecución nocturna nace con los recursos que el recomendador ha aprendido de las ejecuciones anteriores. El VPA aprende de la noche del lunes para la noche del martes.

Efecto esperado: de 40 minutos a unos 9. Y como se ejecuta a las 3 de la madrugada, con el clúster vacío, esos recursos extra no le quitan nada a nadie.

postgres-reservas: la decisión difícil

Aquí hay que pensar. Los datos:

  • Es un StatefulSet con una sola réplica primaria.
  • Tiene QoS Guaranteed (requests = limits), decisión deliberada del módulo 3 para que sea el último candidato al desalojo.
  • Un reinicio implica cortar todas las conexiones, perder la caché de páginas de PostgreSQL y unos segundos de indisponibilidad total de la plataforma.
  • Guarda datos personales de clientes: cualquier riesgo de corrupción es inaceptable.

Decisión: VPA en modo Off, con controlledValues: RequestsAndLimits.

# k8s/entornos/pro/vpa-postgres-reservas.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: postgres-reservas
  namespace: rutas-norte-pro
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: postgres-reservas
  updatePolicy:
    # MODO OFF, sin discusion. Razones:
    #
    # 1. UNA SOLA REPLICA PRIMARIA. Un desalojo es una caida total de la
    #    plataforma. Ni siquiera minReplicas nos protege: con 1 replica el
    #    updater no actuaria, pero no queremos depender de eso.
    #
    # 2. QoS GUARANTEED. Con controlledValues: RequestsAndLimits el VPA
    #    mantiene la proporcion requests:limits, que aqui es 1:1, asi que la
    #    QoS se preservaria. Pero es una propiedad demasiado importante para
    #    dejarla en manos de un controlador.
    #
    # 3. EL REINICIO NO ES GRATIS. PostgreSQL pierde la cache de paginas y las
    #    primeras consultas tras el arranque van a disco. La latencia empeora
    #    durante minutos.
    #
    # 4. EL DIMENSIONADO DE UNA BASE DE DATOS NO SE DEDUCE DEL CONSUMO. El
    #    shared_buffers, el work_mem y el effective_cache_size se configuran
    #    en armonia con la memoria del contenedor. Cambiar la memoria sin
    #    tocar postgresql.conf no mejora nada, o empeora.
    #
    # Aun asi el VPA es MUY util aqui: nos avisara si nos estamos quedando
    # cortos antes de que se manifieste como incidente.
    updateMode: "Off"
  resourcePolicy:
    containerPolicies:
      - containerName: postgres
        minAllowed:
          cpu: "1"
          memory: 2Gi
        maxAllowed:
          cpu: "4"
          memory: 8Gi
        # RequestsAndLimits mantiene la proporcion 1:1 y por tanto la QoS
        # Guaranteed, para el dia que decidamos aplicar la recomendacion a mano.
        controlledValues: RequestsAndLimits
      - containerName: exportador-postgres
        minAllowed:
          cpu: 10m
          memory: 32Mi
        maxAllowed:
          cpu: 100m
          memory: 128Mi

El punto 4 del comentario merece énfasis, porque es el argumento de fondo: el dimensionado de una base de datos es una decisión de configuración, no de observación. Subir la memoria del contenedor sin ajustar shared_buffers en armonía con ella no mejora el rendimiento: solo desperdicia memoria. El VPA no sabe nada de postgresql.conf. Un operador de PostgreSQL (06-07) sí, y ese es el camino correcto para escalar una base de datos.

Tabla resumen de las decisiones

Componente Modo controlledResources Justificación
api-reservas Off ambos (asesor) HPA por CPU; sin reinicios no planificados en el componente crítico
tienda-web Off ambos (asesor) HPA por CPU; ahorro claro pendiente de aplicar a mano
worker-notificaciones Recreate cpu, memory Tolera reinicios; escalará por cola (09-04), sin conflicto
informes-ocupacion Initial cpu, memory Job efímero; no desalojar a mitad; el ahorro de tiempo es enorme
postgres-reservas Off ambos (asesor) Una réplica, QoS Guaranteed, reinicio caro, dimensionado ligado a la configuración
redis-cache Off ambos (asesor) Caché con estado; un reinicio la vacía y descarga toda la carga sobre PostgreSQL

  1. Interacción con LimitRange y ResourceQuota

El VPA no vive solo. En el módulo 3 pusimos barandillas de namespace que interactúan con él de formas que conviene anticipar.

LimitRange

Recordemos el LimitRange de rutas-norte-pro:

apiVersion: v1
kind: LimitRange
metadata:
  name: limites-por-defecto
  namespace: rutas-norte-pro
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      min:
        cpu: 50m
        memory: 64Mi
      max:
        cpu: "4"
        memory: 8Gi

Interacciones:

  1. El max del LimitRange gana sobre el maxAllowed del VPA. Si el VPA recomienda 6 núcleos y el LimitRange topa en 4, el pod mutado será rechazado en admisión. El VPA es consciente de los LimitRange y ajusta su recomendación, pero si los cambias después de crear el VPA, puede haber un desfase.
  2. El min puede elevar una recomendación baja. Si el VPA recomienda 20m y el LimitRange exige un mínimo de 50m, el pod acabará con 50m.
  3. Los default desaparecen de la ecuación cuando el VPA muta el pod, porque el pod ya llega con valores explícitos.

Regla práctica: mantén el maxAllowed del VPA por debajo del max del LimitRange. Así el recorte se ve en uncappedTarget (información útil) en lugar de producir un rechazo en admisión (fallo confuso).

ResourceQuota

La ResourceQuota es más traicionera, porque el fallo aparece tarde y en otro sitio.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-pro
  namespace: rutas-norte-pro
spec:
  hard:
    requests.cpu: "40"
    requests.memory: 80Gi
    limits.cpu: "80"
    limits.memory: 160Gi

El problema surge de la interacción entre HPA y VPA sobre la cuota:

Situacion:  api-reservas, requests.cpu 500m, HPA con maxReplicas 30.
Consumo maximo de cuota: 30 x 500m = 15 nucleos. Cabe en los 40 de la cuota.

El VPA recomienda subir a 800m (crecio la aplicacion).
Se aplica.

Consumo maximo AHORA: 30 x 800m = 24 nucleos.
Sumando tienda-web, worker-notificaciones, postgres y redis: 43 nucleos.
LA CUOTA SON 40.

Resultado: durante el puente de mayo, el HPA intenta crear la replica numero 26
y el ReplicaSet recibe:
  Error creating: pods "api-reservas-..." is forbidden:
  exceeded quota: cuota-pro, requested: requests.cpu=800m,
  used: requests.cpu=39.4, limited: requests.cpu=40

El HPA marca ScalingLimited. La plataforma se queda corta EN EL PICO.

El fallo no aparece cuando se aplica el VPA: aparece semanas después, en el peor momento posible, y el mensaje de error está en los eventos del ReplicaSet, no en el HPA ni en el VPA. Es un fallo diferido y mal señalizado.

Comprobación obligatoria al aplicar cualquier recomendación del VPA:

Σ (requests recomendado × maxReplicas del HPA)  ≤  ResourceQuota × 0,8

El factor 0,8 deja margen para pods efímeros, trabajos y despliegues (que durante un rolling update consumen cuota de la versión vieja y la nueva a la vez).

Cálculo para Rutas Norte con la recomendación:

Componente requests.cpu rec. maxReplicas CPU máxima
api-reservas (api) 412m 30 12,36
api-reservas (exportador) 6m 30 0,18
tienda-web 70m 15 1,05
worker-notificaciones 120m 20 2,40
postgres-reservas 2000m 1 2,00
redis-cache 300m 1 0,30
Total 18,29

18,29 contra una cuota de 40, con margen del 80 % (32). Cabe con holgura. Y fíjate en que la recomendación del VPA reduce el consumo máximo respecto al escenario actual (que sería 15 + 3 + ... ≈ 21), porque tienda-web y el exportador estaban sobredimensionados. Ajustar bien los requests no solo ahorra dinero: da margen para escalar más.

Un aviso sobre controlledValues: RequestsAndLimits

Como vimos, el VPA mantiene la proporción requests:limits. Si tu proporción es 1:2 y el VPA sube el requests un 50 %, el limit sube también un 50 %. Y la ResourceQuota tiene una línea limits.cpu separada que también puede agotarse. Vigila las dos.

  1. Los límites del VPA

Terminamos con lo que el VPA no puede hacer. Conocer las limitaciones evita esperar de él lo que no da.

Necesita historia

Un VPA recién creado no sabe nada. Sus primeras recomendaciones, en las primeras horas, son poco fiables: el upperBound será enorme y el target reflejará solo lo que ha visto. Regla: no tomes decisiones con menos de una semana de datos, y prefiere dos.

Corolario incómodo: para un servicio nuevo, el VPA no ayuda. Hay que estimar a mano, desplegar, y dejar que el VPA corrija después.

Reacciona lento

Con semivida de 24 horas, un cambio real de perfil tarda días en reflejarse plenamente. Esto es deliberado y correcto —no queremos que un pico de cinco minutos cambie los recursos de toda la plataforma—, pero significa que el VPA no sirve para responder a eventos:

  • No sirve para el puente de mayo. Cuando el VPA se dé cuenta de que hace falta más CPU, el puente habrá terminado.
  • No sirve para un despliegue que cambia radicalmente el perfil de consumo. Ahí hay que estimar a mano y dejar que el VPA valide después.

El VPA es para el régimen permanente, no para el transitorio. Los transitorios los cubre el HPA (09-01) y KEDA (09-04).

Recrear un pod con estado no es gratis

En modos Recreate y Auto, cada ajuste es un reinicio. El coste depende del componente:

Componente Coste del reinicio
tienda-web (nginx) Casi nulo: un segundo, sin estado
api-reservas Moderado: 25 s de arranque, pool de conexiones nuevo, caché fría
worker-notificaciones Bajo: la cola reentrega el mensaje en vuelo
redis-cache Alto: se pierde toda la caché, y toda esa carga cae sobre PostgreSQL
postgres-reservas Muy alto: corte de todas las conexiones, caché de páginas perdida

El VPA respeta los PDB (09-05), lo cual mitiga el riesgo, pero no lo elimina: un PDB garantiza que no se caiga todo a la vez, no que el reinicio sea indoloro.

No conoce el contexto de negocio

El VPA ve consumo de CPU y memoria. No ve:

  • Que el puente de mayo empieza el viernes.
  • Que ese pod atiende el proceso de pago y no puede reiniciarse durante una transacción.
  • Que la memoria alta de anoche fue una ejecución de mantenimiento y no el régimen normal.
  • Que shared_buffers de PostgreSQL debe cambiar en armonía con la memoria del contenedor.

Todo eso lo pone una persona. Y esa es, resumida en una frase, la justificación del modo Off.

Otras limitaciones a tener presentes

  • No gestiona recursos que no sean CPU y memoria. El almacenamiento efímero, las GPU y los recursos extendidos quedan fuera.
  • Con updateMode activo, no debería usarse junto a un despliegue continuo agresivo: los desalojos del VPA y los rolling updates se solapan de forma poco predecible.
  • Un solo VPA por carga de trabajo. Dos objetos VPA con targetRef al mismo Deployment producen comportamiento indefinido. Kubernetes no lo impide; tú sí debes.

Errores Comunes y Consejos

Error 1: poner el VPA en modo Auto sobre CPU cuando ya hay un HPA por CPU. El error catastrófico de esta lección. El bucle de realimentación del apartado 11 tarda horas en manifestarse y es difícil de diagnosticar porque cada componente parece funcionar bien por separado. Si tienes HPA por CPU: controlledResources: ["memory"] o modo Off. Sin excepciones.

Error 2: aplicar la recomendación de un VPA de dos días. El upperBound desmesurado delata que hay poca historia. Espera dos semanas. Si tienes prisa, mira el lowerBound: si tu valor actual está por debajo, eso sí es una señal fiable incluso con pocos datos, porque significa que el contenedor ya se ha quedado corto en observaciones reales.

Error 3: no poner maxAllowed. Sin techo, una fuga de memoria hace que la recomendación crezca sin fin. En modo activo, acabarás con un pod pidiendo 30Gi que no cabe en ningún nodo y se queda Pending eternamente. El maxAllowed convierte esa fuga en una señal visible (uncappedTarget alejándose del target) en lugar de en una caída.

Error 4: creer que el VPA modifica el Deployment. No lo hace. El kubectl get deployment seguirá mostrando los valores del manifiesto. La mutación ocurre en el pod, en admisión. Para ver la realidad hay que mirar los pods, no el controlador. Esta confusión genera muchísimos tickets de «el VPA no funciona».

Error 5: VPA activo sobre una carga de una sola réplica. Un desalojo es una caída total. El minReplicas: 2 por defecto protege, pero si lo bajas a 1 «para que funcione», estás pidiendo cortes de servicio. Si tienes una sola réplica, o pones dos, o usas modo Off.

Error 6: olvidar la ResourceQuota. El apartado 13. Subir los requests sin recalcular requests × maxReplicas contra la cuota produce un fallo diferido que se manifiesta en el pico de tráfico, semanas después, con un mensaje de error en un sitio inesperado.

Consejo 1: empieza por los CronJobs. Son el caso más rentable y el de menor riesgo. Nadie espera una respuesta, el modo Initial es seguro, y el ahorro de tiempo de ejecución suele ser espectacular. informes-ocupacion pasando de 40 a 9 minutos es una victoria visible que compra credibilidad para el resto.

Consejo 2: exporta las recomendaciones a Prometheus. El VPA expone métricas, y kube-state-metrics publica kube_verticalpodautoscaler_status_recommendation_containerrecommendations_target. Con eso puedes construir en Grafana (07-04) un panel de «recomendado contra configurado» para toda la plataforma, y una alerta cuando la desviación supere el 30 %:

abs(
  kube_verticalpodautoscaler_status_recommendation_containerrecommendations_target{resource="memory"}
  -
  kube_pod_container_resource_requests{resource="memory"}
)
/ kube_pod_container_resource_requests{resource="memory"} > 0.3

Consejo 3: un VPA en modo Off sobre absolutamente todo. Coste casi nulo, información permanente. Inclúyelo en la plantilla base de todo componente nuevo.

Consejo 4: usa el lowerBound como alerta de riesgo. Un contenedor cuyos requests están por debajo del lowerBound está en peligro real de desalojo o de OOMKilled. Es la señal más accionable que da el VPA, y merece una alerta.

Consejo 5: revisa el uncappedTarget mensualmente. Si crece mes a mes mientras el target se queda pegado al maxAllowed, tienes una fuga de memoria. El VPA la detecta antes de que se convierta en incidente.

Consejo 6: documenta en el manifiesto de dónde salen los números. Un comentario como # requests.memory: 800Mi -- recomendacion VPA 2026-05-11, p95 real 690Mi convierte un número mágico en una decisión trazable. El siguiente que lo lea sabrá si puede tocarlo.

Ejercicios

Ejercicio 1: interpretar una recomendación

Este es el describe del VPA de redis-cache tras tres semanas en modo Off:

Status:
  Recommendation:
    Container Recommendations:
      Container Name:  redis
      Lower Bound:
        Cpu:     180m
        Memory:  1420Mi
      Target:
        Cpu:     245m
        Memory:  1980Mi
      Uncapped Target:
        Cpu:     245m
        Memory:  3410Mi
      Upper Bound:
        Cpu:     390m
        Memory:  2048Mi

El manifiesto actual dice:

resources:
  requests:
    cpu: 500m
    memory: 1Gi
  limits:
    cpu: 500m
    memory: 2Gi

Y el resourcePolicy del VPA:

resourcePolicy:
  containerPolicies:
    - containerName: redis
      minAllowed:
        cpu: 100m
        memory: 512Mi
      maxAllowed:
        cpu: "1"
        memory: 2Gi

Responde: (a) qué pasa con la CPU; (b) qué pasa con la memoria y por qué target y uncappedTarget difieren; (c) qué significa que upperBound sea exactamente 2048Mi; (d) qué QoS tiene el pod ahora y qué implica; (e) qué harías, con qué prioridad, y qué comprobarías antes.

Ejercicio 2: diagnosticar un conflicto

tienda-web lleva tres días con un comportamiento extraño. El equipo reporta:

  • El número de réplicas oscila entre 3 y 12 varias veces al día sin que el tráfico lo justifique.
  • Los pods se reinician con frecuencia, con motivo Evicted.
  • Los requests.cpu de los pods no coinciden con el manifiesto y cambian solos.

La configuración es:

---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tienda-web
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50
---
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tienda-web
  updatePolicy:
    updateMode: "Auto"

Explica exactamente qué está pasando, con la secuencia temporal, y da dos soluciones alternativas con sus manifiestos y sus contrapartidas.

Ejercicio 3: diseñar la estrategia para un componente nuevo

Rutas Norte añade exportador-contable, un componente que genera los ficheros de facturación para la gestoría. Perfil:

  • Es un CronJob que se ejecuta el día 1 de cada mes a las 2:00.
  • Cada ejecución tarda entre 20 y 90 minutos, según el volumen de reservas del mes.
  • Consume mucha memoria: carga en memoria todas las reservas del mes para agregarlas.
  • Si falla, hay que relanzarlo a mano y el equipo de administración se queda sin los ficheros ese día.
  • Ha sido OOMKilled dos de los últimos cuatro meses.
  • Actualmente: requests.memory: 1Gi, limits.memory: 2Gi, requests.cpu: 500m, limits.cpu: "1".
  • El namespace tiene una ResourceQuota de 40 núcleos y 80Gi.

Diseña la estrategia completa: ¿VPA sí o no? ¿Qué modo? ¿Qué resourcePolicy? ¿Cuánto tiempo de observación necesitas y por qué es un caso especial? ¿Qué harías además del VPA? Escribe el manifiesto y justifica cada decisión.


Soluciones

Solución 1

(a) La CPU está sobredimensionada.

El manifiesto pide 500m; el upperBound es 390m. El valor actual está por encima del upperBound, lo que significa que el recomendador está seguro de que sobra CPU. El target es 245m: menos de la mitad de lo configurado. Como redis-cache es una sola réplica, el ahorro absoluto es modesto (255m), pero libera capacidad de planificación en un nodo.

(b) La memoria: target 1980Mi, uncappedTarget 3410Mi.

Esta es la observación crítica del ejercicio. El maxAllowed.memory es 2Gi = 2048Mi, y el uncappedTarget de 3410Mi está muy por encima. El recomendador quiere 3410Mi y las barandillas lo están recortando a 1980Mi.

Además, el requests actual (1Gi = 1024Mi) está por debajo del lowerBound (1420Mi). Doble señal de alarma: falta memoria reservada Y el recomendador quiere mucha más de la que le dejamos.

¿Fuga de memoria o crecimiento legítimo? En Redis hay una tercera posibilidad muy específica, y es la más probable: la caché ha crecido hasta llenar el espacio disponible. Redis, sin una política de expulsión configurada, sigue aceptando claves hasta agotar la memoria. El uncappedTarget creciente no indica un fallo del software: indica que maxmemory no está configurado en Redis y la caché crece sin control.

Comprobación:

kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli CONFIG GET maxmemory
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli CONFIG GET maxmemory-policy
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO memory

Si maxmemory es 0, ese es el problema de fondo, y ningún ajuste de requests lo resuelve: solo retrasa el siguiente OOMKilled.

(c) upperBound exactamente 2048Mi.

No es casualidad: es el maxAllowed. Los límites de la recomendación también se recortan por las barandillas. Cuando ves un upperBound que coincide exactamente con tu maxAllowed, el recomendador te está diciendo que su intervalo de confianza está truncado, y que la recomendación real está fuera de lo que le permites.

(d) QoS del pod.

CPU:     requests 500m == limits 500m
Memoria: requests 1Gi  != limits 2Gi

Como no todos los recursos tienen requests igual a limits, la QoS es Burstable, no Guaranteed (módulo 3).

Implicación seria: bajo presión de memoria en el nodo, un pod Burstable que consume por encima de su requests es candidato prioritario al desalojo. Y redis-cache está consumiendo 1980Mi con un requests de 1024Mi: casi el doble de lo reservado. Es de los primeros que caerán cuando el nodo se llene, y su caída descarga toda la carga sobre postgres-reservas justo cuando el nodo ya está bajo presión. Un efecto dominó de manual.

(e) Plan de acción, por prioridad.

Prioridad 1 (hoy): configurar maxmemory en Redis. Es la corrección de raíz.

# ConfigMap de redis-cache
maxmemory 1500mb
maxmemory-policy allkeys-lru   # Expulsa las claves menos usadas al llegar al tope

Con esto, Redis deja de crecer sin control y empieza a comportarse como una caché de verdad. El uncappedTarget debería estabilizarse en las siguientes semanas.

Prioridad 2 (esta semana): subir el requests.memory a 2Gi e igualarlo al limit.

resources:
  requests:
    cpu: 300m         # Bajado de 500m: el target es 245m, con margen
    memory: 2Gi       # Subido de 1Gi: estaba por debajo del lowerBound
  limits:
    cpu: 300m         # Igual que requests
    memory: 2Gi       # Igual que requests -> QoS Guaranteed

Esto consigue tres cosas: cubre el lowerBound, promueve el pod a QoS Guaranteed (deja de ser candidato prioritario al desalojo, coherente con su papel de amortiguador de PostgreSQL), y libera 200m de CPU.

Prioridad 3 (en un mes): revisar de nuevo. Con maxmemory puesto, comprobar que el uncappedTarget se ha estabilizado por debajo de 2Gi. Si sigue creciendo, hay algo más.

Comprobaciones antes de aplicar:

# 1. ¿Cabe en la ResourceQuota? (redis-cache es 1 replica, impacto pequeno)
kubectl describe resourcequota cuota-pro -n rutas-norte-pro

# 2. ¿Hay un nodo con 2Gi libres de verdad?
kubectl describe nodes | grep -A5 "Allocated resources"

# 3. ¿Cuanto tarda en recuperarse la cache tras el reinicio?
#    (el cambio de requests obliga a recrear el pod)
#    Programarlo en horario de bajo trafico, NUNCA cerca del puente de mayo.

Ese último punto es el que se olvida siempre: cambiar los recursos de redis-cache implica reiniciarlo, y reiniciarlo vacía la caché. Durante los minutos siguientes, toda la carga de consultas de disponibilidad va directa a postgres-reservas. Hay que hacerlo un martes a las 4 de la mañana, no un viernes a las 10.

Solución 2

Qué está pasando: el bucle de realimentación HPA-VPA del apartado 11, en estado puro.

El HPA escala tienda-web por CPU. El VPA está en modo Auto sin controlledResources, lo que significa que controla CPU y memoria. Ambos gobiernan la CPU.

Secuencia temporal:

t=0     3 replicas, requests.cpu 200m, objetivo HPA = 50 % de 200m = 100m.
        Carga total: 480m -> 160m por pod. Ratio 1,6.
        HPA -> techo(3 x 1,6) = 5 replicas.

t=2min  5 replicas, 96m por pod. HPA satisfecho (ratio 0,96, en tolerancia).

t=20min El VPA lleva un rato observando ~96m de consumo. Recomienda
        requests.cpu = 110m (96 x 1,15). El actualizador DESALOJA los pods
        de uno en uno.  <-- REINICIOS "Evicted" que reporta el equipo

t=25min Pods recreados con requests.cpu 110m.
        Objetivo del HPA AHORA = 50 % de 110m = 55m.
        Consumo real sigue en 96m. Ratio = 96/55 = 1,75.
        HPA -> techo(5 x 1,75) = 9 replicas.  <-- OSCILACION que reporta el equipo

t=27min 9 replicas, 53m por pod. HPA satisfecho.
        El VPA observa 53m, recomienda 61m...

t=45min El VPA aplica 61m. Objetivo HPA = 30m. Consumo 53m. Ratio 1,77.
        HPA -> 16 replicas, cortado por maxReplicas a 15.

t=50min 15 replicas, 32m por pod. El VPA recomienda 37m...
        Y el ciclo continua, ahora limitado por el techo.

        Cuando la carga baja de noche, el proceso se invierte: el VPA sube
        el requests (porque los pods estan al limite de una recomendacion
        minuscula), el objetivo del HPA sube, el HPA baja replicas, y vuelta
        a empezar. De ahi la oscilacion 3 <-> 12 "varias veces al dia".

Los tres síntomas quedan explicados: la oscilación de réplicas (HPA reaccionando a objetivos móviles), los Evicted (el actualizador del VPA desalojando), y los requests que no coinciden con el manifiesto y cambian solos (el controlador de admisión mutando los pods).

Comandos que lo confirmarían:

# Los eventos del HPA muestran reescalados frecuentes sin causa de trafico
kubectl describe hpa tienda-web -n rutas-norte-pro

# Los eventos del namespace muestran EvictedByVPA
kubectl get events -n rutas-norte-pro --field-selector reason=EvictedByVPA \
  --sort-by=.lastTimestamp

# Los requests reales de los pods difieren del manifiesto
kubectl get pods -n rutas-norte-pro -l app=tienda-web \
  -o custom-columns=NOMBRE:.metadata.name,CPU:.spec.containers[0].resources.requests.cpu

# La recomendacion del VPA ha ido bajando
kubectl describe vpa tienda-web -n rutas-norte-pro

Solución A (recomendada): VPA en modo Off.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tienda-web
  updatePolicy:
    # OFF. El HPA gobierna la CPU. El VPA queda como asesor: revisamos su
    # recomendacion cada lunes y la aplicamos a mano en una pull request.
    updateMode: "Off"
  resourcePolicy:
    containerPolicies:
      - containerName: nginx
        minAllowed:
          cpu: 50m
          memory: 64Mi
        maxAllowed:
          cpu: 500m
          memory: 512Mi

Ventajas: elimina el conflicto por completo, sin reinicios sorpresa, el manifiesto sigue siendo la verdad, compatible con GitOps. Contrapartida: alguien tiene que aplicar la recomendación a mano.

Solución B: VPA activo solo sobre memoria.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tienda-web
  updatePolicy:
    updateMode: "Recreate"
    minReplicas: 3
  resourcePolicy:
    containerPolicies:
      - containerName: nginx
        # LA LINEA CLAVE: solo memoria. La CPU es territorio del HPA.
        controlledResources: ["memory"]
        minAllowed:
          memory: 64Mi
        maxAllowed:
          memory: 512Mi

Ventajas: la memoria se ajusta sola, sin intervención. Contrapartidas: sigue habiendo reinicios no planificados; el requests.cpu queda sin ajustar (habría que hacerlo a mano igual); durante el puente de mayo habría que pasar a Off para evitar reinicios en el peor momento.

Cuál elegir. Para tienda-web, la A. El ahorro de memoria de nginx es pequeño (hablamos de decenas de Mi por pod) y no compensa el riesgo de reinicios no planificados en la puerta de entrada de la plataforma. La solución B tendría sentido en una carga con consumo de memoria variable y grande, y que tolerase reinicios: worker-notificaciones es mejor candidato.

Acción inmediata mientras se decide:

kubectl patch vpa tienda-web -n rutas-norte-pro --type merge \
  -p '{"spec":{"updatePolicy":{"updateMode":"Off"}}}'

Esto detiene la oscilación en el siguiente ciclo del actualizador, sin borrar nada.

Solución 3

¿VPA sí o no? Sí, rotundamente. Es un caso de libro: un trabajo por lotes con consumo variable que ya ha sido OOMKilled dos veces. El coste de un reinicio es cero (el Job es efímero) y el coste del fallo actual es alto (intervención manual, gestoría sin ficheros).

¿Qué modo? Initial. Igual que informes-ocupacion:

  • Recreate no tiene sentido: desalojar un Job a los 40 minutos de una ejecución de 90 pierde todo el trabajo y empieza de cero. Sería peor que el OOMKilled.
  • Off desaprovecha la ocasión: aquí sí queremos aplicación automática, porque el consumo varía cada mes con el volumen de reservas y nadie va a revisar la recomendación mensualmente.
  • Initial aplica la recomendación en cada ejecución nueva. Perfecto.

El caso especial de la observación: la frecuencia mensual.

Este es el punto interesante del ejercicio. El histograma del recomendador tiene semivida de 24 horas. Una ejecución mensual significa que, cuando llega la siguiente, la muestra anterior tiene treinta semividas de antigüedad: su peso es 2⁻³⁰, es decir, cero.

El VPA, tal cual, es prácticamente inútil para un trabajo mensual. Cada ejecución empieza casi desde cero.

Tres formas de resolverlo, de menos a más recomendable:

  1. Ajustar la semivida del recomendador. El vpa-recommender acepta --cpu-histogram-decay-half-life y --memory-histogram-decay-half-life. Subirlos a 720h (30 días) haría que la muestra del mes anterior pesara la mitad. Contrapartida: es un parámetro global del recomendador, afecta a todos los VPA del clúster y volvería lentísimas las recomendaciones de api-reservas. Solo viable con una segunda instancia del recomendador dedicada, lo cual es complejidad seria.

  2. Ejecutar el trabajo con más frecuencia en pre. Programarlo semanalmente en rutas-norte-pre con datos representativos, dejar que el VPA aprenda ahí, y llevar la recomendación a producción a mano. Funciona, pero requiere datos de prueba realistas.

  3. VPA en Initial + un minAllowed generoso + medición explícita. La opción pragmática y la que recomiendo.

Además del VPA, tres medidas que importan más:

# k8s/entornos/pro/cronjob-exportador-contable.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: exportador-contable
  namespace: rutas-norte-pro
  labels:
    app: exportador-contable
    app.kubernetes.io/part-of: rutas-norte
spec:
  schedule: "0 2 1 * *"          # Dia 1 de cada mes a las 2:00
  concurrencyPolicy: Forbid
  # MEDIDA 1: reintentos automaticos. Si falla, que lo intente solo antes de
  # despertar a nadie. Con 3 intentos y backoff, un OOM puntual se recupera.
  jobTemplate:
    spec:
      backoffLimit: 3
      # MEDIDA 2: plazo maximo. Si a las 4 horas no ha terminado, algo va mal
      # y queremos saberlo antes de que se solape con la carga de la manana.
      activeDeadlineSeconds: 14400
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: exportador
              image: registry.rutasnorte.example/exportador-contable:3.1.0
              resources:
                requests:
                  cpu: 500m
                  # MEDIDA 3, LA IMPORTANTE: subir la memoria YA, sin esperar
                  # al VPA. Dos OOMKilled en cuatro meses con limit de 2Gi
                  # significan que 2Gi no basta. Subimos a 4Gi de entrada.
                  memory: 4Gi
                limits:
                  cpu: "2"
                  # requests == limits en memoria -> QoS Guaranteed para el
                  # contenedor. Se ejecuta de madrugada, el cluster esta vacio,
                  # no le quitamos nada a nadie y garantizamos que no lo
                  # desalojen por presion de memoria del nodo.
                  memory: 4Gi

Y el VPA:

# k8s/entornos/pro/vpa-exportador-contable.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: exportador-contable
  namespace: rutas-norte-pro
  labels:
    app: exportador-contable
    app.kubernetes.io/part-of: rutas-norte
spec:
  targetRef:
    apiVersion: batch/v1
    kind: CronJob
    name: exportador-contable
  updatePolicy:
    # INITIAL: cada ejecucion nueva nace con los recursos aprendidos. Nunca
    # desalojamos un Job a mitad de camino: perderiamos 40 minutos de trabajo.
    updateMode: "Initial"
  resourcePolicy:
    containerPolicies:
      - containerName: exportador
        minAllowed:
          cpu: 500m
          # minAllowed GENEROSO y deliberado. Con ejecucion mensual, el
          # histograma del recomendador (semivida 24 h) esta practicamente
          # vacio cuando llega la siguiente ejecucion. Sin este suelo, el VPA
          # recomendaria valores minusculos basados en casi ningun dato y
          # tendriamos un OOMKilled garantizado. El suelo es nuestro seguro.
          memory: 4Gi
        maxAllowed:
          cpu: "4"
          # Techo alto: el volumen de reservas crece y diciembre (con el
          # cierre anual) puede ser mucho mayor que un mes normal. 8Gi es lo
          # que cabe comodamente en un nodo de 16Gi con el cluster vacio de
          # madrugada. Si el uncappedTarget llega aqui, hay que revisar el
          # algoritmo del exportador: probablemente carga en memoria lo que
          # deberia procesar por lotes.
          memory: 8Gi
        controlledResources: ["cpu", "memory"]
        # RequestsAndLimits mantiene la proporcion 1:1 de memoria y por tanto
        # la QoS del contenedor.
        controlledValues: RequestsAndLimits

Comprobación de la ResourceQuota:

Cuota del namespace: 40 nucleos, 80Gi.
Peor caso del exportador: 4 nucleos y 8Gi (si el VPA llega al maxAllowed).

Como se ejecuta a las 2:00 del dia 1, el resto de la plataforma esta en
minReplicas: api-reservas 4, tienda-web 3, worker 2.
Consumo nocturno estimado: ~8 nucleos, ~14Gi.
Con el exportador al maximo: 12 nucleos, 22Gi. Muy por debajo de la cuota.

CONCLUSION: cabe con holgura. Y es una razon mas para ejecutarlo de noche.

Plan de seguimiento:

Cuándo Qué hacer
Inmediato Aplicar los 4Gi y los reintentos. No esperar al VPA
Tras la 1.ª ejecución kubectl describe vpa para ver el primer target, y medir el pico real con kubectl top pods durante la ejecución
Tras la 3.ª ejecución Comparar los tres target. Si son consistentes, ajustar el minAllowed a un valor más fino
Tras 6 meses Revisar la tendencia. Si el uncappedTarget crece linealmente con el volumen de reservas, el exportador escala mal y hay que procesar por lotes en lugar de cargar todo en memoria

La lección de fondo del ejercicio: cuando la frecuencia de ejecución es mucho menor que la semivida del histograma, el VPA pierde casi toda su utilidad automática. En esos casos, la barandilla (minAllowed) importa más que la recomendación, y la medición manual de las primeras ejecuciones sigue siendo insustituible. El VPA no exime de pensar: acelera el pensar bien.

Conclusión

El VerticalPodAutoscaler no responde a la carga: responde a la ignorancia. Convierte los requests y limits de números que alguien copió de otro manifiesto en valores derivados del consumo real observado durante días.

Lo esencial:

  • El VPA responde a «¿de qué tamaño?»; el HPA a «¿cuántos?». Ejes distintos, constantes de tiempo distintas: días frente a segundos.
  • No está en el núcleo. Hay que instalarlo desde kubernetes/autoscaler, comprobar compatibilidad de versiones, y en la nube mirar primero si el proveedor lo ofrece.
  • Tres componentes: el recomendador calcula (percentiles con decaimiento exponencial, p90 para CPU, pico para memoria), el actualizador desaloja respetando los PDB, y el controlador de admisión muta los pods al nacer. El Deployment nunca cambia.
  • Off es el modo de los equipos serios. El manifiesto sigue siendo la verdad, no hay reinicios sorpresa, funciona con GitOps y cada cambio de recursos pasa por una revisión humana. Initial para trabajos por lotes; Recreate solo para cargas que toleran reinicios.
  • target, lowerBound, upperBound, uncappedTarget. El target es lo que llevas al manifiesto; estar por debajo del lowerBound es una alarma; un uncappedTarget que se aleja del target mes a mes es una fuga de memoria hasta que se demuestre lo contrario.
  • VPA y HPA sobre la misma métrica se destruyen mutuamente. El bucle es real y tarda horas en manifestarse. controlledResources: ["memory"] cuando el HPA escala por CPU, o modo Off. La combinación óptima es HPA por métrica de negocio (09-04) y VPA completo.
  • El redimensionado en caliente llegará y cambiará las reglas, pero hoy no es base para una estrategia de producción.
  • Cuidado con la ResourceQuota: requests recomendado × maxReplicas del HPA debe caber, o el fallo aparecerá en el pico de tráfico semanas después.

En Rutas Norte hemos dejado el VPA en modo Off sobre api-reservas, tienda-web, postgres-reservas y redis-cache, activo en Recreate sobre worker-notificaciones y en Initial sobre informes-ocupacion. Y hemos descubierto, con datos, que api-reservas tenía la memoria por debajo de su lowerBound y que tienda-web reservaba el triple de CPU de la que usa.

Con esto, cada pod pide lo que necesita y el número de pods se adapta al tráfico. Pero queda un supuesto oculto en todo lo que hemos hecho: hemos dado por sentado que, cuando el HPA pide treinta réplicas de api-reservas, hay sitio donde ponerlas. El clúster de Rutas Norte tiene cuatro nodos. Treinta pods de api-reservas más quince de tienda-web más los veinte de worker-notificaciones no caben. El planificador los dejará en Pending y el HPA no podrá hacer nada al respecto: habrá pedido las réplicas, el Deployment las habrá creado, y ahí se quedarán, en cola, mientras la venta del puente de mayo se cae exactamente igual que el año pasado.

En la próxima lección, Autoescalado de Clúster, subimos el último escalón: cómo hacer que el clúster añada nodos cuando los pods no caben, por qué retirarlos es mucho más delicado que añadirlos, cuánto tarda de verdad un nodo en estar listo (y por qué eso no salva una punta repentina), y el truco del sobreaprovisionamiento con pods de relleno que ya tenemos casi todas las piezas para entender.

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