Préambule
Dans l’univers du développement logiciel, la scalabilité d’une application est une pierre angulaire déterminante de son succès à long terme. Alors que les entreprises cherchent constamment à optimiser leurs systèmes pour gérer des charges d’utilisateurs et de données croissantes, l’architecture de ces systèmes joue un rôle crucial.
Nous verrons ensemble comment concevoir une application de manière à ce qu’elle soit scalable, et les solutions possibles qui vous permettront de dormir sur vos deux oreilles !
Cet article est le premier d’une liste de 5 chapitres, concernant les secrets de la scalabilité :
- Les Pratiques de Développement
- L’Infrastructure
- Les Performances et l’Optimisation
- La Fiabilité et la résilience
- Le Monitoring
Scalabilité : Principes à respecter
Dès le développement de votre application, et avant même de penser à l’infrastructure, il y a deux aspects fondamentaux que vous devez considérer pour garantir la scalabilité :
- Les données de l’application
- Les accès concurrents
Les données de l’application
La majorité des applications stockent différents types de données pour pouvoir fonctionner :
- Le contenu de l’application
- Des sessions (pour reconnaître l’utilisateur connecté)
- Des données calculées
- Du cache
- Etc…
Ces données peuvent être stockées de bien des façons, et votre première préoccupation pour atteindre la scalabilité sera de rendre votre application stateless.
Une application stateless (par opposition à une application stateful) ne conserve pas d’état. Plus précisément, cela signifie que l’application ne possède aucune données qui ne soit pas partagée. Que ces données soient stockées en mémoire, ou dans des fichiers, si votre application n’est pas en mesure de partager ces données avec une autre instance, alors cela signifie que votre application est stateful, et donc inadaptée pour la scalabilité.
Inadapté est un grand mot. En réalité, il est possible de mettre en place un système de synchronisation entre plusieurs instances d’un service stateful, mais la solution la plus simple et la moins coûteuse (si la situation le permet), et de vous appuyer sur des outils existants et éprouvés.
Mais alors, comment rendre votre application stateless ? Ce n’est en réalité pas très compliqué, voyez donc les cas suivants :
- Contenu de l’application : stockez ces données dans une base de données (de préférence adaptée à la scalabilité horizontale)
- Données concernant l’utilisateur connecté : utilisez JWT, lequel permet de stocker les données côté client
- Données calculées et autres données de session : stockez ces données en mémoire partagée (ex: avec Redis)
- Fichiers : stockez les fichiers sur un Object Store
Contenu de l’application
Les données formant le contenu de votre application, si celle-ci est dynamique, peuvent être :
- Les recettes d’une application de cuisine
- Les utilisateurs et leur contenu pour un réseau social
- Les tâches d’une application de gestion de projet
- Etc…
Ces données sont les plus classiques, et sont généralement bien identifiées par les développeurs, et stockées dans des bases de données. Le choix de la base de données est important, mais le simple fait de réunir toutes ces données dans une base accessible par n’importe quelle instance de votre service permet d’éviter certains problèmes de scalabilité.
Données concernant l’utilisateur connecté
Les applications emploient très souvent sur un système de session pour avoir un accès rapide aux informations de l’utilisateur connecté. Que ce soit pour son id, ou les rôles qui lui sont attribués, les informations de l’utilisateur connecté peuvent servir à restreindre l’accès à certaines données, ou certaines actions, ou plus encore.
En PHP par exemple, beaucoup d’applications utilisent la variable « $_SESSION » pour stocker ces informations. Or, ces données sont par défaut stockées dans un fichier, qui n’est pas partagé avec les autres instances d’une même application.
Le problème principale qui en découle est que suivant l’instance chargée de traiter la requête de l’utilisateur, celui-ci serait reconnu comme connecté, non connecté, ou encore connecté mais avec des informations différentes, d’une instance à l’autre.
Il existe alors plusieurs solutions possibles :
- Mettre en place JWT (JSON Web Tokens) : cette solution a l’avantage de stocker les données côté client
- Stocker les données de session en base de donnée : ce qui peut alourdir l’application
- Etc…
Aujourd’hui, JWT est une solution éprouvée ayant de nombreux avantages, et si vous ne le connaissez pas, je vous invite à vous renseigner à son sujet, surtout si l’optimisation vous préoccupe.
Quelle que soit la solution que vous retenez, gardez bien en tête que n’importe quelle instance doit pouvoir traiter la même requête et y apporter une réponse identique.
Données calculées, cache, etc…
Quel que soit le langage de programmation utilisé, certaines données peuvent être stockées en cache. Même avec PHP ou d’autres langages équivalents, consistant généralement à traiter une requête HTTP puis à stopper le processus, il est possible de stocker des données en cache, afin d’y avoir accès lors d’une prochaine requête. Cela peut également être le cas pour un « worker », qui tournerait pendant un certain temps en arrière plan, ou même indéfiniment, et qui garderait une information en mémoire.
Ces données sont un problème pour la scalabilité, et un peu plus difficile à identifier que les autres généralement. Imaginez que votre programme garde en mémoire le nombre de fois qu’une action a été effectuée depuis son démarrage, et que la réponse dépende de ce nombre. Deux instances différentes retourneraient alors des résultats différents.
On pourrait également choisir de mettre en cache certaines données, afin d’avoir un accès plus rapide. Mais sommes nous certains que deux instances distinctes délivreraient systématiquement le même résultat si elles s’appuyaient sur des données en cache ?
Heureusement, des solutions dites de « mémoire partagée » permettent de résoudre ce problème. Prenez Redis par exemple (ou Redict), qui est une des solutions les plus connues. Grâce à ces solutions, vous pourrez déployer une ou plusieurs instances d’une base de données spécialement conçue pour gérer des données en mémoire. Chaque instance de votre application pourra ainsi lire et écrire dans cette base de données tout ce qui aurait habituellement été stocké sur votre serveur.
Fichiers
Il se peut que votre application mette à disposition de ses utilisateurs des fichiers (que ce soit les photos de profil de vos utilisateurs, les vidéos qu’ils créent, ou tout autre type de fichier qui n’est pas intégré directement dans le code de votre application).
Si vous stockez ces fichiers directement sur le serveur hébergeant votre application, il y a fort à parier que vous aurez des difficultés pour les rendre accessibles à n’importe quelle instance de votre application, qu’elle soit sur le même serveur ou sur un cluster différent.
Pour cela aussi il existe des solutions dites d’Object Store. Il s’agit ni plus ni moins d’une application dédiée au stockage des fichiers. L’intérêt de ces outils est double :
- Ils sont généralement compatibles avec différents types de système de stockage (ex: S3)
- Ils sont généralement scalables et permettent donc une gestion multi-instances
Donc au lieu de stocker les fichiers uploadés directement sur le serveur, vous pouvez les transférer vers un de ces outils.
Je vous recommande personnellement MinIO, que j’utilise personnellement dans divers projets.
Gérer les accès concurrents
Les accès concurrents représentent une deuxième difficulté courante rencontrée lorsque l’on souhaite rendre scalable une application.
On peut citer deux types de situations :
- Un traitement déclenché par un utilisateur/tâche suite à une action/événement
- Un traitement automatisé déclenché à heure précise (ex: tâche CRON)
Dans certains cas, ces situations ne posent pas de problèmes avec des instances multiples.
Mais lorsque le traitement ne doit pas être déclenché plus d’une fois en même temps, et qu’aucun système n’a été mis en place afin de garantir cette unicité, la scalabilité se retrouve compromise.
Les Locks
Divers outils proposent des mécanismes permettant de garantir un accès unique à une ressource. Vous en avez très probablement déjà rencontré, ne serait-ce que votre Système de Gestion de Base de Données. Prenez MySQL par exemple, celui-ci propose une solution de Lock à travers la commande « FOR UPDATE » (entre autres). Redis, qui offre une solution de mémoire partagée permet également de faire des locks.
Mais de quoi s’agit-il précisément ? Et pourquoi en ai-je besoin ?
Imaginez le cas suivant : une application de réservation de gîtes pour les vacances doit rappeler les détails des réservations aux clients 24h avant le début de la location. Pour cela, une tâche CRON est configurée afin de détecter les rappels à envoyer, et déclencher l’envoi d’e-mails avec un récapitulatif de la réservation.
S’il n’y a qu’une instance de votre application, vous n’aurez aucun problème. Mais si vous avez déployé plusieurs instances, vous risquez d’avoir plusieurs e-mails envoyés en doublons. Et oui, comment vos instances sauront-elles si une autre instance a déjà envoyé ou non l’e-mail ? Vous pourriez stocker un booléen en base permettant de définir le rappel comme ayant été envoyé. Mais si deux instances tournent en même temps, et regardent en base au même moment si le rappel a été envoyé, elles peuvent toutes les deux tirer la même conclusion, sans savoir qu’une autre instance est en train de faire la même chose.
Si vous prenez tout simplement votre serveur MySQL, vous pouvez alors faire un « SELECT … FOR UPDATE » pour récupérer la ligne, ce qui aura pour effet de mettre « en attente » tout autre processus qui viserait à lire les mêmes données. De cette manière, vous pouvez « réservez » une ressources durant tout votre traitement, et éviter les conflits entre différents processus.
MySQL n’est peut-être pas la solution la plus adaptée, il s’agit ici seulement d’un exemple. Libre à vous d’utiliser la solution la plus adaptée à votre situation. Redis par exemple, que nous avons évoqué précédemment, offre une solution de Lock également.
Les files d’attente de messages
Les traitements énoncés précédemment peuvent parfois demander une grosse charge de travaille, et pourraient être décomposés en tâches plus petites. Prenons par exemple l’envoi d’une newsletter personnalisée, qui se déclencherait à heure fixe, et consisterait à envoyer un e-mail personnalisé à chaque utilisateur de l’application (potentiellement des milliers d’utilisateurs).
Dans ce cas, les locks ne suffisent pas, car bien qu’ils permettront d’éviter le déclenchement du traitement sur plusieurs instances à la fois (pour ne pas envoyer les e-mails en double), ils ne permettent pas de répartir le travail entre les différentes instances.
C’est ici qu’entre en jeux les files d’attente de messages. Parmi les solutions les plus connues, on retrouve RabbitMQ, ou encore Kafka. Mais il est tout à fait possible de mettre en place un système similaire seulement avec votre système de gestion de base de données (ex: MySQL).
Le but est simple : dans notre exemple, au lieu d’envoyer tous les e-mails directement, le traitement déclenché à heure fixe devrait générer des messages (comme des tâches) correspondant au travail à effectuer. Ce pourrait être par exemple un message par e-mail à envoyer. Le but étant de rendre ce traitement le plus rapide possible. Dans un second temps, les différents processus (workers) se partageraient les messages (les tâches à réaliser), en les prenant un à un. Une fois le message récupéré, le processus pourrait s’occuper de la génération du contenu de l’e-mail, et de son envoi. Ainsi, en augmentant le nombre de processus dédiés à cette tâche, on pourrait diviser le temps nécessaire pour envoyer tous les e-mails.
Pour aller plus loin en scalabilité
Je parle très souvent d’Architecture Orientée Service sur ce blog, ou de microservices. Il faut savoir que ces approches architecturales, bien que non indispensables pour la scalabilité, permettent un réglage plus fin et maîtrisé des différentes ressources.
En effet, plus votre application sera découpée en petits services, plus vous aurez de flexibilité pour placer des ressources là où c’est nécessaire.
Imaginez une application offrant une multitude de fonctionnalités, mais dont seule celle consistant à générer des PDFs serait très sollicitée. Avec une architecture monolithique ou similaire, vous seriez obligé de multiplier les instances de votre application complète. Alors qu’avec des microservices, seul le service de génération de PDFs pourrait être déployé à plus grande échelle. Au final, pour une expérience utilisateur similaire, vous auriez besoin de moins de ressources systèmes (puisqu’il vous faudrait plus de ressources pour faire tourner toute l’application, que pour un seul microservice).
Ces Architectures sont très intéressantes pour obtenir un niveau très poussé d’optimisation. Mais cela ne signifie pas qu’elles sont indispensables. Même une application monolithique peut être développée de manière à être scalable.
Conclusion
La scalabilité regroupe de nombreuses pratiques, certaines indispensables, et d’autres simplement intéressantes d’un point de vu optimisation. Mais le développement d’une application ne peut s’affranchir de certaines règles, le mieux étant de les prendre en considération dès le début de la conception.
Nous verrons dans un prochain article les aspects de la scalabilité qui concernent l’infrastructure, alors j’espère vous revoir bientôt 🙂