Une seule fonctionnalité qui s’emballe peut suffire à mettre toute votre application à genoux. Un endpoint d’export qui consomme l’intégralité du pool de connexions, un traitement batch qui monopolise tous les threads disponibles, un appel externe qui s’éternise et laisse derrière lui une file de requêtes en attente, et soudain, c’est l’application entière qui ne répond plus, alors qu’un seul composant était réellement en difficulté. Le problème n’est pas la panne locale : c’est qu’elle se propage faute de cloison pour la contenir. Le Bulkhead est précisément le pattern qui pose ces cloisons.
Ce cinquième et dernier volet de la série sur la résilience clôt l’arsenal. Là où les patterns précédents réagissaient à une défaillance (réessayer, borner une attente, couper un circuit, limiter le débit), le Bulkhead adopte une posture préventive : il découpe les ressources en amont pour que, le jour où quelque chose lâche, les dégâts restent confinés.
Le principe : des cloisons étanches
Le terme vient du monde maritime. Sur un navire, un bulkhead est une cloison qui sépare la coque en compartiments étanches. Si l’un d’eux est percé et se remplit d’eau (ou prend feu), l’incident reste contenu dans ce compartiment ; les autres restent secs et le bateau continue de flotter. Sans ces cloisons, la moindre brèche coulerait le navire entier.
L’idée transposée au logiciel est exactement la même : limiter les ressources allouées à chaque composant fonctionnel, de façon à maîtriser l’impact maximal qu’une dégradation ou une forte charge peut provoquer. Plutôt que de laisser tous les composants puiser sans limite dans un réservoir commun de threads, de connexions ou de mémoire, on attribue à chacun son propre quota. Un composant qui sature ne peut alors épuiser que sa part, jamais celle des autres.
Ce principe est plus familier qu’il n’y paraît : on le retrouve, parfois sans le nommer, dans des concepts largement utilisés au quotidien.
- thread pools : un nombre borné de threads dédiés à une catégorie de tâches.
- connection pools : un quota de connexions réservé à une base ou à un service donné.
- semaphores : une primitive qui plafonne le nombre d’opérations concurrentes.
- queues à capacité limitée : une file qui absorbe les pics jusqu’à un seuil, puis rejette le surplus plutôt que de s’effondrer.
Ce que le Bulkhead vous apporte
Cloisonner ne se résume pas à « mettre une limite ». Le pattern produit plusieurs effets concrets qui se renforcent mutuellement.
Isolation des défaillances
C’est le bénéfice fondateur. En cloisonnant les composants fonctionnels, on garantit qu’une défaillance locale reste confinée. Si l’un d’eux tombe ou se dégrade, le problème ne déborde pas sur le reste du système : l’application, dans son ensemble, continue de fonctionner malgré la panne d’une de ses parties.
Contention des ressources maîtrisée
En réservant des ressources dédiées à des opérations spécifiques, on s’assure que chacune dispose en permanence du minimum dont elle a besoin. C’est la garde-fou contre le scénario classique : un composant défaillant qui, en boucle, accapare connexions et threads jusqu’à affamer tous les autres. Avec un quota dédié, son appétit est borné par construction.
Concurrence sous contrôle
Le cœur du pattern, c’est de plafonner le nombre d’opérations simultanées par composant. Ce plafond évite naturellement les surcharges globales : même sous un trafic important, le système reste réactif parce qu’aucun composant ne peut, à lui seul, faire exploser la charge totale.
Une meilleure tolérance aux pannes
En compartimentant l’application, on augmente sa capacité à encaisser les erreurs sans s’effondrer. Le risque d’effet en cascade, cette réaction en chaîne où une panne en déclenche une autre, diminue nettement, et la résilience globale du système s’en trouve améliorée.
Surtout, le Bulkhead ne s’oppose à aucun des patterns vus précédemment : il se combine avec tous. Retry, Timeout, Circuit Breaker, Rate Limiting : chacun protège contre un mode de défaillance particulier, et le Bulkhead vient leur ajouter une couche d’isolation transversale.
En pratique : cloisonner avec cockatiel
Comme pour le Circuit Breaker, inutile de réimplémenter la mécanique à la main. L’exemple ci-dessous utilise cockatiel pour cloisonner un service réseau : on limite à 20 le nombre de requêtes simultanées, avec une file de 10 requêtes en attente au-delà. Tout appel qui dépasserait ces deux capacités est immédiatement rejeté plutôt que mis en attente indéfiniment.
/**
* Le pattern Bulkhead permet d'allouer des ressources
* spécifiques à certaines opérations, de manière à éviter
* qu'une surcharge d'une opération (fonctionnalité) n'affecte les autres.
*/
import { bulkhead, BulkheadPolicy, BulkheadRejectedError } from "cockatiel";
/**
* Ici on définit le composant à cloisonner. C'est un cas simpliste où les
* interactions avec le composant se limitent à un service, mais le Bulkhead
* pourrait être partagé à l'échelle d'un module qui regrouperait par exemple
* plusieurs services qui utilisent le même composant.
*/
class SomeNetworkingService {
bulkheadPolicy: BulkheadPolicy;
constructor() {
/**
* Création d'un Bulkhead qui permet de limiter le nombre
* de requêtes simultanées à 20, avec 10 requêtes qui peuvent être
* mises en attente.
*/
this.bulkheadPolicy = bulkhead(20, 10);
}
async call() {
console.log("bulkhead state", {
executionSlots: this.bulkheadPolicy.executionSlots,
waitingSlots: this.bulkheadPolicy.queueSlots,
});
try {
return await this.bulkheadPolicy.execute(() => {
// network call
});
} catch (exception) {
if (exception instanceof BulkheadRejectedError) {
// La limite du Bulkhead a été dépassée, le call est rejeté
}
}
}
}
L’intérêt de ce rejet explicite mérite d’être souligné : plutôt que de laisser une requête s’empiler sans fin dans un système déjà saturé, on échoue vite et proprement (BulkheadRejectedError). C’est un signal exploitable : on peut renvoyer une réponse dégradée, déclencher un fallback, ou simplement laisser le client réessayer plus tard.
Une variante avec semaphore et Effect
Le même principe peut s’exprimer à un niveau plus bas, à partir d’une semaphore, la primitive de synchronisation qui plafonne le nombre d’opérations concurrentes sur une ressource partagée. L’exemple suivant construit un petit Bulkhead maison avec Effect, exposant plusieurs stratégies d’exécution : mise en attente automatique, rejet immédiat si la capacité est dépassée, ou exécution bornée par un timeout.
/**
* Le pattern Bulkhead (avec Effect) peut être implémenté en utilisant une
* Sémaphore, qui est une primitive de synchronisation permettant de limiter
* le nombre d'opérations concurrentes effectuées sur des ressources partagées.
*/
import { Duration, Effect, Option, pipe, Queue } from "effect";
const makeBulkhead = (config: { capacity: number; waitCapacity: number }) =>
Effect.gen(function* () {
const semaphore = yield* Effect.makeSemaphore(config.capacity);
// On peut utiliser un "Queue" en mémoire pour gérer les opérations en
// attente quand la capacité du Bulkhead est atteinte.
const queue = yield* Queue.bounded<Effect.Effect<any, any, any>>(
config.waitCapacity
);
return {
// avec "withPermits", Effect gère automatiquement une file d'attente
// (FIFO) pour les tâches qui sont en attente d'être exécutées
// (capacité du bulkhead maximum atteinte).
executeAsap: <A, E, R>(task: Effect.Effect<A, E, R>) =>
pipe(task, semaphore.withPermits(1)),
executeOrFail: <A, E, R>(task: Effect.Effect<A, E, R>) =>
pipe(
task,
semaphore.withPermitsIfAvailable(1),
Effect.flatMap(
Option.match({
onNone: () =>
Effect.fail(new Error("Bulkhead capacity exceeded")),
onSome: Effect.succeed,
})
)
),
executeAsapWithTimeout: <A, E, R>(
task: Effect.Effect<A, E, R>,
timeoutDuration: Duration.Duration
) => pipe(task, semaphore.withPermits(1), Effect.timeout(timeoutDuration)),
};
});
const program = Effect.gen(function* () {
const bulkhead = yield* makeBulkhead({ capacity: 10, waitCapacity: 20 });
const task1 = bulkhead.executeOrFail(task(Duration.seconds(1)));
const task2 = bulkhead.executeAsapWithTimeout(
task(Duration.seconds(5)),
Duration.seconds(2)
);
const task3 = bulkhead.executeAsap(task(Duration.seconds(5)));
yield* Effect.all([task1, task2, task3], {
concurrency: "unbounded",
mode: "either",
});
});
Effect.runFork(program);
Les deux approches illustrent la même idée sous deux angles : une bibliothèque dédiée qui encapsule toute la mécanique, ou une primitive de bas niveau que l’on assemble soi-même selon ses besoins. Dans les deux cas, le contrat reste identique : un nombre borné d’exécutions simultanées, une file d’attente plafonnée, et un comportement explicite quand la limite est atteinte.
À l’échelle d’un système distribué
Jusqu’ici, le cloisonnement s’est joué in-process, au sein d’une même application. Mais le Bulkhead change d’échelle sans changer de logique : il s’applique tout aussi bien à un système distribué, où les compartiments deviennent des unités d’infrastructure.
- des quotas d’accès par service : par exemple un plafond de 50 req/s vers le Service B, qui empêche un consommateur d’en saturer un autre.
- des pools de ressources dédiés par destination, de sorte qu’un service lent ne draine pas les connexions destinées aux autres.
- des containers ou pods indépendants dans un orchestrateur, qui isolent les défaillances jusqu’au niveau du runtime.
La cloison logicielle d’hier devient une frontière d’infrastructure, mais l’intention ne bouge pas : contenir l’impact d’une défaillance à l’intérieur de son périmètre.
Conclusion
Le Bulkhead ne cherche pas à empêcher les pannes : il accepte qu’elles arrivent et s’assure simplement qu’elles ne se propagent pas. En attribuant à chaque composant son propre quota de ressources, il transforme une défaillance globale potentielle en un incident local et circonscrit. C’est, au fond, la même sagesse que celle des cloisons d’un navire : on ne promet pas que rien ne percera jamais la coque, on garantit que la brèche ne coulera pas le bateau entier.
Ce pattern referme une série de cinq. Le Retry pour réessayer, le Timeout pour borner l’attente, le Circuit Breaker pour couper l’effet domino, le Rate Limiting pour protéger le serveur, et le Bulkhead pour cloisonner les ressources : aucun n’est suffisant seul, mais combinés intelligemment, ils donnent à vos applications les meilleures chances de rester disponibles et fiables, même dans les pires situations, et donc de rester accessibles à vos utilisateurs. La résilience n’est pas un patch que l’on ajoute après coup ; c’est une intention que l’on tisse dans l’architecture, un pattern à la fois.