Aurora Libros vive en un clúster con tres réplicas fijas de la API. Funciona bien a las cuatro de la tarde y se ahoga el día que un periódico reseña La sombra del viento. Esta lección hace que la plataforma crezca sola con la carga, reparta el tráfico con criterio y te deje ver, con números, dónde está el límite real.
Contenido
- Escalado horizontal y vertical
- Qué escala y qué no en Aurora Libros
- El cuello de botella real: las conexiones de PostgreSQL
- Réplicas de lectura y PgBouncer
- Requisito previo: la aplicación sin estado
- Balanceo de carga: L4 y L7
- El balanceo interno: Service,
kube-proxy, iptables e IPVS - Balanceo de entrada y en el mundo Compose/Swarm
- Algoritmos y afinidad de sesión
- Escalado manual, comparado
- El
HorizontalPodAutoscalera fondo - El HPA de
aurora-api - Métricas personalizadas
VerticalPodAutoscaleryCluster Autoscaler- Disponibilidad: PDB, reparto y anti-afinidad
- Prueba de carga y lectura de los límites
- Escalado horizontal y vertical
| Aspecto | Vertical (scale up) | Horizontal (scale out) |
|---|---|---|
| Qué cambia | Más CPU y RAM en la misma instancia | Más instancias iguales |
| Límite | El nodo más grande que exista | Prácticamente ninguno |
| Coste | Crece de golpe y no linealmente | Lineal y granular |
| Tolerancia a fallos | Ninguna: un punto único | Alta: caen unas y quedan otras |
| Requiere reiniciar | Sí, casi siempre | No |
| Tiempo de reacción | Minutos | Segundos |
| Encaja con | Bases de datos, cachés | Servicios sin estado |
| En Kubernetes | VerticalPodAutoscaler |
HorizontalPodAutoscaler |
El horizontal es el natural en contenedores por una razón sencilla: arrancar otra réplica de aurora-api:2.0.0 cuesta unos segundos y no requiere permiso de nadie, mientras que agrandar una máquina implica pararla. Y aporta algo que el vertical nunca da: redundancia. Tres réplicas no solo aguantan el triple, sino que sobreviven a la pérdida de una.
- Qué escala y qué no en Aurora Libros
| Servicio | ¿Escala horizontalmente? | Por qué |
|---|---|---|
aurora-web |
Sí, trivialmente | Sirve ficheros estáticos, sin estado |
aurora-api |
Sí | Sin estado: la caché está fuera, en aurora-cache |
aurora-cache |
Solo con Redis Cluster | Los datos están repartidos, no duplicados |
aurora-db |
No sin más | Escritura en un único primario; PVC ReadWriteOnce |
La asimetría de la última fila es la realidad de casi toda plataforma: el frontal escala con un número, y la base de datos exige arquitectura. Y como todo el tráfico acaba pasando por ella, el límite del sistema es el suyo.
- El cuello de botella real: las conexiones de PostgreSQL
Cada conexión de PostgreSQL es un proceso del sistema operativo con varios megabytes de memoria propia. El parámetro max_connections vale 100 por defecto, y ese número no es una recomendación: es una pared.
Con el DB_POOL_MAX: "10" de tu ConfigMap y max_connections = 100:
| Réplicas de API | Conexiones abiertas | Resultado |
|---|---|---|
| 3 | 30 | Correcto |
| 8 | 80 | Al límite |
| 10 | 100 | FATAL: sorry, too many clients already |
| 20 (autoescalado) | 200 | La base de datos rechaza la mitad |
Esta es la trampa que hace fracasar el primer autoescalado de mucha gente: el HPA hace su trabajo, crea veinte réplicas para absorber el pico... y la plataforma entera se cae, porque veinte pools de diez conexiones tumban a PostgreSQL. Escalar la API sin mirar la base de datos no reparte la carga: traslada el fallo.
- Réplicas de lectura y PgBouncer
Hay dos soluciones y son complementarias.
Réplicas de lectura. PostgreSQL replica en streaming hacia réplicas de solo lectura. aurora-api manda los SELECT del catálogo a una réplica y las escrituras al primario. Funciona muy bien en Aurora Libros porque su carga es casi toda de lectura, pero introduce el retardo de replicación: un libro recién insertado puede tardar milisegundos en verse en la réplica, así que la operación que acaba de escribir debe leer del primario.
PgBouncer. Un pooler que se coloca delante y multiplexa: mil conexiones de cliente sobre veinte conexiones reales.
# k8s/base/pgbouncer.yaml (extracto)
- name: pgbouncer
image: bitnami/pgbouncer:1.23
env:
- { name: POSTGRESQL_HOST, value: aurora-db }
- { name: PGBOUNCER_POOL_MODE, value: transaction } # el modo que más comparte
- { name: PGBOUNCER_MAX_CLIENT_CONN, value: "1000" }
- { name: PGBOUNCER_DEFAULT_POOL_SIZE, value: "20" }| Modo de pool | Cuándo se devuelve la conexión | Multiplexación | Limitación |
|---|---|---|---|
session |
Al desconectar el cliente | Escasa | Casi como no tenerlo |
transaction |
Al terminar cada transacción | Alta | Sin sentencias preparadas ni SET de sesión |
statement |
Tras cada sentencia | Máxima | Sin transacciones multisentencia |
Con transaction y DB_HOST: pgbouncer, aurora-api puede escalar a cincuenta réplicas manteniendo veinte conexiones reales contra PostgreSQL. Es, con diferencia, la intervención con mejor relación entre esfuerzo y resultado de esta lección.
- Requisito previo: la aplicación sin estado
Una réplica solo es intercambiable si no guarda nada que las demás no tengan. La regla práctica: si apagas cualquier Pod y ningún usuario lo nota, la aplicación es sin estado.
| Estado | Dónde NO puede vivir | Dónde va en Aurora Libros |
|---|---|---|
| Sesión de usuario | Memoria del proceso | aurora-cache (Redis compartido) |
| Carrito de la compra | Variable global | aurora-cache con TTL |
| Caché de catálogo | Memoria local | aurora-cache, compartida por todas |
| Ficheros subidos | Disco del contenedor | Almacenamiento de objetos |
| Contadores y métricas | Variable local | Prometheus, agregando por réplica |
Guardar la sesión en memoria produce el fallo más desconcertante de una plataforma escalada: el usuario inicia sesión (va a la réplica 1), navega (réplica 2) y aparece desconectado. Es exactamente el problema que resuelve tener la caché fuera del proceso, y por eso aurora-cache no es un lujo de rendimiento: es el requisito que permite replicar la API.
- Balanceo de carga: L4 y L7
| Nivel | Qué inspecciona | Puede repartir por | Coste | Ejemplos |
|---|---|---|---|---|
| L4 (transporte) | IP y puerto | Conexión TCP | Muy bajo | kube-proxy, IPVS, HAProxy en TCP |
| L7 (aplicación) | Cabeceras, ruta, método, cookies | Petición HTTP | Mayor | Ingress-nginx, Traefik, Envoy |
La diferencia práctica se ve con las conexiones persistentes. Un balanceador L4 reparte conexiones, no peticiones: si un cliente abre una conexión keep-alive y manda mil peticiones, las mil van al mismo Pod. Con clientes HTTP/1.1 normales apenas se nota, pero con gRPC o HTTP/2 —una sola conexión de larga vida— el reparto L4 se vuelve terriblemente desigual, y ese es el motivo por el que ese tipo de tráfico necesita un balanceador L7.
- El balanceo interno: Service,
kube-proxy, iptables e IPVS
kube-proxy, iptables e IPVSCuando aurora-api resuelve aurora-cache, obtiene una IP virtual que no existe en ninguna interfaz de red: es una dirección ficticia que kube-proxy intercepta con reglas del kernel.
flowchart LR
P["Pod aurora-api"] -->|10.96.184.22:6379| K["kube-proxy<br/>iptables / IPVS"]
K -->|DNAT 33%| A[Pod cache-1]
K -->|DNAT 33%| B[Pod cache-2]
K -->|DNAT 33%| C[Pod cache-3]
Modo de kube-proxy |
Cómo reparte | Complejidad | Algoritmos |
|---|---|---|---|
iptables (por defecto) |
Reglas con probabilidad | O(n): se degrada con miles de servicios | Solo aleatorio |
IPVS |
Tabla hash en el kernel | O(1) | rr, lc, sh, dh, wrr |
nftables |
Sustituto moderno de iptables | O(1) | Aleatorio |
kubectl get svc aurora-api -n aurora -o jsonpath='{.spec.clusterIP}'
kubectl get endpointslices -n aurora -l kubernetes.io/service-name=aurora-api
sudo iptables -t nat -L KUBE-SVC-XXXX -n # dentro del nodoCon iptables, el reparto se implementa encadenando reglas con probabilidades decrecientes: la primera acepta con probabilidad 1/3, la siguiente 1/2 del resto, y la última recoge lo que queda. El resultado es un reparto uniforme, pero aleatorio y sin memoria: no sabe cuántas conexiones tiene cada Pod ni cuánto tarda en responder. Para tener reparto por menos conexiones hay que pasar a IPVS. Y para políticas de verdad —reintentos, cortacircuitos, reparto por latencia— hace falta una malla de servicios como Istio o Linkerd.
- Balanceo de entrada y en el mundo Compose/Swarm
El Ingress opera en L7 y su controlador habla directamente con los Pods, saltándose la IP virtual del Service: consulta los EndpointSlices y balancea él mismo, lo que le permite algoritmos y reintentos que kube-proxy no tiene.
annotations:
nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" # reparto por URI
nginx.ingress.kubernetes.io/proxy-next-upstream: "error timeout http_502"
nginx.ingress.kubernetes.io/load-balance: "ewma" # latencia móvil| Entorno | Quién balancea | Algoritmo por defecto |
|---|---|---|
Compose con --scale |
El DNS de Docker: devuelve las IPs rotadas | Round-robin flojo (el cliente cachea) |
Swarm con ingress |
IPVS en cada nodo | Round-robin |
| Swarm con Nginx propio | Tu upstream |
El que configures |
| Kubernetes interno | kube-proxy |
Aleatorio (iptables) |
| Kubernetes de entrada | Ingress controller | Round-robin, ewma, hash |
El caso de Compose merece una advertencia: con --scale api=3, el DNS interno devuelve las tres IPs, pero el cliente decide cuál usa y muchos clientes HTTP cachean la primera resolución para siempre. Es la razón por la que un docker compose up --scale sin un Nginx delante reparte mucho peor de lo que parece.
- Algoritmos y afinidad de sesión
| Algoritmo | Cómo elige | Cuándo va bien | Riesgo |
|---|---|---|---|
| Round-robin | Por turnos | Peticiones homogéneas | Ignora la carga real |
| Menos conexiones | El backend más ocioso | Peticiones de duración desigual | Necesita estado |
| Hash de IP / URI | Determinista por clave | Cachés, afinidad | Reparto desigual |
| EWMA / latencia | El más rápido últimamente | Backends heterogéneos | Puede oscilar |
| Aleatorio con dos opciones | Elige 2 y toma la menos cargada | Escala muy bien | — |
La afinidad de sesión ata a cada cliente a un backend fijo:
Parece la solución fácil al problema de la sesión en memoria, y es un mal negocio. Rompe el reparto (un proxy corporativo con mil empleados es una sola IP, y todos caen en el mismo Pod), estropea el escalado (los Pods nuevos no reciben tráfico existente, así que escalar no alivia nada de inmediato) y convierte cada despliegue en una pérdida de sesiones. La solución correcta sigue siendo la de la sección 5: sacar el estado del proceso. Aurora Libros no usa afinidad de sesión.
- Escalado manual, comparado
| Plataforma | Comando | Reconciliación |
|---|---|---|
| Compose | docker compose up -d --scale aurora-api=5 |
Recrea contenedores; sin planificador |
| Swarm | docker service scale aurora_aurora-api=5 |
Reparte entre nodos según restricciones |
| Kubernetes | kubectl scale deploy/aurora-api --replicas=5 |
El scheduler coloca según requests |
| Kubernetes (condicional) | kubectl scale --current-replicas=3 --replicas=5 |
Solo si el número actual es el esperado |
kubectl scale deploy/aurora-api -n aurora --replicas=6
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api -wUn aviso que ahorra sustos: si un HPA gobierna el Deployment, un kubectl scale manual dura hasta el siguiente ciclo del autoescalador, unos quince segundos. No es un fallo; es que el estado deseado lo fija ahora el HPA.
- El
HorizontalPodAutoscaler a fondo
HorizontalPodAutoscaler a fondoEl HPA es un controlador más: cada 15 segundos lee las métricas, aplica una fórmula y ajusta spec.replicas.
| Actuales | Uso medio de CPU | Objetivo | Cálculo | Deseadas |
|---|---|---|---|---|
| 3 | 90 % | 70 % | ceil(3 × 90/70) = ceil(3,86) | 4 |
| 4 | 140 % | 70 % | ceil(4 × 2) | 8 |
| 8 | 20 % | 70 % | ceil(8 × 0,29) | 3 |
| 3 | 72 % | 70 % | 3 × 1,03 → dentro de la tolerancia (10 %) | 3 |
Esa tolerancia del 10 % es importante: sin ella, el HPA reaccionaría a cada fluctuación menor y la plataforma no pararía de crear y destruir Pods.
Requisito previo: metrics-server instalado y requests definidas. El porcentaje del HPA es relativo a las requests, no al límite ni a la capacidad del nodo. Un Deployment sin requests deja al HPA sin denominador y su estado aparece como <unknown>; es la causa número uno de "mi HPA no hace nada".
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl top pods -n aurora
- El HPA de
aurora-api
aurora-apiapiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: aurora-api, namespace: aurora }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: aurora-api }
minReplicas: 3
maxReplicas: 12 # 12 × 10 conexiones = 120 > max_connections: ver sección 3
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
- type: Resource
resource: { name: memory, target: { type: Utilization, averageUtilization: 80 } }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # subir rápido: el usuario está esperando
policies:
- { type: Percent, value: 100, periodSeconds: 30 } # como mucho, doblar cada 30 s
- { type: Pods, value: 4, periodSeconds: 30 }
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300 # bajar despacio: 5 min de observación
policies:
- { type: Percent, value: 25, periodSeconds: 60 } # como mucho, -25 % por minutoEl bloque behavior es lo que evita el efecto yo-yo: sin él, una ráfaga crea réplicas, la CPU media baja, el HPA las destruye, la carga vuelve a concentrarse y el ciclo se repite indefinidamente, con Pods que nacen y mueren sin llegar a ser útiles.
La asimetría es deliberada y merece grabarse: subir es urgente, bajar no lo es. Escalar de menos cuesta peticiones perdidas y clientes; escalar de más cuesta unos céntimos de CPU ociosa durante cinco minutos. La ventana de estabilización de 300 segundos hace que el HPA use el máximo de las recomendaciones de los últimos cinco minutos antes de reducir.
Y el maxReplicas: 12 no es un número redondo: sale de la cuenta de la sección 3. Doce réplicas por diez conexiones son 120, ya por encima de max_connections. Con PgBouncer delante, ese techo podría subir tranquilamente.
- Métricas personalizadas
La CPU es un mal indicador para una API que espera mucho por E/S: aurora-api puede estar al 20 % de CPU y con la latencia por las nubes esperando a PostgreSQL. Lo que de verdad describe su carga son las peticiones por segundo y la latencia.
- type: Pods
pods:
metric: { name: http_peticiones_por_segundo }
target: { type: AverageValue, averageValue: "50" }Eso requiere un adaptador que exponga las métricas de Prometheus por la API custom.metrics.k8s.io (prometheus-adapter o KEDA). El circuito completo es: tu API expone /metrics → Prometheus la recoge (ya lo montaste en 05-06) → el adaptador la publica como métrica de Kubernetes → el HPA la consume. Vale la pena cuando el consumo de CPU no se correlaciona con la carga percibida, que es el caso de casi cualquier API ligada a base de datos.
VerticalPodAutoscaler y Cluster Autoscaler
VerticalPodAutoscaler y Cluster AutoscalerEl VPA ajusta requests y limits en lugar del número de réplicas. Su gran valor está en el modo Off, que solo recomienda: te dice, con datos de semanas, qué valores deberían tener tus contenedores, y así ajustas los manifiestos sin adivinar. En modo Auto recrea los Pods para aplicar los nuevos valores, lo que lo hace incompatible con el HPA sobre la misma métrica: si ambos actúan sobre la CPU, se pelean.
El Cluster Autoscaler trabaja un nivel más arriba: cuando hay Pods en Pending porque no caben en ningún nodo, pide una máquina nueva al proveedor cloud; cuando un nodo lleva rato infrautilizado y sus Pods caben en otros, lo drena y lo apaga. Es la pieza que hace que el HPA no choque contra el techo de capacidad, y también la que introduce el retardo real del escalado: crear un nodo tarda minutos, no segundos.
flowchart TB
C[Carga sube] --> H[HPA: más Pods]
H --> Q{¿Caben?}
Q -->|sí| OK[Programados en segundos]
Q -->|no| P[Pods en Pending]
P --> CA[Cluster Autoscaler: nodo nuevo]
CA --> OK2[Programados en minutos]
- Disponibilidad: PDB, reparto y anti-afinidad
Escalar no sirve de nada si las doce réplicas están en el mismo nodo y ese nodo se cae.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: aurora-api }
spec:
minAvailable: 2 # nunca menos de 2 durante mantenimientos
selector: { matchLabels: { app.kubernetes.io/name: aurora-api } } topologySpreadConstraints:
- maxSkew: 1 # como mucho, 1 Pod de diferencia
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # preferencia, no requisito
labelSelector: { matchLabels: { app.kubernetes.io/name: aurora-api } }| Mecanismo | Qué garantiza | Frente a qué protege |
|---|---|---|
PodDisruptionBudget |
Un mínimo disponible durante interrupciones voluntarias | kubectl drain, actualización de nodos |
topologySpreadConstraints |
Reparto equilibrado entre nodos o zonas | Caída de un nodo o una zona |
podAntiAffinity |
Réplicas en nodos distintos (regla dura o blanda) | Caída de un nodo |
El PDB solo cubre las interrupciones voluntarias: si un nodo se apaga de golpe, nadie pregunta. Y hay una trampa clásica: minAvailable: 2 con replicas: 2 bloquea cualquier drenaje para siempre, porque no se puede quitar ninguna sin incumplirlo. Expresa siempre el PDB en función del mínimo del HPA, no del número actual.
- Prueba de carga y lectura de los límites
apiVersion: batch/v1
kind: Job
metadata: { name: carga-libros, namespace: aurora }
spec:
template:
spec:
restartPolicy: Never
containers:
- name: k6
image: grafana/k6:latest
args: ["run","--vus","200","--duration","5m","/guiones/carga.js"]
volumeMounts: [{ name: guiones, mountPath: /guiones }]
volumes: [{ name: guiones, configMap: { name: k6-guiones } }]// carga.js — 200 usuarios virtuales pidiendo el catálogo
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = { thresholds: { http_req_duration: ['p(95)<800'] } };
export default function () {
const r = http.get('http://aurora-api:3000/libros');
check(r, { 'estado 200': (x) => x.status === 200 });
sleep(0.2);
}NAME TARGETS MINPODS MAXPODS REPLICAS
aurora-api 12%/70%, 31%/80% 3 12 3
aurora-api 148%/70%, 44%/80% 3 12 3
aurora-api 148%/70%, 44%/80% 3 12 6
aurora-api 96%/70%, 41%/80% 3 12 9
aurora-api 64%/70%, 38%/80% 3 12 9
aurora-api 9%/70%, 30%/80% 3 12 3| Momento | Réplicas | CPU media | p50 | p95 | Errores |
|---|---|---|---|---|---|
| Reposo (20 pet./s) | 3 | 12 % | 8 ms | 24 ms | 0 |
| Pico, antes del HPA | 3 | 148 % | 310 ms | 1 840 ms | 12 |
| Durante el escalado | 6 | 96 % | 96 ms | 420 ms | 0 |
| Estabilizado | 9 | 64 % | 21 ms | 88 ms | 0 |
| Tras la carga (5 min) | 3 | 9 % | 8 ms | 22 ms | 0 |
El p95 pasó de 1 840 ms a 88 ms sin tocar nada: el HPA lo hizo en poco más de un minuto. Fíjate en la fila del pico: 148 % de utilización significa que cada Pod usa 1,48 veces su request de CPU, es decir, está siendo throttled contra su límite; los doce errores son peticiones que superaron el tiempo de espera del cliente.
| Síntoma bajo carga | Cuello de botella | Qué hacer |
|---|---|---|
| CPU de la API al tope, BD tranquila | API | Escalar réplicas: el HPA lo cubre |
| API ociosa, latencia alta, BD al 100 % | Base de datos | Réplicas de lectura, índices, más caché |
too many clients already |
Conexiones | PgBouncer, bajar DB_POOL_MAX |
| Todo ocioso y aun así lento | Red o dependencia externa | Trazas distribuidas, revisar DNS y keep-alive |
Memoria creciendo hasta OOMKilled |
Fuga en la aplicación | Perfilar el heap; el HPA no arregla una fuga |
| Ratio de aciertos de caché bajo | TTL o clave de caché | Revisar CACHE_TTL y la clave |
La última fila del bloque anterior es la que más importa de la lección: escalar la API solo ayuda cuando el cuello de botella es la API. En los demás casos, añadir réplicas empeora el problema, porque multiplica la presión sobre el componente que ya estaba saturado.
Advertencia. Una prueba de carga contra un entorno compartido puede degradar servicios de otros equipos y disparar alertas de seguridad. Acuerda siempre la ventana, el origen del tráfico y los umbrales con los responsables de infraestructura y seguridad, y no lances nunca carga contra sistemas que no controlas.
Errores Comunes y Consejos
- Escalar la API sin mirar la base de datos. El HPA crea veinte réplicas, PostgreSQL rechaza conexiones y la caída es total. Calcula
réplicas × poolantes de fijarmaxReplicas. - HPA sin
requests. Los objetivos aparecen como<unknown>y no escala nunca. El porcentaje es relativo a la petición. - Sin
metrics-server. El HPA no tiene de dónde leer.kubectl top podses la comprobación de treinta segundos. scaleDownagresivo. Sin ventana de estabilización, la plataforma oscila y ninguna réplica llega a ser útil.- Afinidad de sesión como parche. Rompe el reparto y no arregla el diseño. Saca el estado del proceso.
minAvailableigual areplicasen un PDB. Bloquea todos los mantenimientos del clúster.- Probar la carga contra una API con caché caliente. Mides Redis, no tu sistema. Vacía la caché o varía las claves.
- Consejo: mide antes y después con los mismos percentiles. El p95 y el p99 cuentan la historia; la media la esconde.
- Consejo: fija
maxReplicasa un número que el clúster pueda alojar. Si no, tendrás Pods enPendingy un autoescalado que aparenta funcionar sin hacer nada.
Ejercicios
Ejercicio 1. Calcula y demuestra el límite de conexiones: averigua el max_connections de aurora-db, deduce cuántas réplicas de API caben con DB_POOL_MAX: 10 y provoca el fallo escalando por encima de ese número.
Ejercicio 2. Monta el HPA sobre aurora-api, genera carga con k6 y documenta la tabla de latencias y réplicas antes, durante y después. Explica por qué la bajada tarda mucho más que la subida.
Ejercicio 3. Comprueba que las réplicas están repartidas y que la plataforma sobrevive a un mantenimiento: aplica un PDB y topologySpreadConstraints, drena un nodo y observa qué hace Kubernetes.
Soluciones
Solución 1.
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -tAc 'SHOW max_connections;'
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -tAc \
"SELECT count(*), usename FROM pg_stat_activity WHERE usename='aurora' GROUP BY usename;"
# 100
# 30 | auroraTres réplicas por diez conexiones son exactamente las 30 abiertas: el DB_POOL_MAX del ConfigMap no es teórico, cada Pod abre su pool completo al arrancar aunque no lo use. El techo teórico es (100 − 3 reservadas) / 10 = 9 réplicas.
kubectl scale deploy/aurora-api -n aurora --replicas=12
sleep 30
kubectl logs -n aurora -l app.kubernetes.io/name=aurora-api --tail=2 | grep -i fatal
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api | grep -c Running{"nivel":"error","mensaje":"fallo de conexion",
"detalle":"FATAL: sorry, too many clients already"}
9Nueve Pods Running de doce, y los otros tres en CrashLoopBackOff: sus pools no consiguieron abrir. Pero lo grave no son esos tres, sino el efecto sobre los que sí arrancaron, que empiezan a ver errores intermitentes al renovar conexiones.
kubectl apply -f k8s/base/pgbouncer.yaml
kubectl patch cm aurora-config -n aurora --type=merge -p '{"data":{"DB_HOST":"pgbouncer"}}'
kubectl rollout restart deploy/aurora-api -n aurora
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -tAc \
"SELECT count(*) FROM pg_stat_activity WHERE usename='aurora';"
# 20| Configuración | Réplicas de API | Conexiones reales a PostgreSQL | Techo de réplicas |
|---|---|---|---|
| Directa | 12 | 120 (rechaza) | 9 |
Con PgBouncer (transaction, pool 20) |
12 | 20 | Cientos |
Doce réplicas y veinte conexiones reales: PgBouncer ha desacoplado por completo el escalado de la API del límite de la base de datos. Esa es la razón por la que maxReplicas deja de ser una cuenta de conexiones y puede fijarse por capacidad de cómputo.
La lección de fondo es de método: el límite de un sistema casi nunca está donde escalas. Aquí escalabas la API y el que se rompía era PostgreSQL, un servicio que ni siquiera habías tocado. Antes de subir maxReplicas, hay que preguntarse siempre qué recurso compartido se multiplica con cada réplica.
Solución 2.
kubectl apply -f k8s/hpa.yaml
kubectl get hpa aurora-api -n aurora
kubectl apply -f k8s/carga.yaml # el Job de k6 con 200 usuarios virtuales
kubectl get hpa aurora-api -n aurora -w &
kubectl logs -f job/carga-libros -n aurora | tail -6 http_req_duration..: p(50)=21ms p(95)=88ms p(99)=141ms
http_req_failed....: 0.00% ✓ 0 ✗ 74213
iterations.........: 74213 247.3/s
✓ estado 200| Fase | t | Réplicas | CPU (% de request) | p95 | Errores |
|---|---|---|---|---|---|
| Reposo | 0 | 3 | 12 % | 24 ms | 0 |
| Inicio de la carga | +15 s | 3 | 148 % | 1 840 ms | 12 |
| Primer escalado | +45 s | 6 | 96 % | 420 ms | 0 |
| Segundo escalado | +75 s | 9 | 64 % | 88 ms | 0 |
| Fin de la carga | +5 min | 9 | 9 % | 22 ms | 0 |
| Reducción | +10 min | 3 | 9 % | 22 ms | 0 |
Las dos columnas de la derecha resumen el resultado: el p95 cayó de 1 840 ms a 88 ms, veinte veces, sin que nadie tocara nada, y los doce errores del primer minuto son el precio de haber empezado con tres réplicas.
Los tiempos revelan la asimetría del behavior. La subida fue en dos saltos de 30 segundos —stabilizationWindowSeconds: 0 y la política de doblar como mucho— y en 75 segundos la plataforma estaba estabilizada. La bajada empezó cinco minutos después de que la carga terminara, porque scaleDown.stabilizationWindowSeconds: 300 obliga al HPA a usar el máximo recomendado en esa ventana; y luego bajó despacio, un 25 % por minuto.
Esa lentitud es intencionada, y la justificación es económica antes que técnica. Bajar rápido tiene un riesgo asimétrico: si la carga vuelve a subir treinta segundos después —cosa muy habitual, porque el tráfico llega a ráfagas—, te encuentras otra vez con tres réplicas ahogadas y otro minuto de latencias malas. Mantener nueve réplicas cinco minutos de más cuesta céntimos; quedarte corto en el segundo pico cuesta clientes.
Un detalle metodológico: los 247 iteraciones por segundo del informe son el rendimiento sostenido, no el pico. Al medir, importa más ese número junto al p95 que el máximo instantáneo, que casi siempre se consigue a costa de latencias inaceptables.
Solución 3.
kubectl apply -f k8s/pdb.yaml
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api \
-o custom-columns=POD:.metadata.name,NODO:.spec.nodeName --no-headers | awk '{print $2}' | sort | uniq -cReparto perfecto gracias a topologySpreadConstraints con maxSkew: 1. Sin esa restricción, el scheduler solo mira los recursos libres y es perfectamente capaz de poner las seis en el mismo nodo si ahí cabían.
kubectl drain aurora-worker --ignore-daemonsets --delete-emptydir-data --timeout=120s
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api -o wide | tail -4
kubectl get pdb aurora-api -n auroraevicting pod aurora/aurora-api-6d4f8b7c9-2xkpq
evicting pod aurora/aurora-api-6d4f8b7c9-mv7rn
aurora-api-6d4f8b7c9-k9wtz 1/1 Running aurora-worker2
aurora-api-6d4f8b7c9-p2mvx 1/1 Running aurora-worker2
NAME MIN AVAILABLE ALLOWED DISRUPTIONS
aurora-api 2 4El detalle que hay que observar es que las expulsiones se hicieron de una en una y esperando. El drenaje no expulsa a la vez todos los Pods del nodo: pide permiso al PDB antes de cada uno, y si expulsar el siguiente dejara menos de dos disponibles, espera a que el reemplazo esté listo en otro nodo. Por eso ALLOWED DISRUPTIONS va bajando durante la operación.
# Con un PDB mal calibrado, el drenaje se bloquea para siempre
kubectl patch pdb aurora-api -n aurora -p '{"spec":{"minAvailable":6}}'
kubectl drain aurora-worker2 --ignore-daemonsets --timeout=30s
# error: Cannot evict pod ... violate the pod disruption budget.Ahí está la trampa de la sección 15, en vivo: con minAvailable: 6 y seis réplicas, ninguna expulsión es admisible y el drenaje falla indefinidamente. En una actualización nocturna del clúster, ese PDB dejaría el mantenimiento colgado sin que nadie entienda por qué.
La regla que se deduce: expresa el PDB en función del mínimo del HPA, nunca del número actual de réplicas. Con minReplicas: 3, un minAvailable: 2 (o mejor, maxUnavailable: 1) da margen tanto en reposo como con doce réplicas.
Y la limitación que este ejercicio no cubre: todo esto protege frente a interrupciones voluntarias. Si aurora-worker se apagara de golpe, sus tres Pods desaparecerían sin que el PDB dijera nada, y solo el reparto entre nodos —lo que sí has garantizado— evitaría que Aurora Libros se quedara sin ninguna réplica viva.
Conclusión
La plataforma ya crece y se encoge sola. Distingues el escalado horizontal del vertical y sabes qué servicios de Aurora Libros aceptan cada uno: la API y el frontal replican sin más porque no guardan estado, y la base de datos no, por dos motivos que has medido —el PVC ReadWriteOnce y, sobre todo, el max_connections—. Ese fue el hallazgo más valioso: el límite del sistema no estaba donde escalabas. Doce réplicas por diez conexiones tumbaban PostgreSQL, y PgBouncer en modo transaction lo resolvió dejando doce réplicas sobre veinte conexiones reales, con las réplicas de lectura como la otra mitad de la respuesta.
Entiendes por qué la caché compartida en aurora-cache no es un lujo sino el requisito que permite replicar la API, y por qué la afinidad de sesión es un parche que rompe el reparto en vez de arreglar el diseño. Sabes qué separa L4 de L7, cómo kube-proxy implementa la IP virtual con reglas probabilísticas de iptables —y qué gana IPVS—, cómo el Ingress balancea hablando directamente con los EndpointSlices, y por qué un docker compose up --scale reparte peor de lo que parece.
Has montado el HorizontalPodAutoscaler con su fórmula, su tolerancia del 10 % y un behavior deliberadamente asimétrico: subir en segundos porque el usuario espera, bajar en cinco minutos porque el tráfico llega a ráfagas. Y lo has probado de verdad con k6: el p95 cayó de 1 840 ms a 88 ms en poco más de un minuto mientras el HPA llevaba las réplicas de 3 a 9, y volvió solo a 3 al terminar. Conoces las métricas personalizadas como siguiente paso cuando la CPU no describe la carga, el VPA en modo recomendación y el Cluster Autoscaler con su retardo de minutos. Y has asegurado la disponibilidad con PodDisruptionBudget y topologySpreadConstraints, viendo el drenaje expulsar de una en una y comprobando de primera mano cómo un PDB mal calibrado bloquea un mantenimiento para siempre. Con la tabla que traduce cada síntoma en su cuello de botella real, porque escalar la API solo ayuda cuando el problema es la API.
Queda una última pieza. Aurora Libros aguanta la carga, pero cada vez que publicas una versión nueva sigue habiendo un momento delicado. En la siguiente lección, Estrategias de Despliegue y Rollback, verás cómo conviven la versión vieja y la nueva: rolling update con maxSurge y maxUnavailable, blue-green, canario y shadow, con su coste y su riesgo; ensayarás un despliegue fallido de aurora-api:2.1.0 que no pasa la readiness para ver cómo se detiene solo y deshacerlo; y afrontarás el problema que ninguna estrategia resuelve sola, el de las migraciones de base de datos.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
