Le modèle de cloisonnement : conception de microservices tolérants aux pannes

Le modèle de cloisonnement : conception de microservices tolérants aux pannes

Dans une architecture de microservices, une seule application est décomposée en dizaines ou centaines de services indépendants et collaboratifs. Bien que cette conception améliore la modularité et l’évolutivité, elle introduit également un risque majeur : une défaillance d’un service peut se répercuter et faire tomber l’ensemble du système.

Si un service en aval devient lent ou ne répond plus, les demandes entrantes adressées à vos services en amont commenceront à s’accumuler. S’ils partagent tous la même mémoire, le même processeur ou le même pool de threads, une dépendance lente peut rapidement épuiser toutes les ressources disponibles, provoquant le blocage de l’ensemble de votre application.

Cet échec en cascade est connu sous le nom d’effet domino. Pour éviter cela, les architectes système utilisent le Bulkhead Pattern.

Dans ce guide, nous explorerons ce qu’est le modèle Bulkhead, comment il fonctionne et comment l’implémenter à l’aide d’analogies simples, de concepts architecturaux et d’exemples de code en Java (Resilience4j) et Go.


L’analogie avec le monde réel : les cloisons étanches des navires

Le nom de ce modèle vient de l’industrie de la construction navale.

Une cloison est un mur étanche construit à l’intérieur de la coque d’un navire. Au lieu d’avoir un seul espace ouvert massif à l’intérieur de la coque du navire, l’intérieur est divisé en plusieurs compartiments indépendants et scellés.

Diagramme d'architecture de modèle de cloison montrant les pools de threads partagés et isolés

Si le navire entre en collision avec un obstacle et que sa coque est brisée, de l’eau inondera le compartiment endommagé. Cependant, en raison des cloisons étanches, l’eau est confinée dans ce seul compartiment. Le reste du navire reste sec et flottant, ce qui lui permet de rester à flot et de se mettre en sécurité.

Sans cloisons, l’eau circulerait librement dans toute la coque, finissant par couler le navire.

En génie logiciel :

  • Le navire est l’intégralité de votre application ou service.
  • Les compartiments sont des pools de ressources isolés (threads, connexions, CPU).
  • La Hull Breach est une défaillance ou un ralentissement d’un microservice en aval.
  • Les inondations sont l’épuisement des ressources.

Le problème : pools de ressources partagés et épuisement des threads

Pour comprendre pourquoi les cloisons sont nécessaires, regardons ce qui se passe lorsque les ressources sont partagées à l’échelle mondiale.

Imaginez une passerelle API ou un serveur Web gérant les demandes des utilisateurs. Il dispose d’un seul pool de threads global de 100 threads pour traiter tous les appels entrants. Le serveur interagit avec trois services en aval :

  1. Service de catalogue (rapide, lit la liste des produits)
  2. Service de paiement (rapide, traite le paiement)
  3. Service de recommandation (lent, calcule les articles personnalisés)

Normalement, tout fonctionne bien. Mais supposons que le service de recommandation souffre d’un blocage de base de données et commence à mettre 30 secondes pour répondre au lieu de 200 millisecondes.

Voici ce qui se passe :

  1. Les utilisateurs continuent de visiter la page d’accueil, déclenchant des requêtes auprès du service de recommandation.
  2. Le serveur attribue un thread du pool global à chaque requête.
  3. Le service de recommandation étant lent, ces threads attendent des réponses.
  4. En quelques secondes, les 100 threads du pool attendent le service de recommandation.
  5. Lorsqu’un nouvel utilisateur tente de commander ou d’afficher le catalogue, le serveur ne dispose plus de threads pour traiter sa demande.

Même si les services de catalogue et de paiement sont parfaitement sains, ils sont désormais inaccessibles car la lenteur du service de recommandation a épuisé le pool de threads partagés. L’ensemble du système est hors ligne.


La solution : le modèle de cloison

Le Bulkhead Pattern résout ce problème en partitionnant les pools de ressources afin qu’une défaillance dans une zone n’affecte pas les autres.

Au lieu d’un pool global unique, nous allouons des pools distincts et délimités pour chaque service ou dépendance en aval.

Si nous allouons 10 threads spécifiquement pour le service de recommandation, alors au maximum 10 threads peuvent être bloqués en attente. Si le service de recommandation ralentit, ces 10 threads seront épuisés et les demandes de recommandation ultérieures seront immédiatement rejetées (fail-fast).

Cependant, les 90 threads restants sont toujours réservés aux services Catalogue et Paiement. Les utilisateurs peuvent toujours parcourir les produits et effectuer des achats, même si le widget de recommandation est temporairement indisponible.


Types d’isolation des cloisons

Il existe deux manières principales d’implémenter des cloisons dans les systèmes logiciels :

1. Isolation du pool de threads

Dans ce modèle, chaque dépendance en aval se voit attribuer son propre pool de threads dédié et sa propre file d’attente d’exécution.

Diagramme d'isolation du pool de threads montrant les requêtes entrantes en file d'attente pour les threads de travail
  • Comment ça marche : Le thread principal de l’application confie la tâche à un pool de threads spécifique. Si le pool est plein, la demande est soit mise en file d’attente, soit rejetée.
  • Avantages : Fournit une isolation complète. Si un service devient lent, seul son pool de threads est affecté. Les threads sont isolés au niveau du système d’exploitation/JVM.
  • Inconvénients : introduit une surcharge supplémentaire du processeur en raison de la planification des threads, du changement de contexte et de la gestion des files d’attente.

2. Isolement du sémaphore

Au lieu de créer de nouveaux pools de threads, l’isolation du sémaphore utilise un compteur (un sémaphore) pour limiter le nombre d’appels simultanés autorisés vers un service spécifique.

Diagramme d'isolement de sémaphore montrant les threads de requête exécutant des tâches après l'acquisition d'un permis
  • Comment ça marche : Lorsqu’une requête démarre, elle tente d’acquérir un permis auprès du sémaphore. Si un permis est disponible, il exécute la requête sur le thread appelant et libère le permis une fois terminé. Si aucun permis n’est disponible, la demande est immédiatement rejetée.
  • Avantages : Très léger avec une surcharge pratiquement nulle puisqu’aucun changement de contexte de thread n’est impliqué.
  • Inconvénients : Pas de séparation des threads. Si un appel est bloqué sur une socket réseau sans délai d’attente approprié, il peut toujours bloquer le thread appelant.

Exemples de mise en œuvre

Voyons comment implémenter des cloisons dans deux langages back-end populaires.

1. Java (Resilience4j et Spring Boot)

Resilience4j est une bibliothèque de tolérance aux pannes légère et facile à utiliser conçue pour Java. Vous trouverez ci-dessous comment configurer une cloison pour un service de paiement en aval dans une application Spring Boot.

Configuration (application.yml)

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

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

Implémentation du code

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

Dans Go, nous n’avons pas nécessairement besoin d’un framework lourd car le langage fournit des primitives de concurrence natives comme les Goroutines et les canaux tamponnés. Nous pouvons implémenter une cloison de sémaphore propre en utilisant un canal tamponné :

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

Cas d’utilisation courants du modèle de cloison

Voici quelques scénarios typiques dans lesquels la mise en œuvre d’un modèle de cloison est essentielle :

  • API Gateway Routing : isolation des routes pour différents services backend. Si le service de recommandation tombe en panne, les routes du service de commande sur la passerelle restent pleinement opérationnelles.
  • Pools de connexions à la base de données : division des pools de connexions à la base de données par service ou locataire. Une vague de requêtes analytiques lourdes provenant d’un locataire n’épuisera pas tous les descripteurs de connexion disponibles, économisant ainsi les requêtes transactionnelles pour les autres locataires.
  • Applications SaaS multi-locataires : séparation des ressources de calcul ou des files d’attente d’exécution pour les locataires premium et gratuits. Les pics de ressources du niveau gratuit ne priveront pas les demandes de processeur ou de mémoire du niveau premium.
  • Intégrations d’API tierces : dédier des pools de clients HTTP distincts aux passerelles de paiement externes, aux fournisseurs d’expédition ou aux moteurs de notification. Si un service tiers ralentit, les autres interactions externes se poursuivent sans blocage.

Pourquoi Kafka/les courtiers de messages ne peuvent pas remplacer le modèle de cloison

Une question courante est : “Si nous avons des courtiers de messages comme Apache Kafka, pourquoi avons-nous besoin du modèle Bulkhead ? Ne pouvons-nous pas simplement utiliser des files d’attente pour mettre en mémoire tampon les requêtes ?”

Bien que les courtiers de messages dissocient les systèmes, ils ne peuvent pas remplacer le modèle Bulkhead. Voici pourquoi :

1. Communication synchrone ou asynchrone

Kafka est conçu pour les architectures asynchrones et basées sur les événements. Le producteur transmet un message à un sujet et le consommateur finit par le traiter. Cependant, les applications destinées aux utilisateurs nécessitent souvent une communication synchrone (requête-réponse) (par exemple, le chargement d’un catalogue de produits ou le débit d’une carte de crédit via une API REST/gRPC). L’introduction de Kafka ici nécessite des modèles de requête-réponse complexes, ce qui ajoute une latence et une surcharge élevées. Les cloisons sont spécialement conçues pour protéger ces threads d’exécution synchrone en temps réel.

2. La famine des sujets chez les consommateurs Kafka

Même si votre système est entièrement événementiel et utilise Kafka, vous avez toujours besoin de cloisons ! Supposons qu’un seul microservice consommateur écoute plusieurs sujets Kafka (par exemple, user-registrations et video-transcoding). Si le consommateur alloue tous ses threads de travail internes pour traiter un lot massif de tâches video-transcoding lentes, il connaîtra une pénurie de threads. Le consommateur ne pourra pas traiter les messages user-registrations légers, même si cette partition est saine. Vous avez toujours besoin de cloisons internes (pools de threads séparés) à l’intérieur du service consommateur pour isoler le travail.

3. Frais généraux côté client et exigence de rapidité d’échec

Lorsqu’un service en aval est en panne, une cloison permet au service appelant d’échouer rapidement et de renvoyer immédiatement une réponse de secours. Si vous mettez tout en file d’attente dans Kafka, la file d’attente peut s’allonger à l’infini, entraînant des requêtes obsolètes, une consommation de mémoire élevée et des délais d’attente retardés lors de la récupération du système.

En bref, Kafka dissocie la communication entre les systèmes sur le réseau, tandis que Les cloisons isolent l’exécution des ressources au sein d’une instance d’application en cours d’exécution. Ils sont complémentaires et ne s’excluent pas mutuellement.


Meilleures pratiques lors de l’utilisation de cloisons

  • Toujours définir les délais d’attente : une cloison limite la concurrence, mais elle ne résout pas les lectures lentes des sockets. Combinez des cloisons avec des délais d’attente réseau stricts pour libérer les threads le plus rapidement possible.
  • Combiner avec des disjoncteurs : utilisez des cloisons à côté des disjoncteurs. Si une cloison commence à rejeter les demandes de manière cohérente, le disjoncteur doit se déclencher pour arrêter complètement le trafic et permettre à la salle de service en aval de récupérer.
  • Surveiller la saturation du pool : implémentez des alertes sur la longueur de votre file d’attente de cloison et le nombre de threads actifs. Si une cloison est constamment pleine, vous devrez peut-être faire évoluer votre infrastructure ou optimiser le service en aval.
  • Ajustez les tailles individuellement : n’utilisez pas de limite de taille unique. Mesurez la latence et le taux de requêtes de chaque dépendance pour déterminer les limites de cloison correctes.

Conclusion

Le Bulkhead Pattern est un modèle de conception essentiel pour créer des systèmes résilients à l’échelle du cloud. En partitionnant vos ressources, vous isolez les pannes, évitez les effets en cascade et garantissez qu’un bug localisé ne se transforme pas en panne globale.