Crédit photo Alessia P.
Problématique
La tendance actuelle à distribuer les applications tente de résoudre plusieurs problèmes, dont le besoin d’utiliser conjointement des composants spécialisés et celui de passer à l’échelle, pour des questions de performance ou de gestion de volumes et de charges qui dépassent les capacités d’une seule machine. Les microservices constituent une approche populaire de la distribution logicielle qui consiste à concevoir une application comme un ensemble de composants communiquant au travers d’un réseau.
Cette distribution ne vient cependant pas sans son lot de problèmes. Dans un système centralisé, un appel à une méthode est fondamentalement simple: si la méthode peut prendre du temps à s’exécuter, et si des erreurs peuvent survenir pendant cette exécution, la gestion de l’appel proprement dite ne pose pas de problème.
Avec des composants distribués, ce n’est plus si simple: le composant distant peut ne pas être disponible. Un appel peut être routé vers le mauvais service. La réponse peut se perdre dans les limbes du réseau. Le service peut être tellement occupé que le temps de réponse devient insupportablement long.
Pour limiter ce problème et augmenter leur tolérance aux pannes, les composants implémentent souvent un mécanisme de réessai: au bout d’un moment, sans réponse du composant distant, on tente à nouveau de lui soumettre le même message.
Une solution limitée
Dans de nombreux cas, cela ne fait qu’aggraver les choses: si le composant reçoit les messages mais ne peut faire parvenir sa réponse, il finira par s’écrouler par les demandes répétées de ses clients. Si la réponse tarde à arriver parce que le composant est fort occupé, lui resoumettre périodiquement les mêmes demandes ne fait qu’augmenter la pression qu’il subit.
Si le composant n’est définitivement plus capable de traiter des demandes, réessayer périodiquement de le joindre ne fait qu’entretenir inutilement une attente de ses clients. Le composant appelant cesse lui-même de répondre aux demandes qu’il reçoit, ce qui mène à des défaillances en cascade des composants.
Si le composant redevient soudainement disponible, le mécanisme de réessai de ses clients peut le noyer à nouveau dans une multitude de demandes.
Un mécanisme de réessai ne fonctionne véritablement que pour les pannes très occasionnelles et temporaires. Or, dans une application distribuée, toutes les pannes n’ont pas ces caractéristiques. Les services vont et viennent au rythme des mises à jour et des redéploiements. Des bugs rendent les services inutilisables jusqu’à ce qu’un correctif ou un rollback leur soit appliqué. Une brusque augmentation de l’usage du service peut le ralentir ou le rendre indisponible pendant des heures. La présence de voisins bruyants peut perturber subtilement son bon fonctionnement.
Un mécanisme de réessai ne fonctionne véritablement que pour les pannes très occasionnelles et temporaires.
Un disjoncteur à la rescousse
Une réponse classique à ces problèmes (surtout dans les architectures de microservices, à ma connaissance), consiste à placer un disjoncteur (circuit breaker en anglais) entre l’appelant et l’appelé. Son rôle est de détecter un problème de communication avec un composant tiers, et de « se mettre en sécurité » lorsque cela arrive, rapportant immédiatement une exception à l’émetteur lorsqu’il souhaite communiquer avec le composant problématique, sans même tenter de réaliser la communication.
Mise en œuvre
Supposons que, dans notre implementation originale, l’appel à un service se fasse en appellant la méthode call() d’une classe Service:
public class Service {
public int call() throws UnavailableServiceException {
...
}
}
Service client = ...;
int ret = client.call();
Commençons par introduire un décorateur, qui dans un premier temps ne sera qu’un passe-plat pour le véritable service:
public class CircuitBreaker {
private final Service service;
public CircuitBreaker(Service service) {
this.service = service;
}
public int call() throws UnavailableServiceException {
return service.call();
}
}
À ce stade, et comme souvent avec les décorateurs, il devient évident que le disjoncteur est lui-même un service. Augmentons notre décorateur par une composition:
public interface Service {
public int call() throws UnavailableServiceException;
}
public class ActualService implements Service {
public int call() throws UnavailableServiceException { ... }
}
public class CircuitBreakerService implements Service {
private final Service underlyingService;
public CircuitBreakerService(Service service) {
underlyingService = service;
}
public int call() throws UnavailableServiceException {
return underlyingService.call();
}
}
Nous avons à présent une structure qui nous permet de modifier le comportement d’un appel en nous référant au comportement original. Ce que nous feront dans quelques instants.
Une question d’état
Tout comme pour un disjoncteur électrique, le comportement du disjoncteur logiciel que nous voulons mettre en place se décrit facilement en considérant que celui-ci est à tout moment dans un des états suivants:
- fermé: dans cet état, le disjoncteur transmet les messages au service sous-jacent, car il est probable que celui-ci puisse les traiter. En plus de servir de passe-plat, il observe les problèmes de communication survenus récemment. Lorsque ceux-ci deviennent trop nombreux, le disjoncteur passe dans un état ouvert.
- ouvert: dans cet état, le disjoncteur considère que le service sous-jacent n’est probablement pas capable de traiter de nouvelles demandes. Il ne les transmet donc pas et lève systématiquement une
UnavailableServiceExceptionà chaque tentative d’appel. Lorsque le disjoncteur entre dans un état ouvert, il déclenche un timer. Lorsque ce timer aura expiré, le disjoncteur passera dans un état semi-ouvert. - semi-ouvert: Le disjoncteur souhaite retester la disponibilité du service sous-jacent, mais sans risquer de compromettre la stabilité du système. Un petit nombre d’appels sont transmis au service sous-jacent. Si tous ces appels réussissent, le disjoncteur se met dans un état fermé. Si ne serait-ce qu’un de ces appels échoue, il se met dans un état ouvert.
Implémentons ce comportement avec le patron de conception État:
public abstract class CircuitBrokerState {
protected CircuitBrokerService cbService;
protected Service underlyingService;
public CircuitBrokerState(CircuitBrokerService cbService, Service underlyingService) {
this.cbService = cbService;
this.underlyingService = underlyingService;
}
abstract int call() throws UnavailableServiceException;
}
public class Closed extends CircuitBrokerState {
private List<Instant> recentIssues;
private final TemporalAmount timeWindow;
private final int windowThreshold;
public Closed(CircuitBrokerService cbService, Service service, TemporalAmount timeWindow, int windowThreshold) {
super(cbService, service);
this.timeWindow = timeWindow;
this.windowThreshold = windowThreshold;
this.recentIssues = new ArrayList<>();
}
@Override
public int call() throws UnavailableServiceException {
try {
return underlyingService.call();
} catch(UnavailableServiceException e) {
recordIssue();
if(tooManyRecentIssues()) {
cbService.changeState(new Open(cbService, underlyingService, Duration.ofMinutes(5)));
}
throw e;
}
}
private void recordIssue() {
Instant now = Instant.now();
Instant limit = now.minus(timeWindow);
recentIssues.add(now);
recentIssues = recentIssues.stream().filter(issue -> issue.isAfter(limit)).toList();
}
private boolean tooManyRecentIssues() { return recentIssues.size() > windowThreshold; }
}
public class Open extends CircuitBrokerState {
private Instant since;
private final TemporalAmount duration;
public Open(CircuitBrokerService cbService, Service underlyingService, TemporalAmount duration) {
super(cbService, underlyingService);
since = Instant.now();
this.duration = duration;
}
@Override
public int call() throws UnavailableServiceException {
if(openTooLong()) {
cbService.changeState(new HalfOpen(cbService, underlyingService, 3));
return cbService.call();
} else {
throw new UnavailableServiceException("circuit broker is open");
}
}
private boolean openTooLong() { return Instant.now().isAfter(since.plus(duration)); }
}
public class HalfOpen extends CircuitBrokerState {
private int nbSuccessfulCalls;
private final int threshold;
public HalfOpen(CircuitBrokerService cbService, Service service, int threshold) {
super(cbService, service);
nbSuccessfulCalls = 0;
this.threshold = threshold;
}
@Override
public int call() throws UnavailableServiceException {
if(enoughSuccessfulCalls()) {
cbService.changeState(new Closed(cbService, underlyingService, Duration.ofMinutes(3), 5));
return cbService.call();
}
try {
int ret = underlyingService.call();
nbSuccessfulCalls++;
return ret;
} catch (UnavailableServiceException e) {
cbService.changeState(new Open(cbService, underlyingService, Duration.ofMinutes(5)));
throw new RuntimeException(e);
}
}
private boolean enoughSuccessfulCalls() { return nbSuccessfulCalls >= threshold; }
}
Il ne reste plus qu’à adapter le disjoncteur pour profiter de ce mécanisme d’états:
public class CircuitBrokerService implements Service {
private CircuitBrokerState state;
public CircuitBrokerService(Service service) {
// We tolerate 5 errors in last 3 minutes
this.state = new Closed(this, service, Duration.ofMinutes(3), 5);
}
public void changeState(CircuitBrokerState state) {
this.state = state;
}
@Override
public int call() throws UnavailableServiceException {
return state.call();
}
}
Cela n’a pas magiquement fait disparaître les problèmes d’accessibilité du service sous-jacent, mais à présent, l’application utilisatrice a plus souvent un comportement stable: lorsque le service sous-jacent fonctionne correctement, et lorsqu’il a été repéré comme étant défectueux. Il n’y a plus que deux circonstances durant lesquelles le comportement est un peu moins propre:
- Lorsque le disjoncteur est fermé et que le service commence à dysfonctionner. Le disjoncteur attend de confirmer que le dysfonctionnement s’installe dans le temps avant de s’ouvrir, ce qui permet de ne pas altérer son comportement lorsque l’indisponibilité du service est très temporaire.
- Lorsque le disjoncteur passe en état semi-ouvert. Rien ne permet de dire que le service sous-jacent s’est effectivement rétabli. Néanmoins, si ce n’est pas le cas, le disjoncteur repassera en état ouvert dès la première erreur, si bien qu’un seul appel sera plus problématique.
Raffinements
Plusieurs détails peuvent être pris en compte afin de raffiner l’approche proposée.
Asynchronisme. Comme les appels se font sur des composants distants, ils sont généralement asynchrones. Il faut en tenir compte dans notre implémentation, ce qui complexifie le code.
Tuning. L’approche est caractérisée par plusieurs paramètres: le nombre d’essais devant réussir avant de passer d’un état semi-ouvert à un état fermé, le temps pendant lequel le disjoncteur reste ouvert, etc. Selon les conditions d’utilisation, ces paramètres doivent être ajustés afin de trouver le bon compromis entre l’exploitation optimale du service sous-jacent et la stabilité du système.
Ouverture intelligente. Dans certains cas, la durée d’indisponibilité du service peut être prédite. Par exemple, un service HTTP peut retourner un code 503 (Service indisponible) ou 429 (Trop de requêtes) pour indiquer son indisponibilité. Le code de retour peut être accompagné d’informations spécifiant quand le client peut retenter d’utiliser le service. L’état d’ouverture peut alors être adapté en conséquence. Dans le même ordre d’idée, il est possible d’interroger l’état de certains services sans avoir à exploiter leurs véritables fonctionnalités. Au terme de son timer, un disjoncteur ouvert peut alors procéder à cette interrogation et décider de rester ouvert ou de passer dans un état semi-ouvert (ou directement dans un état fermé selon la confiance qu’il a en la disponibilité rapportée du service). Le comportement du disjoncteur peut également être influencé par la nature des erreurs qui lui sont rapportées: il restera par exemple plus longtemps ouvert si le service ne répond pas du tout que s’il rapporte un trop grand nombre de requêtes.
Fermeture manuelle. Inversement, dans certains cas, il n’est pas possible de prédire lorsque le service sera à nouveau indisponible. La durée d’indisponibilité peut être longue et variable, par exemple si une opération manuelle est nécessaire au rétablissement du service. Dans ce cas, il peut être préférable de laisser le disjoncteur dans un état ouvert jusqu’à ce qu’un opérateur le referme après intervention.
Résilience du client. Le disjoncteur ne prévient pas l’indisponibilité du service. Le client doit donc toujours se préparer à y faire face, par exemple en remontant une erreur, en cherchant une implémentation alternative du service (en utilisant Zookeeper ou Consul, par exemple) ou encore en retournant une valeur par défaut.
Conclusion
Loin d’être un jouet, le disjoncteur est un patron de conception qui s’avère rapidement utile voire indispensable lors du développement ou de l’exploitation de services distribuées. En évitant la propagation systémique de problèmes difficilement identifiables, il joue, au sein de l’application, un rôle similaire à celui des disjoncteurs électriques, à savoir l’interruption aussi rapide et propre que possible d’un composant, qui sans cela risquerait de mettre le feu à la maison avant qu’on s’en rende compte.
En termes d’architecture logicielle, nous avons montré que l’implémentation d’un disjoncteur peut se faire au moyen de patrons de conception éprouvés, dont la mise en œuvre est relativement simple, et dont l’intégration ne nécessite pas la modification des composants existants.