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

  1. La metodología antes que los trucos
  2. Pruebas de carga con k6: el script del puente de mayo
  3. Tipos de prueba y cuándo usar cada uno
  4. Ejecutar la prueba dentro del clúster
  5. Leer los resultados correctamente
  6. Ajuste de la aplicación: el pool de conexiones
  7. Ajuste de la aplicación: consultas, índices y caché
  8. Ajuste de la aplicación: activos estáticos y conexiones persistentes
  9. Ajuste de recursos: la mecánica del estrangulamiento de CPU
  10. El debate sobre quitar el límite de CPU
  11. Memoria y el recolector de basura de Node.js en un contenedor
  12. El tamaño de pod adecuado
  13. Arranque rápido como requisito del autoescalado
  14. Ajuste del clúster: CoreDNS, ndots y kube-proxy
  15. Almacenamiento: cuándo el disco es el límite
  16. El presupuesto de latencia
  17. El resultado medido del puente de mayo
  18. Errores comunes y consejos
  19. Ejercicios
  20. Conclusión

  1. 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 200

Ese 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.

  1. 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.

  1. 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.

  1. 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-carga
kubectl apply -f k8s/pruebas/job-carga-puente-mayo.yaml
kubectl logs -f job/carga-puente-mayo -n rutas-norte-pre

El 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).

  1. 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 crossed

Percentiles frente a media: la lección fundamental

Mira estas dos líneas:

http_req_duration: avg=142ms   med=68ms   p(95)=487ms   p(99)=2.1s   max=8.9s

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 servicio

Esta 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

http_req_failed: 1.34%  ✓ 892  ✗ 65432
errores_reserva: 2.96%  ✓ 254  ✗ 8214

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.

  1. 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

replicas_maximas x pool_por_replica  <=  max_connections x 0,8

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 replica

Cinco 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 replica

312 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.

  1. 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 ms

Seq 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 ms

De 89,5 ms a 0,068 ms. Un factor de 1.316.

Tres detalles importantes del índice:

  1. CONCURRENTLY: crea el índice sin bloquear escrituras. Tarda más pero no interrumpe el servicio. Obligatorio en producción.
  2. El orden de las columnas importa: primero las de igualdad más selectivas. Un índice (origen, destino, fecha) sirve para consultas por origen, por origen+destino y por los tres; no sirve para consultar solo por fecha.
  3. 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.

kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO stats | grep keyspace
keyspace_hits:1284729
keyspace_misses:8471203
Tasa de aciertos = 1.284.729 / (1.284.729 + 8.471.203) = 13,2 %

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).

  1. 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,
});

  1. 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:

cfs_period_us = 100.000 (100 ms)
cfs_quota_us  = 100.000 (100 ms de CPU por cada 100 ms de reloj)

Con limits.cpu: 500m:

cfs_period_us = 100.000 (100 ms)
cfs_quota_us  =  50.000 (50 ms de CPU por cada 100 ms de reloj)

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 ms

Casi 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.

  1. 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 mantiene

Los 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: 1600Mi

Nó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

  1. 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"} == 1

Y 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.

  1. 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.

  1. 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 s

Durante 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: 6

La 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 s

De 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.

  1. Ajuste del clúster: CoreDNS, ndots y kube-proxy

El 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:5

ndots: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"
I0501 09:14:22.847 server_others.go: Using ipvs Proxier.

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.

  1. 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.97

97,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 = on

El 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.

  1. 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 > 300

Cuando 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:

Suma de los presupuestos de los componentes  <=  75 % del objetivo total.
El 25 % restante es margen.

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.

  1. 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=1800

Y 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,91

Responde: (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:

  • maxReplicas del 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-reservas tiene max_connections: 200, compartido con api-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-pre con 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-reservas ha pasado de 94 ms a 280 ms, subiendo unos 10 ms cada día.
  • No ha habido despliegues de api-reservas en 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 OOMKilled ocasionales 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-cache ha 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_time en 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 peor

Y los errores son alarmantes:

errores_reserva: 9,00 %  ->  9 de cada 100 reservas FALLAN.

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 ocupado

Un io_time de 0,91 significa que el disco está ocupado el 91 % del tiempo. Por la teoría de colas del apartado 5:

Espera ≈ TiempoServicio x (0,91 / (1 - 0,91)) = 10,1 x TiempoServicio

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 margen

Escalar 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 20

Las causas probables, en orden:

  1. Claves demasiado específicas (con marca de tiempo o identificador de usuario).
  2. TTL demasiado corto (si es de 1 segundo, casi nunca acierta).
  3. maxmemory sin 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 lecturas

Mejora 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: 1600Mi

Mejora 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 conexiones

4 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/s
Trafico 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:

180 peticiones/s / 47,6 por replica = 3,78  ->  4 replicas

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:

  1. 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.
  2. 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.
  3. Cachear los precios del lote si las agencias consultan rutas repetidas.
  4. 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-lectura

Y 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');"
rss: 1412 MB
heapTotal: 1180 MB
heapUsed: 1094 MB
external: 89 MB
arrayBuffers: 34 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-pro

Esto 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=142ms y p99=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.cpu es 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

Módulo 2: Componentes Principales de Kubernetes

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

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

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

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

© Copyright 2026. Todos los derechos reservados