El patrón Bulkhead: diseño de microservicios tolerantes a fallos

El patrón Bulkhead: diseño de microservicios tolerantes a fallos

En una arquitectura de microservicios, una única aplicación se divide en docenas o cientos de servicios colaboradores independientes. Si bien este diseño mejora la modularidad y la escalabilidad, también introduce un riesgo importante: una falla en un servicio puede provocar una cascada y provocar la caída de todo el sistema.

Si un servicio descendente se vuelve lento o no responde, las solicitudes entrantes a sus servicios ascendentes comenzarán a acumularse. Si todos comparten la misma memoria, CPU o grupo de subprocesos, una dependencia lenta puede agotar rápidamente todos los recursos disponibles y provocar que toda la aplicación falle.

Este fracaso en cascada se conoce como efecto dominó. Para evitarlo, los arquitectos de sistemas utilizan el Patrón Bulkhead.

En esta guía, exploraremos qué es el patrón Bulkhead, cómo funciona y cómo implementarlo utilizando analogías simples, conceptos arquitectónicos y ejemplos de código en Java (Resilience4j) y Go.


La analogía del mundo real: mamparos estancos para barcos

El nombre de este patrón proviene de la industria de la construcción naval.

Un mamparo es una pared estanca construida dentro del casco de un barco. En lugar de tener un único y enorme espacio abierto dentro del casco del barco, el interior está dividido en varios compartimentos sellados e independientes.

Diagrama de arquitectura de patrón de mamparo que muestra grupos de subprocesos compartidos y aislados

Si el barco choca con un obstáculo y su casco se rompe, el agua inundará el compartimento dañado. Sin embargo, debido a los mamparos estancos, el agua queda contenida en ese único compartimento. El resto del barco permanece seco y flotante, lo que le permite mantenerse a flote y llegar a un lugar seguro.

Sin mamparos, el agua fluiría libremente por todo el casco y acabaría hundiendo el barco.

En ingeniería de software:

  • The Ship es tu aplicación o servicio completo.
  • Los compartimentos son grupos de recursos aislados (subprocesos, conexiones, CPU).
  • Hull Breach es una falla o desaceleración en un microservicio posterior.
  • La inundación es el agotamiento de los recursos.

El problema: grupos de recursos compartidos y agotamiento de subprocesos

Para entender por qué son necesarios los mamparos, veamos qué sucede cuando los recursos se comparten globalmente.

Imagine una puerta de enlace API o un servidor web que maneje las solicitudes de los usuarios. Tiene un único grupo de subprocesos global de 100 subprocesos para procesar todas las llamadas entrantes. El servidor interactúa con tres servicios posteriores:

  1. Servicio de catálogo (rápido, lee la lista de productos)
  2. Servicio de pago (rápido, procesa el pago)
  3. Servicio de recomendación (lento, calcula artículos personalizados)

Normalmente todo funciona bien. Pero supongamos que el servicio de recomendación sufre un punto muerto en la base de datos y comienza a tardar 30 segundos en responder en lugar de 200 milisegundos.

Esto es lo que sucede:

  1. Los usuarios continúan visitando la página de inicio, lo que genera solicitudes al Servicio de recomendación.
  2. El servidor asigna un hilo del grupo global a cada solicitud.
  3. Debido a que el servicio de recomendación es lento, estos subprocesos esperan respuestas.
  4. En cuestión de segundos, los 100 subprocesos del grupo están esperando el servicio de recomendación.
  5. Cuando un nuevo usuario intenta pagar o ver el catálogo, al servidor no le quedan hilos para procesar su solicitud.

Aunque los servicios de catálogo y pago están completamente en buen estado, ahora son inaccesibles porque el lento servicio de recomendación ha agotado el grupo de subprocesos compartidos. Todo el sistema se ha desconectado.


La solución: el patrón de mamparo

El Bulkhead Pattern resuelve este problema al dividir los grupos de recursos para que una falla en un área no afecte a las demás.

En lugar de un único grupo global, asignamos grupos separados y delimitados para cada servicio o dependencia posterior.

Si asignamos 10 subprocesos específicamente para el servicio de recomendación, entonces como máximo se pueden bloquear 10 subprocesos esperando en él. Si el servicio de recomendación se ralentiza, esos 10 subprocesos se agotarán y las solicitudes de recomendación posteriores se rechazarán inmediatamente (falla rápida).

Sin embargo, los 90 hilos restantes todavía están reservados para los servicios de Catálogo y Pago. Los usuarios aún pueden buscar productos y realizar compras, incluso si el widget de recomendación no está disponible temporalmente.


Tipos de aislamiento de mamparo

Hay dos formas principales de implementar mamparos en sistemas de software:

1. Aislamiento del grupo de subprocesos

En este modelo, a cada dependencia descendente se le asigna su propio grupo de subprocesos y cola de ejecución dedicados.

Diagrama de aislamiento del grupo de subprocesos que muestra las solicitudes entrantes en cola para subprocesos de trabajo
  • Cómo funciona: el subproceso de la aplicación principal transfiere la tarea a un grupo de subprocesos específico. Si el grupo está lleno, la solicitud se pone en cola o se rechaza.
  • Pros: Proporciona aislamiento completo. Si un servicio se vuelve lento, solo se ve afectado su grupo de subprocesos. Los subprocesos están aislados en el nivel del sistema operativo/JVM.
  • Contras: Introduce una sobrecarga adicional de CPU debido a la programación de subprocesos, el cambio de contexto y la gestión de colas.

2. Aislamiento de semáforo

En lugar de crear nuevos grupos de subprocesos, el aislamiento de semáforos utiliza un contador (un semáforo) para limitar la cantidad de llamadas simultáneas permitidas a un servicio específico.

Diagrama de aislamiento de semáforos que muestra los subprocesos de solicitud que ejecutan tareas después de adquirir un permiso
  • Cómo funciona: Cuando se inicia una solicitud, intenta adquirir un permiso del semáforo. Si hay un permiso disponible, ejecuta la solicitud en el hilo de llamada y libera el permiso cuando finaliza. Si no hay permisos disponibles, la solicitud se rechaza inmediatamente.
  • Pros: Muy liviano con prácticamente cero gastos generales ya que no implica cambio de contexto de subproceso.
  • Contras: No hay separación de hilos. Si una llamada se bloquea en un socket de red sin un tiempo de espera adecuado, aún puede bloquear el hilo de llamada.

Ejemplos de implementación

Veamos cómo implementar mamparos en dos lenguajes de back-end populares.

1. Java (Resilience4j y Spring Boot)

Resilience4j es una biblioteca de tolerancia a fallas liviana y fácil de usar diseñada para Java. A continuación se muestra cómo se configura un mamparo para un servicio de pago posterior en una aplicación Spring Boot.

Configuración (aplicación.yml)

resilience4j.bulkhead:
  instances:
    paymentService:
      maxConcurrentCalls: 10
      maxWaitDuration: 10ms

resilience4j.threadpoolbulkhead:
  instances:
    paymentService:
      maxThreadPoolSize: 10
      coreThreadPoolSize: 5
      queueCapacity: 20

Implementación del código

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class OrderService {

    private final RestTemplate restTemplate;

    public OrderService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply semaphore bulkhead
    @Bulkhead(name = "paymentService", fallbackMethod = "paymentFallback")
    public String processPayment(OrderDetails details) {
        return restTemplate.postForObject("http://payment-service/charge", details, String.class);
    }

    // Fallback method executed when the bulkhead is full
    public String paymentFallback(OrderDetails details, Throwable throwable) {
        return "Payment service is currently busy. Please try again later.";
    }
}

2. Ir (Golang)

En Go, no necesariamente necesitamos un marco pesado porque el lenguaje proporciona primitivas de concurrencia nativas como Goroutines y canales almacenados en buffer. Podemos implementar un mamparo de semáforo limpio usando un canal almacenado en búfer:

package main

import (
	"errors"
	"fmt"
	"net/http"
	"time"
)

// Bulkhead represents a concurrency limiter
type Bulkhead struct {
	semaphore chan struct{}
}

// NewBulkhead initializes a bulkhead with a max concurrency limit
func NewBulkhead(maxConcurrency int) *Bulkhead {
	return &Bulkhead{
		semaphore: make(chan struct{}, maxConcurrency),
	}
}

// Execute runs the task if resource permit is available, otherwise returns error
func (b *Bulkhead) Execute(task func() error) error {
	select {
	case b.semaphore <- struct{}{}:
		// Acquired permit
		defer func() { <-b.semaphore }() // Release permit
		return task()
	default:
		// Bulkhead is full, reject immediately
		return errors.New("bulkhead is full: request rejected")
	}
}

func main() {
	// Allow maximum of 3 concurrent calls
	paymentBulkhead := NewBulkhead(3)

	mockTask := func() error {
		fmt.Println("Processing payment...")
		time.Sleep(2 * time.Second) // Simulate network delay
		return nil
	}

	// Simulate 5 rapid requests
	for i := 1; i <= 5; i++ {
		go func(reqID int) {
			err := paymentBulkhead.Execute(mockTask)
			if err != nil {
				fmt.Printf("Request %d failed: %v\n", reqID, err)
			} else {
				fmt.Printf("Request %d completed successfully\n", reqID)
			}
		}(i)
	}

	// Keep main alive to watch output
	time.Sleep(3 * time.Second)
}

Casos de uso comunes para el patrón de mamparo

A continuación se muestran algunos escenarios típicos en los que es fundamental implementar un patrón de mamparo:

  • API Gateway Routing: Aislar rutas para diferentes servicios backend. Si el Servicio de recomendación deja de funcionar, las rutas del Servicio de pedidos en el Portal permanecen en pleno funcionamiento.
  • Grupos de conexiones de bases de datos: división de grupos de conexiones de bases de datos por servicio o inquilino. Una oleada de consultas analíticas intensas de un inquilino no agotará todos los identificadores de conexión disponibles, lo que guardará consultas transaccionales para otros inquilinos.
  • Aplicaciones SaaS multiinquilino: separación de recursos informáticos o colas de ejecución para inquilinos premium versus gratuitos. Los picos de recursos del nivel gratuito no reducirán las solicitudes de CPU o memoria del nivel premium.
  • Integraciones de API de terceros: Dedicación de grupos de clientes HTTP separados para pasarelas de pago externas, proveedores de envío o motores de notificación. Si un servicio de terceros se ralentiza, otras interacciones externas continúan sin bloqueos.

Por qué Kafka/Message Brokers no puede reemplazar el patrón de mamparo

Una pregunta común es: “Si tenemos intermediarios de mensajes como Apache Kafka, ¿por qué necesitamos el patrón Bulkhead? ¿No podemos simplemente usar colas para almacenar en búfer las solicitudes?”

Si bien los intermediarios de mensajes desacoplan los sistemas, no pueden reemplazar el patrón Bulkhead. He aquí por qué:

1. Comunicación síncrona versus asincrónica

Kafka está diseñado para arquitecturas asincrónicas y basadas en eventos. El productor envía un mensaje a un tema y el consumidor eventualmente lo procesa. Sin embargo, las aplicaciones orientadas al usuario a menudo requieren comunicación sincrónica (solicitud-respuesta) (por ejemplo, cargar un catálogo de productos o cargar una tarjeta de crédito a través de una API REST/gRPC). La introducción de Kafka aquí requiere patrones complejos de solicitud-respuesta, lo que agrega alta latencia y sobrecarga. Los mamparos están diseñados específicamente para proteger estos subprocesos de ejecución sincrónicos en tiempo real.

2. Hambre de hilos dentro de los consumidores de Kafka

Incluso si su sistema está completamente controlado por eventos y usa Kafka, ¡todavía necesita mamparos! Supongamos que un único microservicio de consumidor escucha varios temas de Kafka (por ejemplo, user-registrations y video-transcoding). Si el consumidor asigna todos sus subprocesos de trabajo internos para procesar un lote masivo de trabajos video-transcoding lentos, experimentará una falta de subprocesos. El consumidor no podrá procesar mensajes user-registrations ligeros, aunque esa partición esté en buen estado. Aún necesita mamparos internos (grupos de subprocesos separados) dentro del servicio al consumidor para aislar el trabajo.

3. Requisito de falla rápida y gastos generales del lado del cliente

Cuando un servicio descendente no funciona, un mamparo permite que el servicio de llamada falle rápidamente y devuelva una respuesta alternativa de inmediato. Si, en cambio, pone todo en cola en Kafka, la cola podría crecer infinitamente, lo que provocaría solicitudes obsoletas, un alto consumo de memoria y tiempos de espera retrasados ​​cuando el sistema se recupera.

En resumen, Kafka desacopla la comunicación entre sistemas a través de la red, mientras que Bulkheads aísla la ejecución de recursos dentro de una instancia de aplicación en ejecución. Son complementarios, no mutuamente excluyentes.


Mejores prácticas al utilizar mamparas

  • Establecer siempre tiempos de espera: un mamparo limita la simultaneidad, pero no resuelve las lecturas lentas de sockets. Combine mamparos con tiempos de espera de red estrictos para liberar subprocesos lo más rápido posible.
  • Combine con disyuntores: use mamparas junto a los disyuntores. Si un mamparo comienza a rechazar solicitudes de manera consistente, el disyuntor debe dispararse para detener el tráfico por completo y darle espacio al servicio descendente para recuperarse.
  • Monitorear la saturación del grupo: implemente alertas sobre la longitud de la cola de mamparo y el recuento de subprocesos activos. Si un mamparo está constantemente lleno, es posible que necesite escalar su infraestructura u optimizar el servicio descendente.
  • Ajuste los tamaños individualmente: no utilice un límite único para todos. Mida la latencia y la tasa de solicitudes de cada dependencia para determinar los límites correctos del mamparo.

Conclusión

El Bulkhead Pattern es un patrón de diseño esencial para construir sistemas resilientes a escala de nube. Al dividir sus recursos, aísla las fallas, previene los efectos en cascada y garantiza que un error localizado no se convierta en una interrupción global.