Engineering Blog - Prexy : des règles de recherche et de remplacement super rapides
Bienvenue sur notre premier blog d'ingénierie. Il est peut-être un peu plus technique que les autres blogs auxquels vous êtes habitués, mais nous avons fait de notre mieux pour le rendre compréhensible pour tout le monde. Dans cet article, nous allons parler de Prexy, un nouveau logiciel de Clonable utilisé pour appliquer des règles de substitution.
Contexte
En tant qu'utilisateur de Clonable, vous connaissez peut-être déjà la fonctionnalité des règles de substitution. Vous pouvez utiliser ces règles de recherche et de remplacement pour remplacer des morceaux de texte ou de code par une variante de votre choix. Vous pouvez l'utiliser, par exemple, pour remplacer une clé API ou un identifiant d'analyse afin de créer des analyses différentes pour votre site original et vos clones traduits. En interne, ces mêmes règles sont également utilisées pour remplacer votre nom de domaine original par le nom de domaine de votre clone et un certain nombre d'autres choses.
Depuis la toute première version de Clonable, cette fonctionnalité n'a jamais été mise à jour, ce qui n'est pas surprenant en soi, car elle faisait très bien son travail. Pourtant, des améliorations étaient possibles, tant en termes de performances que de convivialité. De temps en temps, nous, Clonable, prenons un élément de notre produit qui n'a pas été utilisé depuis longtemps pour commencer à l'améliorer. L'année dernière, par exemple, nous avons révisé toute notre infrastructure de données pour la rendre plus rapide, plus évolutive et plus tolérante aux pannes, et avant cela, nous avons également reconstruit la fonctionnalité de traduction d'URL à partir de zéro. Ce trimestre, c'était au tour des règles de substitution.
Oude situatie en beperkingen
Les règles de substitution ne sont appliquées dans Clonable qu'à la toute dernière étape, juste avant de renvoyer la réponse au client. La première version de Clonable a donc choisi d'implémenter cela dans le serveur web NGINX en utilisant replace-filter-nginx-module, un module créé par OpenResty. Le module utilise sregex comme moteur de regex en continu, également créé par OpenResty. L'aspect streaming est important dans ce cas, car nous ne voulons pas charger chaque réponse entièrement en mémoire. Si nous le faisions, un certain nombre de réponses volumineuses pourraient faire planter NGINX ou le faire planter parce qu'il n'y a pas assez de mémoire disponible. Une deuxième caractéristique importante de sregex est qu'il peut traiter plusieurs lignes en parallèle. En effet, chaque clone possède déjà des règles par défaut et, parfois, des règles ajoutées spécifiquement pour le clone. Si ces règles ne pouvaient pas être traitées en parallèle, nous serions toujours obligés de mettre en mémoire tampon l'ensemble de la réponse, car nous devrions être en mesure de la faire correspondre à nouveau à la deuxième règle après la première.
Afgelopen jaar kwamen we echter steeds vaker vreemde performanceproblemen tegen. Deze problemen waren vaak van korte duur, maar konden de responsetijd van een pagina in extreme gevallen seconden langer maken. Na één zo'n piek laadde de pagina vaak weer normaal, wat het probleem moeilijk te reproduceren was. Om dit verder te debuggen hebben we de Clonable-Timings header toegevoegd aan alle responses, wat ons in staat stelde om in grove lijnen te bepalen welke stap van het vertaalproces zo veel tijd in beslag nam. Hieruit kwam naar voren dat in bijna alle gevallen de upstream snel reageerde, en dat ook het vertalen vrij vlot ging. Er zat echter een gat tussen het afronden van de vertaling en het volledig afronden van de request en dit gaf ons een hint dat het met de substitution regels te maken zou kunnen hebben.
Le modèle de concurrence de NGINX
Cependant, cela ne nous a pas encore permis de comprendre pourquoi ces retards ne se produisaient que sporadiquement, et pourquoi cela se produisait alors que les serveurs n'étaient même pas chargés à la moitié de leur capacité. Pour mieux comprendre ce phénomène, nous devons examiner de plus près la manière dont NGINX gère les charges de travail.
NGINX a une architecture de travailleur avec un processus maître et plusieurs processus de travailleur. Les connexions sont réparties entre les travailleurs afin de traiter plusieurs requêtes simultanément. Cette configuration présente toutefois un inconvénient : lorsqu'un travailleur est occupé à traiter une demande, les autres demandes assignées à ce même travailleur doivent attendre. Ainsi, une demande lourde peut entraîner des retards pour plusieurs demandes, même si les autres travailleurs n'ont rien à faire. Cet effet est illustré dans l'image ci-dessous. Lors de la création de cette image, un test de stress est exécuté sur 10 connexions différentes. L'image montre clairement que trois travailleurs sont occupés et qu'un quatrième ne fait presque rien.

Le temps de Prexy
Le goulot d'étranglement étant clairement visible, nous avons élaboré un plan pour l'améliorer. Ce projet a été baptisé Prexy, un amalgame de RegEx et Proxy. Le résultat final devait répondre à trois exigences principales :
Il doit être possible de remplacer directement la configuration actuelle. Les règles de substitution doivent être appliquées de la même manière afin d'éviter de briser les installations existantes.
Les règles de substitution doivent être diffusées pour éviter que la solution n'utilise trop de mémoire.
La nouvelle solution devrait être plus rapide que la solution actuelle.
Als eerste moesten we op zoek naar een krachtige multi-threaded runtime die requests op een efficiënte manier kan verwerken. Na meerdere opties te hebben overwogen landde de keuze op de Tokio runtime voor de programmeertaal Rust. Rust staat erom bekend dat je er snelle programma's mee kunt schrijven, zonder de safety-risico's van andere low-level talen zoals C++. De Tokio runtime is een flexibele asynchronous runtime gemaakt voor netwerkapplicaties. Eén van de belangrijkste functies voor ons is het feit dat hij work-stealing is. Dit houdt in dat, in tegenstelling tot NGINX, een request niet gebonden is aan één worker, maar dat een worker die niets te doen heeft werk kan “stelen” van een andere worker. Op die manier kunnen de resources van onze servers beter benut worden.
Premier prototype
Après avoir choisi les technologies sous-jacentes, nous avons décidé de mettre en place un prototype initial afin d'estimer approximativement le gain de vitesse que ce projet nous apporterait et de savoir s'il en valait la peine. Comme implémentation initiale du moteur regex (la partie qui traite et applique les règles), nous avons utilisé la bibliothèque regex standard (ou crate comme on l'appelle dans la terminologie Rust). Ce moteur, comme sregex, est non backtracking, ce qui réduit le risque d'un problème ReDoS. Dans un ReDoS, une expression régulière est confrontée à une entrée telle que le temps nécessaire à l'évaluation de l'entrée augmente de manière exponentielle. Cela peut être désastreux pour les performances et a provoqué une panne majeure chez Cloudflare. Pour nous, il est donc important que le moteur que nous utilisons soit non rétroactif.
En utilisant ce moteur regex, nous avons construit un premier prototype. Ce prototype utilisait des règles codées en dur et le moteur regex mettait en mémoire tampon l'intégralité de la réponse au lieu de la transmettre en continu, mais cela était suffisant pour un premier test de performance.
En utilisant ce moteur regex, nous avons construit un premier prototype. Ce prototype utilisait des règles codées en dur et le moteur regex mettait en mémoire tampon l'intégralité de la réponse au lieu de la transmettre en continu, mais cela suffisait pour un premier test de performance. Notre configuration de test consistait en deux machines virtuelles, l'une d'entre elles exécutant une configuration sur laquelle l'ancienne et la nouvelle solution pouvaient fonctionner. De cette façon, nous pouvions facilement faire des comparaisons entre les deux méthodes. L'autre serveur exécutait NGINX, qui servait de serveur d'origine et servait un fichier de test. Le même serveur a également exécuté l'outil wrk, que nous avons utilisé pour générer la charge. Ce n'est pas tout à fait optimal, car pour les données pures, il est préférable d'exécuter le générateur de charge sur une VM séparée, mais cette VM disposait de suffisamment de ressources pour que NGINX et wrk n'interfèrent pas l'un avec l'autre.
Les premiers résultats des tests ont été très clairs : Prexy était environ 22 fois plus rapide pour traiter une seule requête (voir l'image ci-dessous). Cela a donné le feu vert final au projet, car avec une différence aussi importante, il y avait suffisamment de place pour absorber les dégradations de performance qui pourraient survenir lors de l'achèvement de la fonctionnalité.

Après des tests initiaux, nous avons mis en œuvre quelques optimisations évidentes, telles que la mise en cache des expressions régulières compilées. Nous avons également mis en place une méthode plus efficace de réutilisation des connexions en amont. Nous avons ainsi obtenu un gain de vitesse d'environ 20 %. Nous avons ensuite testé les deux solutions sous une charge maximale, en envoyant autant de requêtes que possible au serveur. Là encore, la différence était clairement visible et Prexy a atteint un débit environ 25 fois supérieur à celui de l'ancienne solution.

Moteur de regex en continu
L'une des exigences de ce projet était que le moteur de recherche utilisé soit en continu. La création de regex du prototype ne l'étant pas, un travail supplémentaire a été nécessaire dans ce domaine. Cependant, la caisse de regex s'est avérée très optimisée, et nous avons donc décidé de prendre cette implémentation comme base pour notre moteur de streaming. Lorsque l'on fait passer des données par un moteur de regex, il est important de garder à l'esprit certaines choses. Tout d'abord, vous ne savez pas quelles données sont encore à venir. Vous devez donc commencer par travailler avec des correspondances partielles : des correspondances qui ne sont pas encore complètes, mais qui ont déjà correspondu à un ou plusieurs caractères. Lorsque vous trouvez une concordance complète, vous devez ensuite vérifier qu'il n'y a pas de concordances partielles qui se chevauchent, car celles-ci peuvent également finir par être des concordances (et une concordance plus longue l'emporte sur une concordance plus courte en cas de chevauchement). Vous ne pouvez donc commencer à traiter une concordance que si toutes les concordances partielles actuelles s'avèrent ne pas être une concordance complète.
Autres optimisations
Comme prévu, la reconstruction du moteur regex a eu un impact négatif sur les performances de Prexy. Cependant, il y avait encore beaucoup de marge par rapport à l'ancienne solution, et en ajoutant d'autres optimisations, nous avons fini par obtenir des performances encore plus élevées qu'avant la reconstruction du moteur de regex. L'une des optimisations que nous avons appliquées consistait à se rappeler s'il fallait appliquer une règle à un fichier particulier. Les actifs statiques tels que les fichiers CSS et JS ne changent pratiquement jamais, et il est donc inutile d'exécuter une règle à chaque fois alors qu'elle ne correspond jamais à ce fichier spécifique. Une autre solution consistait à mettre en cache le résultat des remplacements, mais l'inconvénient de cette stratégie est qu'il faut beaucoup de mémoire pour stocker tous les fichiers. En se souvenant des règles à appliquer, nous n'avons besoin que de quelques octets par fichier en stockant les informations sous forme de bitmap.
En outre, nous tirons pleinement parti des optimisations du moteur d'expressions rationnelles, par exemple en ne conservant pas les groupes de capture lorsqu'ils ne sont pas utilisés dans le remplacement. Par conséquent, le moteur de recherche ne doit se souvenir que du début et de la fin de la correspondance complète, ce qui permet d'économiser du travail. Nous avons également désactivé les groupes de capture nommés, car cela était assez complexe dans la variante en continu, et comme l'ancienne solution ne le prenait pas en charge non plus, ce n'était pas nécessairement nécessaire. Toutes ces optimisations ont finalement permis d'obtenir les performances suivantes :

Le résultat final
Après avoir testé Prexy de manière approfondie pour s'assurer que son comportement était bien le même que celui de l'ancienne solution, nous avons commencé à le déployer de manière incrémentale. Sur une période d'environ une semaine, nous avons activé Prexy pour chaque clone. Dans notre surveillance, le moment de l'activation était souvent facile à voir. Une diminution d'environ 50 % des temps de réponse n'était pas rare. Des différences mineures d'environ 10 % ont également été observées chez d'autres clients qui avaient très peu de règles de substitution. Notre propre site web est devenu environ 30 % plus rapide (~50 ms).
Dans l'ensemble de notre infrastructure, nous constatons les différences suivantes après le déploiement de Prexy.
33 % de consommation de RAM en moins
20% de consommation de CPU en moins
Des réponses 10 à 50 % plus rapides
Dans l'ensemble, nous pouvons donc dire que Prexy a été un projet réussi. En remplaçant un module NGINX obsolète, nous pouvons maintenant utiliser des techniques plus récentes et plus efficaces qui font une différence clairement mesurable pour tous nos clients. À l'avenir, nous continuerons à améliorer Clonable, à la fois en termes de nouvelles fonctionnalités et d'amélioration des fonctionnalités existantes.
Merci d'avoir lu ce blog d'ingénierie. N'hésitez pas à nous faire savoir si vous souhaitez obtenir plus souvent ce type d'informations techniques sur notre produit. Ce projet vous a enthousiasmé ? Alors jetez un coup d'œil à notre page d'offres d'emploi :)