La lección anterior cerró con una pregunta incómoda: ¿es que hace falta tanto?
Durante todo el módulo hemos dado por buenos unos números que veníamos arrastrando. Que una réplica de api-reservas sostiene 85 peticiones por segundo. Que hacen falta 30 réplicas en el pico. Que el requests.cpu correcto es 412m. Pero nadie ha preguntado por qué una réplica solo aguanta 85 peticiones por segundo, ni si podría aguantar 200 con exactamente los mismos recursos.
Y esa pregunta importa mucho, porque escalar es multiplicar la ineficiencia por el número de réplicas. Si cada réplica desperdicia la mitad de su capacidad, treinta réplicas desperdician quince. Se paga la ineficiencia treinta veces, se arrancan nodos que no harían falta, y el cuello de botella real —que casi nunca está donde uno cree— sigue exactamente donde estaba.
Esta lección cierra el módulo 9 mirando hacia dentro. Vamos a ver la metodología antes que los trucos, cómo hacer una prueba de carga con k6 que simule el puente de mayo y cómo leer sus resultados de verdad, el ajuste de la capa de aplicación (que es donde casi siempre está el problema), la mecánica real del estrangulamiento de CPU, el arranque rápido como requisito del autoescalado, el ajuste del clúster incluido el ndots: 5 que dejamos pendiente en 04-03, y un presupuesto de latencia que convierte el objetivo de negocio en objetivos por componente.
Contenido
- La metodología antes que los trucos
- Pruebas de carga con k6: el script del puente de mayo
- Tipos de prueba y cuándo usar cada uno
- Ejecutar la prueba dentro del clúster
- Leer los resultados correctamente
- Ajuste de la aplicación: el pool de conexiones
- Ajuste de la aplicación: consultas, índices y caché
- Ajuste de la aplicación: activos estáticos y conexiones persistentes
- Ajuste de recursos: la mecánica del estrangulamiento de CPU
- El debate sobre quitar el límite de CPU
- Memoria y el recolector de basura de Node.js en un contenedor
- El tamaño de pod adecuado
- Arranque rápido como requisito del autoescalado
- Ajuste del clúster: CoreDNS,
ndotsy kube-proxy - Almacenamiento: cuándo el disco es el límite
- El presupuesto de latencia
- El resultado medido del puente de mayo
- Errores comunes y consejos
- Ejercicios
- Conclusión
- La metodología antes que los trucos
Internet está lleno de listas de «20 trucos para acelerar Kubernetes». Casi todas son inútiles, porque el ajuste de rendimiento no es aplicar recetas: es un proceso de investigación.
Las cuatro reglas
Regla 1: mide antes de tocar nada.
Sin una medición de partida, no sabrás si tu cambio mejoró algo, lo empeoró, o no hizo nada. La sensación subjetiva de «va más rápido» es completamente poco fiable.
La medición de partida de Rutas Norte, tomada en rutas-norte-pre con una prueba de carga controlada:
LINEA BASE (2026-04-10, rutas-norte-pre, 4 replicas de api-reservas)
Peticiones por segundo sostenidas: 340 rps
Latencia p50: 42 ms
Latencia p95: 287 ms
Latencia p99: 891 ms
Tasa de error: 0,02 %
CPU media por replica: 310m de 412m (75 %)
Conexiones activas a PostgreSQL: 78 de 200Ese bloque es el punto de comparación de todo lo que venga después. Guárdalo, féchalo y anota las condiciones exactas.
Regla 2: encuentra el cuello de botella real.
En cualquier sistema hay un recurso que limita antes que los demás. Todo lo que no sea ese recurso, no importa. Optimizar algo que no es el cuello de botella produce exactamente cero mejora.
flowchart LR
U[Usuario] --> I[Ingress<br/>nginx]
I --> W[tienda-web]
W --> A[api-reservas]
A --> R[redis-cache]
A --> P[postgres-reservas]
A --> M[motor-precios]
style P fill:#f88,stroke:#900,stroke-width:3px
Si el cuello de botella es PostgreSQL, puedes optimizar nginx todo lo que quieras: no cambiará nada. Y peor: puedes escalar api-reservas a treinta réplicas y empeorar la situación, porque treinta réplicas abren treinta pools de conexiones sobre la misma base de datos saturada. Es exactamente el error que advertimos en 09-01.
Regla 3: cambia una sola cosa cada vez.
Si cambias el pool de conexiones, añades un índice y subes el limit de CPU a la vez, y el rendimiento mejora un 40 %, no sabes cuál de los tres lo hizo. Peor: quizá dos mejoraron y uno empeoró, y estás arrastrando un cambio perjudicial sin saberlo.
Un cambio. Medir. Anotar. Siguiente.
Regla 4: vuelve a medir en las mismas condiciones.
Mismo script, misma duración, mismo entorno, mismos datos. Si comparas una prueba de martes a las 10:00 con otra de sábado a las 3:00, estás comparando ruido.
El ciclo completo
flowchart TD
A[1. Definir el objetivo<br/>SLO: p95 < 300 ms al 95 %] --> B[2. Medir la linea base<br/>prueba de carga controlada]
B --> C[3. Identificar el cuello de botella<br/>metricas del modulo 7]
C --> D[4. Formular UNA hipotesis<br/>'el pool es demasiado grande']
D --> E[5. Aplicar UN cambio<br/>en rutas-norte-pre]
E --> F[6. Medir en las mismas condiciones]
F --> G{Mejoro?}
G -->|Si| H[7. Anotar, llevar a produccion,<br/>verificar en Grafana]
G -->|No| I[Revertir. La hipotesis era falsa.<br/>Anotarlo tambien: es informacion]
H --> J{Se alcanzo<br/>el objetivo?}
I --> C
J -->|No| C
J -->|Si| K[FIN. Documentar y parar]
style D fill:#cde,stroke:#369
style K fill:#cfc,stroke:#393
Fíjate en el paso final: cuando se alcanza el objetivo, se para. El ajuste de rendimiento sin objetivo definido es un pozo sin fondo: siempre se puede optimizar más, con retornos cada vez menores. El SLO del módulo 7 es lo que dice cuándo has terminado.
Las herramientas que ya tienes
Todo el instrumental viene del módulo 7:
| Pregunta | Herramienta |
|---|---|
| ¿Cuál es la latencia real por percentiles? | Prometheus + histogram_quantile (07-03) |
| ¿Qué componente consume la latencia? | Trazas distribuidas, o histogramas por componente |
| ¿Cuánta CPU y memoria consume cada pod? | kubectl top y metrics-server (07-02) |
| ¿Hay estrangulamiento de CPU? | container_cpu_cfs_throttled_seconds_total |
| ¿Qué está esperando la aplicación? | Registros con tiempos por fase (07-05) |
| ¿Qué pasa en el clúster? | Eventos y kubectl describe (07-06) |
| ¿Se cumple el SLO? | Paneles de Grafana y presupuesto de error (07-04) |
Antes de tocar nada, esos paneles deben existir y estar poblados. Optimizar a ciegas es adivinar.
- Pruebas de carga con k6: el script del puente de mayo
k6 es una herramienta de pruebas de carga en la que los escenarios se escriben en JavaScript y se ejecutan en un motor Go de alto rendimiento. Es la opción natural en Kubernetes: es un binario único, funciona bien dentro de un contenedor y exporta métricas a Prometheus.
Un script realista
La clave de una prueba útil es que se parezca al tráfico real. Un bucle martilleando un solo endpoint no dice nada: la carga real de Rutas Norte es una secuencia de acciones con pausas entre ellas y proporciones distintas.
// pruebas/carga/puente-mayo.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
// ---------------------------------------------------------------------------
// METRICAS PERSONALIZADAS
// Las metricas de k6 por defecto miden peticiones HTTP. Estas miden
// OPERACIONES DE NEGOCIO, que es lo que le importa a Rutas Norte.
// ---------------------------------------------------------------------------
const errorReservas = new Rate('errores_reserva');
const duracionBusqueda = new Trend('duracion_busqueda_rutas', true);
const duracionReserva = new Trend('duracion_confirmar_reserva', true);
const reservasConfirmadas = new Counter('reservas_confirmadas');
// ---------------------------------------------------------------------------
// CONFIGURACION DEL ESCENARIO
// ---------------------------------------------------------------------------
export const options = {
scenarios: {
// ESCENARIO 1: la avalancha de la apertura de la venta.
// Reproduce lo que ocurre el 25 de abril a las 10:00.
apertura_venta: {
executor: 'ramping-arrival-rate',
// ramping-arrival-rate mantiene una TASA DE LLEGADA constante,
// independientemente de si el sistema responde rapido o lento.
// Es la diferencia critica con ramping-vus: los usuarios reales NO
// esperan a que termines de atender al anterior para llegar.
startRate: 20,
timeUnit: '1s',
preAllocatedVUs: 200,
maxVUs: 3000,
stages: [
{ duration: '2m', target: 20 }, // Calma previa. Linea base.
{ duration: '40s', target: 500 }, // LA AVALANCHA: x25 en 40 segundos
{ duration: '10m', target: 500 }, // Meseta: dos horas comprimidas
{ duration: '5m', target: 120 }, // Descenso gradual
{ duration: '3m', target: 20 }, // Vuelta a la calma
],
gracefulStop: '30s',
},
},
// ---------------------------------------------------------------------------
// UMBRALES: si no se cumplen, k6 termina con codigo de error.
// Esto convierte la prueba en algo que puede FALLAR en un pipeline de CI.
// ---------------------------------------------------------------------------
thresholds: {
// El SLO de Rutas Norte: p95 por debajo de 300 ms.
'http_req_duration': ['p(95)<300', 'p(99)<1000'],
// Menos del 1 % de errores en las reservas. Es lo que cuesta dinero.
'errores_reserva': ['rate<0.01'],
// La busqueda de rutas es la operacion mas frecuente: mas exigente.
'duracion_busqueda_rutas': ['p(95)<200'],
// Confirmar una reserva escribe en PostgreSQL: se le permite mas.
'duracion_confirmar_reserva': ['p(95)<800'],
// Menos del 0,5 % de fallos HTTP en total.
'http_req_failed': ['rate<0.005'],
},
};
// ---------------------------------------------------------------------------
// DATOS DE PRUEBA (ficticios)
// ---------------------------------------------------------------------------
const ORIGENES = ['BIL', 'SDR', 'OVD', 'GIJ', 'SAN', 'VIT', 'LOG', 'PAM'];
const DESTINOS = ['MAD', 'BCN', 'ZAZ', 'VLC', 'SVQ', 'BIO', 'LCG'];
const BASE = __ENV.URL_BASE || 'http://api-reservas.rutas-norte-pre.svc.cluster.local';
function aleatorio(lista) {
return lista[Math.floor(Math.random() * lista.length)];
}
function fechaPuente() {
// Las reservas del puente se concentran en tres dias concretos.
const dias = ['2026-04-30', '2026-05-01', '2026-05-02', '2026-05-03'];
return aleatorio(dias);
}
// ---------------------------------------------------------------------------
// EL RECORRIDO DEL USUARIO
// Reproduce lo que hace una persona de verdad: busca, mira, elige, confirma.
// ---------------------------------------------------------------------------
export default function () {
const origen = aleatorio(ORIGENES);
const destino = aleatorio(DESTINOS);
const fecha = fechaPuente();
let idRuta = null;
// PASO 1: buscar rutas. Es la operacion MAS FRECUENTE (todo el mundo busca).
group('01_buscar_rutas', function () {
const inicio = Date.now();
const res = http.get(
`${BASE}/rutas?origen=${origen}&destino=${destino}&fecha=${fecha}`,
{ tags: { operacion: 'buscar_rutas' } }
);
duracionBusqueda.add(Date.now() - inicio);
const ok = check(res, {
'busqueda devuelve 200': (r) => r.status === 200,
'busqueda devuelve rutas': (r) => {
try {
return JSON.parse(r.body).rutas !== undefined;
} catch (e) {
return false;
}
},
});
if (ok && res.status === 200) {
try {
const rutas = JSON.parse(res.body).rutas;
if (rutas && rutas.length > 0) {
idRuta = aleatorio(rutas).id;
}
} catch (e) { /* cuerpo malformado: se contabiliza en el check */ }
}
});
// PAUSA: el usuario mira los resultados. Entre 2 y 6 segundos.
// ESTE SLEEP ES ESENCIAL. Sin el, la prueba genera un patron de carga
// que no se parece en nada al real, y las conclusiones no valen.
sleep(Math.random() * 4 + 2);
if (!idRuta) {
// No hay rutas para esa combinacion. El usuario se va. Es realista:
// no todo el mundo encuentra lo que busca.
return;
}
// PASO 2: consultar la disponibilidad de plazas.
// Esta consulta DEBERIA servirse desde redis-cache.
group('02_consultar_plazas', function () {
const res = http.get(
`${BASE}/rutas/${idRuta}/plazas`,
{ tags: { operacion: 'consultar_plazas' } }
);
check(res, { 'plazas devuelve 200': (r) => r.status === 200 });
});
sleep(Math.random() * 3 + 1);
// PASO 3: confirmar la reserva.
// Solo el 18 % de las busquedas terminan en reserva. Es la tasa de
// conversion real de Rutas Norte, medida en produccion.
if (Math.random() > 0.18) {
return;
}
group('03_confirmar_reserva', function () {
const inicio = Date.now();
const carga = JSON.stringify({
idRuta: idRuta,
asiento: Math.floor(Math.random() * 54) + 1,
pasajero: {
nombre: `Cliente Prueba ${__VU}-${__ITER}`,
documento: `00000000${(__VU % 10)}X`,
correo: `prueba-${__VU}-${__ITER}@ejemplo.example`,
},
});
const res = http.post(`${BASE}/reservas`, carga, {
headers: { 'Content-Type': 'application/json' },
tags: { operacion: 'confirmar_reserva' },
});
duracionReserva.add(Date.now() - inicio);
const ok = check(res, {
'reserva devuelve 201': (r) => r.status === 201,
'reserva devuelve localizador': (r) => {
try {
return JSON.parse(r.body).localizador !== undefined;
} catch (e) {
return false;
}
},
});
errorReservas.add(!ok);
if (ok) reservasConfirmadas.add(1);
});
sleep(1);
}
// ---------------------------------------------------------------------------
// RESUMEN FINAL
// ---------------------------------------------------------------------------
export function handleSummary(data) {
const m = data.metrics;
const linea = (etiqueta, valor) => ` ${etiqueta.padEnd(38)} ${valor}\n`;
let salida = '\n=== PRUEBA DE CARGA: PUENTE DE MAYO ===\n\n';
salida += linea('Peticiones totales', m.http_reqs.values.count);
salida += linea('Peticiones por segundo (media)', m.http_reqs.values.rate.toFixed(1));
salida += linea('Reservas confirmadas', m.reservas_confirmadas ? m.reservas_confirmadas.values.count : 0);
salida += '\n --- LATENCIA GLOBAL ---\n';
salida += linea('p50', m.http_req_duration.values['p(50)'].toFixed(1) + ' ms');
salida += linea('p95', m.http_req_duration.values['p(95)'].toFixed(1) + ' ms');
salida += linea('p99', m.http_req_duration.values['p(99)'].toFixed(1) + ' ms');
salida += linea('maximo', m.http_req_duration.values.max.toFixed(1) + ' ms');
salida += '\n --- ERRORES ---\n';
salida += linea('Tasa de fallo HTTP', (m.http_req_failed.values.rate * 100).toFixed(3) + ' %');
return {
'stdout': salida,
'resultados/puente-mayo.json': JSON.stringify(data, null, 2),
};
}Los tres detalles que hacen útil este script
1. ramping-arrival-rate en lugar de ramping-vus. La diferencia es fundamental y muy poca gente la conoce:
| Ejecutor | Qué mantiene constante | Comportamiento si el sistema se ralentiza |
|---|---|---|
ramping-vus |
El número de usuarios virtuales | La carga BAJA sola. Cada usuario espera más, así que hace menos peticiones |
ramping-arrival-rate |
La tasa de llegada (peticiones/s) | La carga se mantiene. k6 crea más usuarios virtuales para sostener el ritmo |
Con ramping-vus, un sistema que se degrada genera menos carga, lo que oculta el problema: la prueba se autolimita. Los usuarios reales no se coordinan para esperar. Si abres la venta a las 10:00, llegan a las 10:00 aunque tu API tarde 4 segundos en responder.
Usa ramping-arrival-rate para cualquier prueba que quiera reproducir tráfico real.
2. Los sleep() entre pasos. Un usuario real busca, mira los resultados durante 4 segundos, elige, y confirma. Sin esas pausas, la prueba genera un patrón de concurrencia completamente distinto: mucha menos concurrencia por usuario y muchas más peticiones por segundo con menos conexiones abiertas. Los resultados serían optimistas y engañosos.
3. La tasa de conversión del 18 %. Solo el 18 % de las búsquedas acaban en reserva. Si la prueba hiciera una reserva por cada búsqueda, sobrecargaría PostgreSQL con escrituras muy por encima de lo real y concluiría que la base de datos es el cuello de botella cuando no lo es en producción.
La proporción entre operaciones es tan importante como el volumen total.
- Tipos de prueba y cuándo usar cada uno
No todas las pruebas de carga responden la misma pregunta.
| Tipo | Qué hace | Qué responde | Duración típica |
|---|---|---|---|
| Humo (smoke) | Carga mínima, 1-5 usuarios | ¿Funciona el sistema y el script? | 1-2 min |
| Carga (load) | Carga esperada sostenida | ¿Cumple el SLO en condiciones normales? | 15-30 min |
| Estrés (stress) | Carga creciente hasta romper | ¿Dónde está el límite? ¿Cómo se rompe? | 20-40 min |
| Punta (spike) | Salto brusco y corto | ¿Aguanta la apertura de la venta? | 5-10 min |
| Resistencia (soak) | Carga normal muy prolongada | ¿Hay fugas de memoria o degradación? | 4-24 h |
Prueba de humo
Siempre la primera. Comprueba que el script no tiene errores y que el sistema responde.
export const options = {
vus: 3,
duration: '2m',
thresholds: {
'http_req_failed': ['rate<0.01'],
'http_req_duration': ['p(95)<500'],
},
};Cuesta dos minutos y evita descubrir a los quince minutos de una prueba de estrés que la URL estaba mal.
Prueba de carga
La prueba de referencia. Verifica que el sistema cumple el SLO con la carga esperada.
export const options = {
scenarios: {
carga_normal: {
executor: 'constant-arrival-rate',
rate: 120, // 120 peticiones por segundo, el trafico habitual
timeUnit: '1s',
duration: '20m',
preAllocatedVUs: 100,
maxVUs: 500,
},
},
thresholds: {
'http_req_duration': ['p(95)<300'],
'http_req_failed': ['rate<0.001'],
},
};Prueba de estrés
La más informativa de todas. Sube la carga escalonadamente hasta que el sistema se rompe, y lo importante no es solo dónde se rompe, sino cómo.
export const options = {
scenarios: {
estres: {
executor: 'ramping-arrival-rate',
startRate: 50,
timeUnit: '1s',
preAllocatedVUs: 500,
maxVUs: 5000,
stages: [
{ duration: '3m', target: 100 },
{ duration: '3m', target: 200 },
{ duration: '3m', target: 400 },
{ duration: '3m', target: 600 },
{ duration: '3m', target: 800 },
{ duration: '3m', target: 1200 },
{ duration: '5m', target: 0 }, // Recuperacion: ¿se recupera solo?
],
},
},
// SIN thresholds: queremos que llegue hasta el final aunque falle.
};Lo que hay que observar:
| Observación | Qué significa |
|---|---|
| El punto de inflexión | El rps a partir del cual la latencia se dispara. Es la capacidad real |
| La forma de la degradación | ¿Gradual (bueno) o de golpe (malo)? |
| El comportamiento en saturación | ¿Errores rápidos (bueno) o timeouts largos (malo)? |
| La recuperación | Al bajar la carga, ¿vuelve a la normalidad o se queda degradado? |
Ese último punto es crítico y suele revelar problemas graves. Un sistema que no se recupera solo tras una punta tiene un problema estructural: una cola interna que no se vacía, conexiones que no se liberan, un pool agotado que no se regenera. En producción eso significa que un pico de cinco minutos deja el sistema roto durante horas.
Prueba de punta
Reproduce exactamente la apertura de la venta.
export const options = {
scenarios: {
punta: {
executor: 'ramping-arrival-rate',
startRate: 20,
timeUnit: '1s',
preAllocatedVUs: 100,
maxVUs: 3000,
stages: [
{ duration: '2m', target: 20 }, // Calma
{ duration: '30s', target: 800 }, // PUNTA BRUTAL
{ duration: '3m', target: 800 }, // Sostenido
{ duration: '2m', target: 20 }, // Vuelta a la calma
],
},
},
};Es la prueba que valida todo el módulo 9. Con ella se comprueba si el HPA reacciona a tiempo, si el colchón de sobreaprovisionamiento absorbe el golpe, y si el disparador cron de KEDA hace su trabajo. Ejecútala dos veces: con el precalentamiento cron activado y sin él, y compara.
Prueba de resistencia
La más aburrida y la que detecta los problemas más caros.
export const options = {
scenarios: {
resistencia: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '8h', // OCHO HORAS
preAllocatedVUs: 100,
maxVUs: 300,
},
},
};Lo que se busca:
- Fugas de memoria: la memoria de los pods crece de forma monótona hasta el
OOMKilled. - Fugas de conexiones: las conexiones a PostgreSQL crecen y nunca se liberan.
- Degradación gradual: la latencia p95 crece un 5 % cada hora.
- Fragmentación: el rendimiento cae sin que suba ningún recurso.
Se ejecuta una vez antes de un evento importante. Es la prueba que evita que la plataforma aguante bien las dos primeras horas del puente de mayo y se caiga a la tercera.
- Ejecutar la prueba dentro del clúster
Ejecutar k6 desde tu portátil mide, sobre todo, tu conexión a internet. Para medir la plataforma, la prueba debe correr dentro del clúster, contra rutas-norte-pre.
Como un Job de Kubernetes
# k8s/pruebas/job-carga-puente-mayo.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: script-carga
namespace: rutas-norte-pre
data:
puente-mayo.js: |
// (el contenido del script del apartado 2)
---
apiVersion: batch/v1
kind: Job
metadata:
name: carga-puente-mayo
namespace: rutas-norte-pre
labels:
app: prueba-carga
app.kubernetes.io/part-of: rutas-norte
spec:
backoffLimit: 0 # Si falla, NO reintentar: falsearia los resultados
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: prueba-carga
spec:
restartPolicy: Never
# El generador de carga NO debe competir por recursos con lo que mide.
# Lo mandamos a un nodo dedicado con taint y toleration (06-05).
tolerations:
- key: rol
operator: Equal
value: pruebas
effect: NoSchedule
nodeSelector:
rol: pruebas
containers:
- name: k6
image: grafana/k6:0.52.0
command: ["k6", "run"]
args:
- "--out"
- "experimental-prometheus-rw" # Envia metricas a Prometheus
- "--tag"
- "prueba=puente-mayo"
- "/scripts/puente-mayo.js"
env:
- name: URL_BASE
value: "http://api-reservas.rutas-norte-pre.svc.cluster.local"
- name: K6_PROMETHEUS_RW_SERVER_URL
value: "http://prometheus-operated.monitorizacion.svc.cluster.local:9090/api/v1/write"
- name: K6_PROMETHEUS_RW_TREND_STATS
value: "p(50),p(95),p(99),max"
resources:
# GENEROSO A PROPOSITO. Si k6 se queda sin CPU, no genera la carga
# pedida y la prueba MIENTE: mediras un sistema que aguanta bien
# porque nunca recibio la carga que creias.
requests:
cpu: "2"
memory: 2Gi
limits:
cpu: "4"
memory: 4Gi
volumeMounts:
- name: scripts
mountPath: /scripts
volumes:
- name: scripts
configMap:
name: script-cargakubectl apply -f k8s/pruebas/job-carga-puente-mayo.yaml
kubectl logs -f job/carga-puente-mayo -n rutas-norte-preEl detalle del nodo dedicado no es un lujo. Si k6 corre en el mismo nodo que api-reservas, compiten por CPU, y no sabrás si la latencia alta es del sistema o del generador de carga. Es un error clásico que invalida pruebas enteras.
Y el aviso sobre los recursos de k6 merece énfasis: una prueba de carga con un generador estrangulado es peor que no hacer la prueba, porque produce una falsa sensación de seguridad. Comprueba siempre en la salida de k6 que la tasa de llegada real coincide con la pedida.
Con el operador de k6
Para pruebas distribuidas entre varios pods:
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
name: carga-puente-mayo
namespace: rutas-norte-pre
spec:
parallelism: 4 # Cuatro pods generando carga en paralelo
script:
configMap:
name: script-carga
file: puente-mayo.js
runner:
resources:
requests: {cpu: "2", memory: 2Gi}
limits: {cpu: "2", memory: 2Gi}Necesario cuando un solo pod no puede generar suficiente carga (a partir de unos 2.000-3.000 rps, según el escenario).
- Leer los resultados correctamente
Aquí es donde la mayoría de la gente se equivoca.
La salida de k6
✓ busqueda devuelve 200
✓ busqueda devuelve rutas
✗ reserva devuelve 201
↳ 97% — ✓ 8214 / ✗ 254
checks.........................: 98.42% ✓ 47892 ✗ 768
data_received..................: 1.2 GB 1.8 MB/s
data_sent......................: 89 MB 134 kB/s
duracion_busqueda_rutas........: avg=98.4ms min=12ms med=71ms max=4.2s p(95)=241ms p(99)=892ms
duracion_confirmar_reserva.....: avg=421ms min=89ms med=302ms max=8.9s p(95)=1.4s p(99)=4.1s
errores_reserva................: 2.96% ✓ 254 ✗ 8214
http_req_blocked...............: avg=1.2ms min=0s med=2µs max=1.1s p(95)=4µs
http_req_connecting............: avg=0.9ms min=0s med=0s max=892ms p(95)=0s
✗ http_req_duration..............: avg=142ms min=8ms med=68ms max=8.9s p(95)=487ms p(99)=2.1s
{ expected_response:true }...: avg=131ms min=8ms med=66ms max=6.2s p(95)=443ms
✗ http_req_failed................: 1.34% ✓ 892 ✗ 65432
http_reqs......................: 66324 99.4/s
iteration_duration.............: avg=6.8s min=3.1s med=6.4s max=22s p(95)=12.4s
iterations.....................: 14204 21.3/s
vus............................: 187 min=20 max=1240
vus_max........................: 3000 min=3000 max=3000
reservas_confirmadas...........: 8214 12.3/s
ERRO[0942] thresholds on metrics 'http_req_duration, http_req_failed, errores_reserva' have been crossedPercentiles frente a media: la lección fundamental
Mira estas dos líneas:
La media es 142 ms. La mediana es 68 ms. El p95 es 487 ms. El p99 es 2,1 segundos.
Si informases «la latencia media es de 142 ms», estarías dando una imagen falsa y tranquilizadora. La realidad:
- La mitad de los usuarios tienen una experiencia excelente (68 ms).
- 5 de cada 100 esperan casi medio segundo.
- 1 de cada 100 espera más de 2 segundos.
- Alguien esperó casi 9 segundos.
Con 66.324 peticiones, ese p99 son 663 peticiones de más de 2 segundos. Y con 100 peticiones por segundo, eso es una petición horrible cada segundo, todo el rato.
La media oculta la cola de la distribución, y la cola es donde vive la insatisfacción del usuario.
| Métrica | Qué dice | Utilidad |
|---|---|---|
avg (media) |
El promedio aritmético | Casi ninguna. Un solo valor extremo la distorsiona |
med / p(50) |
La mediana: la mitad está por debajo | Experiencia del usuario típico |
p(95) |
95 de cada 100 están por debajo | La métrica de referencia para el SLO |
p(99) |
99 de cada 100 | La experiencia del peor 1 %. Importa en escala |
max |
El peor caso observado | Útil para detectar timeouts y anomalías |
Un efecto que sorprende a mucha gente: en una sesión con muchas peticiones, el p99 se convierte en la experiencia típica. Si cargar la página de Rutas Norte hace 30 peticiones, la probabilidad de que al menos una caiga en el p99 es 1 - 0,99³⁰ = 26 %. Uno de cada cuatro usuarios sufre el p99 en cada carga de página.
Por eso los SLO se definen sobre p95 y p99, nunca sobre la media.
El punto de inflexión
En una prueba de estrés, esta es la tabla que hay que construir:
| Carga (rps) | p50 | p95 | p99 | Errores | CPU media |
|---|---|---|---|---|---|
| 100 | 41 ms | 78 ms | 142 ms | 0,00 % | 24 % |
| 200 | 44 ms | 91 ms | 178 ms | 0,00 % | 47 % |
| 300 | 48 ms | 118 ms | 241 ms | 0,00 % | 68 % |
| 400 | 58 ms | 187 ms | 412 ms | 0,01 % | 84 % |
| 500 | 89 ms | 421 ms | 1.240 ms | 0,4 % | 94 % |
| 600 | 340 ms | 2.100 ms | 6.800 ms | 8,2 % | 98 % |
| 800 | 1.900 ms | 8.400 ms | timeout | 34,1 % | 99 % |
El punto de inflexión está entre 400 y 500 rps. Hasta 400, la latencia crece de forma suave y proporcionada. A partir de 500, se dispara de forma no lineal: de 400 a 500 rps (un 25 % más de carga) el p95 se multiplica por 2,25.
Esto tiene una explicación teórica precisa, y conocerla ayuda: es la teoría de colas. Cuando la utilización de un recurso se acerca al 100 %, el tiempo de espera tiende a infinito. La fórmula aproximada para una cola simple:
Tiempo de espera ≈ TiempoServicio × (utilizacion / (1 - utilizacion))
Utilizacion 50 %: espera = 1,0 x tiempo de servicio
Utilizacion 80 %: espera = 4,0 x tiempo de servicio
Utilizacion 90 %: espera = 9,0 x tiempo de servicio
Utilizacion 95 %: espera = 19,0 x tiempo de servicio
Utilizacion 99 %: espera = 99,0 x tiempo de servicioEsta es la justificación matemática de por qué el objetivo del HPA debe estar en el 60-70 % y no en el 90 % (09-01). Al 90 % de utilización, el tiempo de espera ya es nueve veces el tiempo de servicio. El margen del 30 % no es conservadurismo: es evitar la zona donde la latencia explota.
La capacidad real por réplica se define en el punto de inflexión, no en el punto de rotura. Con 4 réplicas y una inflexión en 450 rps: 112 rps por réplica. Aplicando el margen del 30 %, el threshold de KEDA sería 78 rps. Muy cerca de los 85 que veníamos usando: los números del módulo estaban bien calibrados.
Errores: cuáles importan
Un 1,34 % de fallos HTTP globales, pero un 2,96 % de fallos en las reservas. La diferencia importa mucho: los fallos se concentran en la operación que genera ingresos.
Una búsqueda fallida es molesta; el usuario reintenta. Una reserva fallida es dinero perdido y, potencialmente, un cobro sin billete.
Segmenta siempre los errores por operación de negocio, no solo globalmente. Es la razón por la que el script define errorReservas como métrica propia.
- Ajuste de la aplicación: el pool de conexiones
Y llegamos a donde casi siempre está el problema.
La aritmética que tumba la base de datos
Este es el error más caro y más frecuente en Kubernetes, y es puramente aritmético.
Configuracion "razonable" de api-reservas (Node.js con pg-pool):
pool.max = 20 conexiones
Situacion normal: 4 replicas.
Conexiones totales: 4 x 20 = 80.
postgres-reservas: max_connections = 200.
80 < 200. Todo bien.
PUENTE DE MAYO: el HPA escala a 30 replicas.
Conexiones totales: 30 x 20 = 600.
max_connections = 200.
400 conexiones RECHAZADAS.Lo que ocurre a continuación es una cascada:
1. Las replicas 11 a 30 no consiguen conexion.
2. Sus peticiones fallan con "too many connections" o esperan hasta timeout.
3. api-reservas devuelve 500. La CPU SUBE (manejo de errores, reintentos).
4. El HPA ve mas CPU y quiere escalar MAS. maxReplicas lo corta en 30.
5. Las 10 replicas que SI tienen conexion reciben todo el trafico.
6. PostgreSQL, con 200 conexiones activas, dedica mas tiempo a cambiar de
contexto entre procesos que a ejecutar consultas.
7. Las consultas se ralentizan. Las conexiones se retienen mas tiempo.
8. Colapso total.Escalar empeoró la situación. Con 4 réplicas la plataforma iba lenta; con 30 está caída.
La regla
El factor 0,8 reserva conexiones para las tareas de mantenimiento, las copias de seguridad, el exportador de métricas y las conexiones de administración.
Para Rutas Norte:
maxReplicas de api-reservas: 30
max_connections de postgres-reservas: 200
Reserva del 20 %: 200 x 0,8 = 160
pool_por_replica <= 160 / 30 = 5,33 -> 5 conexiones por replicaCinco conexiones por réplica, no veinte.
Pero, ¿bastan 5 conexiones?
La pregunta natural es si con 5 conexiones una réplica puede atender el tráfico. La respuesta sale de la ley de Little:
Concurrencia = Tasa de llegada x Tiempo de servicio
Con 5 conexiones y una consulta media de 8 ms:
Consultas por segundo = 5 / 0,008 = 625 consultas/s por replica
Si cada peticion HTTP hace 2 consultas de media:
Peticiones por segundo = 625 / 2 = 312 rps por replica312 rps por réplica, muy por encima de los 112 rps del punto de inflexión. El pool de 5 conexiones no es el limitante.
Esto revela algo importante: el pool de 20 conexiones nunca estuvo justificado. Era un valor por defecto copiado sin pensar. Y no solo era innecesario: era el mecanismo que iba a tumbar la plataforma en el pico.
Configuración correcta
# ConfigMap de api-reservas
apiVersion: v1
kind: ConfigMap
metadata:
name: api-reservas-config
namespace: rutas-norte-pro
data:
# POOL DE CONEXIONES A POSTGRESQL
#
# Calculo: maxReplicas (30) x POOL_MAX <= max_connections (200) x 0,8 = 160
# POOL_MAX <= 160 / 30 = 5,33 -> 5
#
# Verificacion de capacidad (ley de Little):
# 5 conexiones / 8 ms por consulta = 625 consultas/s por replica
# Con 2 consultas por peticion: 312 rps por replica.
# Punto de inflexion medido: 112 rps por replica.
# El pool NO es el limitante. Sobra con 5.
#
# NO SUBIR sin recalcular. Con 30 replicas, cada conexion extra por replica
# son 30 conexiones mas contra un limite de 200.
PG_POOL_MAX: "5"
PG_POOL_MIN: "2"
# Timeout de espera por una conexion libre. 3 segundos: preferimos fallar
# rapido (el usuario reintenta) a acumular peticiones esperando.
PG_POOL_ACQUIRE_TIMEOUT_MS: "3000"
# Cerrar conexiones ociosas a los 30 s: libera recursos en PostgreSQL
# cuando el HPA baja replicas.
PG_POOL_IDLE_TIMEOUT_MS: "30000"
# Reciclar conexiones cada 30 minutos: evita conexiones zombis y fugas
# de memoria del lado del servidor.
PG_POOL_MAX_LIFETIME_MS: "1800000"La solución de fondo: un pool compartido
Cinco conexiones por réplica funciona, pero es frágil: cualquier cambio del maxReplicas obliga a recalcular. La solución robusta es PgBouncer, un multiplexor de conexiones.
flowchart LR
subgraph API["30 replicas de api-reservas"]
A1[replica 1<br/>pool: 20]
A2[replica 2<br/>pool: 20]
A3[...]
A30[replica 30<br/>pool: 20]
end
PB[PgBouncer<br/>600 conexiones de cliente<br/>25 al servidor<br/>modo transaction]
PG[(postgres-reservas<br/>max_connections: 200<br/>25 en uso)]
A1 --> PB
A2 --> PB
A3 --> PB
A30 --> PB
PB --> PG
style PB fill:#cde,stroke:#369,stroke-width:2px
PgBouncer en modo transaction asigna una conexión de servidor solo mientras dura una transacción, no mientras dura la conexión del cliente. Como las transacciones duran milisegundos y las conexiones duran minutos, la multiplexación es enorme: 600 conexiones de cliente sobre 25 de servidor.
# k8s/base/deployment-pgbouncer.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer
namespace: rutas-norte-pro
labels:
app: pgbouncer
app.kubernetes.io/part-of: rutas-norte
spec:
replicas: 3
selector:
matchLabels:
app: pgbouncer
template:
metadata:
labels:
app: pgbouncer
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: pgbouncer
containers:
- name: pgbouncer
image: bitnami/pgbouncer:1.22.1
env:
- name: PGBOUNCER_POOL_MODE
# TRANSACTION: la conexion al servidor se asigna solo durante
# una transaccion. Es lo que permite la multiplexacion masiva.
# ADVERTENCIA: incompatible con sentencias preparadas de sesion,
# cursores con nombre y LISTEN/NOTIFY. La aplicacion debe estar
# escrita teniendolo en cuenta.
value: "transaction"
- name: PGBOUNCER_MAX_CLIENT_CONN
# 1000 conexiones de CLIENTE. Con 30 replicas x 20 = 600. Sobra.
value: "1000"
- name: PGBOUNCER_DEFAULT_POOL_SIZE
# Solo 25 conexiones al SERVIDOR por base de datos y usuario.
# Con 3 replicas de pgbouncer: 75 conexiones totales a PostgreSQL,
# muy por debajo de las 200. Y estables: no dependen del HPA.
value: "25"
- name: PGBOUNCER_RESERVE_POOL_SIZE
value: "5"
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 256Mi}Ventaja decisiva: el número de conexiones a PostgreSQL deja de depender del número de réplicas. El HPA puede escalar a 30, 50 o 100 réplicas sin que PostgreSQL se entere. Es la única forma robusta de combinar autoescalado agresivo con una base de datos relacional.
- Ajuste de la aplicación: consultas, índices y caché
El problema N+1
El patrón que más veces he visto convertir una API rápida en una API lenta.
// MAL: una consulta para las rutas, y una MAS por cada ruta. N+1.
const rutas = await db.query(
'SELECT id, origen, destino, hora_salida FROM rutas WHERE origen=$1 AND destino=$2',
[origen, destino]
);
for (const ruta of rutas) {
// Si hay 30 rutas, esto son 30 consultas ADICIONALES.
ruta.plazasLibres = await db.query(
'SELECT COUNT(*) FROM plazas WHERE ruta_id=$1 AND estado=$2',
[ruta.id, 'libre']
);
}
// TOTAL: 31 consultas para responder UNA peticion.Con 30 rutas y 5 ms por consulta: 155 ms solo en ida y vuelta a la base de datos, sin contar el tiempo de las consultas en sí. Y 31 consultas por petición significa que 100 rps se convierten en 3.100 consultas por segundo contra PostgreSQL.
// BIEN: UNA sola consulta con agregacion.
const rutas = await db.query(`
SELECT
r.id, r.origen, r.destino, r.hora_salida,
COUNT(p.id) FILTER (WHERE p.estado = 'libre') AS plazas_libres
FROM rutas r
LEFT JOIN plazas p ON p.ruta_id = r.id
WHERE r.origen = $1 AND r.destino = $2 AND r.fecha = $3
GROUP BY r.id, r.origen, r.destino, r.hora_salida
ORDER BY r.hora_salida
`, [origen, destino, fecha]);
// TOTAL: 1 consulta.De 31 consultas a 1. El impacto medido en Rutas Norte: de 155 ms a 12 ms en la búsqueda de rutas. Un factor de 13.
Cómo detectarlo: si el número de consultas por petición varía con el tamaño del resultado, tienes un N+1. Es visible en las trazas distribuidas (07-03) como una escalera de llamadas idénticas.
Índices que faltan
-- Diagnostico: ¿como ejecuta PostgreSQL esta consulta?
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM rutas WHERE origen = 'BIL' AND destino = 'MAD' AND fecha = '2026-05-01';Seq Scan on rutas (cost=0.00..18432.00 rows=12 width=84) (actual time=0.031..89.412 rows=8 loops=1)
Filter: ((origen = 'BIL') AND (destino = 'MAD') AND (fecha = '2026-05-01'))
Rows Removed by Filter: 847992
Buffers: shared hit=2841 read=5591
Planning Time: 0.184 ms
Execution Time: 89.487 msSeq Scan con Rows Removed by Filter: 847992. PostgreSQL está leyendo las 848.000 filas de la tabla para devolver 8. Cada vez.
-- El indice compuesto que resuelve la consulta
CREATE INDEX CONCURRENTLY idx_rutas_busqueda
ON rutas (origen, destino, fecha)
INCLUDE (hora_salida, precio_base);Index Scan using idx_rutas_busqueda on rutas (cost=0.42..8.61 rows=12 width=84)
(actual time=0.024..0.041 rows=8 loops=1)
Index Cond: ((origen = 'BIL') AND (destino = 'MAD') AND (fecha = '2026-05-01'))
Buffers: shared hit=5
Planning Time: 0.211 ms
Execution Time: 0.068 msDe 89,5 ms a 0,068 ms. Un factor de 1.316.
Tres detalles importantes del índice:
CONCURRENTLY: crea el índice sin bloquear escrituras. Tarda más pero no interrumpe el servicio. Obligatorio en producción.- El orden de las columnas importa: primero las de igualdad más selectivas. Un índice
(origen, destino, fecha)sirve para consultas pororigen, pororigen+destinoy por los tres; no sirve para consultar solo porfecha. INCLUDE: añade columnas al índice sin que formen parte de la clave, permitiendo un index-only scan que ni siquiera toca la tabla.
Cómo encontrar las consultas problemáticas:
-- Requiere la extension pg_stat_statements
SELECT
substring(query, 1, 80) AS consulta,
calls,
round(mean_exec_time::numeric, 2) AS media_ms,
round(total_exec_time::numeric / 1000, 1) AS total_s,
rows / GREATEST(calls, 1) AS filas_por_llamada
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;Ordena por total_exec_time, no por mean_exec_time. Una consulta de 2 ms ejecutada un millón de veces consume mucho más tiempo total que una de 500 ms ejecutada cien veces. El tiempo total es lo que satura la base de datos.
Usar redis-cache de verdad
redis-cache lleva en la plataforma desde el módulo 6, pero hay que comprobar si se está usando bien.
Un 13 % de aciertos es un desastre. El 87 % de las consultas van igualmente a PostgreSQL, y encima se paga el coste de consultar Redis primero. La caché está haciendo daño, no bien.
Diagnóstico habitual: claves demasiado específicas.
// MAL: la clave incluye la marca de tiempo exacta de la peticion.
// CADA peticion genera una clave distinta. Tasa de aciertos ~0 %.
const clave = `plazas:${rutaId}:${Date.now()}`;
// MAL: incluye el identificador del usuario, que no afecta al resultado.
const clave = `rutas:${origen}:${destino}:${fecha}:${usuarioId}`;
// BIEN: solo los parametros que determinan el resultado.
const clave = `rutas:${origen}:${destino}:${fecha}`;Estrategia de caché por tipo de dato en Rutas Norte:
| Dato | TTL | Justificación |
|---|---|---|
| Catálogo de rutas y horarios | 1 hora | Cambia con el cambio de temporada, casi nunca |
| Disponibilidad de plazas | 10 segundos | Cambia constantemente; 10 s es tolerable y absorbe el 95 % de las lecturas |
| Precios calculados | 5 minutos | El motor de precios recalcula cada pocos minutos |
| Datos del usuario | 15 minutos | Cambian raramente; se invalidan al editar |
| Resultado de una reserva | Nunca | Es una escritura; cachear una escritura es un error |
El TTL de 10 segundos para la disponibilidad merece explicación, porque parece poco. Con 500 consultas por segundo sobre la misma ruta popular:
Sin cache: 500 consultas/s a PostgreSQL.
Con TTL de 10 s: 1 consulta cada 10 s = 0,1 consultas/s a PostgreSQL.
Reduccion: 5.000 veces.
Coste: los datos pueden estar hasta 10 segundos desactualizados.¿Es aceptable mostrar «quedan 12 plazas» cuando en realidad quedan 11? Sí, siempre que la confirmación de la reserva verifique la disponibilidad real contra la base de datos. La caché sirve para mostrar; la verdad se comprueba al escribir.
Configuración de Redis para que se comporte como caché y no como base de datos:
# ConfigMap de redis-cache
maxmemory 1500mb
maxmemory-policy allkeys-lru
# allkeys-lru: al llenarse, expulsa las claves menos usadas recientemente.
# SIN esto, Redis crece hasta el OOMKilled (lo vimos en 09-02).
- Ajuste de la aplicación: activos estáticos y conexiones persistentes
Compresión y caché en tienda-web
# ConfigMap de tienda-web: /etc/nginx/conf.d/rutas-norte.conf
# --- COMPRESION ---
gzip on;
gzip_vary on;
gzip_min_length 1024; # No comprimir lo pequeno: el coste de CPU no compensa
gzip_comp_level 5; # 5 de 9: buen equilibrio. El 9 gasta mucha CPU
# para un 2-3 % mas de compresion
gzip_types
text/plain text/css text/javascript
application/javascript application/json
application/xml image/svg+xml;
# Brotli comprime un 15-20 % mejor que gzip para texto.
# PRECOMPRIMIDO en el build: coste de CPU CERO en tiempo de ejecucion.
brotli_static on;
gzip_static on;
# --- CACHE DE ACTIVOS ---
# Los ficheros con hash en el nombre (app.4f8a2c.js) son INMUTABLES:
# si cambia el contenido, cambia el nombre. Se pueden cachear un ano.
location ~* \.(js|css|woff2|png|jpg|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off; # No registrar activos: reduce E/S un 80 %
}
# El HTML NO se cachea: es el que apunta a los ficheros con hash.
location = /index.html {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
}
# --- CONEXIONES PERSISTENTES HACIA api-reservas ---
upstream api_reservas {
server api-reservas.rutas-norte-pro.svc.cluster.local:8080;
# 64 conexiones persistentes reutilizadas. Sin esto, nginx abre y cierra
# una conexion TCP por peticion: 3 paquetes de handshake mas el cierre.
keepalive 64;
keepalive_requests 1000;
keepalive_timeout 60s;
}
location /api/ {
proxy_pass http://api_reservas/;
# OBLIGATORIO para que keepalive funcione: HTTP/1.1 y sin cabecera
# Connection heredada del cliente.
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}Impacto medido de la compresión:
| Recurso | Sin comprimir | gzip -5 | brotli -11 (precomprimido) |
|---|---|---|---|
app.js |
842 KB | 218 KB | 178 KB |
estilos.css |
156 KB | 24 KB | 19 KB |
Respuesta de /rutas (JSON) |
48 KB | 6 KB | 5 KB |
Una reducción del 75-90 % en el tráfico, con un coste de CPU casi nulo (los activos van precomprimidos; solo el JSON se comprime al vuelo). En una conexión móvil, eso es la diferencia entre 3 segundos y 400 ms de carga de página.
Conexiones persistentes: el coste oculto
Sin keepalive, cada petición de nginx a api-reservas abre una conexión TCP nueva:
Coste de una conexion TCP nueva (dentro del cluster):
SYN -> SYN/ACK -> ACK: ~0,4 ms (3 paquetes)
Cierre (FIN/ACK x2): ~0,2 ms
Total: ~0,6 ms de latencia anadida por peticion
Con 500 peticiones/s: 300 ms/s de latencia acumulada.
Y peor: los puertos efimeros del lado de nginx se agotan.
El rango tipico son ~28.000 puertos. Con TIME_WAIT de 60 s:
28.000 / 60 = 466 conexiones nuevas por segundo COMO MAXIMO.
Por encima de eso: errores de "cannot assign requested address".El agotamiento de puertos efímeros es un límite duro que aparece de golpe y desconcierta muchísimo cuando ocurre. Las conexiones persistentes lo eliminan de raíz.
La misma optimización aplica en api-reservas hacia motor-precios y hacia el resto de servicios internos: usa siempre un agente HTTP con keepAlive activado.
// api-reservas: agente HTTP con conexiones persistentes
const http = require('http');
const agente = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 5000,
});
- Ajuste de recursos: la mecánica del estrangulamiento de CPU
En el módulo 3 vimos que el limit de CPU produce estrangulamiento. Ahora vamos a ver exactamente cómo, porque el mecanismo explica un comportamiento que de otro modo parece imposible.
El planificador CFS y la cuota
El limit de CPU se traduce en una cuota del Completely Fair Scheduler del kernel de Linux, mediante dos parámetros de cgroups:
| Parámetro | Valor por defecto | Significado |
|---|---|---|
cpu.cfs_period_us |
100.000 µs (100 ms) | La duración de cada periodo de contabilidad |
cpu.cfs_quota_us |
Calculado del limit |
Microsegundos de CPU permitidos por periodo |
Con limits.cpu: 1:
Con limits.cpu: 500m:
El funcionamiento: cada 100 ms el contenedor recibe su cuota. Cuando la agota, TODOS sus hilos se paran hasta el siguiente periodo.
Por qué el estrangulamiento aparece antes del 100 %
Aquí está el punto que sorprende a todo el mundo.
api-reservas con limits.cpu: 1 -> cuota de 100 ms por periodo de 100 ms.
Node.js con 4 hilos activos (el bucle de eventos + 3 del pool de libuv
para criptografia, compresion y DNS):
Periodo de 100 ms:
t=0 ms: los 4 hilos empiezan a trabajar
t=25 ms: 4 hilos x 25 ms = 100 ms de CPU consumidos. CUOTA AGOTADA.
t=25 ms: el kernel PARA todos los hilos.
t=100 ms: nuevo periodo. Se reanudan.
RESULTADO: el contenedor solo trabaja 25 ms de cada 100.
Esta parado el 75 % DEL TIEMPO DE RELOJ.
Y sin embargo, kubectl top muestra:
CPU: 1000m (100 % del limite)
Consumio exactamente su cuota. Parece "al limite" y no "estrangulado".
Pero cualquier peticion que llegue en t=30 ms ESPERA 70 ms sin hacer nada.Un pod puede estar gravemente estrangulado mostrando una utilización que parece razonable, porque la utilización se mide como cuota consumida sobre cuota total, no como tiempo útil sobre tiempo de reloj.
Con limits.cpu: 500m (cuota de 50 ms) y los mismos 4 hilos:
t=12,5 ms: cuota agotada.
El contenedor trabaja 12,5 ms de cada 100: el 12,5 % del tiempo.
Una peticion que llegue en t=20 ms espera 80 ms.
Y kubectl top mostraria 500m: "al 100 % de su limite".Cuantos más hilos tiene el proceso, antes agota la cuota y más brutal es el estrangulamiento. Un contenedor con 8 hilos y un limit de 1 núcleo agota su cuota en 12,5 ms y está parado 87,5 ms de cada 100.
La métrica que lo delata
# Porcentaje de periodos en los que el contenedor fue estrangulado
rate(container_cpu_cfs_throttled_periods_total{
namespace="rutas-norte-pro", container="api"
}[5m])
/
rate(container_cpu_cfs_periods_total{
namespace="rutas-norte-pro", container="api"
}[5m])
* 100# Segundos de estrangulamiento por segundo: cuanto tiempo REAL se pierde
rate(container_cpu_cfs_throttled_seconds_total{
namespace="rutas-norte-pro", container="api"
}[5m])Interpretación:
| Porcentaje de periodos estrangulados | Diagnóstico |
|---|---|
| 0-1 % | Sano |
| 1-5 % | Aceptable; picos ocasionales |
| 5-25 % | Problema real. La latencia p99 está sufriendo |
| > 25 % | Grave. La aplicación está parada la mayor parte del tiempo |
Esta métrica debería estar en el panel principal de Grafana de cualquier plataforma en Kubernetes, y casi nunca está. Es la explicación de la mayoría de los misterios de latencia p99 alta con CPU aparentemente normal.
La medición en Rutas Norte
Antes del ajuste:
api-reservas, limits.cpu: 1, 4 replicas, 340 rps:
Periodos estrangulados: 18,4 %
Tiempo estrangulado: 0,31 s/s
Latencia p95: 287 ms
Latencia p99: 891 msCasi uno de cada cinco periodos con el contenedor parado. Ese es el origen del p99 de 891 ms: peticiones que llegaron justo después de agotarse la cuota y esperaron hasta el siguiente periodo.
- El debate sobre quitar el límite de CPU
Es uno de los debates más vivos de la comunidad Kubernetes, y merece exponerse con honestidad porque hay argumentos sólidos en ambos lados.
La propuesta
Quitar limits.cpu por completo, dejando solo requests.cpu.
resources:
requests:
cpu: 412m # Reserva garantizada
memory: 800Mi
limits:
# SIN limits.cpu
memory: 1600Mi # El limite de memoria SI se mantieneLos argumentos a favor
1. El requests ya garantiza el reparto justo. El requests.cpu no es solo una reserva para el planificador: se traduce en cpu.shares (o cpu.weight en cgroups v2), que es el peso relativo en el reparto cuando hay contención. Un pod con 412m de requests recibe garantizado su 412m proporcional cuando el nodo está saturado.
2. La CPU es compresible. A diferencia de la memoria, la CPU se puede quitar y devolver sin matar nada. Un pod que usa CPU de más simplemente irá más despacio cuando llegue la contención; no se corrompe ni muere.
3. La CPU ociosa se desperdicia. Si el nodo tiene CPU libre y tu pod está estrangulado, esa CPU se pierde. Sin limit, el pod aprovecha lo que sobra.
4. Elimina de raíz el estrangulamiento. Sin cuota, no hay periodos estrangulados. El p99 mejora inmediatamente.
5. El arranque es más rápido. Node.js compilando y cargando módulos consume mucha CPU en los primeros segundos. Sin limit, arranca en 8 segundos en lugar de 25. Y eso es directamente relevante para el autoescalado (apartado 13).
Los argumentos en contra
1. Un pod defectuoso puede afectar a los vecinos. Un bucle infinito o una fuga consumiría toda la CPU disponible del nodo. Los demás pods tienen su requests garantizado, pero pierden el margen de ráfaga.
2. El comportamiento se vuelve impredecible. El rendimiento de un pod depende de qué más hay en su nodo. La misma petición tarda 40 ms en un nodo vacío y 90 ms en uno lleno. Eso complica muchísimo el diagnóstico y hace que las mediciones no sean reproducibles.
3. Rompe la QoS Guaranteed. Sin limits iguales a requests, el pod no puede ser Guaranteed (módulo 3). Para postgres-reservas, que queremos que sea el último candidato al desalojo, eso es inaceptable.
4. Puede enmascarar problemas. Un pod que necesita el triple de su requests está mal dimensionado. Con limit, lo notas (estrangulamiento); sin limit, funciona bien hasta que un día el nodo se llena y todo se degrada a la vez.
5. Las pruebas de carga dejan de ser fiables. Si mides en preproducción con nodos vacíos, obtendrás números que no se reproducirán en producción con nodos llenos.
La postura recomendada
| Tipo de carga | limits.cpu |
Justificación |
|---|---|---|
Servicios en el camino crítico (api-reservas, tienda-web) |
Sin limits.cpu, o muy holgado |
La latencia manda; el estrangulamiento es el enemigo |
Trabajos por lotes (informes-ocupacion) |
Con limits.cpu |
Pueden ir despacio; no deben molestar al resto |
Bases de datos (postgres-reservas) |
Con limits.cpu = requests |
QoS Guaranteed y rendimiento predecible |
| Sidecars y agentes | Con limits.cpu bajo |
No deben crecer nunca; un exportador con fuga es un peligro |
| Cargas desconocidas o de terceros | Con limits.cpu |
Aislamiento por precaución |
| Entornos multiinquilino | Con limits.cpu siempre |
El aislamiento es un requisito, no una opción |
Y la decisión para Rutas Norte:
# api-reservas: SIN limite de CPU
resources:
requests:
cpu: 412m # Reserva garantizada. Peso en el reparto.
memory: 800Mi
limits:
# SIN limits.cpu DELIBERADAMENTE.
#
# Motivo: con limits.cpu: 1 mediamos un 18,4 % de periodos estrangulados
# y un p99 de 891 ms. Node.js usa varios hilos (bucle de eventos + pool de
# libuv) y agota la cuota de 100 ms en ~25 ms de reloj.
#
# Sin limite:
# - Estrangulamiento: 0 %
# - p99: 891 ms -> 340 ms
# - Arranque: 25 s -> 9 s (relevante para el autoescalado, 09-01)
#
# Riesgo asumido: un pod defectuoso puede consumir la CPU sobrante del
# nodo. Mitigaciones:
# - El requests garantiza el minimo de todos los demas pods.
# - LimitRange del namespace impone un techo de 4 nucleos por contenedor.
# - Alerta si un pod supera 3x su requests durante 10 minutos.
#
# El LIMITE DE MEMORIA SI SE MANTIENE: la memoria NO es compresible y
# un pod sin limite de memoria puede tumbar el nodo entero.
memory: 1600MiNótese la última línea: el límite de memoria siempre se mantiene. La asimetría es esencial. La CPU es compresible (te la quitan y vas más lento); la memoria no lo es (si no la hay, el kernel mata procesos). Un pod sin límite de memoria con una fuga puede provocar el OOMKilled de pods vecinos inocentes, o dejar el nodo NotReady.
Y la alerta que acompaña a la decisión:
# Un pod que consume mas del triple de su requests de forma sostenida
(
rate(container_cpu_usage_seconds_total{namespace="rutas-norte-pro"}[10m])
/
on(pod, container) kube_pod_container_resource_requests{resource="cpu"}
) > 3
- Memoria y el recolector de basura de Node.js en un contenedor
El problema del heap por defecto
Node.js decide el tamaño máximo de su heap de JavaScript al arrancar, y su heurística mira la memoria del sistema, no la del cgroup del contenedor.
Nodo de 16 GiB. Contenedor con limits.memory: 1Gi.
Node.js (segun version y configuracion) puede decidir un heap maximo
basandose en los 16 GiB del host, no en el 1 GiB del contenedor.
Resultado: el recolector de basura no se activa con urgencia porque cree
que tiene memoria de sobra. El heap crece hasta rozar el GiB del contenedor.
El kernel: OOMKilled.
Y lo peor: el proceso muere SIN aviso ni traza. Solo aparece "OOMKilled"
en el estado del pod. Node.js ni se entera.Las versiones modernas de Node.js son conscientes de los cgroups y ajustan mejor, pero el comportamiento depende de la versión y de la configuración del contenedor, así que la práctica correcta es no confiar en la heurística.
La solución: fijar el heap explícitamente
env:
# --max-old-space-size fija el tamano maximo del heap de objetos antiguos
# en MEGABYTES.
#
# Calculo para un limits.memory de 1600Mi:
# Heap de JavaScript: 1024 MB (--max-old-space-size=1024)
# Buffers y ArrayBuffer: ~200 MB (fuera del heap)
# Codigo nativo y bibliotecas: ~150 MB
# Pila y estructuras internas: ~80 MB
# Margen de seguridad: ~146 MB
# -------------------------------------
# TOTAL: 1600 MB
#
# REGLA PRACTICA: heap = 65-70 % del limits.memory. El resto NO es
# desperdicio: es memoria que Node.js necesita fuera del heap.
- name: NODE_OPTIONS
value: "--max-old-space-size=1024"La regla del 65-70 % es la que hay que recordar. El error habitual es poner --max-old-space-size igual al limits.memory, lo que garantiza un OOMKilled en cuanto el heap se acerque a su máximo: la memoria fuera del heap no cabe.
Diagnosticar problemas de memoria
# Uso de memoria frente al limite
container_memory_working_set_bytes{namespace="rutas-norte-pro", container="api"}
/
on(pod, container) kube_pod_container_resource_limits{resource="memory"}# OOMKilled recientes: la senal inequivoca
increase(kube_pod_container_status_restarts_total{namespace="rutas-norte-pro"}[1h]) > 0
and on(pod, container)
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} == 1Y la firma de una fuga de memoria: el working_set crece de forma monótona y nunca baja, ni siquiera en los valles de tráfico nocturno. Si baja en los valles, es comportamiento normal del recolector de basura.
El detalle de container_memory_working_set_bytes
Es la métrica que el kernel usa para decidir el OOMKilled, y no es lo mismo que container_memory_usage_bytes:
| Métrica | Qué incluye | Para qué sirve |
|---|---|---|
container_memory_usage_bytes |
Todo, incluida la caché de páginas reclamable | Casi nada; sobreestima |
container_memory_working_set_bytes |
Uso real menos la caché reclamable | La que decide el OOMKilled |
container_memory_rss |
Memoria anónima residente | Útil para detectar fugas |
Usa siempre working_set para las alertas de memoria. Alertar sobre usage_bytes produce falsos positivos constantes, porque incluye caché de ficheros que el kernel liberará sin problema.
- El tamaño de pod adecuado
Con una cantidad fija de recursos, ¿muchos pods pequeños o pocos grandes?
Presupuesto: 12 nucleos y 24 GiB para api-reservas.
Opcion A: 30 pods de 400m y 800Mi
Opcion B: 12 pods de 1 nucleo y 2 GiB
Opcion C: 6 pods de 2 nucleos y 4 GiB| Criterio | Muchos pequeños (A) | Pocos grandes (C) |
|---|---|---|
| Granularidad del escalado | Fina: +400m por paso | Gruesa: +2 núcleos por paso |
| Impacto de la caída de un pod | 1/30 = 3,3 % | 1/6 = 16,7 % |
| Sobrecarga por pod (runtime, sidecars) | Alta: 30 sidecars, 30 pools | Baja: 6 |
| Distribución entre nodos y zonas | Fácil | Difícil: quizá no caben |
| Aprovechamiento de varios núcleos | Malo si la app es monohilo | Bueno si la app es multihilo |
| Tiempo de arranque total al escalar | 30 arranques | 6 arranques |
| Conexiones a PostgreSQL | 30 pools | 6 pools |
| Caché en memoria por pod | Fragmentada, poco efectiva | Consolidada, más efectiva |
| Eficiencia de empaquetado en nodos | Alta | Baja: huecos desaprovechados |
El criterio decisivo: el modelo de concurrencia
Node.js es de un solo hilo para el código JavaScript. Un proceso de Node.js no puede usar más de un núcleo para ejecutar tu código, por mucha CPU que le des.
Pod de api-reservas con 4 nucleos:
- El bucle de eventos usa 1 nucleo como maximo.
- El pool de libuv (4 hilos por defecto) usa algo mas para E/S de ficheros,
DNS, criptografia y compresion.
- En la practica, un proceso de Node.js aprovecha ~1,3 nucleos.
Los otros 2,7 nucleos ESTAN DESPERDICIADOS.Para Node.js, muchos pods pequeños es la opción correcta, con un tamaño de entre 0,5 y 1,5 núcleos por pod.
Para otras plataformas la respuesta cambia:
| Plataforma | Tamaño recomendado | Motivo |
|---|---|---|
| Node.js (un proceso) | 0,5-1,5 núcleos | Monohilo para JavaScript |
Node.js con cluster |
1 núcleo por trabajador | Varios procesos en el pod |
| Java / JVM | 2-4 núcleos | Multihilo real; el GC se beneficia de más núcleos y memoria |
| Go | 1-4 núcleos | Multihilo eficiente; escala bien en ambos sentidos |
| Python (WSGI) | 0,5-1 núcleo por trabajador | El GIL limita a un hilo por proceso |
| nginx | 0,2-0,5 núcleos | Muy eficiente; muchas réplicas pequeñas |
| PostgreSQL | 4-8 núcleos | Un proceso por conexión; se beneficia de mucha memoria |
Decisión final para Rutas Norte:
# api-reservas: 12 pods de 1 nucleo (opcion B), no 30 de 400m ni 6 de 2 nucleos.
#
# Contra la opcion A (30 pods de 400m):
# - 30 pools de conexiones contra PostgreSQL (ver apartado 6).
# - 30 sidecars exportadores: 30 x 6m = 180m solo en observabilidad.
# - 400m es poco para el ARRANQUE de Node.js, que consume mucha CPU
# compilando: el arranque pasaria de 9 s a mas de 30 s.
#
# Contra la opcion C (6 pods de 2 nucleos):
# - Node.js no aprovecha 2 nucleos con un solo proceso.
# - Perder 1 de 6 replicas es perder el 16,7 % de la capacidad.
# - Granularidad de escalado demasiado gruesa.
#
# 1 nucleo es el punto dulce: suficiente para el bucle de eventos y el pool
# de libuv, con margen para el arranque, y granularidad fina para el HPA.
resources:
requests:
cpu: 412m # Del VPA (09-02): consumo real en regimen permanente
memory: 800Mi
limits:
memory: 1600Mi # Sin limits.cpu (apartado 10)El requests de 412m con capacidad de ráfaga hasta lo que el nodo permita es lo mejor de ambos mundos: se reserva lo que se consume habitualmente, pero se puede usar más durante el arranque y las puntas.
- Arranque rápido como requisito del autoescalado
Este apartado conecta directamente con todo lo anterior del módulo, y es la razón por la que el rendimiento y el escalado no son temas separados.
El cálculo
El HPA detecta la sobrecarga y pide replicas nuevas.
Tiempo hasta que una replica nueva atiende trafico:
Decision del HPA: 0-15 s (ciclo de 15 s)
Planificacion: 1 s
Descarga de la imagen: 0-60 s (0 si esta cacheada)
Arranque del contenedor: 5-30 s
Sonda de readiness OK: 3-10 s
----------------------------------------------
TOTAL: 9-116 sDurante todo ese tiempo, las réplicas existentes están sobrecargadas. Un arranque de 90 segundos convierte el autoescalado en algo casi inútil para puntas rápidas.
Palanca 1: imagen ligera
# --- Etapa de construccion ---
FROM node:20.15-bookworm AS constructor
WORKDIR /app
COPY package*.json ./
# npm ci con --omit=dev: solo dependencias de produccion
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# --- Etapa final: SOLO lo necesario para ejecutar ---
FROM node:20.15-alpine
WORKDIR /app
# Usuario sin privilegios (08-02)
RUN addgroup -g 10001 rutasnorte && adduser -u 10001 -G rutasnorte -D rutasnorte
COPY --from=constructor --chown=10001:10001 /app/node_modules ./node_modules
COPY --from=constructor --chown=10001:10001 /app/dist ./dist
COPY --from=constructor --chown=10001:10001 /app/package.json ./
USER 10001
EXPOSE 8080
CMD ["node", "dist/servidor.js"]Impacto medido:
| Imagen | Tamaño | Descarga (sin caché) | Descarga (cacheada) |
|---|---|---|---|
node:20 (completa) |
1,1 GB | 48 s | 0 s |
node:20-slim |
280 MB | 14 s | 0 s |
node:20-alpine multietapa |
94 MB | 5 s | 0 s |
De 48 a 5 segundos. Y como beneficio adicional del módulo 8: menos paquetes significa menos superficie de ataque y muchas menos vulnerabilidades en el escaneo de Trivy (08-06).
Palanca 2: precarga de imágenes en los nodos
Si la imagen ya está en el nodo, la descarga tarda cero. Dos formas:
# k8s/entornos/pro/daemonset-precarga-imagenes.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: precarga-imagenes
namespace: rutas-norte-pro
labels:
app: precarga-imagenes
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
matchLabels:
app: precarga-imagenes
template:
metadata:
labels:
app: precarga-imagenes
spec:
# Prioridad baja: si hace falta el hueco, que se vaya.
priorityClassName: relleno-capacidad
# Los initContainers DESCARGAN las imagenes en el nodo y terminan.
# A partir de ahi quedan en la cache local de containerd.
initContainers:
- name: precargar-api
image: registry.rutasnorte.example/api-reservas:1.14.2
command: ["/bin/true"]
- name: precargar-web
image: registry.rutasnorte.example/tienda-web:3.2.0
command: ["/bin/true"]
- name: precargar-worker
image: registry.rutasnorte.example/worker-notificaciones:2.3.0
command: ["/bin/true"]
containers:
- name: pausa
image: registry.k8s.io/pause:3.9
resources:
requests: {cpu: 5m, memory: 8Mi}
limits: {cpu: 10m, memory: 16Mi}Como es un DaemonSet, se ejecuta en cada nodo, incluidos los que arranque el Cluster Autoscaler (09-03). Coste: unos pocos megabytes de disco por nodo y 5m de CPU. Beneficio: 5-48 segundos menos en cada escalado.
Este DaemonSet es una de las mejores relaciones coste-beneficio de todo el módulo.
La alternativa en la nube es hornear las imágenes en la AMI o imagen de máquina de los nodos, lo cual es aún más rápido pero requiere reconstruir la imagen de máquina en cada despliegue.
Palanca 3: sondas bien ajustadas
Del módulo 7, pero con el matiz del rendimiento:
# MAL: el pod tarda 20 s de mas en recibir trafico sin ninguna razon.
readinessProbe:
httpGet: {path: /preparado, port: 8080}
initialDelaySeconds: 30 # <- Espera 30 s incluso si esta listo en 5
periodSeconds: 10 # <- Comprueba cada 10 s: pierde hasta 10 s mas
failureThreshold: 3
# BIEN: startupProbe para el arranque, readinessProbe agresiva despues.
startupProbe:
httpGet: {path: /salud, port: 8080}
# Comprueba cada segundo desde el segundo 0. En cuanto arranque, listo.
periodSeconds: 1
# 40 intentos x 1 s = 40 segundos de margen maximo para el arranque.
# Si tarda mas, algo va mal y queremos saberlo.
failureThreshold: 40
readinessProbe:
httpGet: {path: /preparado, port: 8080}
# SIN initialDelaySeconds: la startupProbe ya cubre el arranque.
periodSeconds: 2 # Comprobacion frecuente: reacciona rapido
timeoutSeconds: 1
failureThreshold: 2
successThreshold: 1
livenessProbe:
httpGet: {path: /salud, port: 8080}
periodSeconds: 10
timeoutSeconds: 3
# failureThreshold alto: NO matar el pod por un pico de latencia temporal.
# Una livenessProbe agresiva bajo carga alta produce reinicios en cascada:
# el pod va lento -> falla la sonda -> se reinicia -> menos capacidad ->
# los demas van mas lentos -> se reinician tambien. Espiral de muerte.
failureThreshold: 6La advertencia sobre la livenessProbe merece énfasis. Es una de las causas más frecuentes de caídas totales bajo carga: la sonda de vida, pensada para detectar procesos colgados, acaba matando pods sanos que simplemente van lentos, y el resultado es un colapso en cascada precisamente en el momento de más tráfico. Bajo duda, haz la livenessProbe mucho más permisiva que la readinessProbe.
El resultado acumulado
ANTES:
Descarga de imagen (1,1 GB, sin cachear): 48 s
Arranque de Node.js (con limits.cpu: 1): 25 s
initialDelaySeconds de la readiness: 30 s
Ciclos de sonda hasta pasar: 10 s
----------------------------------------------------
TOTAL: 113 s
DESPUES:
Descarga (94 MB, precargada por DaemonSet): 0 s
Arranque de Node.js (sin limits.cpu): 9 s
startupProbe (comprueba cada segundo): 1 s
readinessProbe (periodo de 2 s): 2 s
----------------------------------------------------
TOTAL: 12 sDe 113 a 12 segundos. Un factor de 9,4.
Y esto cambia cualitativamente el autoescalado: con 12 segundos de arranque, el HPA puede reaccionar a una punta antes de que el usuario la note. Con 113 segundos, no.
- Ajuste del clúster: CoreDNS,
ndots y kube-proxy
ndots y kube-proxyEl ndots: 5 y la latencia de las llamadas salientes
Aquí resolvemos algo que dejamos pendiente en 04-03.
Todo pod tiene un /etc/resolv.conf generado por Kubernetes:
nameserver 10.96.0.10
search rutas-norte-pro.svc.cluster.local svc.cluster.local cluster.local
options ndots:5ndots:5 significa: si el nombre a resolver tiene menos de 5 puntos, prueba primero con todos los sufijos de search antes de intentarlo como nombre absoluto.
Veamos qué pasa al resolver api.correos-externos.example (2 puntos, menos de 5):
1. api.correos-externos.example.rutas-norte-pro.svc.cluster.local -> NXDOMAIN
2. api.correos-externos.example.svc.cluster.local -> NXDOMAIN
3. api.correos-externos.example.cluster.local -> NXDOMAIN
4. api.correos-externos.example -> OK
CUATRO consultas DNS para resolver un nombre.
Y como el resolutor de glibc consulta A y AAAA: OCHO consultas en total.Con 300 correos por segundo de worker-notificaciones, cada uno resolviendo el servidor SMTP:
300 resoluciones/s x 8 consultas = 2.400 consultas DNS/s a CoreDNS.
Si cada resolucion anade 3 ms de latencia (4 idas y vueltas fallidas):
300 x 3 ms = 900 ms/s de latencia acumulada desperdiciada.
Y CoreDNS con 2.400 consultas/s de las cuales el 75 % son NXDOMAIN inutiles.Tres soluciones, de menor a mayor alcance:
Solución 1: el punto final (FQDN absoluto).
// MAL: 4 consultas por el ndots:5
const servidor = 'smtp.correos-externos.example';
// BIEN: el punto final marca el nombre como ABSOLUTO.
// El resolutor NO prueba los sufijos de search. UNA sola consulta.
const servidor = 'smtp.correos-externos.example.';Un solo carácter que elimina el 75 % de las consultas DNS. Es la solución más barata que existe y casi nadie la conoce.
Solución 2: bajar ndots en el pod.
spec:
dnsConfig:
options:
- name: ndots
# Con ndots: 2, cualquier nombre con 2 o mas puntos se prueba
# primero como absoluto. Los nombres internos como
# "postgres-reservas.rutas-norte-pro" (1 punto) siguen funcionando
# con los sufijos de search.
value: "2"
# Fallar rapido si el DNS no responde.
- name: timeout
value: "1"
- name: attempts
value: "2"Cuidado: bajar ndots puede romper la resolución de nombres cortos de servicios internos. Si tu código usa postgres-reservas a secas (0 puntos), sigue funcionando con ndots: 2. Si usa postgres-reservas.rutas-norte-pro (1 punto), también. Pero conviene probarlo.
Solución 3: NodeLocal DNSCache.
Un DaemonSet que pone una caché de DNS en cada nodo. Los pods consultan 169.254.20.10 (una dirección local del nodo) en lugar de ir por red a CoreDNS.
| Ventaja | Detalle |
|---|---|
| Latencia | De ~2 ms (red) a ~0,1 ms (local) |
| Carga sobre CoreDNS | Reducción del 70-90 % |
| Resiliencia | Un fallo de CoreDNS no rompe las consultas cacheadas |
| Conexiones | Usa TCP hacia CoreDNS, evitando el problema de conntrack con UDP |
Es la solución más completa y la recomendada para cualquier clúster con tráfico serio. La menciono aquí porque es el complemento natural del ajuste de ndots.
CoreDNS: réplicas y caché
# ConfigMap de CoreDNS (kube-system/coredns)
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health { lameduck 5s }
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
# Politica de reparto entre servidores DNS externos.
policy sequential
}
# CACHE: la optimizacion mas importante de CoreDNS.
# 3600 s (1 h) para respuestas positivas del cluster
# 30 s para respuestas negativas (NXDOMAIN)
# El TTL corto para negativas es DELIBERADO: cuando se crea un
# Service nuevo, no queremos que los pods sigan recibiendo NXDOMAIN
# durante una hora.
cache 3600 {
success 9984 3600
denial 9984 30
}
loop
reload
loadbalance
}Dimensionado de las réplicas:
Regla practica: 1 replica de CoreDNS por cada 8-10 nodos, con un minimo de 2.
Cluster de Rutas Norte: 9-12 nodos en el pico.
Replicas: 3 (una por zona, ver 09-05).
Si las metricas muestran latencia de DNS alta:
histogram_quantile(0.99,
sum(rate(coredns_dns_request_duration_seconds_bucket[5m])) by (le)
) > 0.05
...la solucion suele ser NodeLocal DNSCache, no mas replicas de CoreDNS.kube-proxy en modo IPVS
Recordemos 04-01: kube-proxy tiene dos modos principales.
| Aspecto | iptables (defecto) |
IPVS |
|---|---|---|
| Estructura | Cadenas de reglas secuenciales | Tablas hash en el kernel |
| Complejidad de búsqueda | O(n) con el número de servicios | O(1) |
| Tiempo de actualización de reglas | O(n): con 5.000 servicios, segundos | O(1): milisegundos |
| Algoritmos de balanceo | Solo aleatorio | rr, lc, dh, sh, sed, nq |
| Umbral práctico | Hasta ~1.000 servicios | Más de 1.000 servicios |
# Activar IPVS en minikube
minikube start -p rutas-norte --extra-config=kube-proxy.mode=ipvs
# Verificar
kubectl logs -n kube-system -l k8s-app=kube-proxy | grep -i "proxy mode"Para Rutas Norte, con menos de 50 servicios, IPVS no aporta nada medible. Es una optimización para clústeres grandes. La menciono para que sepas cuándo importa: si tu clúster tiene más de mil servicios y observas que los cambios en los endpoints tardan en propagarse, ese es el síntoma.
La elección del tamaño de nodo
| Tamaño | Ventajas | Inconvenientes |
|---|---|---|
| Pequeños (2-4 núcleos) | Granularidad fina del CA; menor impacto por fallo; empaquetado eficiente | Más sobrecarga de sistema (~0,5 núcleos y 1,5 GiB por nodo); más DaemonSets |
| Grandes (16-32 núcleos) | Menos sobrecarga proporcional; menos DaemonSets; mejor para pods grandes | Grano grueso del CA; un fallo pierde mucho; empaquetado con huecos |
El cálculo que decide:
Sobrecarga por nodo (kubelet, sistema, DaemonSets): ~0,95 nucleos y 2 GiB.
Nodos de 4 nucleos: 0,95 / 4 = 23,8 % de sobrecarga
Nodos de 8 nucleos: 0,95 / 8 = 11,9 %
Nodos de 16 nucleos: 0,95 / 16 = 5,9 %
Con 12 nucleos utiles necesarios:
Con nodos de 4: 12 / 3,05 = 3,93 -> 4 nodos = 16 nucleos comprados (33 % de exceso)
Con nodos de 8: 12 / 7,05 = 1,70 -> 2 nodos = 16 nucleos comprados (33 % de exceso)
Con nodos de 16: 12 / 15,05 = 0,80 -> 1 nodo = 16 nucleos comprados (33 % de exceso)
Coinciden por casualidad en este caso, pero el nodo de 16 tiene TODO en una
maquina: un fallo se lleva la plataforma entera. Y el CA no puede ajustar
con granularidad menor a 16 nucleos.Recomendación para Rutas Norte: nodos de 8 núcleos y 32 GiB. Equilibrio entre sobrecarga (11,9 %) y granularidad, y suficientes para que postgres-reservas tenga margen en el grupo de datos.
- Almacenamiento: cuándo el disco es el límite
postgres-reservas usa la StorageClass rutasnorte-rapida de 05-04. Hay un momento en que el disco es el verdadero límite, y conviene saber reconocerlo.
La aritmética de IOPS
StorageClass rutasnorte-rapida: SSD con 3.000 IOPS aprovisionadas.
Una escritura de reserva en PostgreSQL implica:
- Escritura en el WAL (write-ahead log): 1 IOPS
- fsync del WAL (obligatorio para durabilidad): 1 IOPS
- Actualizacion de paginas de datos (diferida): ~0,3 IOPS amortizadas
- Actualizacion de indices: ~0,5 IOPS
TOTAL: ~2,8 IOPS por reserva
Reservas por segundo maximas por IOPS:
3.000 / 2,8 = 1.071 reservas/s
Y las lecturas que NO estan en shared_buffers ni en la cache del sistema
tambien consumen IOPS.Con 300 transacciones por segundo en el pico, el disco no es el límite para las escrituras. Pero las lecturas pueden serlo si la caché no es efectiva.
Diagnosticar si el disco es el cuello de botella
-- Tasa de aciertos de la cache de PostgreSQL
SELECT
datname,
blks_hit,
blks_read,
round(100.0 * blks_hit / GREATEST(blks_hit + blks_read, 1), 2) AS tasa_aciertos
FROM pg_stat_database
WHERE datname = 'reservas'; datname | blks_hit | blks_read | tasa_aciertos
----------+-----------+-----------+---------------
reservas | 892471203 | 18472941 | 97.9797,97 % de aciertos: excelente. Solo el 2 % de las lecturas van a disco.
| Tasa de aciertos | Diagnóstico |
|---|---|
| > 99 % | Óptimo |
| 95-99 % | Bueno |
| 90-95 % | Aumentar shared_buffers o la memoria del pod |
| < 90 % | El disco es probablemente el cuello de botella |
Y las métricas del nodo:
# IOPS de lectura y escritura del volumen
rate(node_disk_reads_completed_total{device="nvme1n1"}[5m])
+ rate(node_disk_writes_completed_total{device="nvme1n1"}[5m])
# LA METRICA CLAVE: tiempo que el disco esta ocupado.
# Si supera 0,8 (80 %), el disco esta saturado.
rate(node_disk_io_time_seconds_total{device="nvme1n1"}[5m])node_disk_io_time_seconds_total por encima de 0,8 significa que el disco está saturado, y ninguna cantidad de CPU o memoria adicional lo arreglará.
Ajustes de PostgreSQL relacionados con el disco
# ConfigMap de postgres-reservas
data:
postgresql.conf: |
# --- MEMORIA (limits.memory del pod: 8 GiB) ---
# shared_buffers: el 25 % de la memoria del contenedor. Es la
# recomendacion clasica y sigue siendo buena.
shared_buffers = 2GB
# effective_cache_size: estimacion de la cache TOTAL disponible
# (shared_buffers + cache de paginas del sistema). NO reserva memoria:
# solo informa al planificador de consultas para que prefiera indices.
effective_cache_size = 6GB
# work_mem por operacion de ordenacion o hash. CUIDADO: se multiplica
# por el numero de operaciones concurrentes. Con 200 conexiones y
# 3 operaciones cada una: 200 x 3 x 16MB = 9,6 GB. MAS QUE EL LIMITE.
# Por eso 16MB y no mas.
work_mem = 16MB
maintenance_work_mem = 512MB
# --- ESCRITURA A DISCO ---
# Repartir los fsync del checkpoint a lo largo del 90 % del intervalo,
# en lugar de hacerlos de golpe. Evita picos de latencia periodicos.
checkpoint_completion_target = 0.9
# 15 minutos entre checkpoints: menos escrituras, mas tiempo de
# recuperacion tras una caida. Compromiso razonable.
checkpoint_timeout = 15min
max_wal_size = 4GB
min_wal_size = 1GB
# --- COSTES DE ACCESO (para el planificador) ---
# random_page_cost = 1.1 en SSD (por defecto es 4.0, pensado para discos
# mecanicos). Sin este ajuste, el planificador EVITA los indices creyendo
# que el acceso aleatorio es caro, y hace escaneos secuenciales inutiles.
# ES UNO DE LOS AJUSTES MAS RENTABLES EN SSD.
random_page_cost = 1.1
effective_io_concurrency = 200
# --- CONEXIONES ---
max_connections = 200
# --- ESTADISTICAS ---
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
track_io_timing = onEl random_page_cost = 1.1 merece destacarse: es un cambio de una línea que puede transformar el plan de ejecución de muchas consultas. El valor por defecto de 4.0 asume discos mecánicos donde un acceso aleatorio cuesta cuatro veces más que uno secuencial. En SSD la diferencia es mínima, y con el valor por defecto el planificador rechaza índices perfectamente buenos.
- El presupuesto de latencia
Todo lo anterior son técnicas. El presupuesto de latencia es lo que las convierte en un plan.
La idea
El negocio define un objetivo: «el 95 % de las búsquedas de rutas deben responder en menos de 300 ms». Ese número es útil para el SLO, pero no le dice a nadie qué hacer. El presupuesto de latencia lo reparte entre los componentes, convirtiendo un objetivo global en objetivos locales que cada equipo puede medir y cumplir.
El presupuesto de Rutas Norte
Objetivo: p95 de la búsqueda de rutas < 300 ms.
flowchart LR
U[Usuario] -->|20 ms| I[Ingress<br/>nginx]
I -->|5 ms| W[tienda-web]
W -->|3 ms| A[api-reservas]
A -->|4 ms| R[redis-cache]
A -->|8 ms| P[postgres-reservas]
A -->|15 ms| M[motor-precios]
style A fill:#cde,stroke:#369
| Componente | Presupuesto p95 | % del total | Qué incluye |
|---|---|---|---|
| Red del usuario al Ingress | 40 ms | 13,3 % | Latencia de internet, TLS (no controlable) |
| Ingress (nginx) | 8 ms | 2,7 % | Enrutado, terminación TLS |
| Red interna | 6 ms | 2,0 % | 3 saltos de red del clúster |
tienda-web |
12 ms | 4,0 % | Proxy inverso, compresión |
api-reservas (propio) |
35 ms | 11,7 % | Lógica, serialización, validación |
redis-cache |
6 ms | 2,0 % | Consulta de disponibilidad (95 % de aciertos) |
postgres-reservas |
45 ms | 15,0 % | Consulta de rutas con índice |
motor-precios |
80 ms | 26,7 % | Cálculo de precios dinámicos |
| Margen de seguridad | 68 ms | 22,7 % | Variabilidad, reintentos, GC |
| TOTAL | 300 ms | 100 % |
Tres cosas que hace bien este presupuesto:
1. Incluye un margen explícito del 22,7 %. Sin margen, cualquier variación (una pausa del recolector de basura, un reintento, un pico de red) rompe el objetivo. Un presupuesto que suma exactamente 300 ms está garantizado a fallar.
2. Identifica al consumidor principal. motor-precios se lleva el 26,7 % del presupuesto. Si hay que optimizar algo, es eso. Sin el presupuesto, nadie sabría por dónde empezar.
3. Separa lo controlable de lo que no. Los 40 ms de red del usuario no se pueden reducir con código; solo con una CDN o un punto de presencia más cercano. Es una decisión distinta y hay que verla como tal.
De presupuesto a alertas
Cada línea del presupuesto se convierte en una alerta:
# api-reservas (tiempo propio, sin dependencias)
histogram_quantile(0.95,
sum(rate(api_reservas_duracion_propia_segundos_bucket{
namespace="rutas-norte-pro"
}[5m])) by (le)
) * 1000 > 35
# postgres-reservas (tiempo de consulta visto desde la API)
histogram_quantile(0.95,
sum(rate(api_reservas_duracion_consulta_bd_segundos_bucket{
namespace="rutas-norte-pro"
}[5m])) by (le)
) * 1000 > 45
# motor-precios (el mayor consumidor: alerta prioritaria)
histogram_quantile(0.95,
sum(rate(motor_precios_duracion_segundos_bucket{
namespace="rutas-norte-pro"
}[5m])) by (le)
) * 1000 > 80
# Y el objetivo global, que es el que le importa al negocio
histogram_quantile(0.95,
sum(rate(ingress_nginx_request_duration_seconds_bucket{
ingress="rutas-norte-publico", path="/rutas"
}[5m])) by (le)
) * 1000 > 300Cuando salta la alerta global, las alertas por componente te dicen inmediatamente cuál se ha salido de su presupuesto. Es la diferencia entre «la plataforma va lenta» y «motor-precios está en 140 ms sobre un presupuesto de 80». La segunda frase se puede accionar; la primera, no.
La regla del margen
Una heurística que funciona bien:
Y una comprobación de coherencia: si al hacer el presupuesto la suma de lo que realmente tardan los componentes ya supera el objetivo, no hay ajuste que valga. Hay que rediseñar: cachear más agresivamente, calcular en segundo plano, o cambiar el objetivo.
- El resultado medido del puente de mayo
Cerremos con los números. Esto es el módulo 9 completo, medido.
La tabla del antes y el después
| Métrica | 2025 (antes) | 2026 (después) | Mejora |
|---|---|---|---|
| DISPONIBILIDAD | |||
| Minutos de caída total | 47 min | 0 min | — |
| Peticiones con error | 12,4 % | 0,08 % | 155 veces mejor |
| Reservas perdidas por errores | ~2.800 | 11 | 254 veces mejor |
| LATENCIA (p95, búsqueda de rutas) | |||
| En calma | 287 ms | 94 ms | 3,1 veces |
| En el pico | 4.200 ms | 168 ms | 25 veces |
| p99 en el pico | timeout | 412 ms | — |
| CAPACIDAD | |||
| Peticiones por segundo sostenidas | 340 | 2.680 | 7,9 veces |
| Capacidad por réplica (rps) | 85 | 224 | 2,6 veces |
| Réplicas necesarias en el pico | 4 (fijas) | 12 (auto) | — |
| RECURSOS | |||
| Estrangulamiento de CPU | 18,4 % | 0,0 % | — |
| Conexiones a PostgreSQL en el pico | 600 (400 rechazadas) | 75 (estables) | — |
Aciertos de redis-cache |
13,2 % | 94,7 % | 7,2 veces |
| Consultas a PostgreSQL por segundo | 8.400 | 340 | 24 veces menos |
| Tiempo de arranque de un pod | 113 s | 12 s | 9,4 veces |
| COSTE | |||
| Nodos en el pico | 4 (insuficientes) | 9 | — |
| Coste mensual medio | 712 € | 855 € | +20 % |
| Coste del mes del puente | 712 € | 912 € | +28 % |
| Coste por 1.000 reservas | 8,90 € | 1,12 € | 8 veces mejor |
La última fila es la que hay que enseñar a la dirección. El coste absoluto subió un 20 %, pero el coste por reserva bajó ocho veces, porque la plataforma vende ocho veces más.
Qué aportó cada cambio
Y ahora la tabla más útil: el desglose por cambio, medido individualmente en rutas-norte-pre.
| Cambio | Aporte a la capacidad por réplica | Coste de implementación |
|---|---|---|
| Corregir el N+1 de la búsqueda de rutas | +65 % (85 → 140 rps) | 1 día de desarrollo |
Índice compuesto en rutas |
+28 % (140 → 179 rps) | 1 hora |
| Caché de disponibilidad en Redis (TTL 10 s) | +19 % (179 → 213 rps) | 2 días de desarrollo |
Quitar limits.cpu |
+5 % (213 → 224 rps) | 10 minutos |
| Pool de conexiones a 5 + PgBouncer | 0 % en régimen normal | 1 día |
Compresión y caché en tienda-web |
0 % (afecta al ancho de banda) | 2 horas |
random_page_cost = 1.1 |
Incluido en el índice | 1 minuto |
Y hay que leer esta tabla con cuidado, porque contiene la lección más importante:
Los tres primeros cambios son de la aplicación y aportan el 92 % de la mejora. El ajuste de Kubernetes (quitar limits.cpu) aporta un 5 %.
El ajuste del pool de conexiones aporta 0 % en régimen normal, y sin embargo es probablemente el cambio más importante de la lista: es lo que impide que la plataforma se caiga cuando el HPA escala a 30 réplicas. Su valor no está en la mejora del rendimiento medio, sino en eliminar un modo de fallo catastrófico.
Esta distinción —entre cambios que mejoran el rendimiento y cambios que eliminan modos de fallo— es la que separa un ajuste ingenuo de uno profesional.
La conclusión sobre el módulo entero
Sin el ajuste de rendimiento:
Capacidad por replica: 85 rps.
Para 2.680 rps: 2.680 / 85 = 32 replicas.
CPU necesaria: 32 x 412m = 13,2 nucleos.
Nodos: 5 nodos de 8 nucleos solo para api-reservas.
Con el ajuste:
Capacidad por replica: 224 rps.
Para 2.680 rps: 2.680 / 224 = 12 replicas.
CPU necesaria: 12 x 412m = 4,9 nucleos.
Nodos: 2 nodos de 8 nucleos.
AHORRO: 3 nodos en el pico. Y, mas importante, el pico CABE en un cluster
que no requiere reconfigurar los grupos de nodos ni renegociar cuotas.Ajustar el rendimiento no es una alternativa al escalado: es lo que hace que el escalado sea asequible. Escalar una aplicación ineficiente funciona, pero cuesta el triple y llega a los límites de capacidad mucho antes.
Errores Comunes y Consejos
Error 1: optimizar sin medir. Cambiar cosas «que suenan a que van a ir mejor» produce, en el mejor de los casos, nada. En el peor, regresiones que nadie detecta. Mide la línea base, féchala, y compara siempre contra ella.
Error 2: informar la latencia media. Con avg=142ms y p99=2.1s, la media es una mentira estadísticamente correcta. El p99 es la experiencia de 1 de cada 100 peticiones, y en una página con 30 peticiones eso es 1 de cada 4 usuarios. Usa p95 y p99 siempre.
Error 3: ramping-vus en una prueba de carga. Cuando el sistema se ralentiza, los usuarios virtuales generan menos carga, y la prueba se autolimita ocultando el problema. Usa ramping-arrival-rate: los usuarios reales no se coordinan para esperar.
Error 4: un pool de conexiones grande con autoescalado. maxReplicas × pool ≤ max_connections × 0,8. Con 30 réplicas y un pool de 20, tienes 600 conexiones contra un límite de 200. Es una bomba que estalla exactamente cuando el HPA escala, o sea, en el peor momento.
Error 5: --max-old-space-size igual al limits.memory. El heap de JavaScript no es toda la memoria del proceso: quedan fuera los buffers, el código nativo y las estructuras internas. La regla es 65-70 % del límite.
Error 6: livenessProbe agresiva. Bajo carga, un pod que va lento falla la sonda, se reinicia, reduce la capacidad, hace que los demás vayan más lentos, y se produce un colapso en cascada. Haz la livenessProbe mucho más permisiva que la readinessProbe.
Error 7: creer que el estrangulamiento aparece al 100 %. Con un limit de 1 núcleo y 4 hilos, la cuota se agota en 25 ms de cada 100. El pod está parado el 75 % del tiempo mostrando una utilización del 100 %. Vigila container_cpu_cfs_throttled_periods_total.
Error 8: initialDelaySeconds alto «por si acaso». Cada segundo de retraso es un segundo en el que la réplica nueva no atiende tráfico mientras las existentes están saturadas. Usa startupProbe con periodo de 1 segundo y quita el initialDelaySeconds de la readiness.
Error 9: correr k6 en el mismo nodo que la aplicación. Compiten por CPU y no sabrás si la latencia es del sistema o del generador. Nodo dedicado con taint, y recursos generosos para k6.
Consejo 1: pon el estrangulamiento en el panel principal. Es la métrica que explica la mayoría de los misterios de p99 alto, y casi nadie la tiene. Un panel con throttled_periods / periods por contenedor ahorra horas de investigación.
Consejo 2: usa pg_stat_statements ordenado por total_exec_time. Una consulta de 2 ms ejecutada un millón de veces satura más que una de 500 ms ejecutada cien. El tiempo total es lo que importa.
Consejo 3: el punto final en los FQDN externos. smtp.correos-externos.example. con punto final elimina 3 de cada 4 consultas DNS. Un carácter, un 75 % menos de tráfico DNS.
Consejo 4: automatiza la prueba de carga en el pipeline. Con thresholds definidos, k6 devuelve un código de error cuando no se cumplen. Una prueba de carga que falla la construcción evita que una regresión de rendimiento llegue a producción.
Consejo 5: documenta el presupuesto de latencia junto al código. Un fichero PRESUPUESTO-LATENCIA.md en el repositorio, con la tabla y las alertas, convierte un objetivo abstracto en una responsabilidad compartida y verificable.
Consejo 6: guarda los resultados de cada prueba con su fecha y su configuración. La serie histórica es lo que detecta las degradaciones lentas: un 3 % peor cada mes pasa desapercibido, pero un 30 % en un año es catastrófico.
Consejo 7: ajusta antes de escalar, siempre. Antes de subir el maxReplicas, pregunta por qué una réplica solo aguanta lo que aguanta. Cuesta menos y el beneficio se multiplica por cada réplica.
Ejercicios
Ejercicio 1: leer una prueba de carga
Esta es la salida de una prueba de estrés contra rutas-norte-pre con 6 réplicas de api-reservas:
✓ busqueda devuelve 200
✗ reserva devuelve 201
↳ 91% — ✓ 6142 / ✗ 607
checks.........................: 94.21% ✓ 38921 ✗ 2391
duracion_busqueda_rutas........: avg=142ms min=18ms med=94ms max=8.1s p(95)=387ms p(99)=2.4s
duracion_confirmar_reserva.....: avg=891ms min=112ms med=612ms max=14.2s p(95)=3.1s p(99)=9.8s
errores_reserva................: 9.00% ✓ 607 ✗ 6142
http_req_duration..............: avg=284ms min=12ms med=118ms max=14.2s p(95)=982ms p(99)=4.2s
http_req_failed................: 3.42% ✓ 1621 ✗ 45782
http_reqs......................: 47403 158.0/s
iterations.....................: 9847 32.8/s
vus............................: 412 min=50 max=1800Y las métricas del sistema durante la prueba:
CPU media por replica de api-reservas: 680m de 1000m limit (68 %)
Periodos de CPU estrangulados: 31,2 %
Memoria media: 620Mi de 1600Mi (39 %)
Conexiones activas a PostgreSQL: 118 de 200
Tasa de aciertos de redis-cache: 22,4 %
Consultas a PostgreSQL por segundo: 3.840
Latencia p95 de las consultas a PostgreSQL: 41 ms
IOPS del disco de postgres: 2.847 de 3.000 aprovisionadas
Tiempo de E/S del disco (io_time): 0,91Responde: (a) ¿se cumple un SLO de p95 < 300 ms? (b) ¿cuál es el cuello de botella principal y cómo lo justificas? (c) ¿por qué la CPU al 68 % coexiste con un 31,2 % de estrangulamiento? (d) ordena por prioridad las tres primeras acciones que tomarías, con la mejora esperada de cada una; (e) ¿qué NO harías y por qué?
Ejercicio 2: calcular el pool y el presupuesto de latencia
Rutas Norte añade api-empresas, una API para agencias de viaje que reservan lotes de billetes.
Datos:
maxReplicasdel HPA: 20.- Cada petición hace 6 consultas a PostgreSQL (es un lote de 6 billetes).
- Duración media de cada consulta: 14 ms.
postgres-reservastienemax_connections: 200, compartido conapi-reservas(que ya usa 75 conexiones a través de PgBouncer).- Objetivo de negocio: p95 de la reserva de un lote < 900 ms.
- Tráfico esperado: 40 peticiones por segundo, hasta 180 en el pico.
- Medido en
rutas-norte-precon 4 réplicas: p95 de 640 ms, de los cuales 310 ms son consultas a PostgreSQL.
Calcula: (a) el tamaño máximo del pool por réplica; (b) si ese pool basta para el tráfico del pico, usando la ley de Little; (c) un presupuesto de latencia completo para los 900 ms, con margen; (d) si el presupuesto es alcanzable con los datos medidos, y qué harías si no lo es; (e) el threshold de KEDA que usarías y su justificación.
Ejercicio 3: diagnosticar una degradación progresiva
Rutas Norte tiene un problema desde hace tres semanas. Los síntomas:
- La latencia p95 de
api-reservasha pasado de 94 ms a 280 ms, subiendo unos 10 ms cada día. - No ha habido despliegues de
api-reservasen ese periodo. - El número de réplicas ha subido de 4 a 9 de media, porque KEDA escala más.
- La CPU por réplica no ha cambiado: sigue en unos 310m.
- La memoria por réplica ha pasado de 620Mi a 1.480Mi, subiendo cada día.
- Hay
OOMKilledocasionales desde hace cuatro días (2-3 al día). - El tráfico ha crecido solo un 8 % en ese periodo.
- La tasa de aciertos de
redis-cacheha bajado del 94,7 % al 71,2 %. - Las consultas a PostgreSQL por segundo han pasado de 340 a 1.890.
- El p95 de las consultas a PostgreSQL sigue en 8 ms.
- El disco de PostgreSQL:
io_timeen 0,34 (sin cambios).
Diagnostica el problema: ¿cuál es la causa raíz más probable y cuál es la cadena causal completa? ¿Qué comandos ejecutarías, en qué orden, para confirmarlo? ¿Cuál es la solución inmediata y cuál la de fondo? ¿Qué prueba de las del apartado 3 habría detectado esto antes de que llegara a producción?
Soluciones
Solución 1
(a) ¿Se cumple el SLO de p95 < 300 ms? NO, rotundamente.
http_req_duration p95: 982 ms (objetivo: 300 ms) -> 3,3 veces peor
duracion_busqueda_rutas p95: 387 ms -> 1,3 veces peor
duracion_confirmar_reserva p95: 3.100 ms -> 10,3 veces peorY los errores son alarmantes:
Nueve de cada cien clientes que intentan comprar un billete no lo consiguen. Eso es dinero perdido directamente, y es mucho peor que la latencia.
Fíjate también en la asimetría: la búsqueda va mal pero tolerable (387 ms); la reserva va catastróficamente mal (3,1 s). El problema está en el camino de escritura.
(b) El cuello de botella principal: EL DISCO DE POSTGRESQL.
La evidencia decisiva:
IOPS del disco: 2.847 de 3.000 aprovisionadas -> 94,9 % de la capacidad
Tiempo de E/S del disco (io_time): 0,91 -> 91 % del tiempo ocupadoUn io_time de 0,91 significa que el disco está ocupado el 91 % del tiempo. Por la teoría de colas del apartado 5:
Cada operación de disco espera diez veces su propio tiempo de servicio. El disco está saturado.
Y la causa de que llegue tanta E/S al disco es la caché:
Tasa de aciertos de redis-cache: 22,4 %
-> El 77,6 % de las consultas de disponibilidad van a PostgreSQL.
Consultas a PostgreSQL: 3.840/s con 158 peticiones/s
-> 24,3 consultas por peticion HTTP.24 consultas por petición es una barbaridad. Con una petición que debería hacer 1-2 consultas, esto es la firma inequívoca de un problema N+1 (apartado 7).
Cadena causal completa:
1. La cache de Redis no funciona (22,4 % de aciertos).
2. Hay un problema N+1: 24 consultas por peticion.
3. 3.840 consultas/s contra PostgreSQL.
4. La cache de PostgreSQL no da abasto: muchas van a disco.
5. El disco al 91 % de ocupacion: cada operacion espera 10 veces su tiempo.
6. Las consultas se ralentizan (el p95 de 41 ms ya lo refleja).
7. Las conexiones se retienen mas tiempo: 118 de 200 activas.
8. Las escrituras (reservas) sufren mas que las lecturas: 3,1 s de p95.
9. Los timeouts producen el 9 % de errores en las reservas.Nota importante sobre lo que NO es el cuello de botella:
CPU: 680m de 1000m (68 %) -> hay margen
Memoria: 620Mi de 1600Mi (39 %) -> hay mucho margen
Conexiones: 118 de 200 (59 %) -> hay margenEscalar api-reservas NO ayudaría. Más réplicas harían más consultas contra el mismo disco saturado, empeorando la situación. Es el error de 09-01 en estado puro.
(c) CPU al 68 % con 31,2 % de estrangulamiento.
Esta pregunta pone a prueba lo del apartado 9. La aparente contradicción se resuelve entendiendo que son dos medidas distintas:
"CPU 680m de 1000m (68 %)" es la MEDIA sobre el intervalo de medicion.
"31,2 % de periodos estrangulados" cuenta periodos de 100 ms individuales.
Que ocurre realmente dentro de cada periodo de 100 ms:
Periodo A (69 % de los casos): el pod usa 40 ms de sus 100 ms de cuota.
No se estrangula.
Periodo B (31 % de los casos): llega una rafaga. Node.js usa varios hilos
(bucle de eventos + libuv para la
serializacion JSON y la compresion).
4 hilos x 25 ms = 100 ms. CUOTA AGOTADA
en 25 ms de reloj.
El pod esta PARADO 75 ms.
Media ponderada: 0,69 x 40 + 0,31 x 100 = 27,6 + 31 = 58,6...
Ajustando por la distribucion real de rafagas: ~68 %. Coincide.La media del 68 % oculta que en 3 de cada 10 periodos el pod está parado 75 ms. Y esas paradas son exactamente lo que produce el p99 de 4,2 segundos: peticiones que llegaron justo tras agotarse la cuota.
El estrangulamiento es un problema de la cola de la distribución, y por eso la media de CPU nunca lo revela.
(d) Las tres primeras acciones, por prioridad:
Acción 1: corregir el N+1 (24 consultas por petición → 2).
Es la de mayor impacto con diferencia. Ataca la causa raíz de toda la cadena.
-- Antes: 1 consulta de rutas + 23 consultas de plazas y precios
-- Despues: 1 consulta con agregacion
SELECT
r.id, r.origen, r.destino, r.hora_salida, r.precio_base,
COUNT(p.id) FILTER (WHERE p.estado = 'libre') AS plazas_libres
FROM rutas r
LEFT JOIN plazas p ON p.ruta_id = r.id
WHERE r.origen = $1 AND r.destino = $2 AND r.fecha = $3
GROUP BY r.id, r.origen, r.destino, r.hora_salida, r.precio_base
ORDER BY r.hora_salida;Mejora esperada:
Consultas: 3.840/s -> 316/s (division por 12)
IOPS del disco: 2.847 -> ~240 (por debajo del 10 % de la capacidad)
io_time: 0,91 -> ~0,12
p95 de las consultas: 41 ms -> ~6 ms
p95 global estimado: 982 ms -> ~250 ms
Errores de reserva: 9,0 % -> < 0,5 %Coste: 1-2 días de desarrollo. Retorno: el mayor de los tres.
Acción 2: arreglar la caché de Redis (22,4 % → > 90 % de aciertos).
# Diagnostico primero
kubectl exec -n rutas-norte-pre redis-cache-0 -- redis-cli INFO stats
kubectl exec -n rutas-norte-pre redis-cache-0 -- redis-cli --scan --count 20Las causas probables, en orden:
- Claves demasiado específicas (con marca de tiempo o identificador de usuario).
- TTL demasiado corto (si es de 1 segundo, casi nunca acierta).
maxmemorysin configurar y las claves siendo expulsadas antes de tiempo.
Corrección típica:
// Clave: solo los parametros que determinan el resultado
const clave = `plazas:${rutaId}`;
const ttl = 10; // 10 segundos: absorbe el 95 % de las lecturasMejora esperada: reducción adicional del 60-70 % de las consultas restantes.
Coste: 1 día. Se hace después del N+1 porque el N+1 es lo que genera el volumen.
Acción 3: quitar limits.cpu (o subirlo a 2).
resources:
requests:
cpu: 500m
memory: 800Mi
limits:
# Sin limits.cpu: elimina el 31,2 % de periodos estrangulados
memory: 1600MiMejora esperada:
Estrangulamiento: 31,2 % -> 0 %
p99: 4,2 s -> ~1,5 s (elimina las esperas de 75 ms)
p95: mejora del 10-15 %Coste: 10 minutos. Es lo más barato de la lista, pero no lo primero, porque con el disco al 91 % la CPU no es el limitante. Una vez resueltas las acciones 1 y 2, sí lo será.
(e) Qué NO haría, y por qué:
NO subir el maxReplicas ni escalar api-reservas.
Es lo primero que hace mucha gente y es exactamente lo contrario de lo que hay que hacer:
Con 6 replicas: 3.840 consultas/s. Disco al 91 %.
Con 12 replicas: 7.680 consultas/s. Disco al... no puede pasar del 100 %.
Resultado: las consultas se encolan. La latencia SUBE.
Las conexiones se agotan (12 replicas x pool). Mas errores.
Coste: el doble. Rendimiento: peor.NO aprovisionar más IOPS en la StorageClass todavía.
Subir de 3.000 a 10.000 IOPS aliviaría el síntoma y costaría bastante más al mes. Pero el problema no es que falten IOPS: es que se hacen 24 consultas donde deberían hacerse 2. Pagar por absorber una ineficiencia es la peor decisión posible, porque la ineficiencia sigue ahí y crece con el tráfico.
Tras corregir el N+1, los 3.000 IOPS sobrarán con holgura.
NO subir el max_connections de PostgreSQL.
Con 118 de 200 en uso, las conexiones no son el limitante. Y subir max_connections empeoraría las cosas: más procesos de PostgreSQL compitiendo por el mismo disco saturado, con más cambios de contexto.
NO añadir más memoria a los pods.
39 % de uso. No es el problema.
La lección: las tres acciones que NO haría son las tres que un equipo bajo presión haría primero, porque son las más rápidas de aplicar. Todas cuestan dinero y ninguna resuelve nada. Diagnosticar antes de actuar ahorra más que cualquier optimización.
Solución 2
(a) Tamaño máximo del pool por réplica:
max_connections de postgres-reservas: 200
Reserva para mantenimiento, copias y metricas: 40 (20 %)
Disponible para aplicaciones: 160
Ya usadas por api-reservas (via PgBouncer): 75
------------------------------------------------------
Disponible para api-empresas: 85
maxReplicas de api-empresas: 20
Pool maximo por replica: 85 / 20 = 4,25 -> 4 conexiones4 conexiones por réplica.
(b) ¿Basta ese pool para el pico? Aplicamos la ley de Little.
Concurrencia = Tasa de llegada x Tiempo de servicio
Cada peticion hace 6 consultas de 14 ms = 84 ms de tiempo de base de datos.
Consultas por segundo que sostiene una replica con 4 conexiones:
4 conexiones / 0,014 s = 285,7 consultas/s
Peticiones por segundo por replica:
285,7 / 6 consultas = 47,6 peticiones/s
Con 20 replicas: 20 x 47,6 = 952 peticiones/sTrafico del pico: 180 peticiones/s
Capacidad con 20 replicas: 952 peticiones/s
952 >> 180. EL POOL BASTA CON MUCHISIMO MARGEN.Comprobemos cuántas réplicas hacen falta de verdad:
Con 4 réplicas basta para el pico. El maxReplicas: 20 es muy generoso, pero eso no es malo: da margen si la estimación de tráfico se queda corta, y las réplicas solo existen si el escalador las pide.
Verificación adicional: ¿y si las 20 réplicas estuvieran activas a la vez?
20 replicas x 4 conexiones = 80 conexiones.
Disponibles para api-empresas: 85.
80 < 85. CABE, con 5 de margen.(c) Presupuesto de latencia para 900 ms:
| Componente | Presupuesto p95 | % | Justificación |
|---|---|---|---|
| Red del cliente al Ingress | 60 ms | 6,7 % | Las agencias usan API, no navegador: menos latencia de red |
| Ingress (nginx) + TLS | 15 ms | 1,7 % | Terminación TLS y enrutado |
| Red interna del clúster | 10 ms | 1,1 % | 2 saltos |
api-empresas (lógica propia) |
90 ms | 10,0 % | Validación de 6 billetes, serialización, reglas de negocio |
postgres-reservas (6 consultas) |
300 ms | 33,3 % | 6 × 50 ms de presupuesto por consulta |
redis-cache (validación de disponibilidad) |
20 ms | 2,2 % | 2 consultas de 10 ms |
motor-precios (precios del lote) |
130 ms | 14,4 % | Cálculo para 6 billetes |
| Escritura y confirmación de la transacción | 60 ms | 6,7 % | Commit con fsync del WAL |
| Margen de seguridad | 215 ms | 23,9 % | Variabilidad, reintentos, GC, contención |
| TOTAL | 900 ms | 100 % |
Comprobación de la regla del margen:
Suma de los componentes (sin margen): 685 ms
685 / 900 = 76,1 % <= 75 %... justo en el limite.
Aceptable, pero ajustado. Conviene vigilarlo.(d) ¿Es alcanzable con los datos medidos?
MEDIDO en rutas-norte-pre con 4 replicas:
p95 total: 640 ms
De los cuales, PostgreSQL: 310 ms
Resto (aplicacion, red, motor-precios): 330 ms
PRESUPUESTADO:
PostgreSQL: 300 ms -> medido 310 ms. DESVIACION: +10 ms (+3,3 %)
Resto: 385 ms -> medido 330 ms. Por debajo. BIEN.
Total: 900 ms -> medido 640 ms. MARGEN: 260 ms (28,9 %)SÍ es alcanzable. El sistema ya cumple el objetivo con un 29 % de margen.
Pero hay dos observaciones importantes:
Observación 1: las consultas a PostgreSQL están justo en el presupuesto.
310 ms medidos / 6 consultas = 51,7 ms por consulta.
Presupuesto: 50 ms por consulta.
Y el enunciado dice que la duracion media de cada consulta es 14 ms.
310 ms medidos / 6 = 51,7 ms de p95 por consulta, frente a 14 ms de media.
RATIO p95/media = 3,7. Es alto.Un ratio p95/media de 3,7 sugiere variabilidad alta: algunas consultas son mucho más lentas que otras. Merece investigarse:
SELECT
substring(query, 1, 60),
calls,
round(mean_exec_time::numeric, 2) AS media,
round(stddev_exec_time::numeric, 2) AS desviacion,
round(max_exec_time::numeric, 2) AS maximo
FROM pg_stat_statements
WHERE query LIKE '%billetes%'
ORDER BY total_exec_time DESC;Si una de las 6 consultas es mucho más lenta que las otras, es candidata a un índice.
Observación 2: la medida es con 4 réplicas y tráfico bajo.
El p95 de 640 ms se midió en condiciones favorables. Hay que repetir la medición con la carga del pico (180 rps) antes de dar el presupuesto por bueno. La teoría de colas nos avisa de que la latencia crece de forma no lineal con la utilización.
Qué haría si no fuese alcanzable:
En orden de rentabilidad:
- Reducir las 6 consultas a 1 o 2. Un lote de 6 billetes debería poder consultarse con
WHERE id = ANY($1). De 300 ms a 60 ms. Es el cambio de mayor impacto. - Paralelizar las consultas independientes. Si las 6 no dependen unas de otras,
Promise.all()las ejecuta en paralelo: de 300 ms secuenciales a ~60 ms. - Cachear los precios del lote si las agencias consultan rutas repetidas.
- Renegociar el objetivo con el negocio. 900 ms para reservar 6 billetes puede ser demasiado exigente; 1.500 ms quizá sea perfectamente aceptable para una API B2B.
(e) El threshold de KEDA:
Capacidad medida por replica: 47,6 peticiones/s (limitada por el pool).
Pero hay que verificar cual es el limitante REAL:
- Por pool: 47,6 rps
- Por CPU: hay que medirlo con la prueba de estres
Tomamos el mas restrictivo. Supongamos que la prueba de estres da un punto
de inflexion en 42 rps por replica (la CPU limita antes que el pool).
Aplicamos el margen del 30 % para cubrir el tiempo de arranque:
42 x 0,7 = 29,4 -> threshold: 29 rps por replica
Verificacion en el pico:
180 rps / 29 = 6,2 -> 7 replicas.
7 replicas x 4 conexiones = 28 conexiones a PostgreSQL. Muy holgado.# k8s/entornos/pro/scaledobject-api-empresas.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-empresas
namespace: rutas-norte-pro
spec:
scaleTargetRef:
name: api-empresas
# 3 replicas de suelo: una por zona (09-05). Es una API B2B con contrato
# de servicio; no puede tener arranques en frio.
minReplicaCount: 3
# 20 replicas de techo.
# VERIFICACION DE CONEXIONES: 20 x 4 = 80 <= 85 disponibles. Cabe.
# NO SUBIR sin recalcular el pool (ver ConfigMap).
maxReplicaCount: 20
fallback:
failureThreshold: 3
replicas: 7 # La capacidad del pico: numero seguro conocido
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 4
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Percent
value: 25
periodSeconds: 120
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
metricName: api_empresas_peticiones_por_segundo
query: |
sum(rate(api_empresas_peticiones_total{namespace="rutas-norte-pro"}[2m]))
# 29 peticiones/s por replica.
#
# Calculo:
# Punto de inflexion medido (prueba de estres): 42 rps por replica.
# Margen del 30 % para cubrir el arranque: 42 x 0,7 = 29,4 -> 29.
#
# Verificacion del pool (ley de Little):
# 4 conexiones / 14 ms = 285,7 consultas/s por replica
# 285,7 / 6 consultas por peticion = 47,6 rps por replica
# El POOL soporta 47,6 rps; la CPU limita antes en 42.
# El pool NO es el limitante. Correcto.
threshold: "29"
activationThreshold: "2"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-lecturaY el ConfigMap con el cálculo documentado:
apiVersion: v1
kind: ConfigMap
metadata:
name: api-empresas-config
namespace: rutas-norte-pro
data:
# POOL DE CONEXIONES: 4 por replica.
#
# Calculo:
# max_connections de postgres-reservas: 200
# Reserva para mantenimiento y metricas (20 %): -40
# Usadas por api-reservas (via PgBouncer): -75
# Disponibles para api-empresas: 85
# maxReplicas de api-empresas: 20
# Pool = 85 / 20 = 4,25 -> 4
#
# NO SUBIR el pool ni el maxReplicas sin rehacer este calculo.
# Con 20 replicas, cada conexion extra por replica son 20 conexiones mas.
PG_POOL_MAX: "4"
PG_POOL_MIN: "1"
PG_POOL_ACQUIRE_TIMEOUT_MS: "2000"Solución 3
Diagnóstico: una fuga de memoria en api-reservas que está destruyendo la efectividad de la caché.
Vamos por partes, porque la cadena causal es la parte interesante.
Las pistas y lo que descartan:
| Observación | Qué indica |
|---|---|
| Sin despliegues en 3 semanas | No es un cambio de código. Es algo acumulativo |
| La memoria crece cada día, sin bajar nunca | Firma inequívoca de fuga de memoria |
OOMKilled desde hace 4 días |
La memoria ha llegado al límite |
| CPU sin cambios (310m) | No es un problema de cómputo |
| Tráfico solo +8 % | No es crecimiento orgánico |
| p95 de PostgreSQL sin cambios (8 ms) | PostgreSQL no es el cuello de botella |
io_time del disco sin cambios (0,34) |
El disco tampoco |
| Aciertos de Redis: 94,7 % → 71,2 % | Aquí está la clave |
| Consultas a PostgreSQL: 340 → 1.890 (5,6×) | Consecuencia de la caché degradada |
La cadena causal:
1. Hay una fuga de memoria en api-reservas. La memoria crece ~40Mi/dia.
(620Mi -> 1.480Mi en 21 dias = 41Mi/dia)
2. Al llegar a ~1.500Mi de un limite de 1.600Mi, el pod es OOMKilled.
3. CADA REINICIO POR OOMKilled VACIA LA CACHE EN MEMORIA DEL PROCESO.
Si api-reservas mantiene una cache local (ademas de Redis), se pierde.
4. Y MAS IMPORTANTE: los reinicios rompen el patron de acceso a Redis.
Un pod recien arrancado hace consultas "frias": pide claves que no
estan cacheadas porque el trabajo de poblar la cache lo hacia el pod
anterior con sus datos de sesion.
5. La tasa de aciertos de Redis cae del 94,7 % al 71,2 %.
6. El 28,8 % de fallos (frente al 5,3 % anterior) se traduce en
5,4 veces mas consultas a PostgreSQL: 340 -> 1.890/s.
7. Mas consultas -> mas latencia por peticion (aunque cada consulta
individual siga tardando 8 ms, ahora hay muchas mas por peticion).
8. La latencia p95 sube: 94 ms -> 280 ms.
9. KEDA detecta mas latencia o mas peticiones por replica y escala:
4 -> 9 replicas.
10. MAS REPLICAS EMPEORAN LA CACHE: mas pods, cada uno con menos
peticiones, patrones de acceso mas dispersos, y mas pods acumulando
la fuga en paralelo.
ES UN CICLO QUE SE REALIMENTA.El detalle que confirma el diagnóstico: la CPU no ha cambiado.
Si el problema fuera más carga real, la CPU habría subido. Si fuera un problema de PostgreSQL, el p95 de las consultas habría subido. La única variable que ha cambiado de forma monótona y sostenida es la memoria. Todo lo demás es consecuencia.
Comandos de diagnóstico, en orden:
# 1. Confirmar los OOMKilled y su frecuencia
kubectl get pods -n rutas-norte-pro -l app=api-reservas \
-o custom-columns='NOMBRE:.metadata.name,REINICIOS:.status.containerStatuses[0].restartCount,ULTIMO:.status.containerStatuses[0].lastState.terminated.reason'NOMBRE REINICIOS ULTIMO
api-reservas-7c9d4f8b6d-2mkjp 3 OOMKilled
api-reservas-7c9d4f8b6d-4nqzx 2 OOMKilled
api-reservas-7c9d4f8b6d-7wtrv 0 <none># 2. Confirmar el crecimiento monotono de la memoria (la prueba definitiva)
# En Prometheus:
# container_memory_working_set_bytes{namespace="rutas-norte-pro", container="api"}
# Rango: 30 dias.
#
# Si la linea SUBE Y NUNCA BAJA, ni siquiera en los valles nocturnos,
# es una fuga. Si baja por las noches, es comportamiento normal del GC.# 3. Comprobar la configuracion del heap de Node.js
kubectl get deployment api-reservas -n rutas-norte-pro \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="NODE_OPTIONS")].value}{"\n"}'# 4. Ver la composicion de la memoria del proceso
kubectl exec -n rutas-norte-pro api-reservas-7c9d4f8b6d-7wtrv -c api -- \
node -e "const m = process.memoryUsage();
console.log('rss:', (m.rss/1048576).toFixed(0), 'MB');
console.log('heapTotal:', (m.heapTotal/1048576).toFixed(0), 'MB');
console.log('heapUsed:', (m.heapUsed/1048576).toFixed(0), 'MB');
console.log('external:', (m.external/1048576).toFixed(0), 'MB');
console.log('arrayBuffers:', (m.arrayBuffers/1048576).toFixed(0), 'MB');"heapUsed de 1.094 MB creciendo: la fuga está en el heap de JavaScript, no en buffers nativos. Eso apunta a objetos JavaScript que se acumulan.
# 5. Investigar Redis: ¿que ha cambiado?
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO stats | grep -E "keyspace|expired|evicted"
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO memory | grep -E "used_memory_human|maxmemory"
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli DBSIZE# 6. Correlacionar reinicios con caidas de la tasa de aciertos.
# En Grafana, superponer:
# - kube_pod_container_status_restarts_total{container="api"}
# - redis_keyspace_hits_total / (hits + misses)
#
# Si cada reinicio coincide con una caida de la tasa de aciertos,
# la cadena causal queda CONFIRMADA.# 7. Capturar una instantanea del heap para encontrar la fuga
kubectl exec -n rutas-norte-pro api-reservas-7c9d4f8b6d-7wtrv -c api -- \
kill -USR2 1
# (requiere que la aplicacion tenga el manejador de heapdump)Las causas de fuga más probables en una API Node.js:
| Causa | Cómo se manifiesta |
|---|---|
Un Map o array global que crece sin límite |
Una caché en memoria sin TTL ni límite de tamaño |
| Escuchadores de eventos que no se eliminan | emitter.on() sin su off() correspondiente |
| Temporizadores que no se cancelan | setInterval por petición sin clearInterval |
| Cierres que capturan objetos grandes | Callbacks que retienen el objeto de petición completo |
| Un pool de conexiones que no libera | Conexiones que no se devuelven al pool |
La sospecha principal, dado el contexto: una caché en memoria dentro de api-reservas, añadida quizá hace un mes, sin límite de tamaño ni política de expulsión. Encaja perfectamente:
- Crece de forma monótona (cada ruta nueva consultada añade una entrada).
- Crece más rápido cuanto más tráfico distinto hay.
- No baja nunca, porque nada expulsa entradas.
// EL PATRON SOSPECHOSO
const cacheRutas = new Map(); // <- Sin limite de tamano
app.get('/rutas', async (req, res) => {
const clave = `${req.query.origen}:${req.query.destino}:${req.query.fecha}`;
if (cacheRutas.has(clave)) return res.json(cacheRutas.get(clave));
const rutas = await consultarRutas(req.query);
cacheRutas.set(clave, rutas); // <- CRECE PARA SIEMPRE
res.json(rutas);
});Con 8 orígenes × 7 destinos × 365 fechas = 20.440 combinaciones posibles, y cada entrada ocupando ~50 KB, la caché puede llegar a 1 GB. Exactamente lo que estamos viendo.
Solución inmediata (hoy):
# 1. Subir el limite de memoria para detener los OOMKilled mientras se
# investiga. NO es una solucion: es una tirita.
resources:
requests:
memory: 800Mi
limits:
memory: 2560Mi # De 1600Mi a 2560Mi
env:
# Y ajustar el heap coherentemente: 65 % de 2560 = 1664
- name: NODE_OPTIONS
value: "--max-old-space-size=1664"# 2. Reinicio ordenado de todas las replicas para volver al punto de partida
kubectl rollout restart deployment/api-reservas -n rutas-norte-proEsto compra unos días: con 41Mi/día y 2.560Mi de límite, el siguiente OOMKilled llegaría en unas 6-7 semanas en lugar de 4 días.
Solución de fondo (esta semana):
// Sustituir el Map sin limite por una cache LRU acotada
const { LRUCache } = require('lru-cache');
const cacheRutas = new LRUCache({
// TECHO DURO: 500 entradas. Con ~50 KB por entrada, unos 25 MB maximo.
max: 500,
// TTL de 5 minutos: los horarios no cambian, pero acota el crecimiento.
ttl: 1000 * 60 * 5,
// Techo por tamano tambien, por si alguna entrada es enorme
maxSize: 50 * 1024 * 1024, // 50 MB
sizeCalculation: (valor) => JSON.stringify(valor).length,
updateAgeOnGet: false,
});Y la decisión de arquitectura que merece plantearse: ¿hace falta esta caché en memoria si ya existe redis-cache?
Cache en memoria del proceso:
+ Mas rapida (0,001 ms frente a 1 ms de Redis)
- Se duplica en cada replica: 9 replicas = 9 copias de los mismos datos
- Se pierde en cada reinicio
- Fragmenta la tasa de aciertos: cada replica cachea lo suyo
- Es la causa de esta fuga
Cache en Redis:
+ Compartida entre TODAS las replicas: mucha mejor tasa de aciertos
+ Sobrevive a los reinicios
+ Un solo punto donde ajustar TTL y politica de expulsion
- 1 ms de latencia de red
RECOMENDACION: eliminar la cache en memoria y usar solo Redis.
El milisegundo de diferencia es irrelevante frente al presupuesto de
latencia de 300 ms, y elimina la fuga, la fragmentacion y el problema
de los reinicios de una vez.Y las medidas preventivas:
# ALERTA 1: crecimiento monotono de memoria (la que habria detectado esto
# en el dia 3, no en el dia 21).
# deriv() calcula la pendiente: si es positiva durante 6 horas seguidas,
# la memoria solo sube.
deriv(
container_memory_working_set_bytes{namespace="rutas-norte-pro", container="api"}[6h]
) > 0# ALERTA 2: cualquier OOMKilled
increase(kube_pod_container_status_restarts_total{namespace="rutas-norte-pro"}[1h]) > 0
and on(pod, container)
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} == 1# ALERTA 3: caida de la tasa de aciertos de la cache
(
rate(redis_keyspace_hits_total[10m])
/
(rate(redis_keyspace_hits_total[10m]) + rate(redis_keyspace_misses_total[10m]))
) < 0.85¿Qué prueba lo habría detectado antes?
La prueba de resistencia (soak test) del apartado 3, sin ninguna duda.
export const options = {
scenarios: {
resistencia: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '8h', // OCHO HORAS de carga constante
preAllocatedVUs: 100,
maxVUs: 300,
},
},
};Por qué esta y no otra:
| Prueba | ¿Habría detectado la fuga? |
|---|---|
| Humo (2 min) | No. Demasiado corta |
| Carga (20 min) | No. La fuga es de 41Mi/día = 1,7Mi/hora. En 20 minutos son 0,6Mi: ruido |
| Estrés (25 min) | No. Mide el límite, no la evolución temporal |
| Punta (8 min) | No. Demasiado corta |
| Resistencia (8 h) | SÍ. 8 horas × 1,7Mi/h = 14Mi de crecimiento, claramente visible en la gráfica |
Y con una prueba de resistencia más agresiva (tráfico del pico durante 8 horas), el crecimiento sería mucho mayor y evidente en la primera hora.
Qué mirar durante la prueba de resistencia:
# Si la memoria sube y NO baja en 8 horas de carga CONSTANTE, hay fuga.
container_memory_working_set_bytes{namespace="rutas-norte-pre", container="api"}
# Si las conexiones a PostgreSQL crecen sin liberarse, hay fuga de conexiones.
pg_stat_activity_count{datname="reservas"}
# Si la latencia p95 crece un 5 % cada hora, hay degradacion progresiva.
histogram_quantile(0.95, sum(rate(api_reservas_duracion_segundos_bucket[10m])) by (le))La lección del ejercicio, y del módulo entero: una prueba de resistencia de 8 horas ejecutada una vez al mes cuesta unas horas de un nodo de pruebas y detecta problemas que en producción tardan tres semanas en manifestarse, degradan el servicio progresivamente, disparan el coste de infraestructura por el escalado innecesario, y son extraordinariamente difíciles de diagnosticar cuando ya han montado una cadena causal de diez pasos.
Conclusión
El ajuste de rendimiento es lo que hace que todo lo demás del módulo 9 sea asequible. Escalar una aplicación ineficiente funciona, pero multiplica la ineficiencia por el número de réplicas: se paga treinta veces, se arrancan nodos que no harían falta, y se llega a los límites de capacidad mucho antes.
Lo esencial:
- La metodología antes que los trucos. Medir la línea base, encontrar el cuello de botella real, cambiar una sola cosa cada vez, volver a medir en las mismas condiciones, y parar cuando se alcanza el objetivo.
- Las pruebas de carga con k6 deben usar
ramping-arrival-rate(los usuarios reales no esperan), incluir pausas realistas entre pasos y respetar las proporciones entre operaciones. Los cinco tipos —humo, carga, estrés, punta, resistencia— responden preguntas distintas. - Los percentiles, nunca la media. Con
avg=142msyp99=2.1s, la media es una mentira tranquilizadora. Y en una página con 30 peticiones, uno de cada cuatro usuarios sufre el p99. - El punto de inflexión define la capacidad real, y la teoría de colas explica por qué: al 90 % de utilización la espera ya es nueve veces el tiempo de servicio. Esa es la justificación matemática del objetivo del 60-70 % en el HPA.
- El pool de conexiones es la trampa más cara del autoescalado.
maxReplicas × pool ≤ max_connections × 0,8, o PgBouncer para desacoplar el número de conexiones del número de réplicas. - La aplicación es donde está el problema. El N+1, los índices que faltan y una caché mal usada aportaron el 92 % de la mejora de Rutas Norte. El ajuste de Kubernetes aportó un 5 %.
- El estrangulamiento de CPU aparece mucho antes del 100 %: con cuatro hilos y una cuota de 100 ms, se agota en 25 ms y el pod está parado el 75 % del tiempo mostrando una utilización del 100 %. Vigila
container_cpu_cfs_throttled_periods_total. - Quitar
limits.cpues defendible para servicios en el camino crítico y mala idea para trabajos por lotes, bases de datos y entornos multiinquilino. El límite de memoria siempre se mantiene: la memoria no es compresible. - El arranque rápido es un requisito del autoescalado, no un lujo. De 113 a 12 segundos con una imagen ligera, un DaemonSet de precarga y sondas bien ajustadas.
- El punto final en los FQDN externos elimina tres de cada cuatro consultas DNS por el
ndots: 5. Un carácter. - El presupuesto de latencia convierte un objetivo de negocio en objetivos por componente, con un margen explícito del 25 %, y cada línea se convierte en una alerta que dice exactamente quién se ha salido.
El resultado medido del puente de mayo de 2026: cero minutos de caída frente a 47, un 0,08 % de errores frente al 12,4 %, una capacidad por réplica de 224 peticiones por segundo frente a 85, y un coste por mil reservas de 1,12 € frente a 8,90 €. La plataforma no solo aguantó: aguantó costando ocho veces menos por reserva vendida.
Con esto cerramos el módulo 9. Rutas Norte tiene una plataforma que crece cuando hace falta, con pods del tamaño correcto, sobre nodos que aparecen solos, anticipándose a los eventos conocidos, sin romperse durante el mantenimiento, y ajustada para no desperdiciar la capacidad que paga.
Y sin embargo, hay algo que se ha ido acumulando durante nueve módulos y que ya no se puede ignorar.
Echa un vistazo al directorio k8s/. Están el Deployment de api-reservas, su Service, su ConfigMap, su Secret, su HPA, su VPA, su PDB, su NetworkPolicy, su ServiceMonitor y su ScaledObject. Multiplicado por seis componentes. Y multiplicado otra vez por tres entornos, porque rutas-norte-dev tiene minReplicas: 1 y rutas-norte-pro tiene minReplicas: 4, porque las imágenes llevan etiquetas distintas, porque los límites de recursos difieren, porque en desarrollo no hay TLS.
Son más de ciento veinte ficheros YAML, muchos de ellos idénticos salvo por tres líneas. Cuando cambia el puerto de api-reservas, hay que tocarlo en nueve sitios. Cuando se despliega una versión nueva, alguien ejecuta una secuencia de kubectl apply de memoria, en el orden correcto, esperando no olvidarse ninguno. Cuando alguien pregunta «¿qué versión hay en preproducción?», la respuesta honesta es «déjame que mire».
Y hemos rozado el problema varias veces sin resolverlo: el campo replicas que hay que quitar del manifiesto versionado (09-01), la recomendación del VPA que hay que llevar a mano a una pull request (09-02), el PDB que quedó con un valor del puente de mayo anterior (09-05). Todos son síntomas de lo mismo: la fuente de verdad está repartida entre ciento veinte ficheros y la memoria de tres personas.
En el módulo 10, Ecosistema y Herramientas de Kubernetes, atacamos exactamente eso: entornos locales reproducibles con minikube y kind, la construcción de clústeres con kubeadm, el empaquetado y la parametrización con Helm, la personalización por entorno sin duplicar con Kustomize, y el salto definitivo a GitOps con Argo CD y Flux, donde el repositorio deja de ser una carpeta de ficheros que alguien aplica a mano y pasa a ser la única fuente de verdad de lo que hay en el clúster. Terminaremos con Kubernetes gestionado —EKS, AKS y GKE— y qué cambia cuando el plano de control no es tuyo.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
