Tolerancia a fallos
"Todo lo que puede fallar, fallará. La pregunta de ingeniería es: ¿cuándo falla, el sistema colapsa o se degrada gracefully?"
Qué vas a aprender en este capítulo
Un sistema distribuido confiable no es aquel que nunca falla — es aquel que falla bien. Este capítulo cubre los patrones de diseño para resiliencia: Circuit Breaker, Retry con backoff, Bulkhead, y cómo evitar el temido split-brain. Termina con Chaos Engineering: probar sistemáticamente cómo falla el sistema.
4.1 Patrones de resiliencia
💡 Intuición
Cuando un servicio falla, la respuesta instintiva es reintentar inmediatamente. Esto puede empeorar las cosas: si el servicio está sobrecargado y millones de clientes reintentan en el mismo segundo, el pico de tráfico lo derriba completamente.
Los patrones de resiliencia controlan cómo el sistema reacciona a los fallos para minimizar el daño.
📐 Fundamento
1. Retry con Exponential Backoff + Jitter:
import time
import random
def retry_con_backoff(func, max_intentos=5, espera_inicial=1.0):
"""Reintenta con espera exponencial y jitter aleatorio."""
for intento in range(max_intentos):
try:
return func()
except Exception as e:
if intento == max_intentos - 1:
raise # último intento: propagar el error
# Espera exponencial: 1s, 2s, 4s, 8s...
espera = espera_inicial * (2 ** intento)
# Jitter: ±50% aleatorio para evitar que todos reintenten al mismo tiempo
jitter = espera * 0.5 * random.random()
espera_total = espera + jitter
print(f"Intento {intento+1} falló: {e}. Reintentando en {espera_total:.1f}s...")
time.sleep(espera_total)
# Uso:
resultado = retry_con_backoff(lambda: llamar_servicio_cocina(pedido_id))
2. Circuit Breaker (interruptor):
Inspirado en los interruptores eléctricos: si hay demasiados fallos, "abre el circuito" y rechaza las llamadas temporalmente para que el servicio dañado se recupere.
from enum import Enum
from datetime import datetime, timedelta
class EstadoCircuito(Enum):
CERRADO = 'closed' # normal, deja pasar
ABIERTO = 'open' # cortado, rechaza todo
SEMICERRADO = 'half-open' # probando si se recuperó
class CircuitBreaker:
def __init__(self, umbral_fallo=5, timeout_recuperacion=30):
self.estado = EstadoCircuito.CERRADO
self.fallos = 0
self.umbral = umbral_fallo
self.timeout = timeout_recuperacion
self.ultimo_fallo = None
def llamar(self, func):
if self.estado == EstadoCircuito.ABIERTO:
# Verificar si ya pasó el timeout de recuperación
if datetime.now() - self.ultimo_fallo > timedelta(seconds=self.timeout):
self.estado = EstadoCircuito.SEMICERRADO
else:
raise Exception("Servicio no disponible (circuit breaker abierto)")
try:
resultado = func()
self._on_exito()
return resultado
except Exception as e:
self._on_fallo()
raise
def _on_exito(self):
self.fallos = 0
self.estado = EstadoCircuito.CERRADO
def _on_fallo(self):
self.fallos += 1
self.ultimo_fallo = datetime.now()
if self.fallos >= self.umbral:
self.estado = EstadoCircuito.ABIERTO
print(f"Circuit breaker ABIERTO después de {self.fallos} fallos")
# Uso:
cb = CircuitBreaker(umbral_fallo=3, timeout_recuperacion=30)
def obtener_menu():
return cb.llamar(lambda: servicio_menu.get('/platillos'))
try:
menu = obtener_menu()
except Exception:
menu = menu_en_cache # fallback a caché local
3. Bulkhead (mamparo):
Aislar recursos para que el fallo de un componente no afecte a otros — como los mamparos de un barco que evitan que una inundación en un compartimento hunda todo el barco.
from concurrent.futures import ThreadPoolExecutor
# Sin bulkhead: todas las operaciones comparten el mismo pool de threads
# Si el servicio de reportes se cuelga, también bloquea los pedidos
# Con bulkhead: pools separados
pool_pedidos = ThreadPoolExecutor(max_workers=20, thread_name_prefix='pedidos')
pool_reportes = ThreadPoolExecutor(max_workers=5, thread_name_prefix='reportes')
# El pool de reportes solo puede usar 5 threads
# Si se llenan, las solicitudes de reporte se rechazan/esperan
# Pero el pool de pedidos (20 threads) nunca se ve afectado
4. Timeout:
import asyncio
async def llamar_con_timeout(coro, timeout_segundos):
try:
return await asyncio.wait_for(coro, timeout=timeout_segundos)
except asyncio.TimeoutError:
raise Exception(f"Timeout después de {timeout_segundos}s")
5. Fallback:
Cuando el servicio falla, usar una alternativa degradada en lugar de fallar completamente.
def obtener_menu_del_dia():
try:
return api_menu.get('/menu/hoy', timeout=2)
except Exception:
# Fallback: menú estático en caché
return MENU_DEFAULT_CACHED
4.2 Split-brain
💡 Intuición
Split-brain: cuando una partición de red divide el clúster en dos grupos que no pueden comunicarse entre sí. Cada mitad cree que la otra falló y elige su propio líder.
Es el peor escenario: dos "cerebros" procesando escrituras independientemente. Cuando la red se recupera, los datos son inconsistentes y hay que resolver el conflicto.
📐 Fundamento
Ejemplo de split-brain:
Clúster de 4 nodos: [N1, N2, N3, N4]
Nodo actual: N1 (leader, term=5)
Partición de red:
Grupo A: [N1, N2] ← creen que N3, N4 fallaron
Grupo B: [N3, N4] ← creen que N1, N2 fallaron
Grupo A: N1 sigue siendo leader (2/4 nodos, NO tiene quórum)
Grupo B: N3 gana elección, se convierte en leader (2/4 nodos, NO tiene quórum)
En Raft: ninguno puede commitear sin quórum (3/4)
→ ambos grupos rechazan escrituras → sistema disponible pero en modo solo-lectura
Cuando la red se recupera:
→ Raft resuelve automáticamente: el leader con el term mayor gana
→ Los entries no commiteados del leader perdedor se descartan
Por qué Raft previene split-brain:
El quórum mayoritario garantiza que dos grupos nunca pueden tener quórum simultáneamente si hay una partición. Con 5 nodos y quórum de 3: si el grupo A tiene 2 nodos, el grupo B tiene 3 → solo B tiene quórum y puede operar.
Problemas que SÍ pueden causar split-brain:
- Sistemas sin quórum (ej: replicación síncrona sin Raft).
- Bugs en la detección de fallos.
- Configuración incorrecta del timeout de elección.
Prevención:
- Usar Raft o Paxos correctamente implementados.
- Quórum impar siempre.
- Fencing tokens: Cuando el nuevo líder se elige, se emite un token numérico incremental. Las escrituras al almacenamiento llevan el token. El almacenamiento rechaza escrituras de líderes viejos (token menor).
# Almacenamiento con fencing:
class AlmacenamientoSeguro:
def __init__(self):
self.ultimo_token = 0
def escribir(self, clave, valor, token_lider):
if token_lider <= self.ultimo_token:
raise Exception(f"Token {token_lider} obsoleto (último: {self.ultimo_token})")
self.ultimo_token = token_lider
self.datos[clave] = valor
4.3 Chaos Engineering
💡 Intuición
Netflix tiene un equipo que se llama Chaos Monkey (y después Chaos Engineering). Su trabajo: destruir partes de la infraestructura de producción durante horas laborables — intencionalmente.
¿Por qué? Porque la mejor forma de saber si el sistema tolera fallos es generarlos antes de que ocurran por accidente. Si el sistema no puede tolerar la pérdida de un servidor a las 2 PM de un martes cuando todo el equipo está disponible, definitivamente no lo tolerará a las 3 AM de un viernes festivo.
📐 Fundamento
Principios de Chaos Engineering:
- Definir el "estado estable": ¿Cuál es el comportamiento normal? Métricas: requests/segundo, latencia P99, tasa de errores.
- Hipótesis: "Si falla el servidor de base de datos del local SMN, el sistema seguirá procesando pedidos de los otros locales."
- Introducir el fallo: Matar un proceso, bloquear tráfico de red, llenar el disco.
- Observar: ¿Se mantuvo el estado estable?
- Minimizar el radio de impacto: Empezar con experimentos pequeños (1 nodo, staging primero).
Herramientas:
# Python: chaos engineering básico
import subprocess
import time
class ChaosExperiment:
def simular_latencia_red(self, interfaz='eth0', latencia_ms=200, duracion_s=30):
"""Agrega latencia artificial a la red."""
subprocess.run([
'tc', 'qdisc', 'add', 'dev', interfaz,
'root', 'netem', 'delay', f'{latencia_ms}ms'
])
time.sleep(duracion_s)
subprocess.run(['tc', 'qdisc', 'del', 'dev', interfaz, 'root'])
def matar_proceso(self, nombre: str):
"""Mata un proceso por nombre."""
subprocess.run(['pkill', '-f', nombre])
def llenar_disco(self, gb: int = 1):
"""Crea un archivo grande para simular disco lleno."""
subprocess.run(['dd', 'if=/dev/zero', f'of=/tmp/chaos_{gb}gb',
'bs=1M', f'count={gb*1024}'])
Gremlin, Chaos Monkey, AWS Fault Injection Simulator: Herramientas de producción que hacen esto de forma controlada y con rollback automático.
Observabilidad — los tres pilares:
Para saber si el sistema falla bien, necesitamos visibilidad:
| Pilar | Herramienta | Qué responde |
|---|---|---|
| Métricas | Prometheus + Grafana | ¿Cuánto? (latencia, throughput, errores) |
| Logs | Elasticsearch + Kibana | ¿Qué pasó? (eventos, errores específicos) |
| Trazas | Jaeger, Zipkin | ¿Dónde? (qué servicio causó la lentitud) |
🛠️ En la práctica
La Esquina Cloud: runbook de incidentes
Cuando el sistema de delivery falla a las 8 PM (hora pico):
Detección:
- Alerta: tasa de errores > 5% en los últimos 5 minutos
- Alerta: latencia P99 > 2 segundos
- Fuente: Prometheus + Grafana dashboard
Clasificación:
- ¿Está caído el servicio de pedidos? → Verificar health endpoint
- ¿Está caída la BD? → Verificar réplica disponible
- ¿Está caída la red entre locales? → Verificar conectividad
Mitigación:
- Si cae el servicio de pedidos: failover automático a réplica (< 30 segundos)
- Si cae la BD: promover réplica, actualizar connection strings
- Si cae la red entre locales: cada local opera en modo offline, sincroniza al recuperar
Postmortem (análisis post-incidente):
- ¿Qué pasó?
- ¿Por qué pasó?
- ¿Cómo lo detectamos?
- ¿Cómo lo resolvimos?
- ¿Qué haremos para que no vuelva a pasar?
El postmortem se comparte con todo el equipo — no es para culpar, sino para aprender.
4.4 Ejercicios
✏️ Ejercicio 4.1 — Diseñar circuit breaker
Para el servicio de delivery de La Esquina Cloud, configurá el Circuit Breaker:
- El servicio de mapas externos (Google Maps API) a veces se cae.
- Cuando falla, queremos usar una ruta precomputada estática como fallback.
- ¿Cuál sería el umbral de fallos y el timeout de recuperación apropiados? Justificá.
- ¿Qué fallback usarías?
Solución
Configuración:
umbral_fallo = 3— 3 fallos consecutivos abren el circuito. Google Maps raramente falla por más de 30s; si falla 3 veces, probablemente hay un problema real.timeout_recuperacion = 60— después de 60 segundos, intentar de nuevo (la mayoría de caídas de APIs se resuelven en < 1 minuto).
Fallback:
def calcular_ruta_delivery(origen, destino):
try:
return cb_maps.llamar(lambda: google_maps.directions(origen, destino))
except Exception:
# Fallback: usar distancia en línea recta × 1.3 (factor de zigzag típico en ciudad)
dist_directa = haversine(origen, destino)
tiempo_estimado = (dist_directa * 1.3) / VELOCIDAD_PROMEDIO_KMH * 60
return {
'distancia_km': dist_directa * 1.3,
'tiempo_min': tiempo_estimado,
'ruta': 'ESTIMADA (sin mapa disponible)'
}
Por qué este fallback: Es mejor dar una estimación aproximada que rechazar la solicitud de delivery completamente. El cliente prefiere saber "~25 minutos estimados" que ver un error.
4.5 Para profundizar
- Netflix Tech Blog — artículos sobre Chaos Engineering y resiliencia.
- "Release It!" — Michael Nygard, el libro de patrones de resiliencia.
- Kleppmann, DDIA, cap. 8 — los problemas de las redes distribuidas.
- Siguiente: Arquitecturas modernas.
Definiciones nuevas: tolerancia a fallos, resiliencia, Circuit Breaker, Bulkhead, Retry con backoff, Jitter, Fallback, Timeout, split-brain, fencing token, Chaos Engineering, observabilidad, métricas, logs, trazas, postmortem.