Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Patterns pour haute dispo et scalabilité d’une appli web – Partie IV

Poster un commentaire

Maîtriser les pannes transitoires

Les applications modernes sont construites à partir d’un certain nombre de briques, de modules, plus ou moins indépendants et en interaction permanente.

La question que je vais aborder est celle de savoir ce qui se passe si un de ces modules a une défaillance. La défaillance peut venir du module lui-même ou bien de sa connectivité. Savoir ce qui se passe, mais surtout quelles stratégies peut t’on mettre en place pour limiter les impacts pour votre client en souplesse et si possible de façon transparente.

Les points abordés dans cet article:

  • Erreurs transitoires ou permanentes
  • Pattern de réitération (transient fault handling pattern)
  • Pattern du disjoncteur (circuit breaker pattern)
  • Mise en œuvre pratique
  • Considérations pratiques

Quelle est la cause du problème?

2016-08-14_00-21-16

 

A gauche une architecture traditionnelle. Chaque module qui composes l’application n’existe qu’en une seule version. Si l’accès à un des modules est défaillant, c’est toute l’application qui est impactées.

 

A droite la situation est différente. les modules sont tous dupliqués. Comme on peut le voir, l’application peut survivre à plusieurs défaillances simultanées. L’appli est dite robuste. Nous examinerons une autre solution possible au chapitre qui suit.

 

On distingue deux types de pannes:

  1. Pannes transitoires (transient fault)
    Pannes qui se résolvent d’elles même: perte réseau temporaire, serveur surchargé…
  2. Pannes persistantes (persistent fault)
    Des pannes qui ne peuvent se réparer d’elles mêmes ou qui prennent du temps: un serveur qui tombe en panne, désastre dans un data center…

Après le généralités, entrons dans le vif du sujet.

Les patterns

Stratégie de réitération

La plupart des pannes transitoires se traitent simplement en recommençant l’opération, c’est ce que j’appelle une stratégie de réitération. Ce mécanisme peut être pris en charge automatiquement par divers framework. Pour éviter que le service en difficulté se retrouve inondé de requêtes de réitération, on impose un incrément fixe ou variable entre deux essais.

 

Est t’il possible de réitérer n’importe quelle opération?

Il n’est pas exclus que l’opération modifie quelque part un état du système comme un compteur, envoie un email ou lance une commande.

La qualité d’une commande à pouvoir être répétée est appelée idempotence. Il ne faudra pas oublier de la vérifier avant de mettre en place une stratégie de réitération.

Si le service cible est SQL Server par exemple, il faudra être particulièrement prudent avec les procédures stockées et vérifier qu’elles soient bien transactionnelles.

 

Note: les stratégies de réitération peuvent concerner n’importe quel service distants, pas seulement SQL Server.

La mise en place ou pas dépendra de la confiance que vous avez dans la connectivité vers le service. Elle ne sera pas la même à l’intérieur d’un data center ou bien s’il faut changer de data center (typiquement le cas sous Azure), voire traverser Internet, un wifi…

Pattern disjoncteur

Le service peut mettre un peu de temps pour récupérer ou se chauffer après récupération.

S’il est réclamé par de nombreux clients, même avec une politique de réitération avec incrément il va se retrouver la cible d’un très grand nombre de demandes. Le service pourrait alors finir par se bloquer complètement et refuser des requêtes qu’en principe il aurai du pouvoir servir.

Il est donc nécessaire de compléter le mécanisme avec un disjoncteur (circuit breaker). Il s’agit d’un pattern dont l’intention est la suivante:

Libérer un service en difficulté le temps qu’il puisse se reconstruire et redémarrer.

 

Un autre intérêt de ce pattern est de proposer un mécanisme standard de notification qu’un service rencontre des difficultés avec toutes les informations utiles pour prendre une décision.

 

Le pattern disjoncteur est en souvent implémenté par un machine à 3 états :

  1. ouvert
  2. semi-ouvert
  3. fermé

 

 

L’algorithme:

2016-08-14_23-19-37

Comme on le voit, le pattern installe un filtre (le disjonteur) entre le client et le service. Ce filtre implémente un algorithme qui décide ou pas si la requête cliente est transmise au service.

Initialement le service est Closed. Toutes les requêtes sont transmises au service par le disjoncteur.

Si la requête lève une exception, le disjoncteur passe en mode Open et plus aucune requête ne sera transmise au service. Un chronomètre se déclenche alors. Le passage en mode Open est notifié au client via une exception spécifique par exemple pour qu’il puisse prendre certaines mesures comme passer dans un mode dégradé ou modifier son comportement en conséquence.

Le chronomètre finit par atteindre un seuil, le disjoncteur passe en mode HalfOpen. Dans ce mode les requêtes sont à nouveau transmises au service, mais sous surveillance. Si une exception est levée, cela signifie que le service est encore en convalescence, on le rebascule en Open et on relance le timer. Si la requête réussie, on bascule le service à Closed et le disjoncteur transmet toutes les requêtes à nouveau.

 

Il existe de nombreuses variantes. Par exemple on ne rebascule pas immédiatement en Open, mais on attend qu’un certain nombre de requêtes soient réussies. Le disjoncteur peut également analyser la nature des exceptions reçues du service et selon le cas appliquer un temps d’attente plus ou moins long…

La démo fournie plus loin gère le premier cas.

 

Important:

Il faut entendre le terme exception au sens le plus large. Ce qui est surveillé peut également être le temps de réponse du service afin de le protéger d’une surcharge via un disjoncteur. On aura ainsi quelque chose de ce genre:

2016-08-14_23-53-31

Le service de surveillance teste ce que vous souhaitez: le service renvoie t’il un Http 200, temps de réponse, nombre de requête par seconde…

Si le service est une base de données un SELECT le plus simple possible comme un simple:

select 1

 

Que fait t’on pendant que le disjoncteur est ouvert lorsqu’une requête se présente?

Tout d’abord il faut que le client soit notifié pour qu’il puisse mettre en place une action appropriée. On a vu que le disjoncteur lève une exception particulière. Charge ensuite au contrôleur de retourner un « HTTP 503 Service Unavailable » pour signifier la non disponibilité du service. La réponse peut envoyer des informations supplémentaires comme la durée estimée de la remise en service du service.

 

On peut ensuite essayer de basculer sur une méthode de secours:

  • Basculer sur un autre noeud
  • Basculer vers un service de substitution
  • Utiliser les caches
  • Enregistrer les commandes dans une queue ou autre storage pour les traiter en différé

Si aucunes de ces actions ne sont possibles, des méthodes plus passives peuvent être envisagées:

  • Afficher un message d’erreur convivial expliquant la situation.
  • Renvoyer une valeur par défaut
  • Ignorer l’erreur et continuer
  • Afficher un message recommandant de recommencer plus tard
  • Afficher des informations partielles

Le choix dépend bien entendu de votre application et du module en défaut. Il faut analyser avec soin chaque situation que l’on choisit de gérer.

Mise en œuvre pratique

Stratégie de réitération

Certains services gèrent eux même les stratégies de réitération, c’est le cas des storages Azure. Par exemple dans le cas d’une TABLE:

String _storageConnectionString = ConfigurationManager.AppSettings["storageCnx"];
CloudStorageAccount _storageAccount = CloudStorageAccount.Parse(_storageConnectionString);
 
CloudTableClient tableClient = _storageAccount.CreateCloudTableClient();
tableClient.DefaultRequestOptions.RetryPolicy = 
      new ExponentialRetry(TimeSpan.FromMilliseconds(500), 3);

La stratégie rejoue la requête jusqu’à 3 fois, avec un incrément exponentiel de l’intervalle entre deux essais en commençant au bout de 500 ms.

Le code précédent est en fait celui mis en place par défaut dans le SDK Azure. On a rarement besoin de le surcharger. DefaultRequestOptions est un paramètre global qui s’applique à tous les storages. On peut modifier le comportement au niveau du storage en définissant un IRequestOptions. C’est un des paramètres attendu par les méthodes d’accès au storage (Add…).

 

Si votre service ne gère pas nativement la réitération, on peut utiliser des librairies spécialisées comme Enterprise Library Transient Fault (ELTF). Le code suivant montre comment on pourrait construire une stratégie adaptée à des requêtes SQL Azure:


// On recommence jusqu'à 5 fois, au bout d'une seconde pour la première fois
// on incrémente de 2 secondes supplémentaires à chaque essai successif
Incremental retryStrategy = new Incremental(5, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2));
 
// Active la stratégie de réitération pour les pannes concernant SQL Azure
SqlAzureRetry = new RetryPolicy<SqlDatabaseTransientErrorDetectionStrategy>(retryStrategy);

On fait toujours les choses en deux temps:

  1. Création d’une stratégie de réitération
  2. Activation de la stratégie pour un service donné

 

Note: On a besoin d’installer deux Nugets:

  1. EnterpriseLibrary.TransientFaultHandling
  2. EnterpriseLibrary.TransientFaultHandling.Data

 

On utilise ensuite la stratégie lors de chaque appel à la base de données SQL Azure, par exemple on pourrait réécrire RaceRepository.Like():

public void Like(int idCourse)
{
   Course course = SqlAzureRetry.ExecuteAction<Course>(() =>
      {
         return Context.Courses.FirstOrDefault(c => c.CourseId == idCourse);
      });
 
      course.Likes++;
}

 

La version asynchrone est:


public async Task SaveAsync()
{
   await SqlAzureRetry.ExecuteAsync(async () =>
      {
         await Context.SaveChangesAsync();
      }
   );
}

 

ELTF est fournit avec des blocs d’applications prêts à l’emploi pour toute une gamme de services, mais en cas de besoin spécifique, le framework propose un modèle d’extensibilité.

 

Il existe aussi des librairies tierces comme Polly.

J’en ignore tout! La pub nous explique qu’elle gère de façon thread-safe tous les scénarios liés à la réitération des commandes.

Le point intéressant est que la librairie propose aussi le pattern disjoncteur ce que fait pas ELTF.

Il s’agit en outre d’une interface fluent, ça c’est quelque chose que j’aime bien!!

 

Dans tous les cas l’appli de démo a été mise à jour dans toutes ses versions.

Pattern disjoncteur

Le projet EscarGoDisjoncteur propose une implémentation du disjoncteur. Il est inspiré de cet article, mais pas trop car le code est incomplet et pas testé:

https://msdn.microsoft.com/en-us/library/dn589784.aspx

Le modèle objet est le suivant:

2016-08-14_00-42-43

CircuitBreaker et CircuitBreakerStateStore sont les deux classes principales.

CircuitBreaker est le point d’entrée du pattern, il expose une méthode qui attend une fonction à protéger par le disjoncteur parmi leurs paramètres. CircuitBreakerStateStore se charge de gérer les états du disjoncteur.

 

CircuitBreaker implémente l’algorithme de la machine à états présenté plus haut. A titre de démonstration il ne bascule en Closed le disjoncteur qu’au bout d’un certain nombre de requêtes réussies.

Ce code est difficile à tester en direct, j’ai donc ajouté des tests unitaires je pense complets:

2016-08-15_00-13-52

Commençons par CircuitBreakerStateStore.

C’est la classe qui se charge de gérer les états du disjoncteur et le contexte. Sa méthode Trip est invoquée chaque fois qu’un service lève une exception.


public void Trip(Exception ex)
{
   lock (_padLock)
   {
      LastStateChangedDateUtc = DateTime.UtcNow;
      LastException = ex;
      _currentTry = 0;
      State = CircuitBreakerStateEnum.Open;
   }
}

 

Elle bascule le disjoncteur en Open, enregistre l’exception et la date UTC où l’événement s’est produit.

La méthode CloseCircuitBreaker implémente la logique qui passe complètement en Open lorsque l’on est en HalfOpen:

public virtual void CloseCircuitBreaker()
{
   lock (_padLock)
   {
      Interlocked.Increment(ref _currentTry);
 
      if (_currentTry > _maxTry)
      {
 
         if (_currentTry > _maxTry)
         {
            _currentTry = 0;
           State = CircuitBreakerStateEnum.Closed;
         }
     }
   }
}

Mon exemple déclenche la bascule en Open, une fois réussi un certain nombre d’essais. Cette méthode est appelée par CircuitBreaker uniquement si le disjoncteur est HalfOpen. Comment s’effectue cet appel justement?

CircuitBreaker n’implémente qu’une seule méthode, Execute. La méthode est structurée ainsi:

public void Execute(Action action)
{
   if (!_circuitBreakerStateStore.IsClosed)
   {
      // le disjoncteur est ouvert
 
      // suite du code ...
   }
 
 
   // disjoncteur fermé, on exécute l'action
   try
   {
      action();
   }
   catch (Exception ex)
   {
      // on a une exception, on ouvre immédiatement le disjoncteur
      _circuitBreakerStateStore.Trip(ex);
 
      // Remplace l'exception par CircuitBreakerOpenException pour que le client
      // sache que le disjoncteur est maintenant ouvert
      throw new CircuitBreakerOpenException(ex);
   }
}

Action est l’action protégée par le disjoncteur, celle qui interagit avec le service.

On examinera le cas où le disjoncteur est ouvert plus loin. Le cas ordinaire est simple: action est exécutée, si une exception est levée on bascule le disjoncteur en Open et déclenche le chronomètre du HalfOpen. On lève ensuite CircuitBreakerOpenException pour notifier le client.

 

Le disjoncteur est maintenant ouvert, que fait t’on si des appels continuent d’arriver?

Le code manquant est le suivant:

lock (_padLock)
{
   if (!_circuitBreakerStateStore.IsClosed)
   {
      // le disjoncteur est ouvert
      if (_circuitBreakerStateStore.HasTimeoutCompleted())
      {
         // on peut tenter de passer en HalfOpen
         try
         {
 
            _circuitBreakerStateStore.SetHalfOpen();
 
            action();
 
            // implémente la logique qui décide si on ouvre complètement le disjoncteur
            _circuitBreakerStateStore.CloseCircuitBreaker();
 
            return;
         }
         catch (Exception ex)
         {
            // encore une exception, on attend encore un peu pour ouvrir
 
            // faire un log ici ...
 
            _circuitBreakerStateStore.Trip(ex);
            throw new CircuitBreakerOpenException(_circuitBreakerStateStore.LastException);        }
      }
 
      // permet au code client de savoir que le disjoncteur est ouvert et ainsi savoir s'il doit lancer une logique
      // spécifique
 
      // faire un log ici ...
 
      throw new CircuitBreakerOpenException(_circuitBreakerStateStore.LastException);
   }
}

On regarde si le chronomètre a atteint sa durée de seuil.

  • Si ce n’est pas le cas on lève CircuitBreakerOpenException pour indiquer que le disjoncteur est encore ouvert.
  • Si c’est le cas, alors on bascule en HalfOpen et joue l’action réclamée. En cas de réussite on joue CloseCircuitBreaker qui décide si on passe en Closed de la façon vue précédemment. Autrement on rebascule en Open et re-déclenche le chronomètre. On re-notifie le client de cette triste nouvelle.

 

Une fois construit notre disjoncteur, comment l’installe t’on dans l’application?

var breaker = new CircuitBreaker();
 
try
{
   breaker.ExecuteAction(() =>
   {
      // Operation protégée par le disjoncteur
      ...
   });
}
catch (CircuitBreakerOpenException ex)
{
   // Le disjoncteur a été ouvert
   // une action appripriée à mettre ici
   ...
}
catch (Exception ex)
{
   ...
}

 

Bibliographie

 

 

 

 

 

Publicités

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s