Technologie
Si vous avez déjà développé un service web ou une application web, vous connaissez le processus : choisir une base de données, sélectionner un framework de service web (et de nos jours, choisir également un framework front-end, mais ne nous attardons pas là-dessus).
Cela fait des décennies que ce schéma est en place, et peu de personnes remettent en question s’il s’agit toujours de la meilleure méthode pour créer une application web. Cependant, de nombreux changements ont eu lieu au cours de la dernière décennie :
- Les disques sont désormais beaucoup plus rapides (NVMe).
- Les disques sont également plus robustes (EBS/EFS, etc.).
- La RAM est très abordable ; pour la plupart des startups, il est probable que toutes vos données puissent tenir dans la RAM.
- Vous pouvez louer une machine avec des centaines de cœurs si vous le souhaitez.
Ce n’était pas le cas lorsque j’ai commencé à travailler dans une startup Rails en 2010. Mais surtout, un changement majeur s’est produit au cours de la dernière décennie :
- L’algorithme de consensus Raft a été publié en 2014, avec de nombreuses implémentations robustes facilement accessibles.
Dans cet article, nous allons explorer une nouvelle architecture pour le développement web. Nous l’utilisons avec succès pour Screenshotbot, et nous espérons que vous l’adopterez également.
Je vais diviser cet article en trois parties : Explorer, Élargir et Extraire, en faisant référence aux 3X de Kent Beck. Vos besoins varieront à chaque étape de votre startup, et je vais démontrer comment utiliser cette architecture dans chacune de ces phases.
Explorer la Technologie
Vous êtes une nouvelle startup. Vous itérez sur un produit, vous n’avez aucune idée de la manière dont les gens vont l’utiliser, ni même s’ils vont l’utiliser.
Pour la plupart des startups aujourd’hui, cela signifierait choisir Rails, Django, Node ou un autre framework, soutenu par une base de données comme MySQL, PostgreSQL ou MongoDB.
« Gardez-le simple », dites-vous, et cela semble suffisamment simple.
Mais est-ce vraiment aussi simple que cela pourrait l’être ? Pourrions-nous simplifier encore davantage ? Que se passerait-il si le service web et l’instance de base de données n’étaient qu’un seul et même élément ? Je ne parle pas d’utiliser quelque chose comme SQLite, où vos données sont toujours sérialisées, mais plutôt de considérer que toute la mémoire de votre RAM est votre base de données.
Imaginez toutes les choses incroyables que vous pourriez créer si vous n’aviez jamais à sérialiser des données en requêtes SQL. Tout d’abord, vous n’avez pas besoin de plusieurs serveurs front-end communiquant avec une seule base de données ; il vous suffit d’obtenir un serveur plus puissant avec plus de RAM et de CPU si nécessaire. Qu’en est-il des index ? Vous pouvez utiliser des index en mémoire, essentiellement des tables de hachage pour rechercher des objets. Vous n’avez pas besoin d’index sophistiqués comme les B-arbres, optimisés pour la latence des disques. (En fait, vous pouvez utiliser certains index qui n’étaient probablement pas possibles avec des bases de données traditionnelles. Un index utilisant des collections fonctionnelles a été crucial pour la scalabilité de Screenshotbot.)
Vous n’aurez également pas besoin d’architectures spéciales pour réduire les allers-retours vers votre base de données. En particulier, vous n’aurez pas besoin de cette gestion asynchrone, car vos threads ne sont plus limités par les entrées/sorties. Récupérer des données devient simplement une question de lecture de la RAM. Le débogage du code devient également beaucoup plus simple.
Vous n’avez pas besoin de services pour exécuter des tâches en arrière-plan, car ces tâches sont simplement des threads s’exécutant dans ce grand processus.
Vous n’avez pas besoin de protocoles de concurrence complexes, car la plupart de vos exigences en matière de concurrence peuvent être satisfaites par de simples mutex en mémoire et des variables de condition.
Mais vient alors la question cruciale : comment récupérer lorsque votre processus plante ? La réponse est simple : prenez périodiquement un instantané de tout ce qui se trouve dans la RAM.
Attendez, que se passe-t-il si vous avez apporté des modifications depuis le dernier instantané ? Voici l’astuce : chaque fois que vous modifiez des parties de la RAM, vous écrivez une transaction sur le disque. Ainsi, si vous avez une ligne comme foo.setBar(2)
, cela écrira d’abord une transaction indiquant que nous avons changé le champ bar
de foo
à 2, puis définira effectivement le champ à 2. Une opération comme new Foo()
écrira une transaction sur le disque pour indiquer qu’un objet Foo a été créé, puis renverra le nouvel objet.
Ainsi, si votre processus plante et redémarre, il recharge d’abord l’instantané, puis rejoue les journaux de transactions pour récupérer complètement l’état. (Notez que les modifications d’index n’ont pas besoin de faire partie du journal des transactions. Par exemple, s’il y a un index sur le champ bar
de Foo
, alors setBar
devrait simplement mettre à jour l’index, qui sera mis à jour qu’il soit lu à partir d’un instantané ou d’une transaction.)
Enfin, cette architecture permet d’écrire un nouveau type de code qui n’était pas possible auparavant. Étant donné que toutes les requêtes sont traitées par le même processus, qui ne se fait généralement pas tuer, cela signifie que vous pouvez stocker des fermetures en mémoire qui peuvent être utilisées pour servir des pages. Par exemple, sur Screenshotbot, si vous voyez un URL comme « https://screenshotbot.io/n/nnnnnnn », il s’agit en réalité d’une fermeture sur le serveur, où nnnnnnn
correspond à une fermeture interne. Étonnamment, ce simple changement signifie que nous n’avons pas besoin de sérialiser des objets lors des transitions de page. La fermeture a des références aux objets, donc nous n’avons pas besoin de transmettre des identifiants d’objet à chaque requête. En JavaScript, cela pourrait hypothétiquement ressembler à :
function renderMyObject(obj) {
return ...
obj.delete())>Supprimer
...
}
Tout cela signifie que vous pouvez itérer rapidement. Si vous devez déboguer, il n’y a qu’un seul service à déboguer. Si vous devez profiler le code, il n’y a qu’un seul service à profiler (plus besoin des journaux de requêtes lentes de MySQL). Il n’y a qu’un seul service à surveiller : si ce service tombe en panne, le site sera également hors ligne, mais comme il n’y a qu’un seul service et un seul serveur, la probabilité de défaillance est également beaucoup plus faible. Si le serveur tombe, AWS mettra automatiquement en ligne un nouveau serveur pour le remplacer en quelques minutes.
Il est également beaucoup plus facile d’écrire du code de test, car vous n’avez plus besoin de simuler des bases de données.
Élargir la Technologie
Vous avancez rapidement, itérant et développant des idées, tout en attirant lentement des clients en cours de route.
Puis un jour, vous obtenez un client de renom. Bingo, vous entrez maintenant dans la phase d’Élargissement de votre startup.
Mais il y a un hic : ce client de renom exige une disponibilité de 99,999 %.
Il est certain que l’architecture que nous venons de décrire ne peut pas gérer cela. Si le serveur tombe, nous devrions attendre plusieurs minutes qu’AWS le remette en ligne. Une fois qu’il est de nouveau opérationnel, nous pourrions attendre plusieurs minutes pour que notre processus restaure même l’instantané depuis le disque. Même les redéploiements sont délicats :
Optimisation de l’Architecture des Services Web
Redémarrer un service peut entraîner une interruption du serveur pendant plusieurs minutes.
C’est ici qu’intervient le Protocole de Consensus Raft.
Raft est un algorithme et un protocole remarquables. Il prend votre machine à états finis (votre serveur web/base de données) et réplique essentiellement le journal des transactions. Ainsi, nous pouvons étendre notre architecture simple sur trois machines. Si le leader tombe en panne, un nouveau leader est élu en quelques secondes et continue de traiter les requêtes.
Nous avons transformé notre service basique en une base de données hautement disponible, sans modifier fondamentalement la manière dont les développeurs écrivent leur code.
Avec ce mécanisme, il est également possible de déployer progressivement sans jamais arrêter le serveur. (Bien que nous redémarrions rarement nos processus serveur, nous en parlerons plus tard.) Étant donné qu’il n’y a qu’un seul service, il est également facile de calculer vos garanties de disponibilité.
Préparation à la Croissance
Votre startup connaît un bon développement et vous avez des milliers de clients importants.
Pour être franc, Screenshotbot n’est pas encore à ce stade, mais nous nous préparons à cette éventualité, avec une surveillance en place pour anticiper les goulets d’étranglement.
La solution ici est quelque chose que les grandes entreprises appliquent déjà à leurs bases de données : le sharding. Vous pouvez diviser vos services web en fragments, chaque fragment étant son propre cluster. En particulier, chez Screenshotbot, nous appliquons déjà cette méthode : chacun de nos clients d’entreprise dispose de son propre cluster dédié. (Petite anecdote : Meta a adopté Raft pour gérer la réplication de chacun de ses clusters MySQL, donc nous faisons essentiellement la même chose sans utiliser de base de données distincte.)
Je ne sais pas à quoi m’attendre, car je suis plutôt du genre à résoudre les problèmes au fur et à mesure. Le principal goulet d’étranglement que je prévois est l’évolutivité du fil de validation. Les fils de lecture se parallélisent très bien. Il y a un fil de validation qui applique chaque transaction une à une. Il s’avère que la latence du disque est sans importance ici, puisque l’algorithme Raft peut simplement valider plusieurs transactions ensemble sur le disque. Ma principale préoccupation est que le coût CPU pour appliquer les transactions dépasse la performance d’un seul cœur. Je doute fortement que cela se produise, mais c’est une possibilité. À ce stade, nous pourrions profiler le coût des validations et l’améliorer (par exemple, déplacer une partie du travail hors du fil de transaction), ou nous pourrions simplement envisager le sharding. Je prévois probablement d’écrire un autre article de blog lorsque cela se produira.
Notre Technologie
Maintenant que j’ai expliqué le concept, laissez-moi vous parler de notre stack technologique et pourquoi elle s’est révélée si adaptée à cette architecture.
Nous utilisons Common Lisp. Mon implémentation initiale de Screenshotbot utilisait MySQL, mais je l’ai rapidement remplacée par bknr.datastore précisément parce que gérer la concurrence avec MySQL était difficile et Screenshotbot est une application hautement concurrente. BKNR Datastore est une bibliothèque qui gère l’architecture décrite dans la section Explorer, mais conçue pour Common Lisp. (Il existe des bibliothèques similaires pour d’autres langages, mais elles sont peu nombreuses.)
Common Lisp est également fortement multithreadé, ce qui est crucial pour cette architecture, car vos requêtes web sont traitées par des fils dans un seul processus. Ruby ou Python ne conviendraient pas à cette exigence.
Nous utilisons également le concept de fermetures que j’ai mentionné précédemment. Cela signifie que nous ne pouvons pas redémarrer le serveur fréquemment (si vous redémarrez le serveur, vous perdez les fermetures). Donc, recharger le code se fait simplement par un rechargement à chaud dans le processus en cours. Il s’avère que Common Lisp excelle dans ce domaine : une grande partie de la norme concerne le rechargement du code. (Par exemple, si la définition de classe change, comment mettre à jour les objets de cette classe ? Il existe une norme pour cela.)
Il nous arrive de redémarrer les serveurs. Actuellement, il semble que nous ne redémarrions les serveurs qu’une fois tous les mois ou deux mois. Lorsque cela est nécessaire, nous procédons simplement à un redémarrage progressif avec notre cluster Raft. Nous utilisons un cluster de 3 serveurs par installation, ce qui permet à un serveur de tomber en panne. Nous n’utilisons pas Kubernetes, car nous n’en avons pas besoin (du moins, pas encore).
Pour l’implémentation de Raft, nous avons écrit notre propre bibliothèque personnalisée basée sur bknr.datastore. Nous avons construit et open-sourcé bknr.cluster, qui utilise en arrière-plan la fantastique bibliothèque Braft de Baidu. Braft est extrêmement solide, et je le recommande vivement. Braft gère également les instantanés en arrière-plan, ce qui signifie que pendant que nous prenons des instantanés, le serveur peut continuer à traiter des requêtes.
Pour stocker des fichiers image ou des blobs qui ne devraient pas faire partie de la base de données, nous utilisons EFS (un NFS hautement disponible) partagé entre les trois serveurs. EFS est plus facile à utiliser que S3, car nous n’avons pas à gérer les conditions d’erreur. EFS rend également notre code plus testable, car nous n’interagissons pas avec un serveur externe, mais écrivons simplement sur le disque.
Quelle est notre capacité d’évolutivité ? Nous avons quelques grands clients d’entreprise, mais un client en particulier est très connu. Screenshotbot fonctionne sur leur CI, donc nous recevons des requêtes API des centaines de fois pour chaque commit et Pull Request. Malgré cela, nous n’avons besoin que d’une machine de 4 cœurs et 16 Go pour traiter leurs requêtes. (Et des machines similaires pour les répliques, principalement en veille.) Même avec cela, l’utilisation du CPU atteint un maximum de 20 %, mais même alors, la plupart de cela provient du traitement d’images, donc nous avons beaucoup de marge pour évoluer avant d’avoir besoin d’augmenter le nombre de cœurs.
Conclusion sur notre Architecture
Je pense que cette architecture est excellente pour les nouvelles startups, et j’espère que davantage d’entreprises l’adopteront. Évidemment, vous devrez développer certains des outils que nous avons créés pour le langage de votre choix. (Cependant, si vous choisissez d’utiliser Common Lisp, tout est à votre disposition et entièrement open-source.)
Nous sommes très reconnaissants envers les personnes derrière bknr.datastore, Braft et Raft, car sans leur travail, nous ne pourrions pas réaliser tout cela.
Si vous avez trouvé cela utile ou intéressant, n’hésitez pas à le partager sur les réseaux sociaux. Pour toute question, vous pouvez me contacter à [email protected].