La lección anterior terminó en un hueco de 30 segundos: el tiempo entre que inv-bcn deja de responder y Patroni promociona a inv-vlc. Durante ese intervalo nada está "roto" según los mecanismos de failover, y sin embargo la plataforma puede caer entera. La razón es que el fallo no se queda donde ocurre: pedidos espera a inventario, Kong espera a pedidos, Ana espera a Kong, y cada espera consume un recurso finito (un hilo, una conexión, un slot en una cola). En 01-04 se vio un cliente con un timeout simple y se dejó el circuit breaker "para más adelante"; en 02-03, los deadlines de gRPC. Esta lección construye el conjunto completo de patrones de resiliencia: las técnicas con las que quien llama a un servicio que falla no se hunde con él. Se implementan desde cero en servicios/comun/resiliencia.py, se aplican al cliente gRPC de inventario en pedidos, se reproducen en una simulación, y se muestra dónde vivir cada uno (librería, sidecar, o ambos).
Contenido
- Anatomía de un fallo en cascada
- Timeouts: por llamada, presupuesto total y propagación de deadlines
- Reintentos seguros: backoff, jitter y retry budget
- Circuit breaker: la máquina de estados
- Fallback y degradación controlada
- Bulkhead, backpressure y load shedding
- Otros patrones: hedged requests, rate limiting interno, idempotency keys
servicios/comun/resiliencia.pyy su uso enpedidos- La simulación:
simulaciones/cascada.py - Dónde implementarlos: librería, sidecar (Envoy) o ambos
- Errores comunes y consejos
- Ejercicios y soluciones
- Conclusión
- Anatomía de un fallo en cascada
Supongamos la configuración real de Kilómetro Cero un sábado de la Semana de la Vendimia: pedidos tiene 3 réplicas con 32 hilos cada una (96 en total) y recibe 100 peticiones/s; cada petición llama a inventario (ReservarStock, normalmente 40 ms) y a pagos. Kong tiene un pool de 200 conexiones hacia pedidos.
A las 10:12:00, el disco de inv-bcn se degrada y ReservarStock pasa a tardar 4 s.
sequenceDiagram
participant Ana
participant Kong
participant P as pedidos (96 hilos)
participant I as inventario (4 s / llamada)
Note over I: 10:12:00 disco degradado
Ana->>Kong: POST /pedidos (x100/s)
Kong->>P: 100 peticiones/s
P->>I: ReservarStock (sin timeout)
Note over P: 10:12:01 → 96 hilos ocupados esperando 4 s cada uno<br/>Capacidad real: 96/4 = 24 peticiones/s. Llegan 100.
Kong->>P: peticiones en cola en el socket
Note over Kong: 10:12:03 → 200 conexiones agotadas.<br/>Kong responde 503 a TODO, incluido GET /catalogo
Ana-->>Ana: "El sitio no va"
Note over I: 10:12:30 Patroni conmuta a inv-vlc. Ya es tarde:<br/>hay miles de peticiones encoladas y reintentos acumulados
Lo que ocurre en cifras: con 4 s por llamada, 96 hilos atienden como mucho 24 peticiones/s; llegan 100, así que cada segundo se acumulan 76 peticiones en las colas de aceptación. En tres segundos Kong ha agotado su pool y empieza a rechazar todas las rutas, también /api/v1/catalogo, que no depende de inventario para nada. Y cuando inv-vlc es promocionado, no hay alivio inmediato: las colas están llenas de peticiones viejas cuyos clientes ya se han rendido, y los reintentos del front han multiplicado la carga. Esto es un fallo en cascada: un componente lento propaga su lentitud a todo el que lo espera, a través del agotamiento de recursos compartidos.
Los patrones de esta lección atacan cada eslabón:
| Eslabón de la cascada | Patrón que lo corta |
|---|---|
| Esperar 4 s por una llamada que suele tardar 40 ms | Timeout |
| Volver a intentar sin control y multiplicar la carga | Reintentos con backoff, jitter y presupuesto |
| Seguir llamando a algo que lleva 30 s fallando | Circuit breaker |
| No tener nada que responder cuando no se puede llamar | Fallback / degradación |
Que la espera por inventario consuma los hilos que catalogo necesita |
Bulkhead |
| Aceptar más trabajo del que se puede hacer | Backpressure / load shedding |
| Reintentar una operación con efectos (cobrar dos veces) | Idempotency keys |
- Timeouts: por llamada, presupuesto total y propagación de deadlines
Un timeout es la decisión de dejar de esperar. Sin él, un hilo bloqueado en un socket puede quedarse así indefinidamente; con él, libera el recurso y devuelve un error que el resto del sistema puede manejar. Tres niveles:
- Por llamada: cada operación remota tiene un límite acorde a su latencia normal.
ReservarStocktarda 40 ms en p50 y 120 ms en p99: un timeout de 300 ms deja margen para la cola y corta lo anómalo. La regla práctica es fijarlo entre 2 y 5 veces el p99 observado (07-01), nunca "30 segundos por si acaso". - Presupuesto total de la petición:
POST /pedidospromete p99 < 500 ms. Ese es el presupuesto; cada llamada interna gasta parte, y la siguiente recibe lo que queda. SiReservarStockha consumido 280 ms,Cobrarno puede recibir un timeout de 2 s: recibe 220 ms, o la petición se aborta antes de cobrar. - Propagación de deadlines: en gRPC (02-03), el deadline viaja en los metadatos (
grpc-timeout), y cada servicio de la cadena lo hereda y lo recorta.inventario, al recibir una llamada con 220 ms de deadline, puede pasar 200 ms a su consulta SQL (statement_timeout), y si se agota, responderDEADLINE_EXCEEDEDen lugar de hacer trabajo que nadie va a leer. En HTTP no hay estándar; Kilómetro Cero propaga una cabeceraX-Deadlinecon el instante absoluto (época en ms), quepedidosconvierte en deadline gRPC.
Una implementación del presupuesto:
# km0/servicios/comun/resiliencia.py (parte 1: timeouts)
"""Patrones de resiliencia para las llamadas entre servicios de Kilómetro Cero."""
import random
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Iterable, TypeVar
T = TypeVar("T")
class TiempoAgotado(Exception):
"""El presupuesto de tiempo de la petición se ha agotado."""
@dataclass
class Presupuesto:
"""Presupuesto de tiempo de una petición: un instante límite absoluto.
Se crea una vez al entrar la petición (o a partir de la cabecera X-Deadline)
y se pasa a cada llamada interna, que solo puede usar lo que queda.
"""
limite: float # time.monotonic() en el que la petición debe haber terminado
@classmethod
def de_segundos(cls, segundos: float) -> "Presupuesto":
return cls(limite=time.monotonic() + segundos)
def restante(self) -> float:
return max(0.0, self.limite - time.monotonic())
def timeout_para(self, maximo: float) -> float:
"""Timeout de una llamada concreta: su máximo propio o lo que quede, lo menor."""
restante = self.restante()
if restante <= 0:
raise TiempoAgotado("presupuesto de la petición agotado")
return min(maximo, restante)
def con_timeout(fn: Callable[[float], T], maximo: float, presupuesto: Presupuesto | None = None) -> T:
"""Ejecuta fn(timeout) con el timeout adecuado.
fn recibe el timeout en segundos y es responsable de aplicarlo (gRPC: timeout=;
psycopg: statement_timeout; requests: timeout=). No se usan hilos para "matar"
la llamada: el timeout debe respetarse en la propia E/S, que es lo único fiable.
"""
timeout = presupuesto.timeout_para(maximo) if presupuesto else maximo
return fn(timeout)La razón de que con_timeout no envuelva la función en un hilo con join(timeout) merece explicación: en Python (y en casi cualquier runtime) no se puede matar un hilo bloqueado en E/S; el "timeout" externo solo devolvería el control al llamante dejando el hilo bloqueado igualmente, que es justo el recurso que se quería proteger. El timeout que funciona es el que aplica la librería de red en el socket.
- Reintentos seguros: backoff, jitter y retry budget
Reintentar es la respuesta natural a un fallo transitorio, y la forma más fácil de convertir un problema pequeño en una tormenta. Reglas para que un reintento sea seguro:
- Solo errores transitorios.
UNAVAILABLE,DEADLINE_EXCEEDED,503, conexión rechazada o reiniciada: sí.INVALID_ARGUMENT,PERMISSION_DENIED,NOT_FOUND,400,403: no; la segunda vez fallará igual.FAILED_PRECONDITION("no hay stock"): tampoco, es una respuesta, no un fallo. - Solo operaciones idempotentes (02-05).
ConsultarStock: siempre.ReservarStockcon clave de idempotencia (pedido_id+ línea): sí.Cobrarsin clave: jamás, porque un timeout no dice si el cobro se hizo o no. - Backoff exponencial: espera creciente entre intentos (100 ms, 200, 400, 800), para dar tiempo a que lo transitorio pase.
- Jitter: aleatoriedad en la espera. Sin ella, mil clientes que fallaron a la vez reintentan a la vez, exactamente 100 ms después, y luego 200 ms después: ondas sincronizadas que mantienen tumbado al servicio (thundering herd). Con "full jitter" (
uniform(0, base × 2^n)), las oleadas se dispersan. - Límite de intentos (3 en total es un buen máximo) y respeto del deadline: no tiene sentido un tercer intento si el presupuesto ya está agotado.
- Retry budget: además del límite por llamada, un límite global: los reintentos no pueden superar, por ejemplo, el 10 % de las llamadas normales en una ventana. Si
inventarioestá caído del todo, un 10 % extra de carga es soportable; un 200 % (3 intentos por cada petición) es lo que impide que se recupere.
# km0/servicios/comun/resiliencia.py (parte 2: reintentos)
class PresupuestoReintentos:
"""Retry budget: los reintentos no pueden superar una fracción de las llamadas.
Ventana deslizante simple: cada llamada normal 'gana' `ratio` créditos;
cada reintento gasta uno. Sin créditos, no se reintenta.
"""
def __init__(self, ratio: float = 0.1, minimo: int = 10):
self.ratio, self.minimo = ratio, minimo
self.creditos = float(minimo)
self._lock = threading.Lock()
def registrar_llamada(self) -> None:
with self._lock:
self.creditos = min(self.creditos + self.ratio, 100.0)
def permitir_reintento(self) -> bool:
with self._lock:
if self.creditos >= 1:
self.creditos -= 1
return True
return False
def reintentar(fn: Callable[[float], T], *, maximo_por_intento: float, presupuesto: Presupuesto,
transitorias: Iterable[type[BaseException]], intentos: int = 3,
base: float = 0.1, tope: float = 2.0,
budget: PresupuestoReintentos | None = None,
al_reintentar: Callable[[int, BaseException, float], None] | None = None) -> T:
"""Ejecuta fn con reintentos seguros.
- Solo reintenta las excepciones listadas en `transitorias`.
- Backoff exponencial con full jitter: espera uniforme en [0, min(tope, base * 2^n)].
- Nunca espera más de lo que queda de presupuesto; si no queda, propaga el último error.
- Respeta el retry budget global si se proporciona.
"""
transitorias = tuple(transitorias)
ultimo: BaseException | None = None
for n in range(intentos):
try:
if budget:
budget.registrar_llamada()
return con_timeout(fn, maximo_por_intento, presupuesto)
except transitorias as e:
ultimo = e
if n == intentos - 1:
break
if budget and not budget.permitir_reintento():
break # sin crédito: no empeorar la tormenta
espera = random.uniform(0, min(tope, base * (2 ** n)))
if espera >= presupuesto.restante():
break # no llegaríamos a tiempo ni intentándolo
if al_reintentar:
al_reintentar(n + 1, e, espera)
time.sleep(espera)
except TiempoAgotado:
raise
assert ultimo is not None
raise ultimo
- Circuit breaker: la máquina de estados
Cuando inventario lleva 20 segundos devolviendo UNAVAILABLE a cada llamada, seguir intentándolo es inútil para pedidos (gasta 300 ms de timeout en cada petición para nada) y dañino para inventario (que intenta recuperarse bajo una lluvia de peticiones). El circuit breaker (Nygard, Release It!) es un interruptor que, a partir de cierto ratio de fallos, deja de hacer las llamadas y falla inmediatamente, y cada cierto tiempo deja pasar una llamada de prueba para ver si el destino se ha recuperado.
stateDiagram-v2
[*] --> Cerrado
Cerrado --> Abierto: fallos >= umbral<br/>en la ventana<br/>(p. ej. 50 % de 20 llamadas)
Abierto --> Semiabierto: pasa el tiempo<br/>de enfriamiento (10 s)
Semiabierto --> Cerrado: la(s) llamada(s)<br/>de prueba tienen éxito
Semiabierto --> Abierto: una llamada<br/>de prueba falla
note right of Cerrado
Tráfico normal.
Se cuentan éxitos y fallos.
end note
note right of Abierto
Toda llamada falla al instante
con CircuitoAbierto. Cero carga
sobre la dependencia.
end note
note right of Semiabierto
Deja pasar N llamadas de prueba;
el resto sigue fallando rápido.
end note
Decisiones de diseño:
- Qué cuenta como fallo: excepciones transitorias y timeouts; no los errores de negocio (
FAILED_PRECONDITIONpor falta de stock es una respuesta correcta y no debe abrir el circuito). - Umbral y ventana: en ratio y con un mínimo de llamadas (50 % de fallos sobre al menos 20 llamadas en los últimos 10 s), no "5 fallos seguidos", que con tráfico alto se alcanza por ruido.
- Enfriamiento: cuánto esperar en abierto antes de probar (5-30 s). Demasiado corto, y se martillea la dependencia; demasiado largo, y se prolonga la degradación tras la recuperación.
- Qué devolver en abierto: una excepción específica (
CircuitoAbierto) que el llamante convierte en fallback (sección 5), en503conRetry-After, o en una respuesta degradada. Nunca la excepción original disfrazada. - Uno por dependencia (y por instancia de servicio):
pedidostiene un circuito haciainventarioy otro haciapagos; quepagosfalle no debe abrir el deinventario. - Observable: su estado es un gauge de Prometheus (
km0_circuit_estado{dependencia}, 0/1/2) y cada transición es un log (circuito_abierto, el que Jordi encontró en 07-02).
# km0/servicios/comun/resiliencia.py (parte 3: circuit breaker)
from collections import deque
from prometheus_client import Gauge, Counter
CIRCUIT_ESTADO = Gauge("km0_circuit_estado", "Estado del circuit breaker: 0 cerrado, 1 semiabierto, 2 abierto",
["servicio", "dependencia"])
CIRCUIT_RECHAZOS = Counter("km0_circuit_rechazos_total", "Llamadas rechazadas con el circuito abierto",
["servicio", "dependencia"])
class Estado(Enum):
CERRADO = 0
SEMIABIERTO = 1
ABIERTO = 2
class CircuitoAbierto(Exception):
def __init__(self, dependencia: str, reintentar_en: float):
super().__init__(f"circuito abierto hacia {dependencia}; reintentar en {reintentar_en:.1f}s")
self.dependencia, self.reintentar_en = dependencia, reintentar_en
class CircuitBreaker:
def __init__(self, servicio: str, dependencia: str, *, transitorias: Iterable[type[BaseException]],
ventana_s: float = 10.0, minimo_llamadas: int = 20, ratio_fallos: float = 0.5,
enfriamiento_s: float = 10.0, pruebas_semiabierto: int = 3, log=None):
self.servicio, self.dependencia = servicio, dependencia
self.transitorias = tuple(transitorias)
self.ventana_s, self.minimo, self.ratio = ventana_s, minimo_llamadas, ratio_fallos
self.enfriamiento, self.pruebas = enfriamiento_s, pruebas_semiabierto
self.log = log
self._estado = Estado.CERRADO
self._abierto_desde = 0.0
self._pruebas_en_vuelo = 0
self._resultados: deque[tuple[float, bool]] = deque() # (instante, exito)
self._lock = threading.Lock()
self._publicar()
# --- estado observable -------------------------------------------------
@property
def estado(self) -> Estado:
return self._estado
def _publicar(self) -> None:
CIRCUIT_ESTADO.labels(self.servicio, self.dependencia).set(self._estado.value)
def _transicion(self, nuevo: Estado) -> None:
if nuevo is not self._estado:
if self.log:
self.log.warning(f"circuito_{nuevo.name.lower()}", dependencia=self.dependencia,
desde=self._estado.name.lower())
self._estado = nuevo
self._publicar()
# --- ventana de resultados ----------------------------------------------
def _podar(self, ahora: float) -> None:
while self._resultados and ahora - self._resultados[0][0] > self.ventana_s:
self._resultados.popleft()
def _ratio_fallos(self, ahora: float) -> tuple[int, float]:
self._podar(ahora)
total = len(self._resultados)
fallos = sum(1 for _, ok in self._resultados if not ok)
return total, (fallos / total if total else 0.0)
# --- decisión antes de llamar -------------------------------------------
def _antes(self) -> None:
ahora = time.monotonic()
with self._lock:
if self._estado is Estado.ABIERTO:
transcurrido = ahora - self._abierto_desde
if transcurrido < self.enfriamiento:
CIRCUIT_RECHAZOS.labels(self.servicio, self.dependencia).inc()
raise CircuitoAbierto(self.dependencia, self.enfriamiento - transcurrido)
self._transicion(Estado.SEMIABIERTO)
self._pruebas_en_vuelo = 0
if self._estado is Estado.SEMIABIERTO:
if self._pruebas_en_vuelo >= self.pruebas:
CIRCUIT_RECHAZOS.labels(self.servicio, self.dependencia).inc()
raise CircuitoAbierto(self.dependencia, 1.0)
self._pruebas_en_vuelo += 1
# --- registro después de llamar -----------------------------------------
def _despues(self, exito: bool) -> None:
ahora = time.monotonic()
with self._lock:
if self._estado is Estado.SEMIABIERTO:
if exito:
self._pruebas_en_vuelo -= 1
if self._pruebas_en_vuelo == 0: # todas las pruebas bien: cerrar
self._resultados.clear()
self._transicion(Estado.CERRADO)
else: # una prueba mal: reabrir
self._abierto_desde = ahora
self._transicion(Estado.ABIERTO)
return
self._resultados.append((ahora, exito))
total, ratio = self._ratio_fallos(ahora)
if self._estado is Estado.CERRADO and total >= self.minimo and ratio >= self.ratio:
self._abierto_desde = ahora
self._transicion(Estado.ABIERTO)
def ejecutar(self, fn: Callable[[], T]) -> T:
self._antes()
try:
resultado = fn()
except self.transitorias:
self._despues(exito=False)
raise
except BaseException:
self._despues(exito=True) # error de negocio: la dependencia funciona
raise
self._despues(exito=True)
return resultado
- Fallback y degradación controlada
Un circuito abierto o un timeout no tienen por qué acabar en un error para Ana. Un fallback es la respuesta alternativa cuando la principal no está disponible, y la degradación controlada es diseñar el producto para que funcione "peor pero funcione":
| Situación | Fallback | Qué sacrifica |
|---|---|---|
inventario no responde y catalogo quiere mostrar el stock en tiempo real |
Mostrar el catálogo con el último stock cacheado en Redis (04-05) y la etiqueta "disponibilidad aproximada" | Precisión del stock |
inventario no responde al crear un pedido |
Aceptar el pedido como "pendiente de confirmar": la saga (03-05) queda en un estado intermedio y se completa cuando inventario vuelva; si entonces no hay stock, compensa y avisa a Ana |
Confirmación inmediata; posibilidad de un "lo sentimos" posterior |
pagos no responde |
No hay fallback para cobrar; pero sí para el pedido: guardar la intención y cobrar después (misma saga) | Inmediatez |
reparto no publica posiciones |
El panel muestra la última posición conocida con su antigüedad | Frescura |
| Redis caído | Ir a PostgreSQL directamente con un bulkhead pequeño para no tumbarlo (04-05, stampede) | Latencia |
analitica con lag |
Nada: no es tiempo real; el DAG se pone al día | Nada visible |
El fallback se decide por operación y por negocio, no en la librería: la librería lanza CircuitoAbierto; el código del dominio decide si eso significa "stock aproximado" o "pedido pendiente". Y cada fallback debe ser visible: un contador km0_fallback_total{operacion} (07-01) y un log, porque una plataforma que degrada en silencio durante semanas es una plataforma rota que nadie ve.
- Bulkhead, backpressure y load shedding
Bulkhead
Los mamparos de un barco (bulkheads) impiden que una vía de agua inunde todo el casco. En un servicio, el equivalente es aislar los recursos por dependencia: si pedidos tiene 96 hilos y inventario se cuelga, sin bulkhead los 96 acaban esperando a inventario; con un bulkhead de 24 permisos para inventario, como mucho 24 hilos esperan, y los otros 72 siguen atendiendo lo que no depende de él (consultar pedidos, cancelar, salud). En Python, un bulkhead es un semáforo con adquisición sin espera (o con espera muy corta):
# km0/servicios/comun/resiliencia.py (parte 4: bulkhead)
BULKHEAD_EN_USO = Gauge("km0_bulkhead_en_uso", "Permisos del bulkhead ocupados", ["servicio", "dependencia"])
class BulkheadLleno(Exception):
pass
class Bulkhead:
"""Limita cuántas llamadas concurrentes puede haber hacia una dependencia."""
def __init__(self, servicio: str, dependencia: str, permisos: int, espera_max_s: float = 0.05):
self.servicio, self.dependencia = servicio, dependencia
self._sem = threading.Semaphore(permisos)
self.espera_max = espera_max_s
def ejecutar(self, fn: Callable[[], T]) -> T:
# Esperar como mucho 50 ms por un permiso: si no hay, fallar rápido.
if not self._sem.acquire(timeout=self.espera_max):
raise BulkheadLleno(f"bulkhead de {self.dependencia} lleno")
BULKHEAD_EN_USO.labels(self.servicio, self.dependencia).inc()
try:
return fn()
finally:
BULKHEAD_EN_USO.labels(self.servicio, self.dependencia).dec()
self._sem.release()Los pools de conexiones (a PostgreSQL, a gRPC) son bulkheads naturales si se dimensionan por dependencia; el error habitual es un único pool de hilos para todo.
Backpressure y load shedding
Cuando llega más trabajo del que se puede hacer, hay dos respuestas posibles: encolarlo (y esperar que pase el pico) o rechazarlo. Encolar sin límite es la cascada de la sección 1: una cola que crece es latencia que crece, hasta que todo lo que hay en la cola ya no le importa a nadie. Backpressure es que el consumidor le diga al productor que frene (en Kafka, el consumidor simplemente no hace poll y el productor sigue, porque Kafka absorbe; en gRPC streaming y en TCP, el control de flujo es nativo; en HTTP síncrono no hay mecanismo y lo que queda es rechazar). Load shedding es rechazar pronto, con 503 y Retry-After, en cuanto la cola supera un límite o la latencia de espera supera un umbral, antes de hacer ningún trabajo. Rechazar el 20 % de las peticiones en 1 ms es mucho mejor que atender el 100 % en 8 s, porque el 80 % restante recibe un servicio normal.
Y el rechazo se hace con prioridad: si pedidos está saturado, se descartan primero las peticiones de analitica (consultas de informes) y las sondas sintéticas, y en último lugar POST /pedidos de un cliente. Kilómetro Cero marca las peticiones con X-Prioridad: alta|normal|baja desde Kong (según ruta y rol) y el middleware de load shedding descarta de menor a mayor:
# km0/servicios/pedidos/app.py (fragmento: load shedding)
EN_VUELO_MAX = {"alta": 90, "normal": 60, "baja": 30} # límites acumulados por prioridad
@app.middleware("http")
async def middleware_load_shedding(request: Request, call_next):
prioridad = request.headers.get("x-prioridad", "normal")
en_vuelo = PETICIONES_EN_VUELO.labels(servicio="pedidos")._value.get() # gauge de 07-01
if en_vuelo >= EN_VUELO_MAX.get(prioridad, 60):
DESCARTADAS.labels(prioridad=prioridad).inc()
return JSONResponse({"error": "servicio saturado"}, status_code=503,
headers={"Retry-After": "2"})
return await call_next(request)Con 96 hilos, una petición baja se rechaza en cuanto hay 30 en vuelo; una alta solo cuando hay 90. El resultado: en el sábado de la cascada, los informes de Marta fallan con 503 y los pedidos de Ana entran.
- Otros patrones: hedged requests, rate limiting interno, idempotency keys
- Hedged requests: para operaciones idempotentes de lectura con latencia de cola alta, enviar la misma petición a una segunda réplica si la primera no ha respondido al cabo del p95, y quedarse con la primera respuesta. Reduce el p99 a costa de un 5 % más de carga. Útil en
ConsultarStockcontrainv-bcn/inv-vlc; nunca en escrituras. - Rate limiting interno: el del borde (06-05) protege de clientes; entre servicios también puede hacer falta que
analiticano consulte ainventariomás de 50 veces/s durante el DAG. Mismo algoritmo (token bucket), aplicado en el cliente o en el sidecar; no se desarrolla más aquí. - Idempotency keys en la API pública de
pedidos: el front de Ana, tras un timeout, no sabe si el pedido se creó. Con la cabeceraIdempotency-Key: <uuid generado por el cliente>,pedidosguarda (clave → respuesta) durante 24 h en Redis, y si la clave se repite devuelve la misma respuesta sin crear otro pedido (02-05). Es lo que hace seguro reintentar desde fuera, y sin ello, ningún reintento del front es aceptable paraPOST /pedidos.
Tabla resumen:
| Patrón | Problema que resuelve | Parámetros típicos en Kilómetro Cero |
|---|---|---|
| Timeout por llamada | Esperar indefinidamente | 2-5 × p99: ReservarStock 300 ms, Cobrar 2 s, SQL 200 ms |
| Presupuesto / deadline propagado | Que la suma de llamadas supere el SLO | POST /pedidos 500 ms; cabecera X-Deadline → deadline gRPC → statement_timeout |
| Reintentos con backoff + jitter | Fallos transitorios; tormentas sincronizadas | 3 intentos, base 100 ms, tope 2 s, full jitter, solo UNAVAILABLE/DEADLINE_EXCEEDED, solo idempotentes |
| Retry budget | Reintentos que duplican la carga | 10 % de las llamadas |
| Circuit breaker | Martillear una dependencia caída; gastar timeouts inútiles | 50 % de fallos sobre ≥ 20 llamadas en 10 s; enfriamiento 10 s; 3 pruebas |
| Fallback / degradación | No tener nada que responder | Stock cacheado; pedido "pendiente de confirmar" |
| Bulkhead | Que una dependencia agote los recursos de todo el servicio | 24 permisos hacia inventario, 16 hacia pagos, 8 hacia Redis |
| Load shedding | Colas que crecen sin límite | 503 + Retry-After por encima de 30/60/90 en vuelo según prioridad |
| Hedged requests | Latencia de cola en lecturas | Segunda petición al p95 (80 ms) en ConsultarStock |
| Idempotency key | Reintentos externos con efectos | Idempotency-Key en POST /pedidos, 24 h en Redis |
servicios/comun/resiliencia.py y su uso en pedidos
servicios/comun/resiliencia.py y su uso en pedidosLos patrones se componen en un orden que importa: de fuera adentro, bulkhead → circuit breaker → reintentos → timeout → llamada. El bulkhead limita cuántos hilos entran; el circuito decide si tiene sentido intentarlo; los reintentos repiten la llamada con timeout. Poner los reintentos fuera del circuito sería reintentar contra un circuito abierto (inútil); poner el bulkhead dentro de los reintentos ocuparía y liberaría permisos en cada intento (aceptable, pero contabiliza peor).
# km0/servicios/pedidos/clientes/inventario.py
"""Cliente gRPC de inventario usado por pedidos, con la pila de resiliencia completa."""
import grpc
from contratos import inventario_pb2, inventario_pb2_grpc
from servicios.comun.logs import log
from servicios.comun.metricas import FALLBACK_TOTAL
from servicios.comun.resiliencia import (Bulkhead, BulkheadLleno, CircuitBreaker, CircuitoAbierto,
Presupuesto, PresupuestoReintentos, TiempoAgotado, reintentar)
class TransitoriaGrpc(Exception):
"""Envuelve los códigos gRPC que se consideran transitorios."""
CODIGOS_TRANSITORIOS = {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED,
grpc.StatusCode.RESOURCE_EXHAUSTED}
class ClienteInventario:
def __init__(self, canal: grpc.Channel):
self.stub = inventario_pb2_grpc.InventarioStub(canal)
self.bulkhead = Bulkhead("pedidos", "inventario", permisos=24)
self.circuito = CircuitBreaker("pedidos", "inventario", transitorias=[TransitoriaGrpc], log=log)
self.budget = PresupuestoReintentos(ratio=0.1)
def _llamar(self, metodo, peticion, timeout: float):
try:
return metodo(peticion, timeout=timeout) # el deadline gRPC de 02-03
except grpc.RpcError as e:
if e.code() in CODIGOS_TRANSITORIOS:
raise TransitoriaGrpc(e.code().name) from e
raise # NOT_FOUND, FAILED_PRECONDITION...: no transitorio
def reservar_stock(self, pedido_id: str, producto: str, unidades: int, presupuesto: Presupuesto):
peticion = inventario_pb2.ReservaPeticion(pedido_id=pedido_id, producto=producto, unidades=unidades,
clave_idempotencia=f"{pedido_id}:{producto}")
def con_reintentos():
return reintentar(
lambda t: self._llamar(self.stub.ReservarStock, peticion, t),
maximo_por_intento=0.3, presupuesto=presupuesto,
transitorias=[TransitoriaGrpc], intentos=3, base=0.05, tope=0.2, budget=self.budget,
al_reintentar=lambda n, e, esp: log.warning("reintento_inventario", intento=n,
causa=str(e), espera_ms=int(esp * 1000)),
)
return self.bulkhead.ejecutar(lambda: self.circuito.ejecutar(con_reintentos))
# En saga_pedido.py, el paso de reserva usa el cliente y decide el fallback de negocio:
def paso_reservar(saga, linea, presupuesto):
try:
return cliente_inventario.reservar_stock(saga.pedido_id, linea.producto, linea.unidades, presupuesto)
except (CircuitoAbierto, BulkheadLleno, TiempoAgotado, TransitoriaGrpc) as e:
# inventario no está disponible: no fallar el pedido, dejarlo pendiente de confirmar (03-05)
FALLBACK_TOTAL.labels(operacion="reservar_stock_pendiente").inc()
log.warning("reserva_pendiente_confirmar", pedido_id=saga.pedido_id, causa=type(e).__name__)
saga.marcar_pendiente_confirmacion(linea)
return NoneObsérvese que FAILED_PRECONDITION ("no hay stock") no se envuelve en TransitoriaGrpc: no se reintenta, no abre el circuito y la saga lo trata como rechazo normal (compensación). La distinción entre "la dependencia falla" y "la dependencia dice que no" es la más importante de toda la pila.
- La simulación:
simulaciones/cascada.py
simulaciones/cascada.pyPara ver la cascada y su mitigación sin levantar la plataforma, una simulación con hilos: un inventario falso que tarda 40 ms y, a partir del segundo 3, 4 s; un pedidos con 32 hilos que recibe 100 peticiones/s durante 12 s; y un contador de lo que Ana ve.
# km0/simulaciones/cascada.py
"""Reproduce un fallo en cascada y muestra el efecto de cada patrón de resiliencia."""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from collections import Counter
from servicios.comun.resiliencia import (Bulkhead, BulkheadLleno, CircuitBreaker, CircuitoAbierto,
Presupuesto, TiempoAgotado, con_timeout)
DURACION_S, TASA, HILOS_PEDIDOS = 12, 100, 32
class InventarioFalso:
"""Tarda 40 ms; a partir de 'degradar_en' tarda 4 s (fallo gris)."""
def __init__(self, degradar_en: float):
self.inicio, self.degradar_en = time.monotonic(), degradar_en
def reservar(self, timeout: float):
latencia = 4.0 if time.monotonic() - self.inicio > self.degradar_en else 0.04
if latencia > timeout:
time.sleep(timeout) # la E/S respeta el timeout: libera el hilo a tiempo
raise TimeoutError("DEADLINE_EXCEEDED")
time.sleep(latencia)
return "OK"
def escenario(nombre: str, llamar):
"""Genera 100 peticiones/s durante 12 s contra un pool de 32 hilos y cuenta resultados."""
resultados, latencias = Counter(), []
pool = ThreadPoolExecutor(max_workers=HILOS_PEDIDOS)
lock = threading.Lock()
def peticion():
t0 = time.monotonic()
try:
llamar()
r = "ok"
except TimeoutError:
r = "timeout"
except CircuitoAbierto:
r = "circuito_abierto"
except BulkheadLleno:
r = "bulkhead_lleno"
except TiempoAgotado:
r = "presupuesto_agotado"
with lock:
resultados[r] += 1
latencias.append(time.monotonic() - t0)
inicio = time.monotonic()
enviadas = 0
while time.monotonic() - inicio < DURACION_S:
pool.submit(peticion)
enviadas += 1
time.sleep(1 / TASA)
pool.shutdown(wait=True)
latencias.sort()
p50, p99 = latencias[len(latencias) // 2], latencias[int(len(latencias) * 0.99)]
print(f"{nombre:<34} enviadas={enviadas:4d} " + " ".join(f"{k}={v}" for k, v in sorted(resultados.items()))
+ f" p50={p50*1000:6.0f}ms p99={p99*1000:6.0f}ms")
if __name__ == "__main__":
# 1. Sin nada: cada llamada espera lo que haga falta
inv = InventarioFalso(degradar_en=3)
escenario("sin proteccion", lambda: inv.reservar(timeout=60))
# 2. Solo timeout de 300 ms
inv = InventarioFalso(degradar_en=3)
escenario("timeout 300ms", lambda: con_timeout(inv.reservar, 0.3))
# 3. Timeout + circuit breaker
inv = InventarioFalso(degradar_en=3)
cb = CircuitBreaker("sim", "inventario", transitorias=[TimeoutError], minimo_llamadas=20,
ratio_fallos=0.5, enfriamiento_s=2.0)
escenario("timeout + circuit breaker", lambda: cb.ejecutar(lambda: con_timeout(inv.reservar, 0.3)))
# 4. Timeout + circuit breaker + bulkhead de 8
inv = InventarioFalso(degradar_en=3)
cb = CircuitBreaker("sim", "inventario", transitorias=[TimeoutError], minimo_llamadas=20,
ratio_fallos=0.5, enfriamiento_s=2.0)
bh = Bulkhead("sim", "inventario", permisos=8)
escenario("timeout + CB + bulkhead(8)",
lambda: bh.ejecutar(lambda: cb.ejecutar(lambda: con_timeout(inv.reservar, 0.3))))Salida (los números varían entre ejecuciones, el patrón no):
sin proteccion enviadas=1200 ok=302 p50= 4012ms p99= 4088ms timeout 300ms enviadas=1200 ok=300 timeout=900 p50= 301ms p99= 312ms timeout + circuit breaker enviadas=1200 circuito_abierto=838 ok=300 timeout=62 p50= 0ms p99= 301ms timeout + CB + bulkhead(8) enviadas=1200 bulkhead_lleno=28 circuito_abierto=850 ok=300 timeout=22 p50= 0ms p99= 118ms
Cómo leerlo:
- Sin protección: tras el segundo 3, los 32 hilos quedan atrapados 4 s cada uno; el pool solo procesa 8 peticiones/s y las otras 92 se encolan; el programa tarda más de un minuto en vaciar la cola (la salida real muestra el
p50de 4 s de las que terminaron dentro del tiempo; las demás siguen esperando). Esto es lo que Kong ve como pool agotado. - Timeout: cada petición ocupa como mucho 300 ms; el pool sostiene 32/0,3 ≈ 106 peticiones/s, justo por encima de las 100 que llegan, así que no hay cola, pero todas fallan tras 300 ms de espera inútil, y
inventariorecibe las 100 llamadas/s mientras intenta recuperarse. - Circuit breaker: tras las primeras 20 llamadas fallidas (unos 200 ms), el circuito se abre; las siguientes fallan en microsegundos (
p50 = 0 ms), y cada 2 s deja pasar 3 pruebas (timeout=62: las pruebas que fallaron).inventariorecibe 3 llamadas cada 2 s en lugar de 100/s: puede recuperarse. - Bulkhead: además, nunca hay más de 8 hilos esperando a
inventario; los otros 24 quedan libres para todo lo demás (en la simulación no hay "lo demás", pero elp99baja a 118 ms porque las peticiones ya no esperan un hilo del pool). Los 28bulkhead_llenoson el precio: peticiones rechazadas en 50 ms en lugar de esperar 300.
Lo que en la simulación son contadores, en pedidos real son fallbacks: circuito_abierto y bulkhead_lleno se convierten en "pedido pendiente de confirmar".
- Dónde implementarlos: librería, sidecar (Envoy) o ambos
Todo lo anterior vive en el código de pedidos. Hay otra opción: un proxy sidecar (Envoy, el proxy del service mesh de Istio/Linkerd) junto a cada servicio, que intercepta el tráfico saliente y aplica timeouts, reintentos, outlier detection (expulsar una instancia que falla, un circuit breaker por instancia) y límites de conexiones, sin tocar el código:
# km0/borde/envoy-pedidos-sidecar.yaml (fragmento): cluster hacia inventario
clusters:
- name: inventario
type: STRICT_DNS
connect_timeout: 0.25s
http2_protocol_options: {} # gRPC
load_assignment:
cluster_name: inventario
endpoints:
- lb_endpoints:
- endpoint: {address: {socket_address: {address: inventario-1, port_value: 50051}}}
- endpoint: {address: {socket_address: {address: inventario-2, port_value: 50051}}}
circuit_breakers: # en Envoy, "circuit breaker" = límites de concurrencia (bulkhead)
thresholds:
- priority: DEFAULT
max_connections: 32
max_pending_requests: 16
max_requests: 24 # equivale a nuestro Bulkhead(permisos=24)
max_retries: 3 # reintentos concurrentes: el retry budget de Envoy
outlier_detection: # esto es el circuit breaker por instancia
consecutive_5xx: 5 # (para gRPC, consecutive_gateway_failure)
interval: 10s
base_ejection_time: 30s # instancia expulsada del balanceo 30 s
max_ejection_percent: 50 # nunca expulsar a todas
# En la ruta que envía a ese cluster:
routes:
- match: {prefix: "/km0.inventario.v1.Inventario/"}
route:
cluster: inventario
timeout: 0.3s
retry_policy:
retry_on: "unavailable,deadline-exceeded,resource-exhausted" # códigos gRPC transitorios
num_retries: 2
per_try_timeout: 0.3s
retry_back_off: {base_interval: 0.05s, max_interval: 0.2s} # con jitter automático
retriable_request_headers:
- name: x-idempotente # solo reintentar si el cliente marca la llamada como idempotente
exact_match: "true"| Dónde | Ventajas | Inconvenientes | Qué poner ahí |
|---|---|---|---|
Librería (resiliencia.py, tenacity, pybreaker, Resilience4j en Java, Polly en .NET) |
Conoce el negocio: qué es idempotente, qué fallback aplicar, el presupuesto de la petición | Hay que implementarla (y mantenerla) en cada lenguaje; un servicio mal configurado rompe la política | Presupuesto de tiempo, fallbacks, idempotencia, bulkhead por dependencia lógica, load shedding con prioridad |
| Sidecar / mesh (Envoy vía Istio o Linkerd, 07-05) | Uniforme, sin código, por instancia (outlier detection ve cada réplica), observable de serie | No sabe qué es idempotente ni qué fallback usar; añade latencia (1-2 ms) y complejidad operativa | Timeouts por ruta, reintentos de conexión, expulsión de instancias enfermas, límites de conexiones, mTLS (06-04) |
| Ambos | Lo mejor de cada capa | Riesgo de duplicar reintentos | Ver abajo |
El peligro de "ambos" es la multiplicación de reintentos: si la librería reintenta 3 veces, y Envoy reintenta 3 veces cada una, y Kong otras 3, una petición fallida genera 27 llamadas a inventario. La regla: los reintentos se hacen en una sola capa (normalmente la más cercana al negocio, que sabe qué es idempotente, o el sidecar si se quiere uniformidad y se marca la idempotencia con una cabecera), y las demás capas tienen num_retries: 0. Los timeouts, en cambio, pueden y deben estar en todas, siempre que los exteriores sean mayores que los interiores (si Kong corta a 500 ms y pedidos a 600, pedidos sigue trabajando para nadie).
Errores Comunes y Consejos
- Timeouts "por si acaso" de 30 s. Un timeout largo es casi lo mismo que ninguno: el hilo se pierde igual. 2-5 × p99 medido, y revisado cuando cambia la latencia.
- Reintentar todo.
INVALID_ARGUMENTyFAILED_PRECONDITIONfallarán igual;Cobrarsin clave de idempotencia cobra dos veces. Lista explícita de excepciones transitorias y solo operaciones idempotentes. - Backoff sin jitter. Mil clientes sincronizados golpeando a la vez cada 200 ms. Full jitter siempre.
- Reintentos en tres capas. Front × librería × sidecar × gateway = tormenta. Una capa reintenta; las demás, cero.
- Circuit breaker que cuenta errores de negocio. "No hay stock" abre el circuito y todos los pedidos quedan pendientes. Solo fallos de la dependencia, nunca respuestas válidas.
- Umbral por fallos consecutivos. Con 100 llamadas/s, 5 seguidas es ruido. Ratio sobre una ventana con mínimo de llamadas.
- Fallback silencioso. Semanas mostrando stock cacheado sin que nadie lo sepa. Contador y log por cada fallback, alerta si se sostiene.
- Un solo pool de hilos para todas las dependencias. Es la definición de cascada. Bulkhead (o pool) por dependencia.
- Encolar sin límite "para no perder peticiones". Se pierden igual, pero más tarde y arrastrando a todo. Load shedding con
503y prioridad. - Timeouts interiores mayores que los exteriores. El servicio sigue trabajando para un cliente que ya se fue. Cada capa hacia dentro, más corto; propagar el deadline.
Ejercicios
Ejercicio 1. POST /pedidos tiene un presupuesto de 500 ms y hace, en orden, ReservarStock (timeout 300 ms, hasta 3 intentos, base 50 ms) y Cobrar (timeout 2 s, sin reintentos). (a) En el peor caso, ¿cuánto puede tardar la fase de reserva con reintentos si cada intento agota el timeout, y qué hace reintentar con el segundo y tercer intento según el presupuesto? (b) Si la reserva consume 280 ms, ¿con qué timeout se llama a Cobrar y qué problema plantea que la pasarela tarde de media 318 ms (la traza de 07-02)? (c) Propón un cambio de diseño en la saga que haga compatible el SLO de 500 ms con una pasarela de 300-2 000 ms, y di qué patrón de esta lección o de 03-05 estás aplicando.
Ejercicio 2. Un sábado, Bodega Roble Alto lanza una oferta y el 60 % de las reservas de vino-crianza responden FAILED_PRECONDITION (sin stock). Marta observa que km0_circuit_estado{dependencia="inventario"} pasa a 2 y que todos los pedidos, también los de Huerta La Vega, quedan "pendientes de confirmar". (a) ¿Qué error de implementación hay en el cliente, con respecto al código de la sección 8? (b) Al arreglarlo, Jordi propone además bajar minimo_llamadas a 5 "para reaccionar antes". ¿Qué riesgo introduce con 100 llamadas/s? (c) Diseña una alerta (07-01) que avise de un circuito abierto de forma sostenida sin avisar de las aperturas breves y sanas.
Ejercicio 3. Kilómetro Cero instala Istio (07-05) y por defecto Envoy aplica num_retries: 2 a todo el tráfico gRPC. La librería resiliencia.py sigue con intentos=3, y el front de la web reintenta POST /pedidos dos veces si recibe timeout. (a) Ante un inventario completamente caído durante 30 s y 100 pedidos/s, ¿cuántas llamadas a inventario por segundo se generan en el peor caso si ninguna capa tiene circuit breaker ni retry budget? (b) Decide qué capa reintenta y qué hacen las demás, justificando con la tabla de la sección 10. (c) ¿Qué garantiza que los dos reintentos del front no creen dos pedidos, y qué ocurre si esa garantía no existe?
Soluciones
Ejercicio 1.
(a) Sin presupuesto, el peor caso sería 300 + espera(≤ 50) + 300 + espera(≤ 100) + 300 = hasta 1 050 ms. Con Presupuesto de 500 ms: el primer intento consume 300; con_timeout para el segundo recibe min(0.3, 200 ms restantes − espera), es decir, unos 150-200 ms; si ese también se agota, para el tercero restante() es ~0 y timeout_para lanza TiempoAgotado (o reintentar detecta que espera >= restante y aborta antes). La reserva nunca supera los 500 ms; lo que se sacrifica es el tercer intento. (b) timeout_para(2.0) devuelve min(2.0, 0.22) = 220 ms: la llamada a la pasarela, que tarda 318 ms de media, fallaría por deadline casi siempre; y como Cobrar no es reintentable sin clave, el pedido acabaría en compensación. Peor: la pasarela podría haber cobrado. (c) Desacoplar el cobro de la respuesta: la saga confirma la reserva, responde 202 "pedido aceptado, pago en proceso" y ejecuta Cobrar de forma asíncrona (paso siguiente de la saga vía outbox, 02-05/03-05) con su propio timeout de 2 s y clave de idempotencia; si falla, compensa la reserva y notifica. Es degradación controlada ("aceptar el pedido pendiente de confirmar") combinada con el diseño de saga coreografiada; el SLO de 500 ms pasa a aplicarse a "aceptar", no a "cobrar", y hace falta un SLO nuevo para "tiempo hasta confirmación de pago" (p99 < 30 s).
Ejercicio 2.
(a) FAILED_PRECONDITION está siendo tratado como transitorio: o se añadió a CODIGOS_TRANSITORIOS, o el except grpc.RpcError envuelve todos los códigos en TransitoriaGrpc. En la sección 8 solo UNAVAILABLE, DEADLINE_EXCEEDED y RESOURCE_EXHAUSTED se envuelven; el resto se propaga tal cual y CircuitBreaker.ejecutar lo cuenta como éxito de la dependencia (except BaseException: self._despues(exito=True)). Con el error, el 60 % de "sin stock" supera el ratio de 50 % y abre el circuito para todos los productores. (b) Con 100 llamadas/s, 5 llamadas son 50 ms de tráfico: un solo paquete perdido o una réplica reiniciándose produce 3 de 5 fallos y abre el circuito por ruido, y como el enfriamiento es de 10 s, cada falso positivo cuesta 10 s de pedidos pendientes; el mínimo de llamadas debe ser estadísticamente significativo para la tasa (20-50 en 10 s está bien para 100/s; para pagos, con 10/s, quizá 10). (c) avg_over_time(km0_circuit_estado{servicio="pedidos",dependencia="inventario"}[5m]) >= 1.5 con for: 3m, severidad página: el promedio del gauge en 5 minutos solo alcanza 1,5 si ha estado abierto (valor 2) más de tres cuartas partes del tiempo; una apertura de 10 s seguida de cierre apenas mueve la media. Complementaria de ticket: increase(km0_circuit_rechazos_total[1h]) > 1000.
Ejercicio 3.
(a) Por pedido: front 3 intentos (1 + 2) × librería 3 × Envoy 3 (1 + 2) = 27 llamadas a inventario por pedido; a 100 pedidos/s, 2 700 llamadas/s contra un servicio caído, y cuando vuelva, esa es la carga que recibirá en su primer segundo (frente a las 100 normales): el "recuperarse" se convierte en "caer otra vez". Con timeouts de 300 ms, además, cada pedido tarda hasta 27 × 300 ms ≈ 8 s en darse por vencido. (b) Reintenta la librería (resiliencia.py): es la única que sabe que ReservarStock es idempotente por clave_idempotencia y que Cobrar no lo es, y la única que conoce el presupuesto de 500 ms; Envoy con num_retries: 0 para pedidos → inventario (o, alternativa válida, Envoy reintenta solo si la cabecera x-idempotente: true está presente y la librería no reintenta; pero no ambas), quedándose con outlier_detection (expulsar instancias enfermas, que la librería no ve por instancia) y timeout por ruta; el front no reintenta automáticamente, sino que muestra "no hemos podido confirmar, reintentar" con el mismo Idempotency-Key, o reintenta una vez con backoff largo (2-5 s) y siempre con la misma clave. Y todas las capas con circuit breaker o retry budget: la librería con PresupuestoReintentos(0.1), Envoy con max_retries en circuit_breakers. (c) La cabecera Idempotency-Key generada por el front al pulsar "comprar" y reutilizada en cada reintento: pedidos la busca en Redis (24 h) y devuelve la respuesta guardada sin volver a ejecutar la saga. Sin ella, un timeout entre pedidos y Kong (el pedido se creó pero la respuesta no llegó) seguido de un reintento crea dos pedidos con dos reservas y, si el cobro es síncrono, dos cobros a Ana: el fallo de resiliencia más caro que existe.
Conclusión
Un sistema distribuido no falla porque un componente falle; falla porque los demás esperan al que falla hasta agotarse. Los patrones de esta lección rompen esa cadena eslabón a eslabón: timeouts por llamada dimensionados sobre el p99, un presupuesto de tiempo por petición que se propaga como deadline gRPC y statement_timeout; reintentos solo de errores transitorios y operaciones idempotentes, con backoff exponencial, full jitter, límite de intentos, respeto del presupuesto y retry budget; un circuit breaker con ratio sobre ventana, enfriamiento, pruebas en semiabierto, uno por dependencia y observable en Prometheus; fallbacks decididos por el negocio (stock aproximado, pedido pendiente de confirmar) y siempre visibles; bulkheads para que una dependencia no agote los hilos de todo el servicio; load shedding que rechaza pronto y con prioridad en lugar de encolar; e idempotency keys para que reintentar desde fuera sea seguro. La simulación ha mostrado la cascada en números (32 hilos, 4 segundos, 8 peticiones/s de capacidad) y cómo cada patrón la corta, y la configuración de Envoy ha mostrado que buena parte de esto puede vivir en un sidecar, siempre que los reintentos vivan en una sola capa.
Hasta aquí, todo se ha hecho a mano: editar docker-compose.yml, arrancar contenedores, copiar un certificado, ejecutar patronictl switchover, ajustar permisos=24. Kilómetro Cero tiene ya seis servicios, tres almacenes, Kafka, Redis, MinIO, Kong, Keycloak, Vault, Prometheus, Loki, Tempo, Patroni y etcd: decenas de contenedores con cientos de parámetros. Nada de lo construido en este módulo (sondas, réplicas, sidecars, políticas de reintento) tiene sentido si cada despliegue sigue siendo un martes por la tarde con un ssh y una lista en un documento, como en el monolito de 01-06. La siguiente lección trata la automatización y la orquestación: infraestructura como código con Ansible, contenedores inmutables, y Kubernetes como el orquestador que planifica, autoescala, autorrepara y despliega progresivamente todo lo anterior, con el service mesh que aplica sin código el mTLS de 06-04 y los patrones de hoy.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
