Il modello Bulkhead: progettazione di microservizi tolleranti agli errori

Il modello Bulkhead: progettazione di microservizi tolleranti agli errori

In un’architettura a microservizi, una singola applicazione viene suddivisa in dozzine o centinaia di servizi indipendenti e collaboranti. Sebbene questa progettazione migliori la modularità e la scalabilità, introduce anche un rischio importante: un guasto in un servizio può provocare il collasso dell’intero sistema.

Se un servizio a valle diventa lento o non risponde, le richieste in entrata ai servizi a monte inizieranno ad accumularsi. Se condividono tutti la stessa memoria, CPU o pool di thread, una dipendenza lenta può esaurire rapidamente tutte le risorse disponibili, causando l’arresto anomalo dell’intera applicazione.

Questo fallimento a cascata è noto come effetto domino. Per prevenirlo, gli architetti di sistema utilizzano il Bulkhead Pattern.

In questa guida esploreremo cos’è il modello Bulkhead, come funziona e come implementarlo utilizzando semplici analogie, concetti architettonici ed esempi di codice in Java (Resilience4j) e Go.


L’analogia con il mondo reale: le paratie stagne delle navi

Il nome di questo modello deriva dall’industria della costruzione navale.

Una paratia è una parete stagna costruita all’interno dello scafo di una nave. Invece di avere un unico, enorme spazio aperto all’interno dello scafo della nave, l’interno è suddiviso in diversi compartimenti indipendenti e sigillati.

Diagramma dell'architettura del modello Bulkhead che mostra i pool di thread condivisi e isolati

Se la nave si scontra con un ostacolo e il suo scafo viene sfondato, l’acqua si riverserà nel compartimento danneggiato. Tuttavia, a causa delle paratie stagne, l’acqua è contenuta in quell’unico compartimento. Il resto della nave rimane asciutto e galleggiante, permettendole di rimanere a galla e raggiungere la sicurezza.

Senza le paratie, l’acqua scorrerebbe liberamente attraverso l’intero scafo, finendo per affondare la nave.

Nell’ingegneria del software:

  • La Nave è la tua intera applicazione o servizio.
  • I Compartimenti sono pool di risorse isolati (thread, connessioni, CPU).
  • The Hull Breach è un errore o un rallentamento in un microservizio downstream.
  • L’Inondazione è l’esaurimento delle risorse.

Il problema: pool di risorse condivise ed esaurimento dei thread

Per capire perché sono necessarie le paratie, diamo un’occhiata a cosa succede quando le risorse vengono condivise a livello globale.

Immagina un gateway API o un server Web che gestisce le richieste degli utenti. Dispone di un unico pool di thread globale di 100 thread per elaborare tutte le chiamate in entrata. Il server interagisce con tre servizi downstream:

  1. Servizio catalogo (veloce, legge l’elenco dei prodotti)
  2. Servizio di pagamento (veloce, elabora il checkout)
  3. Servizio di raccomandazione (lento, calcola gli articoli personalizzati)

Normalmente, tutto funziona bene. Ma supponiamo che il Recommendation Service soffra di un blocco del database e inizi a impiegare 30 secondi per rispondere invece di 200 millisecondi.

Ecco cosa succede:

  1. Gli utenti continuano a visitare la home page, attivando richieste al Servizio Consigli.
  2. Il server assegna un thread dal pool globale a ciascuna richiesta.
  3. Poiché il servizio di raccomandazione è lento, questi thread restano in attesa di risposte.
  4. In pochi secondi, tutti i 100 thread nel pool sono in attesa del servizio di raccomandazione.
  5. Quando un nuovo utente tenta di effettuare il checkout o visualizzare il catalogo, il server non ha più thread per elaborare la sua richiesta.

Anche se i servizi Catalogo e Pagamento sono completamente integri, ora non sono raggiungibili perché il lento Recommendation Service ha esaurito il pool di thread condiviso. L’intero sistema è andato offline.


La soluzione: il modello a paratia

Il Bulkhead Pattern risolve questo problema suddividendo i pool di risorse in modo che un errore in un’area non influenzi le altre.

Invece di un singolo pool globale, assegniamo pool separati e delimitati per ciascun servizio o dipendenza downstream.

Se assegniamo 10 thread specificatamente per il servizio di raccomandazione, al massimo 10 thread potranno essere bloccati in attesa di esso. Se il servizio di consigli rallenta, questi 10 thread verranno esauriti e le successive richieste di consigli verranno rifiutate immediatamente (fail-fast).

Tuttavia, i restanti 90 thread sono ancora riservati al Catalogo e ai Servizi di pagamento. Gli utenti possono comunque sfogliare i prodotti ed effettuare acquisti, anche se il widget dei consigli è temporaneamente non disponibile.


Tipi di isolamento delle paratie

Esistono due modi principali per implementare le paratie nei sistemi software:

1. Isolamento del pool di thread

In questo modello, a ciascuna dipendenza downstream viene assegnato il proprio pool di thread dedicato e la propria coda di esecuzione.

Diagramma di isolamento del pool di thread che mostra le richieste in entrata in coda per i thread di lavoro
  • Come funziona: il thread dell’applicazione principale trasferisce l’attività a un pool di thread specifico. Se il pool è pieno, la richiesta viene accodata o rifiutata.
  • Pro: Fornisce un isolamento completo. Se un servizio diventa lento, viene interessato solo il relativo pool di thread. I thread sono isolati a livello di sistema operativo/JVM.
  • Contro: introduce un sovraccarico aggiuntivo della CPU dovuto alla pianificazione dei thread, al cambio di contesto e alla gestione delle code.

2. Isolamento del semaforo

Invece di creare nuovi pool di thread, l’isolamento del semaforo utilizza un contatore (un semaforo) per limitare il numero di chiamate simultanee consentite a un servizio specifico.

Diagramma di isolamento del semaforo che mostra i thread di richiesta che eseguono attività dopo aver acquisito un permesso
  • Come funziona: Quando inizia una richiesta, tenta di acquisire un permesso dal semaforo. Se è disponibile un permesso, esegue la richiesta sul thread chiamante e rilascia il permesso una volta terminato. Se non ci sono permessi disponibili, la richiesta viene immediatamente respinta.
  • Pro: molto leggero con un sovraccarico praticamente nullo poiché non è coinvolta la commutazione del contesto del thread.
  • Contro: Nessuna separazione del filo. Se una chiamata viene bloccata su un socket di rete senza un timeout adeguato, può comunque bloccare il thread chiamante.

Esempi di implementazione

Diamo un’occhiata a come implementare le paratie in due popolari linguaggi back-end.

1. Java (Resilience4j e Spring Boot)

Resilience4j è una libreria di tolleranza agli errori leggera e facile da usare progettata per Java. Di seguito è illustrato come configurare una paratia per un servizio di pagamento downstream in un’applicazione Spring Boot.

Configurazione (application.yml)

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

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

Implementazione del codice

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. Vai (Golang)

In Go, non abbiamo necessariamente bisogno di un framework pesante perché il linguaggio fornisce primitive di concorrenza native come Goroutine e canali bufferizzati. Possiamo implementare una paratia semaforica pulita utilizzando un canale bufferizzato:

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)
}

Casi d’uso comuni per il modello Bulkhead

Ecco alcuni scenari tipici in cui l’implementazione di un modello a paratia è fondamentale:

  • Routing gateway API: isolamento dei percorsi per diversi servizi backend. Se il servizio di raccomandazione si interrompe, gli instradamenti del servizio ordini sul gateway rimangono pienamente operativi.
  • Pool di connessione al database: divisione dei pool di connessione al database per servizio o tenant. Un’ondata di query analitiche pesanti da parte di un tenant non esaurirà tutti gli handle di connessione disponibili, risparmiando query transazionali per altri tenant.
  • Applicazioni SaaS multi-tenant: separazione delle risorse di elaborazione o delle code di esecuzione per tenant premium rispetto a tenant gratuiti. I picchi di risorse del livello gratuito non faranno morire di fame le richieste del livello Premium di CPU o memoria.
  • Integrazioni API di terze parti: dedicare pool di client HTTP separati per gateway di pagamento esterni, fornitori di spedizioni o motori di notifica. Se un servizio di terze parti rallenta, le altre interazioni esterne continuano senza blocchi.

Perché i broker Kafka/Message non possono sostituire il modello Bulkhead

Una domanda comune è: “Se disponiamo di broker di messaggi come Apache Kafka, perché abbiamo bisogno del modello Bulkhead? Non possiamo semplicemente utilizzare le code per bufferizzare le richieste?”

Anche se i broker di messaggi disaccoppiano i sistemi, non possono sostituire il modello Bulkhead. Ecco perché:

1. Comunicazione sincrona e comunicazione asincrona

Kafka è progettato per architetture asincrone, guidate da eventi. Il produttore spinge un messaggio verso un argomento e il consumatore alla fine lo elabora. Tuttavia, le applicazioni rivolte all’utente spesso richiedono una comunicazione sincrona (richiesta-risposta) (ad esempio, il caricamento di un catalogo di prodotti o l’addebito di una carta di credito tramite un’API REST/gRPC). L’introduzione di Kafka qui richiede modelli di richiesta-risposta complessi, che aggiungono latenza e sovraccarico elevati. Le paratie sono progettate specificamente per proteggere questi thread di esecuzione sincrona in tempo reale.

2. Discussione su Starvation all’interno di Kafka Consumers

Anche se il tuo sistema è interamente basato sugli eventi e utilizza Kafka, hai comunque bisogno di paratie! Supponiamo che un singolo microservizio consumer ascolti più argomenti Kafka (ad esempio, user-registrations e video-transcoding). Se il consumatore alloca tutti i suoi thread di lavoro interni per elaborare un batch enorme di lavori lenti video-transcoding, sperimenterà la fame dei thread. Il consumer non sarà in grado di elaborare messaggi leggeri user-registrations, anche se la partizione è integra. Sono ancora necessarie paratie interne (pool di thread separati) all’interno del servizio consumer per isolare il lavoro.

3. Requisito di sovraccarico lato client e di fail-fast

Quando un servizio downstream è inattivo, una paratia consente al servizio chiamante di eseguire il fail-fast e restituire immediatamente una risposta di fallback. Se invece metti tutto in coda in Kafka, la coda potrebbe crescere all’infinito, portando a richieste obsolete, elevato consumo di memoria e timeout ritardati quando il sistema si ripristina.

In breve, Kafka disaccoppia la comunicazione tra i sistemi sulla rete, mentre i bulkhead isolano l’esecuzione delle risorse all’interno di un’istanza dell’applicazione in esecuzione. They are complementary, not mutually exclusive.


Migliori pratiche quando si utilizzano le paratie

  • Imposta sempre i timeout: una paratia limita la concorrenza, ma non risolve le letture lente dei socket. Combina i bulkhead con timeout di rete rigorosi per rilasciare i thread il più velocemente possibile.
  • Combinato con interruttori automatici: utilizzare le paratie insieme agli interruttori automatici. Se una paratia inizia a rifiutare le richieste in modo coerente, l’interruttore automatico dovrebbe scattare per fermare del tutto il traffico e consentire il ripristino della stanza di servizio a valle.
  • Monitora la saturazione del pool: implementa avvisi sulla lunghezza delle code del bulkhead e sui conteggi dei thread attivi. Se una paratia è costantemente piena, potrebbe essere necessario ridimensionare la propria infrastruttura o ottimizzare il servizio a valle.
  • Personalizza le dimensioni individualmente: non utilizzare un limite valido per tutti. Misura la latenza e il tasso di richiesta di ciascuna dipendenza per determinare i limiti corretti della paratia.

Conclusione

Il Bulkhead Pattern è un modello di progettazione essenziale per la creazione di sistemi resilienti su scala cloud. Partizionando le risorse, si isolano i guasti, si prevengono effetti a cascata e si garantisce che un bug localizzato non si trasformi in un’interruzione globale.