TechCorp ya ve lo que pasa (06-01, 06-02) y sobrevive a los fallos (06-03). Queda la tercera pregunta que Marta lleva haciendo desde 01-05: el Black Friday en que el catálogo recibió ×20 tráfico y la tienda estuvo 50 minutos sin vender. En el monolito la única palanca era "una máquina más grande"; en Kubernetes tenemos réplicas, autoescalado y la posibilidad de escalar solo el servicio que lo necesita. Pero escalar no es gratis ni automático: hay que saber qué hace escalable a un servicio, cómo se le dice al clúster que crezca, cómo se comprueba antes con pruebas de carga y, sobre todo, dónde están los cuellos de botella que ninguna réplica arregla (la base de datos, un event loop bloqueado, un pool mal dimensionado). Esta lección recorre esas capas de fuera adentro.

Contenido

  1. Escalado vertical y horizontal: qué hace escalable a un servicio
  2. Autoescalado en Kubernetes: HPA para servicio-catalogo
  3. Escalar consumidores por longitud de cola: KEDA para servicio-inventario
  4. requests, PodDisruptionBudget y el escalado del clúster
  5. El caso Black Friday: dimensionar y comprobar con k6
  6. Caché: Redis en Catálogo y caché HTTP en el gateway
  7. Pools de conexiones: la cuenta que hay que hacer
  8. Consultas, índices y el event loop de Node
  9. Escalar la mensajería: colas competidoras y orden
  10. Escalar la base de datos: réplicas de lectura y particionado
  11. Tabla "síntoma → dónde mirar → remedio"

  1. Escalado vertical y horizontal: qué hace escalable a un servicio

Vertical (scale up) Horizontal (scale out)
Qué es Más CPU/memoria al mismo proceso Más réplicas del mismo proceso
En Kubernetes Subir resources.limits (05-02) Subir replicas / HPA
Límite El tamaño del nodo; Node.js usa un solo hilo, así que más CPU apenas ayuda El estado compartido (BD, colas)
Coste del cambio Reinicio del pod Ninguno si el servicio es "escalable"
Cuándo Bases de datos, procesos con estado Servicios HTTP y consumidores sin estado

Un servicio es horizontalmente escalable cuando dos réplicas se comportan igual que una y no se estorban. La plantilla de 04-02 ya lo garantiza, y conviene recordar por qué:

  • Sin estado en el proceso. Nada en memoria que una segunda réplica necesite: ni sesiones (el JWT de 07-01 viaja con la petición), ni cachés "de verdad" (Redis, apartado 6), ni ficheros locales. La caché en memoria de la plantilla (Map con TTL) es tolerable solo si perderla no rompe nada.
  • Conexiones limitadas y conocidas. Cada réplica abre su pool de pg (apartado 7) y su canal de RabbitMQ; N réplicas = N pools. Si no se cuenta, la BD se queda sin conexiones antes que el servicio sin CPU.
  • Arranque y parada rápidos y limpios. readinessProbe (05-02) para no recibir tráfico antes de tiempo; SIGTERM (06-03) para no perder peticiones al reducir réplicas.
  • Trabajos periódicos que toleran réplicas. El relay del outbox y el vigilante (06-03) usan FOR UPDATE SKIP LOCKED para que dos réplicas no se pisen; sin eso, escalar Pedidos duplicaría eventos.

  1. Autoescalado en Kubernetes: HPA para servicio-catalogo

El HorizontalPodAutoscaler observa una métrica y ajusta replicas del Deployment entre un mínimo y un máximo. Para Catálogo, el servicio que multiplica ×20:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: servicio-catalogo
  namespace: techcorp
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: servicio-catalogo
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60            # % sobre resources.requests.cpu
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second     # métrica personalizada servida por Prometheus Adapter
        target:
          type: AverageValue
          averageValue: "150"               # peticiones/s por pod
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - { type: Percent, value: 100, periodSeconds: 60 }   # como mucho duplicar cada minuto
    scaleDown:
      stabilizationWindowSeconds: 300                         # esperar 5 min antes de reducir
      policies:
        - { type: Pods, value: 2, periodSeconds: 60 }

Línea a línea:

  • scaleTargetRef: el Deployment de 05-02. A partir de ahora no se fija replicas en el manifiesto (el HPA lo pisaría); Kustomize deja el campo fuera y el HPA manda.
  • minReplicas: 2 por disponibilidad (un pod puede morir en cualquier momento); maxReplicas: 20 es el techo de coste y también protege a MongoDB de recibir 200 conexiones.
  • CPU al 60 %: el porcentaje se calcula sobre resources.requests.cpu (100m en 05-02), no sobre limits. Con requests: 100m, el HPA añade réplicas cuando la media supera 60m. Por eso requests debe reflejar el consumo real en régimen normal (apartado 4).
  • Métrica personalizada http_requests_per_second: viene de la http_requests_total de 06-01 a través del Prometheus Adapter, que expone consultas PromQL como métricas de la API custom.metrics.k8s.io. Su configuración (fragmento):
rules:
  - seriesQuery: 'http_requests_total{namespace!="",pod!=""}'
    resources: { overrides: { namespace: { resource: namespace }, pod: { resource: pod } } }
    name: { matches: "^(.*)_total$", as: "${1}_per_second" }
    metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'

Convierte http_requests_total en http_requests_per_second por pod con rate de 2 minutos. Con dos métricas, el HPA calcula las réplicas necesarias para cada una y toma la mayor.

  • behavior: sin él, el HPA reacciona con las políticas por defecto. Aquí subimos rápido (duplicar por minuto: en Black Friday no hay tiempo) y bajamos despacio (5 minutos de estabilización, 2 pods por minuto) para evitar el flapping con picos cortos.

Se comprueba con kubectl get hpa -n techcorp (TARGETS 45%/60%, 80/150) y kubectl describe hpa servicio-catalogo muestra cada decisión. En 05-04 escalábamos a mano con kubectl scale; ese comando sigue sirviendo para un ajuste puntual, pero el HPA lo revertirá en el siguiente ciclo (15 s).

  1. Escalar consumidores por longitud de cola: KEDA para servicio-inventario

Inventario no recibe HTTP: consume inventario.pedidos. Su carga no se ve en CPU hasta que ya va tarde; se ve en rabbitmq_queue_messages_ready. El HPA no habla RabbitMQ, pero KEDA (Kubernetes Event-Driven Autoscaling) sí: crea y gestiona un HPA por debajo a partir de scalers para colas, Prometheus, cron, etc.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: servicio-inventario
  namespace: techcorp
spec:
  scaleTargetRef:
    name: servicio-inventario
  minReplicaCount: 2
  maxReplicaCount: 10
  cooldownPeriod: 120
  triggers:
    - type: rabbitmq
      metadata:
        protocol: amqp
        queueName: inventario.pedidos
        mode: QueueLength
        value: "50"                        # objetivo: 50 mensajes pendientes por réplica
      authenticationRef:
        name: keda-rabbitmq-auth           # TriggerAuthentication que apunta al Secret pedidos-rabbitmq (05-02)
  • mode: QueueLength, value: 50: KEDA quiere réplicas = ceil(mensajes_listos / 50); con 400 mensajes esperando pasa a 8 réplicas.
  • cooldownPeriod: 120: dos minutos con la cola vacía antes de reducir.
  • authenticationRef reutiliza las credenciales del Secret de RabbitMQ; no se ponen en el YAML.
  • KEDA también puede escalar a cero (minReplicaCount: 0) para trabajos esporádicos; para Inventario mantenemos 2 porque el primer mensaje no debe esperar un arranque.

Escalar consumidores tiene un efecto sobre el orden de los mensajes que se trata en el apartado 9, y un límite claro: más réplicas de Inventario significa más carga sobre su PostgreSQL. maxReplicaCount: 10 sale de la cuenta del apartado 7.

  1. requests, PodDisruptionBudget y el escalado del clúster

Tres piezas del equipo de Plataforma que hacen posible lo anterior:

  • resources.requests reales. El scheduler coloca pods según requests; el HPA calcula porcentajes sobre ellos. Si requests.cpu: 100m pero el servicio usa 300m en reposo, el HPA verá "300 %" y escalará al máximo sin motivo; si usa 20m, nunca escalará. Se ajustan mirando container_cpu_usage_seconds_total (06-01) del p50 en horario normal, y limits con margen para picos (05-02: 100m/500m para Pedidos; Catálogo pasa a 200m/1000m tras las pruebas del apartado 5).
  • PodDisruptionBudget: al reducir réplicas, vaciar un nodo (kubectl drain) o actualizar el clúster, Kubernetes puede matar varios pods a la vez. El PDB pone un suelo:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: servicio-catalogo, namespace: techcorp }
spec:
  minAvailable: 1                          # o maxUnavailable: 1 para Deployments grandes
  selector: { matchLabels: { app: servicio-catalogo } }

Con esto, un drain que dejaría a Catálogo con cero pods espera a que otro nodo lo levante. Va en la base de Kustomize de todos los servicios con minReplicas ≥ 2.

  • Cluster Autoscaler (o Karpenter): cuando el HPA pide 20 réplicas y no caben en los nodos, los pods quedan Pending; el autoescalador del clúster añade nodos (y los quita al bajar la carga). Es responsabilidad de Plataforma/proveedor; para los equipos de servicio basta saber que "réplica pendiente durante minutos" significa que el clúster está creciendo, y que tarda 2-4 minutos: por eso Catálogo entra en Black Friday con minReplicas subido a 6 (un overlay de Kustomize temporal), no confiando solo en la reacción.

  1. El caso Black Friday: dimensionar y comprobar con k6

Datos de 01-05: ~3.000 pedidos/día en un día normal (≈0,03/s de media, con picos de 0,5/s); Catálogo unas 200 peticiones/s en hora punta. En Black Friday: catálogo ×20 (4.000/s) y pedidos ×3 (picos de 1,5/s, unos 130 pedidos/minuto).

Dimensionar es medir cuánto aguanta una réplica y dividir. Para eso están las pruebas de carga con k6, en el repositorio de cada servicio bajo pruebas/carga/:

// servicio-catalogo/pruebas/carga/catalogo.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 200 },      // rampa hasta 200 usuarios virtuales
    { duration: '5m', target: 200 },      // meseta
    { duration: '2m', target: 1000 },     // pico ×5 sobre la meseta
    { duration: '5m', target: 1000 },
    { duration: '2m', target: 0 }         // bajada
  ],
  thresholds: {
    http_req_duration: ['p(95)<300'],     // p95 por debajo de 300 ms
    http_req_failed: ['rate<0.01'],       // menos del 1 % de errores
    checks: ['rate>0.99']
  }
};

const IDS = ['p-501,p-777', 'p-501', 'p-777,p-812,p-903'];

export default function () {
  const ids = IDS[Math.floor(Math.random() * IDS.length)];
  const res = http.get(`${__ENV.URL_BASE}/v1/productos?ids=${ids}`, { headers: { 'X-Request-Id': `k6-${__VU}-${__ITER}` } });
  check(res, { 'estado 200': (r) => r.status === 200, 'tiene productos': (r) => r.json('productos').length > 0 });
  sleep(0.5);
}
  • stages describe la forma del tráfico: rampas y mesetas, con un pico que simula la avalancha. Cada usuario virtual (VU) ejecuta la función en bucle con medio segundo de pausa: 1.000 VU ≈ 2.000 peticiones/s si el servidor responde al instante.
  • thresholds convierte la prueba en pasa/no pasa: son los mismos objetivos que el panel de Grafana de 06-01 pinta en rojo (300 ms) y que 06-05 formalizará como SLO. Se ejecuta en CI contra staging (k6 run -e URL_BASE=https://staging.api.techcorp.example pruebas/carga/catalogo.js) desde un Job del pipeline de 05-03, en una etapa manual antes de campañas.
  • X-Request-Id con prefijo k6-: los logs de la prueba se distinguen (y se pueden excluir) en Loki.

Cómo leer el resumen de k6:

http_req_duration..............: avg=48ms  min=6ms  med=31ms  max=2.1s  p(90)=110ms p(95)=240ms
  ✓ { expected_response:true }.: p(95)<300
http_req_failed................: 0.32%  ✓ 41   ✗ 12760
http_reqs......................: 12801  1830/s
vus_max........................: 1000
  • p(95)=240ms con 1830/s sobre 2 réplicas ⇒ una réplica sostiene ~900 peticiones/s dentro del objetivo. Para 4.000/s hacen falta al menos 5; con margen ×2 para el HPA y el ruido, minReplicas: 6 en la campaña y maxReplicas: 20 de techo. Con requests.cpu: 200m, la CPU al 60 % se cruza mucho antes que las 150 peticiones/s por pod, así que la métrica de CPU será la que escale en la práctica.
  • Un max=2.1s aislado con p(95) sano suele ser un arranque de pod o un GC; se comprueba en la traza (06-02) antes de preocuparse.
  • Si el p95 no baja al añadir réplicas, el cuello está detrás: MongoDB, red o el gateway. Ahí entran los apartados siguientes.
  • Pedidos ×3 son 1,5 pedidos/s: sus 2 réplicas sobran de largo; lo que hay que vigilar en Pedidos es la saga (RabbitMQ, Inventario, Pagos), no HTTP.

  1. Caché: Redis en Catálogo y caché HTTP en el gateway

La forma más barata de servir 4.000 peticiones/s es no calcularlas. GET /v1/productos?ids= en 03-01 ya devuelve Cache-Control: public, max-age=30; ahora añadimos caché en dos capas:

Cache-aside en productosServicio con Redis. Antes de ir a MongoDB se mira Redis; si falta, se lee, se guarda con TTL y se devuelve:

// servicio-catalogo/src/servicios/productosServicio.js
function crearProductosServicio({ repositorio, redis, ttlSegundos = 30, logger }) {
  async function obtenerPorIds(ids) {
    const claves = ids.map((id) => `producto:${id}`);
    const cacheados = await redis.mget(claves);                             // una sola ida y vuelta
    const resultado = new Map();
    const faltan = [];
    ids.forEach((id, i) => (cacheados[i] ? resultado.set(id, JSON.parse(cacheados[i])) : faltan.push(id)));
    if (faltan.length) {
      const leidos = await repositorio.buscarPorIds(faltan);
      const pipeline = redis.pipeline();
      for (const p of leidos) { resultado.set(p.id, p); pipeline.set(`producto:${p.id}`, JSON.stringify(p), 'EX', ttlSegundos); }
      await pipeline.exec();
      logger.debug({ aciertos: ids.length - faltan.length, fallos: faltan.length }, 'caché de productos');
    }
    return ids.map((id) => resultado.get(id)).filter(Boolean);
  }
  return { obtenerPorIds };
}
  • mget y pipeline agrupan las operaciones: una petición con 3 ids hace 2 idas y vuelta a Redis, no 6.
  • El TTL de 30 s coincide con el max-age HTTP: nadie ve un precio más viejo de lo que ya prometíamos.
  • Invalidación por evento: Catálogo consume su propio evento producto.actualizado (publicado cuando el equipo de Experiencia de compra cambia un producto) y ejecuta redis.del('producto:p-501'); así un cambio de precio se ve al instante y no a los 30 s. Un contador cache_aciertos_total{resultado="acierto|fallo"} (06-01) mide la eficacia: en Catálogo, con 30 s de TTL, se espera > 95 % de aciertos.
  • Si Redis no responde, se salta la caché (timeout de 50 ms y catch que loguea warn y va a MongoDB): la caché nunca puede ser causa de fallo (06-03).

¿Rompe esto la regla de Marta de 04-01 ("dos motores de base de datos, PostgreSQL y MongoDB, y punto")? No: Redis aquí es una caché, no una base de datos: no guarda nada que no esté en MongoDB, se puede vaciar en cualquier momento (FLUSHDB) y el servicio sigue funcionando. La regla es sobre dónde vive la verdad, no sobre qué procesos hay en el clúster. Redis se despliega con Helm en techcorp (una instancia con réplica, 512 Mi) y se configura con REDIS_URL en el ConfigMap.

Caché HTTP en el gateway y CDN. Traefik (o un CDN delante) puede cachear respuestas public, max-age=30 de GET /v1/productos por URL: el 90 % de las lecturas de un producto popular en Black Friday ni siquiera llega a Catálogo. Requiere que la clave de caché sea estable (los ids ordenados: el cliente de 04-04 ya los ordena) y que las respuestas personalizadas (GET /v1/pedidos, con Authorization) lleven Cache-Control: private, no-store.

  1. Pools de conexiones: la cuenta que hay que hacer

Cada réplica abre un pool de conexiones a PostgreSQL. La cuenta que hay que hacer antes de subir maxReplicas:

conexiones a PostgreSQL = réplicas × tamaño del pool × procesos por réplica (+ relay + vigilante + jobs)

Para servicio-pedidos: pg.Pool({ max: 10 }) × 2 réplicas + relay y vigilante en el mismo pool = 20 conexiones. Con maxReplicas: 10 serían 100 solo de Pedidos, y PostgreSQL trae max_connections = 100 por defecto para todos los servicios que compartan instancia (Clientes, Pagos, Inventario tienen bases separadas pero, en dev, el mismo servidor). Opciones:

  • Bajar max del pool: para un servicio Node con 100 ms por consulta, 10 conexiones sostienen ~100 consultas/s por réplica; suele sobrar. Pedidos pasa a max: 5.
  • Poner un pooler delante (PgBouncer en modo transacción): 200 conexiones de aplicación se multiplexan en 20 reales. Es lo habitual con muchas réplicas; Plataforma lo añade cuando algún servicio supera 8 réplicas.
  • Configurar timeouts del pool (connectionTimeoutMillis: 2000, idleTimeoutMillis: 30000) para que la espera por una conexión sea un error rápido (06-03), no un cuelgue.
  • Vigilar la saturación (USE, 06-01): prom-client puede exponer pg_pool_esperando (pool.waitingCount) y pg_pool_activas; una cola de espera creciente con CPU baja es la firma de un pool pequeño.

Lo mismo aplica al driver de MongoDB (maxPoolSize, 100 por defecto: demasiado para 20 réplicas → 20) y a los canales de RabbitMQ (uno por consumidor y uno para publicar, no uno por mensaje).

  1. Consultas, índices y el event loop de Node

Escalar réplicas no arregla una consulta lenta: la multiplica. Herramientas por orden de uso:

EXPLAIN ANALYZE en PostgreSQL. La consulta de obtener(id) de Pedidos (04-04) hace LEFT JOIN a clientes_ref (la copia local de datos del cliente que mantiene el consumidor pedidos.clientes):

EXPLAIN ANALYZE
SELECT p.*, c.nombre AS cliente_nombre
FROM pedidos p LEFT JOIN clientes_ref c ON c.id = p.cliente_id
WHERE p.id = 'ped-88213';
Nested Loop Left Join  (cost=0.71..16.76 rows=1 width=180) (actual time=0.041..0.043 rows=1 loops=1)
  ->  Index Scan using pedidos_pkey on pedidos p  (actual time=0.022..0.023 rows=1 loops=1)
        Index Cond: (id = 'ped-88213'::text)
  ->  Index Scan using clientes_ref_pkey on clientes_ref c  (actual time=0.010..0.010 rows=1 loops=1)
Planning Time: 0.180 ms
Execution Time: 0.071 ms

Dos Index Scan: perfecto. Lo que hay que temer es un Seq Scan sobre pedidos en la consulta de listado por cliente (WHERE cliente_id = $1 ORDER BY creado_en DESC), que a 3.000 pedidos/día en un año recorre un millón de filas: CREATE INDEX pedidos_cliente_creado_idx ON pedidos (cliente_id, creado_en DESC) y la paginación por cursor de 03-01 (WHERE (creado_en, id) < ($cursor…)) usan el mismo índice y mantienen el coste constante aunque la tabla crezca. La extensión pg_stat_statements dice qué consultas consumen más tiempo total; es lo primero que se mira cuando la BD está alta de CPU.

Índices en MongoDB. asegurarIndices() de 04-02 crea { _id } (implícito) y { categoria: 1, nombre: 1 }; db.productos.find({...}).explain('executionStats') debe mostrar IXSCAN, no COLLSCAN, y totalDocsExamined cercano a nReturned.

Un solo hilo. Node atiende miles de conexiones con un hilo porque nunca lo bloquea; una operación síncrona de 100 ms (el calcularRecomendaciones que la traza de 06-02 destapó, un JSON.parse de 20 MB, bcrypt síncrono, una regex catastrófica) detiene todas las peticiones de esa réplica durante 100 ms. La métrica nodejs_eventloop_lag_seconds (06-01) lo delata; el remedio es hacerlo asíncrono, sacarlo a un evento, o, si es cómputo puro e imprescindible, moverlo a worker_threads (mención: un pool de hilos para tareas CPU-intensivas; en TechCorp no hay ninguna).

Detalles que suman: compression() en Express para respuestas grandes (listados de productos: 60-80 % menos bytes), keep-alive en los clientes HTTP (fetch de Node 20 lo hace por defecto: evita un handshake TCP por petición entre Pedidos y Catálogo), no serializar cuerpos gigantes en eventos (pedido.creado lleva ids y cantidades, no el catálogo entero) y prefetch de RabbitMQ acorde al coste del mensaje: 10 para Inventario (consultas rápidas), 2-3 para Pagos (llamadas de segundos al PSP).

  1. Escalar la mensajería: colas competidoras y orden

Varias réplicas de Inventario consumiendo inventario.pedidos es el patrón de consumidores competidores: RabbitMQ reparte los mensajes en round-robin entre los consumidores conectados, cada mensaje se entrega a uno, y el rendimiento crece casi linealmente hasta que la BD dice basta. Es lo que KEDA explota en el apartado 3.

El precio es el orden: dos mensajes del mismo pedido (pedido.creado y, un segundo después, pedido.cancelado por un cliente arrepentido) pueden ir a réplicas distintas y procesarse en orden inverso. Cómo se trata en TechCorp:

  • Diseñar los consumidores para desorden: la máquina de estados de Pedidos ignora transiciones imposibles (pago.confirmado sobre CANCELADO no hace nada, 06-03) y cada evento lleva ocurrido_en para descartar los más antiguos que el estado actual. Es la solución que ya tenemos y basta para el 99 % de los casos.
  • Particionar por clave cuando el orden es imprescindible: el exchange x-consistent-hash de RabbitMQ (o Kafka con particiones por pedidoId, 03-02) envía todos los mensajes de un mismo pedidoId a la misma cola/consumidor. Más complejo; TechCorp no lo necesita hoy.
  • El caso opuesto: un solo consumidor garantiza orden pero no escala; solo para colas de muy bajo volumen.

Escalar el propio RabbitMQ: en producción, clúster de 3 nodos con quorum queues (x-queue-type: quorum, replicadas por Raft: un nodo caído no pierde la cola) en lugar de las clásicas; lo declara Plataforma en el chart de 05-02 y no cambia el código.

  1. Escalar la base de datos: réplicas de lectura y particionado

Los servicios se escalan solos; la base de datos es el techo. Palancas, de menor a mayor esfuerzo:

  1. Menos consultas: caché (apartado 6), pools correctos (7), índices (8). Resuelve la mayoría de los casos.
  2. Réplicas de lectura: PostgreSQL con streaming replication; el PedidoRepositorio usa dos pools (PEDIDOS_DB_URL para escrituras y la saga, PEDIDOS_DB_LECTURA_URL para las vistas de consulta del CQRS de 02-05: GET /v1/pedidos?clienteId=). Precio: retraso de replicación de milisegundos a segundos; un GET /v1/pedidos/{id} justo después del POST debe ir al primario o aceptar Retry-After (03-01 ya devuelve 202 y Location, así que el cliente espera). En un proveedor gestionado es una casilla.
  3. Particionado (sharding, mención): repartir pedidos por rango de fecha (PARTITION BY RANGE (creado_en), mensual, para archivar) o por hash de cliente_id entre varias instancias. TechCorp lo evaluará cuando la tabla supere decenas de millones de filas; hoy es innecesario. MongoDB tiene sharding nativo por clave, con la misma advertencia.
  4. Escalado vertical de la instancia: la palanca legítima para las bases de datos: más CPU, RAM y IOPS. Cuesta dinero, no complejidad.

Regla: primero medir (pg_stat_statements, latencia de consultas en las trazas de 06-02), luego elegir la palanca más barata que resuelva el síntoma.

  1. Tabla "síntoma → dónde mirar → remedio"

Síntoma (métrica de 06-01) Dónde mirar Remedio probable
p95 de http_request_duration_seconds sube con la carga y CPU del pod > 80 % kubectl top pod, HPA Más réplicas: HPA con requests bien puestos
p95 sube pero CPU baja y pg_pool_esperando > 0 Pool de pg, pg_stat_activity Ampliar pool o PgBouncer; revisar consultas lentas
nodejs_eventloop_lag_seconds > 0,1 s Traza con huecos (06-02), código síncrono Eliminar la operación bloqueante; worker_threads si es cómputo
rabbitmq_queue_messages_ready{queue="inventario.pedidos"} crece sin parar Réplicas de Inventario, su BD KEDA / más consumidores; comprobar que la BD de Inventario aguanta
cache_aciertos_total{resultado="fallo"} alto en Catálogo Redis (¿caído? TTL muy bajo), invalidaciones excesivas Revisar TTL, tamaño de Redis, evento producto.actualizado
BD al 100 % de CPU con réplicas de servicio ociosas pg_stat_statements, EXPLAIN ANALYZE Índices, caché, réplica de lectura
Pods Pending al escalar kubectl describe pod ("Insufficient cpu") Cluster Autoscaler; ajustar requests; subir minReplicas antes de la campaña
Muchos 429 en el gateway con servicios sanos Rate limit del gateway (03-04) Subir el límite para clientes legítimos; el 429 está haciendo su trabajo
Latencia sube tras un despliegue con la misma carga Comparar por version (06-01), traza Regresión de rendimiento: revertir el canary (05-04) y perfilar

Errores Comunes y Consejos

  • HPA sobre un Deployment con replicas fijado en Git. Argo CD (05-03) y el HPA se pelean: uno pone 2, el otro 8, cada minuto. Quitar replicas del manifiesto (o ignoreDifferences en Argo).
  • requests inventados. Con requests.cpu: 1000m para un servicio que usa 50m, el HPA nunca supera el 5 % y jamás escala; con 10m, escala al máximo enseguida. Medir.
  • Escalar el servicio y olvidar la base de datos. 20 réplicas × 10 conexiones = 200 > max_connections. La cuenta del apartado 7 antes de subir el máximo.
  • Caché sin invalidación ni TTL. Precios viejos durante horas. TTL siempre; evento de invalidación cuando importe.
  • Caché que se convierte en dependencia. Si Redis cae y el servicio falla, la caché ha dejado de ser caché. Timeout corto y fallback a la BD.
  • Pruebas de carga contra producción o desde el portátil. Los resultados no significan nada (o tiran producción). Staging con datos y tamaño realistas, generador de carga dentro del clúster o en una máquina dedicada.
  • Fijarse en la media. avg=48ms esconde un p95 de 240 y un p99 de 900. Percentiles siempre.
  • Añadir réplicas para arreglar una consulta sin índice. La BD empeora. Índice primero.
  • Consejo: hacer la prueba de k6 antes de cada campaña y guardar el resumen junto al tag de la imagen; comparar con la anterior detecta regresiones.
  • Consejo: cada servicio documenta su "hoja de capacidad": peticiones/s por réplica al p95 objetivo, requests/limits, tamaño del pool, min/max del HPA y la fecha de la última prueba de carga.

Ejercicios

Ejercicio 1: dimensionar Pedidos y su base de datos

Una réplica de servicio-pedidos sostiene 40 POST /v1/pedidos/s al p95 < 500 ms con pg.Pool({ max: 5 }). Para el Black Friday se esperan picos de 1,5 pedidos/s de HTTP más el consumidor de la saga y el relay. Propón minReplicas/maxReplicas, calcula las conexiones máximas a PostgreSQL y di si hace falta PgBouncer.

Ejercicio 2: KEDA para Notificaciones

servicio-notificaciones consume notificaciones.pedidos y envía correos a través de un proveedor que admite 20 envíos/s por conexión. Escribe el ScaledObject con valores justificados y explica qué límite impide subir maxReplicaCount sin más.

Ejercicio 3: diagnóstico

Durante la prueba de k6, el p95 de Catálogo pasa de 90 ms con 200 VU a 1,2 s con 1.000 VU; el HPA ha subido a 12 réplicas, la CPU media de los pods es del 25 % y cache_aciertos_total{resultado="acierto"} es del 40 %. ¿Dónde está el cuello de botella y qué dos cosas comprobarías primero?

Soluciones

Ejercicio 1

1,5 pedidos/s es el 4 % de la capacidad de una réplica: minReplicas: 2 (disponibilidad, no capacidad) y maxReplicas: 4 sobra con holgura para picos y para el trabajo asíncrono. Conexiones: 4 réplicas × 5 = 20 al primario, más el Job de migraciones ocasional (05-02) y el vigilante/relay que comparten el pool: ~20-22. Con max_connections = 100 compartidas por Pedidos, Clientes, Pagos e Inventario, Pedidos consume una quinta parte: aceptable, no hace falta PgBouncer todavía. Se anota en la hoja de capacidad que si maxReplicas supera 8 o se añaden más servicios a la misma instancia, se revisa.

Ejercicio 2

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: servicio-notificaciones, namespace: techcorp }
spec:
  scaleTargetRef: { name: servicio-notificaciones }
  minReplicaCount: 1                       # las notificaciones toleran segundos de espera; 1 basta en reposo
  maxReplicaCount: 5
  cooldownPeriod: 300
  triggers:
    - type: rabbitmq
      metadata: { protocol: amqp, queueName: notificaciones.pedidos, mode: QueueLength, value: "100" }
      authenticationRef: { name: keda-rabbitmq-auth }

value: 100 porque un correo tarda ~50 ms y 100 mensajes se vacían en 5 s por réplica; no hay prisa de negocio. El límite real es el proveedor de correo: 20 envíos/s por conexión y probablemente una cuota global; 5 réplicas × 20 = 100/s, que ya está cerca de la cuota contratada. Subir maxReplicaCount sin subir la cuota solo produciría 429 del proveedor (que el consumidor trataría como transitorio y enviaría a notificaciones.pedidos.reintento, 06-03). El límite viene de la dependencia externa, no del clúster.

Ejercicio 3

Réplicas al 25 % de CPU y latencia disparada: el cuello no es Catálogo, está detrás. La pista es la caché: 40 % de aciertos es muy bajo para un TTL de 30 s con productos populares; el 60 % de las peticiones bajan a MongoDB. Comprobar primero (1) Redis: ¿está respondiendo o el timeout de 50 ms está saltando (warn en Loki "caché no disponible") de modo que todo va a la BD?; ¿los ids llegan en distinto orden y generan claves distintas? (aquí las claves son por producto, así que sería la ejecución del pipeline); (2) MongoDB: db.currentOp(), CPU de la instancia y explain de buscarPorIds: con _id indexado debería ser rápido; si la instancia está saturada de conexiones (12 réplicas × maxPoolSize 100 = 1.200), ahí está el problema: bajar maxPoolSize a 20 y, si es Redis, arreglarlo antes de volver a lanzar la prueba. Añadir más réplicas no ayudaría; probablemente empeoraría.

Conclusión

Escalar en TechCorp deja de ser "una máquina más grande" para convertirse en un conjunto de decisiones medibles. Un servicio de la plantilla es escalable porque no guarda estado, limita sus conexiones y arranca y para con limpieza; el HorizontalPodAutoscaler de servicio-catalogo (2-20 réplicas, CPU al 60 % y http_requests_per_second vía Prometheus Adapter, con behavior asimétrico) y el ScaledObject de KEDA para servicio-inventario sobre inventario.pedidos deciden las réplicas; requests reales, PodDisruptionBudget y el Cluster Autoscaler sostienen esas decisiones; k6 (pruebas/carga/catalogo.js, p(95)<300) las comprueba antes del Black Friday. Debajo, el rendimiento se gana con caché cache-aside en Redis (TTL 30 s, invalidación por producto.actualizado, nunca dependencia), pools de conexiones que caben en max_connections, índices comprobados con EXPLAIN ANALYZE, un event loop sin bloqueos, consumidores competidores que toleran el desorden y réplicas de lectura para las vistas de consulta. Tenemos observabilidad, resiliencia y capacidad; lo que falta es acordar qué es "ir bien" y qué hacer cuando no: cuánta latencia y cuántos errores aceptamos, cuándo debe sonar el teléfono de la guardia y cómo se gestiona y se aprende de un incidente. Ese es el cierre del módulo: SLOs, alertas y gestión de incidentes.

Curso de Microservicios

Módulo 1: Introducción a los Microservicios

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados