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:

  1. Usar Raft o Paxos correctamente implementados.
  2. Quórum impar siempre.
  3. 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:

  1. Definir el "estado estable": ¿Cuál es el comportamiento normal? Métricas: requests/segundo, latencia P99, tasa de errores.
  2. Hipótesis: "Si falla el servidor de base de datos del local SMN, el sistema seguirá procesando pedidos de los otros locales."
  3. Introducir el fallo: Matar un proceso, bloquear tráfico de red, llenar el disco.
  4. Observar: ¿Se mantuvo el estado estable?
  5. 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:

  1. ¿Está caído el servicio de pedidos? → Verificar health endpoint
  2. ¿Está caída la BD? → Verificar réplica disponible
  3. ¿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):

  1. ¿Qué pasó?
  2. ¿Por qué pasó?
  3. ¿Cómo lo detectamos?
  4. ¿Cómo lo resolvimos?
  5. ¿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?

4.5 Para profundizar


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.