L’architecture logicielle : le socle de toute application web

L'architecture logicielle : la base pour ne pas devenir esclave de son application en production

Si on n’y prête pas une attention toute particulière, une application en production peut vite devenir comme une casserole de lait sur le feu : la lâcher des yeux, c’est prendre le risque de perdre tout le contenu !

Dans notre précédent billet, abordant le sujet des mises en production, nous avons abordé de manière très superficielle les bases de l’architecture logicielle de notre solution Jenji. Nous allons ici étudier le sujet beaucoup plus en profondeur et voir comment l’architecture de Jenji a été conçue pour que toutes les équipes puissent dormir sur leurs 2 oreilles.

Une architecture logicielle pour pallier à des être humains et des machines faillibles

Créer et faire évoluer une architecture logicielle est une problématique complexe et implique de très nombreux choix. Il est impossible de gagner sur tous les tableaux. Si vous en prenez conscience, vous pourrez faire les bons choix pour satisfaire vos objectifs. Chez Jenji, l’un de ces objectifs est de pouvoir rentrer chez soi le soir en étant serein sur le fait que l’application ne va pas intégralement s’effondrer dans la nuit.

L’architecture de Jenji a été guidée par 2 contraintes connues dès le départ : le besoin de traiter des fichiers aux formats variés et de sources inconnues, ainsi que la création d’applications mobiles et web hautement disponibles et capables un jour de servir des millions de clients.

Les potentialités d’erreurs existent à quasiment tous les niveaux. Il est donc illusoire d’espérer toutes les anticiper et les gérer avant de les rencontrer. Rien que pour notre solution, nous avons dû faire face à des dizaines de modèles de mobiles différents, à l’intégration des données dans les logiciels comptables des clients, à la nécessité d’une couverture réseau au fin fond de la Creuse, à l’environnement mouvant des machines virtuelles dans le cloud ou encore, à des fichiers renommés par les utilisateurs dans l’espoir d’en changer le format… Bref, les surprises ont été nombreuses au fil des années… Et beaucoup sont certainement encore à venir !

L’environnement n’est pas le seul en cause, les être humains sont aussi sources d’erreurs. Quel développeur n’a jamais causé le moindre bug ? Quel architecte n’a jamais regretté une décision quelques mois ou années plus tard ?

Une architecture logicielle en évolution

Nous avons donc pensé le système, non pas pour qu’il n’échoue jamais, mais pour être certain que n’importe quel sous-système pourrait décider arbitrairement d’arrêter de fonctionner.

Nous sommes arrivés à quelques principes simples pour résister et réagir au mieux à une  défaillance ou panne imprévue :

  • Aucune donnée ne doit être perdue, ever ! ;
  • Tous les autres systèmes doivent continuer à fonctionner ;
  • Nous devons être au courant moins d’une minute après un début d’incident.

Un des choix d’architecture les plus importants a été l’organisation des différents modules de l’application. La solution retenue pour Jenji est une application web avec un backend (la définition est dans l’article précédent) relativement monolithique et une myriade de petites applications gravitant autour, chacune dédiée à une tâche bien précise.

Le tout est connecté par divers moyens en fonction des besoins : DynamoDB Streams, queues SQS, buckets S3, etc… Peu importe le moyen, l’objectif premier est toujours le même : créer un espace dans lequel il est possible de déposer des données et de les oublier (fichiers à traiter, ID d’objets à exporter, ID d’utilisateurs auxquels envoyer un email...). Le service dont c’est la responsabilité est alors chargé d’effectuer son traitement sur toutes les données qu’il reçoit, aussi efficacement que possible. Ce service peut aussi, lors de son traitement, déposer des données dérivées dans d’autres zones tampon, et créer ainsi des chaînes de traitements.

Ce motif limite au maximum le risque de perte de données et permet de disposer de petits systèmes simples, relativement indépendants, extrêmement performants et accessibles depuis n’importe quel autre service de la plateforme. Chaque module peut aussi avoir ses dépendances spécifiques, avec leurs versions. Ceci réduit les risques de conflits potentiels sur les bibliothèques partagées par plusieurs dépendances directes. Enfin, les déploiements d’évolutions et correctifs sont fractionnés et plus facilement maîtrisés.

Une telle organisation n’est cependant pas exempte de défauts. En cas de chaînage, un délai est introduit à chaque étape et un maillon défaillant peut bloquer toute sa chaîne. Le nombre d’applications à surveiller est élevé (déjà plus de 50 chez Jenji !) et tend à augmenter avec le temps. Pour certaines fonctionnalités, il peut être nécessaire de déployer, dans le bon ordre, un grand nombre de services interdépendants. L’aspect asynchrone des traitements complexifie aussi grandement la mise à jour des informations sur les interfaces avec lesquelles les utilisateurs travaillent.

Architecture logicielle et micro-services, maxi problèmes ?

En plus de sa complexité, cette organisation en micro-services implique de s’organiser pour absorber ou contourner plusieurs types de défaillances.

Pour gérer les défaillances temporaires d’un ou plusieurs systèmes externes, tous nos services sont configurés avec des timeouts courts. Si quelque chose qui s’exécute normalement en quelques millisecondes prend tout d’un coup plus de quelques secondes, il y a un souci. Probablement mineur et passager mais à quoi bon attendre ? La solution classique est efficace : échouer rapidement et retenter un peu plus tard, avec un délai qui augmente à chaque nouvel échec (exponential backoff).

Généraliser ce modèle d’échec rapide suivi de multiples nouvelles tentatives permet d’absorber une très grande majorité des erreurs typiques du cloud, liées aux changements réseau, aux VMs (Machines Virtuelles) qui meurent puis ressuscitent dans les secondes qui suivent, etc...

Pour répondre à une défaillance du service lui-même, le cloud, bien utilisé, peut aider en fournissant automatiquement la surveillance/gestion des machines et services. Chez Jenji, nous utilisons les services ECS et EC2 d’AWS pour gérer nos clusters de machines. EC2 gère les machines/VMs et s’assure qu’une machine qui s'arrête va être remplacée rapidement. ECS, lui, s’assure que tous les services sont bien en cours d'exécution. Si certains manquent à l’appel, ils sont redémarrés au plus vite sur les machines saines disponibles. Le seul souci est alors de s’assurer que le code est capable de supporter les interruptions/reprises impromptues.

Chaque système traitant une pile de données est aussi susceptible de bloquer sur le traitement d’une donnée en particulier. Ce phénomène de “poison-pill” peut généralement être contourné sans trop de difficultés, mais la sanction en cas d’oubli ou erreur est dure : blocage complet du service jusqu’à intervention humaine... Lors de la reprise des opérations, la charge soudaine engendrée sur les services en aval peut causer d’autres défaillances…

Le cas particuliers des systèmes critiques dans une architecture logicielle

Chez Jenji, la base de tout le système est la saisie des notes de frais. Sans cette entrée de données, aucun autre système n’a le moindre sens. Quand un composant d’une application présente ce niveau de criticité, il est vital de le concevoir en conséquence.

Pour ce système très particulier, nous avons fait le choix de ne pas implémenter les API avec le reste de notre backend. Cette option aurait été bien plus simple, mais le moindre souci sur le cluster hébergeant le backend aurait alors risqué d’impacter cette unique opération critique.

Toute création de notes de frais passe donc par une API distincte, utilisant les services API Gateway et AWS Lambda. API Gateway expose un endpoint HTTPS et délègue le traitement de l’action à une Lambda, fonction autonome que nous avons implémentée et maintenons mais dont l’exécution est laissée à la charge d’AWS. Pas de machine à gérer, pas de cluster à dimensionner, pas de load-balancer à configurer... AWS s’occupe de tout et provisionne, en temps réel, autant d’instances de notre Lambda que nécessaire pour répondre aux requêtes entrantes. Si, pour une raison quelconque, une exécution échoue, elle n’aura ainsi aucun impact sur les autres. Et, petit bonus, en période creuse, nous  ne payons pas plusieurs serveurs en train de se tourner les pouces !

La résilience d’une application est, nous en sommes convaincus, une problématique dont les réponses doivent impérativement intervenir à tous les niveaux. L’architecture globale, le socle d’exécution (bare metal, machines virtuelles, docker ou serverless... peu importe) et le code doivent être pensés avec cet objectif en tête. Et avec beaucoup d’huile de coude et de petits soins, au fil du temps, votre application peut devenir un marin au pied sûr, bras croisés à la proue de son navire au milieu de la tempête.

Pour faciliter la gestion de vos notes de frais, n’hésitez pas à tester notre solution en la téléchargeant gratuitement. Et si l'aventure vous tente, nous vous attendons !

À vous de jouer : rejoignez-nous !