Comment Braze exploite Ruby à grande échelle
Publié: 2022-08-18Si vous êtes un ingénieur qui lit Hacker News, Developer Twitter ou toute autre source d'information similaire, vous avez presque certainement rencontré un millier d'articles avec des titres tels que "Speed of Rust vs C", "What Makes Node. js plus rapide que Java ?" ou "Pourquoi utiliser Golang et comment démarrer". Ces articles affirment généralement qu'il existe un langage spécifique qui est le choix évident pour l'évolutivité ou la vitesse, et que la seule chose à faire est de l'adopter.
Pendant que j'étais à l'université et ma première année ou deux en tant qu'ingénieur, je lisais ces articles et je lançais immédiatement un projet favori pour apprendre la nouvelle langue ou le nouveau cadre du jour. Après tout, il était garanti qu'il fonctionnerait « à l'échelle mondiale » et « plus vite que tout ce que vous avez jamais vu », et qui peut résister à cela ? Finalement, j'ai compris que je n'avais en fait besoin d'aucune de ces choses très spécifiques pour la plupart de mes projets. Et au fur et à mesure que ma carrière progressait, je me suis rendu compte qu'aucun choix de langue ou de cadre ne me donnerait réellement ces choses gratuitement.
Au lieu de cela, j'ai découvert que c'est l'architecture qui est en fait le plus grand levier lorsque vous cherchez à faire évoluer les systèmes, pas les langages ou les frameworks.
Chez Braze, nous opérons à une immense échelle mondiale. Et oui, nous utilisons Ruby et Rails comme deux de nos principaux outils pour le faire. Cependant, il n'y a pas de valeur de configuration « global_scale = true » qui rend tout cela possible. C'est le résultat d'une architecture bien pensée qui s'étend au plus profond des applications jusqu'aux topologies de déploiement. Les ingénieurs de Braze examinent constamment les goulots d'étranglement et cherchent à rendre notre système plus rapide, et la réponse n'est généralement pas « s'éloigner de Ruby » : il s'agira presque certainement d'un changement d'architecture.
Voyons donc comment Braze tire parti d'une architecture réfléchie pour résoudre réellement la vitesse et une échelle mondiale massive - et où Ruby et Rails s'y adaptent (et ne le font pas) !
La puissance de la meilleure architecture de sa catégorie
Une simple requête Web
En raison de l'échelle à laquelle nous opérons, nous savons que les appareils associés aux bases d'utilisateurs de nos clients feront chaque jour des milliards de requêtes Web qui devront être servies par un serveur Web Braze. Et même dans le plus simple des sites Web, vous allez avoir un flux relativement complexe associé à une requête d'un client vers le serveur et inversement :
Cela commence par le résolveur DNS du client (généralement son FAI) qui détermine à quelle adresse IP aller, en fonction du domaine dans l'URL de votre site Web.
Une fois que le client a une adresse IP, il enverra la demande à son routeur de passerelle, qui l'enverra au routeur "prochain saut" (ce qui peut se produire plusieurs fois), jusqu'à ce que la demande se dirige vers l'adresse IP de destination.
À partir de là, le système d'exploitation du serveur recevant la requête gérera les détails du réseau et notifiera au processus d'attente du serveur Web qu'une requête entrante a été reçue sur le socket/port sur lequel il écoutait.
Le serveur Web écrira la réponse (la ressource demandée, peut-être un index.html) à ce socket, qui reviendra à travers les routeurs jusqu'au client.
Des trucs assez compliqués pour un site Web simple, non ? Heureusement, beaucoup de ces choses sont prises en charge pour nous (plus à ce sujet dans une seconde). Mais notre système a toujours des magasins de données, des tâches en arrière-plan, des problèmes de concurrence, et bien plus encore ! Plongeons dans ce à quoi cela ressemble.
Les premiers systèmes qui prennent en charge l'échelle
Le DNS et les serveurs de noms ne nécessitent généralement pas beaucoup d'attention dans la plupart des cas. Votre serveur de noms de domaine de premier niveau aura probablement quelques entrées pour mapper "votresiteweb.com" aux serveurs de noms de votre domaine, et si vous utilisez un service comme Amazon Route 53 ou Azure DNS, ils géreront le nom serveurs pour votre domaine (par exemple, gestion des enregistrements A, CNAME ou d'autres types d'enregistrements). Vous n'avez généralement pas à penser à la mise à l'échelle de cette partie, car cela sera géré automatiquement par les systèmes que vous utilisez.
La partie routage du flux peut cependant devenir intéressante. Il existe quelques algorithmes de routage différents, comme Open Shortest Path First ou Routing Information Protocol, tous conçus pour trouver la route la plus rapide/la plus courte du client au serveur. Parce qu'Internet est en fait un graphe connecté géant (ou, alternativement, un réseau de flux), il peut y avoir plusieurs chemins qui peuvent être exploités, chacun avec un coût correspondant supérieur ou inférieur. Il serait prohibitif de faire le travail pour trouver l'itinéraire le plus rapide absolu, donc la plupart des algorithmes utilisent des heuristiques raisonnables pour obtenir un itinéraire acceptable. Les ordinateurs et les réseaux ne sont pas toujours fiables, nous comptons donc sur Fastly pour améliorer la capacité de nos clients à acheminer plus rapidement vers nos serveurs.
Fastly fonctionne en fournissant des points de présence (POP) partout dans le monde avec des connexions très rapides et fiables entre eux. Considérez-les comme l'autoroute interétatique d'Internet. Les enregistrements A et CNAME de nos domaines pointent vers Fastly, ce qui fait que les demandes de nos clients sont directement transmises à l'autoroute. À partir de là, Fastly peut les acheminer au bon endroit.
La porte d'entrée pour braser
D'accord, donc la demande de notre client a emprunté l'autoroute Fastly et se trouve juste devant la porte d'entrée de la plate-forme Braze - que se passe-t-il ensuite ?
Dans un cas simple, cette porte d'entrée serait un serveur unique acceptant les requêtes. Comme vous pouvez l'imaginer, cela ne serait pas très évolutif, nous pointons donc Fastly vers un ensemble d'équilibreurs de charge. Il existe toutes sortes de stratégies que les équilibreurs de charge peuvent utiliser, mais imaginez que, dans ce scénario, Fastly round-robins demande à un pool d'équilibreurs de charge de manière uniforme. Ces équilibreurs de charge mettront les demandes en file d'attente, puis les distribueront aux serveurs Web, qui, nous pouvons également imaginer, reçoivent les demandes des clients de manière circulaire. (En pratique, il peut y avoir des avantages pour certains types d'affinité, mais c'est un sujet pour une autre fois.)
Cela nous permet d'augmenter le nombre d'équilibreurs de charge et le nombre de serveurs Web en fonction du débit de requêtes que nous recevons et du débit de requêtes que nous pouvons traiter. Jusqu'à présent, nous avons construit une architecture capable de gérer un assaut géant de demandes sans transpirer ! Il peut même gérer des modèles de trafic en rafale grâce à l'élasticité des files d'attente de requêtes des équilibreurs de charge, ce qui est génial !
Les serveurs Web
Enfin, nous arrivons à la partie passionnante (Ruby) : le serveur Web. Nous utilisons Ruby on Rails, mais ce n'est qu'un framework Web - le serveur Web réel est Unicorn. Unicorn fonctionne en démarrant un certain nombre de processus de travail sur une machine, où chaque processus de travail écoute sur un socket du système d'exploitation pour le travail. Il gère la gestion des processus pour nous et reporte l'équilibrage de charge des demandes au système d'exploitation lui-même. Nous avons juste besoin de notre code Ruby pour traiter les requêtes le plus rapidement possible ; tout le reste est efficacement optimisé en dehors de Ruby pour nous.
Étant donné que la majorité des requêtes effectuées par notre SDK dans les applications de nos clients ou via notre API REST sont asynchrones (c'est-à-dire que nous n'avons pas besoin d'attendre la fin de l'opération pour renvoyer une réponse spécifique aux clients), la majorité de nos Les serveurs d'API sont extraordinairement simples : ils valident la structure de la demande, toutes les contraintes de clé d'API, puis jettent la demande dans une file d'attente Redis et renvoient une réponse 200 au client si tout se vérifie.
Ce cycle de requête/réponse prend environ 10 millisecondes pour que le code Ruby soit traité, et une partie de ce cycle est consacrée à l'attente sur Memcached et Redis. Même si nous devions réécrire tout cela dans un autre langage, il n'est pas vraiment possible d'en tirer beaucoup plus de performances. Et, en fin de compte, c'est l'architecture de tout ce que vous avez lu jusqu'à présent qui nous permet d'adapter ce processus d'ingestion de données pour répondre aux besoins toujours croissants de nos clients.
Les files d'attente de travaux
C'est un sujet que nous avons exploré dans le passé, donc je n'aborderai pas cet aspect aussi profondément - pour en savoir plus sur notre système de file d'attente des travaux, consultez mon article sur Atteindre la résilience avec les files d'attente. De manière générale, ce que nous faisons, c'est tirer parti de nombreuses instances Redis qui agissent comme des files d'attente de tâches, tamponnant davantage le travail qui doit être fait. Semblables à nos serveurs Web, ces instances sont réparties sur plusieurs zones de disponibilité, afin d'offrir une plus grande disponibilité en cas de problème dans une zone de disponibilité particulière, et elles sont fournies par paires primaire/secondaire en utilisant Redis Sentinel pour la redondance. Nous pouvons également les mettre à l'échelle horizontalement et verticalement pour optimiser à la fois la capacité et le débit.
Les travailleurs
C'est certainement la partie la plus intéressante : comment faire évoluer les travailleurs ?
Avant tout, nos travailleurs et nos files d'attente sont segmentés selon un certain nombre de dimensions : clients, types de travail, magasins de données nécessaires, etc. Cela nous permet d'avoir une haute disponibilité ; par exemple, si un magasin de données particulier rencontre des difficultés, d'autres fonctions continueront à fonctionner parfaitement. Cela nous permet également de mettre à l'échelle automatiquement les types de travailleurs indépendamment, en fonction de l'une de ces dimensions. Nous finissons par être en mesure de gérer la capacité des travailleurs de manière évolutive horizontalement, c'est-à-dire que si nous avons plus d'un certain type de travail, nous pouvons faire évoluer plus de travailleurs.
Voici l'endroit où vous pourriez commencer à voir l'importance du choix de la langue ou du framework. En fin de compte, un travailleur plus efficace pourra faire plus de travail, plus rapidement. Les langages compilés comme C ou Rust ont tendance à être beaucoup plus rapides pour les tâches de calcul que les langages interprétés comme Ruby, et cela peut conduire à des travailleurs plus efficaces pour certaines charges de travail. Cependant, je passe beaucoup de temps à regarder les traces, et le traitement brut du processeur en est étonnamment peu important dans l'ensemble chez Braze. La majeure partie de notre temps de traitement est consacrée à attendre les réponses des magasins de données ou des demandes externes, et non à faire des calculs ; nous n'avons pas besoin de code C fortement optimisé pour cela.
Les magasins de données
Jusqu'à présent, tout ce que nous avons couvert est assez évolutif. Alors prenons une minute et parlons de l'endroit où nos employés passent le plus clair de leur temps : les magasins de données.
Quiconque a déjà fait évoluer des serveurs Web ou des travailleurs asynchrones utilisant une base de données SQL a probablement rencontré un problème d'échelle spécifique : les transactions. Vous pouvez avoir un point de terminaison qui s'occupe de terminer une commande, ce qui crée deux FulfillmentRequests et un PaymentReceipt. Si tout cela ne se produit pas dans une transaction, vous pouvez vous retrouver avec des données incohérentes. L'exécution simultanée de nombreuses transactions sur une seule base de données peut entraîner beaucoup de temps passé sur des verrous, voire un blocage. Chez Braze, nous prenons ce problème de mise à l'échelle de front avec les modèles de données eux-mêmes, grâce à l'indépendance des objets et à la cohérence éventuelle. Grâce à ces principes, nous pouvons extraire une grande partie des performances de nos magasins de données.

Objets de données indépendants
Nous exploitons fortement MongoDB chez Braze, pour de très bonnes raisons : à savoir, cela nous permet de mettre à l'échelle sensiblement horizontalement les fragments MongoDB et d'obtenir des augmentations quasi linéaires du stockage et des performances. Cela fonctionne très bien pour nos profils d'utilisateurs en raison de leur indépendance les uns par rapport aux autres - il n'y a pas d'instructions JOIN ou de relations de contrainte à maintenir entre les profils d'utilisateurs. Au fur et à mesure que chacun de nos clients grandit ou que nous ajoutons de nouveaux clients (ou les deux), nous pouvons simplement ajouter de nouvelles bases de données et de nouveaux fragments aux bases de données existantes pour augmenter notre capacité. Nous évitons explicitement les fonctionnalités telles que les transactions multi-documents pour maintenir ce niveau d'évolutivité.
Outre MongoDB, nous utilisons souvent Redis comme magasin de données temporaire pour des choses comme la mise en mémoire tampon des informations d'analyse. Étant donné que la source de vérité pour bon nombre de ces analyses existe dans MongoDB sous forme de documents indépendants pendant un certain temps, nous maintenons un pool évolutif horizontalement d'instances Redis pour agir en tant que tampons ; selon cette approche, l'ID de document haché est utilisé dans un schéma de partitionnement basé sur des clés, répartissant uniformément la charge en raison de l'indépendance. Les travaux périodiques vident ces tampons d'un magasin de données mis à l'échelle horizontalement vers un autre magasin de données mis à l'échelle horizontalement. Échelle atteinte !
De plus, nous utilisons Redis Sentinel pour ces instances, tout comme nous le faisons pour les files d'attente de tâches mentionnées ci-dessus. Nous déployons également de nombreux "types" de ces clusters Redis à des fins différentes, nous fournissant un flux d'échec contrôlé (c'est-à-dire que si un type particulier de cluster Redis a des problèmes, nous ne voyons pas de fonctionnalités non liées commencer à échouer simultanément).
Cohérence éventuelle
Braze exploite également la cohérence éventuelle comme principe pour la plupart des opérations de lecture. Cela nous permet de tirer parti de la lecture des membres principaux et secondaires des jeux de réplicas MongoDB dans la plupart des cas, ce qui rend notre architecture plus efficace. Ce principe dans notre modèle de données nous permet d'utiliser fortement la mise en cache sur l'ensemble de notre pile.
Nous utilisons une approche multicouche à l'aide de Memcached. En gros, lors de la demande d'un document à partir de la base de données, nous vérifions d'abord un processus Memcached local à la machine avec une très faible durée de vie (TTL), puis vérifions une instance Memcached distante (avec un TTL plus élevé), avant de demander directement à la base de données. Cela nous aide à réduire considérablement les lectures de bases de données pour les documents courants, tels que les paramètres des clients ou les détails des campagnes. « Éventuel » peut sembler effrayant, mais, en réalité, ce n'est que quelques secondes, et cette approche réduit énormément le trafic de la source de vérité. Si vous avez déjà suivi un cours d'architecture informatique, vous reconnaîtrez peut-être à quel point cette approche est similaire au fonctionnement d'un système de cache CPU L1, L2 et L3 !
Avec ces astuces, nous pouvons extraire une grande partie des performances de la partie sans doute la plus lente de notre architecture, puis la mettre à l'échelle horizontalement selon les besoins lorsque notre débit ou nos besoins en capacité augmentent.
Où Ruby et Rails s'intègrent
Voici le problème : il s'avère que lorsque vous consacrez beaucoup d'efforts à la construction d'une architecture holistique où chaque couche s'adapte bien horizontalement, la vitesse du langage ou de l'exécution est beaucoup moins importante que vous ne le pensez. Cela signifie que les choix de langages, de frameworks et d'environnements d'exécution sont effectués avec un ensemble d'exigences et de contraintes entièrement différent.
Ruby et Rails avaient fait leurs preuves en aidant les équipes à itérer rapidement lorsque Braze a été lancé en 2011, et ils sont toujours utilisés par GitHub, Shopify et d'autres grandes marques, car cela continue de rendre cela possible. Ils continuent d'être activement développés par les communautés Ruby et Rails, respectivement, et ils ont tous deux encore un grand ensemble de bibliothèques open source disponibles pour une variété de besoins. La paire est un excellent choix pour une itération rapide, car elle offre une immense flexibilité et conserve une grande simplicité pour les cas d'utilisation courants. Nous constatons que c'est extrêmement vrai chaque jour où nous l'utilisons.
Maintenant, cela ne veut pas dire que Ruby on Rails est une solution parfaite qui fonctionnera bien pour tout le monde. Mais chez Braze, nous avons constaté que cela fonctionne très bien pour alimenter une grande partie de notre pipeline d'ingestion de données, notre pipeline d'envoi de messages et notre tableau de bord orienté client, qui nécessitent tous une itération rapide et sont au cœur du succès de Braze. plate-forme dans son ensemble.
Quand nous n'utilisons pas Ruby
Mais attendez! Tout ce que nous faisons chez Braze n'est pas en Ruby. Il y a quelques endroits au fil des ans où nous avons appelé à orienter les choses vers d'autres langages et technologies pour diverses raisons. Jetons un coup d'œil à trois d'entre eux, juste pour donner un aperçu supplémentaire des moments où nous nous appuyons sur Ruby et ne nous y appuyons pas.
1. Services de l'expéditeur
Il s'avère que Ruby n'est pas très doué pour gérer un très haut degré de requêtes réseau simultanées en un seul processus. C'est un problème car lorsque Braze envoie des messages au nom de nos clients, certains fournisseurs de services de fin de ligne peuvent exiger une demande par utilisateur. Lorsque nous avons une pile de 100 messages prêts à être envoyés, nous ne voulons pas attendre que chacun d'eux se termine avant de passer au suivant. Nous préférons de loin faire tout ce travail en parallèle.
Entrez dans nos «services d'expéditeur», c'est-à-dire des microservices sans état écrits en Golang. Notre code Ruby dans l'exemple ci-dessus peut envoyer les 100 messages à l'un de ces services, qui exécutera toutes les requêtes en parallèle, attendra qu'elles se terminent, puis renverra une réponse groupée à Ruby. Ces services sont nettement plus efficaces que ce que nous pourrions faire avec Ruby en matière de réseautage simultané.
2. Connecteurs courants
Notre fonction d'exportation de données à grand volume Braze Currents permet aux clients Braze de diffuser en continu des données vers un ou plusieurs de nos nombreux partenaires de données. La plate-forme est alimentée par Apache Kafka et le streaming est effectué via les connecteurs Kafka. Vous pouvez techniquement les écrire en Ruby, mais la manière officiellement prise en charge est avec Java. Et en raison du degré élevé de prise en charge de Java, l'écriture de ces connecteurs est beaucoup plus facile à faire en Java qu'en Ruby.
3. Apprentissage automatique
Si vous avez déjà travaillé dans l'apprentissage automatique, vous savez que le langage de prédilection est Python. Les nombreux packages et outils pour les charges de travail d'apprentissage automatique en Python éclipsent le support Ruby équivalent - des éléments tels que les notebooks TensorFlow et Jupyter sont essentiels pour notre équipe, et ces types d'outils n'existent tout simplement pas ou ne sont pas bien établis dans le monde Ruby. En conséquence, nous nous sommes penchés sur Python lorsqu'il s'agit de créer des éléments de notre produit qui tirent parti de l'apprentissage automatique.
Quand la langue compte
Évidemment, nous avons quelques bons exemples ci-dessus où Ruby n'était pas le choix idéal. Il existe de nombreuses raisons pour lesquelles vous pourriez choisir une autre langue. En voici quelques-unes que nous pensons particulièrement utiles à prendre en compte.
Construire de nouvelles choses sans changer les coûts
Si vous envisagez de créer un système entièrement nouveau, avec un nouveau modèle de domaine et aucune intégration étroitement couplée avec les fonctionnalités existantes, vous aurez peut-être la possibilité d'utiliser un langage différent si vous le souhaitez. Surtout dans les cas où votre organisation évalue différentes opportunités, un projet greenfield plus petit et isolé pourrait être une excellente expérience dans le monde réel pour essayer un nouveau langage ou un nouveau cadre.
Écosystème linguistique spécifique à la tâche et ergonomie
Certaines tâches sont beaucoup plus faciles avec un langage ou un framework spécifique - nous aimons particulièrement Rails et Grape pour le développement de fonctionnalités de tableau de bord, mais le code d'apprentissage automatique serait un cauchemar absolu à écrire dans Ruby, car les outils open source n'existent tout simplement pas. Vous voudrez peut-être utiliser un framework ou une bibliothèque spécifique pour implémenter une sorte de fonctionnalité ou d'intégration, et parfois votre choix de langage sera influencé par cela, car cela se traduira presque certainement par une expérience de développement plus facile ou plus rapide.
Vitesse d'exécution
Parfois, vous devez optimiser la vitesse d'exécution brute, et le langage utilisé influencera fortement cela. Il y a une bonne raison pour laquelle de nombreuses plateformes de trading haute fréquence et systèmes de conduite autonome sont écrits en C++ ; le code compilé nativement peut être très rapide ! Nos services d'expéditeur exploitent les primitives de parallélisme/concurrence de Golang qui ne sont tout simplement pas disponibles dans Ruby pour cette raison précise.
Connaissance du développeur
D'un autre côté, vous construisez peut-être quelque chose d'isolé ou avez une bibliothèque en tête que vous souhaitez utiliser, mais votre choix de langue est complètement inconnu du reste de votre équipe. L'introduction d'un nouveau projet dans Scala avec une forte tendance à la programmation fonctionnelle peut introduire une barrière de familiarité pour les autres développeurs de votre équipe, ce qui entraînerait finalement un isolement des connaissances ou une diminution de la vitesse nette. Nous trouvons cela particulièrement important chez Braze, car nous mettons fortement l'accent sur l'itération rapide, nous avons donc tendance à encourager l'utilisation d'outils, de bibliothèques, de frameworks et de langages qui sont déjà largement utilisés dans l'organisation.
Dernières pensées
Si je pouvais remonter dans le temps et me dire une chose à propos de l'ingénierie logicielle dans les systèmes géants, ce serait ceci : pour la plupart des charges de travail, vos choix d'architecture globale définiront vos limites de mise à l'échelle et votre vitesse plus qu'un choix de langage ne le fera jamais. Cette idée est prouvée chaque jour ici chez Braze.
Ruby et Rails sont des outils incroyables qui, lorsqu'ils font partie d'un système correctement architecturé, évoluent incroyablement bien. Rails est également un cadre très mature, et il soutient notre culture chez Braze d'itération et de production rapide de valeur réelle pour le client. Cela fait de Ruby et Rails des outils idéaux pour nous, des outils que nous prévoyons de continuer à utiliser pendant des années.
Intéressé à travailler chez Braze? Nous recrutons pour divers postes au sein de nos équipes d'ingénierie, de gestion de produits et d'expérience utilisateur. Consultez notre page Carrières pour en savoir plus sur nos postes vacants et notre culture.
