
La construction du réseau le plus rapide nécessite des efforts dans de nombreux domaines. Nous investissons beaucoup de temps dans notre matériel afin de disposer de machines efficaces et rapides. Nous investissons dans des accords de peering afin de pouvoir communiquer avec toutes les parties d'Internet avec un délai minimal. En outre, nous devons également investir dans les logiciels sur lesquels nous exploitons notre réseau, d'autant plus que chaque nouveau produit peut sinon ajouter un délai de traitement supplémentaire.
Quelle que soit la vitesse à laquelle les messages arrivent, nous créons un goulot d'étranglement si ce logiciel met trop de temps à réfléchir à la manière de traiter et de répondre aux demandes. Aujourd'hui, nous sommes ravis de vous faire part d'une mise à niveau importante de notre logiciel qui réduit le temps médian de réponse de 10 ms et offre un gain de performance de 25 %, selon les tests de performance CDN réalisés par des tiers.
Nous avons passé l'année dernière à reconstruire les principaux composants de notre système, et nous venons de réduire considérablement la latence du trafic transitant par notre réseau pour des millions de nos clients. Dans le même temps, nous avons renforcé la sécurité de notre système et réduit le temps nécessaire à la création et à la mise sur le marché de nouveaux produits.
Par où avons-nous commencé ?
Chaque requête qui arrive à Cloudflare commence un parcours à travers notre réseau. Elle peut provenir d'un navigateur chargeant une page web, d'une application mobile appelant une API ou d'un trafic automatisé provenant d'un autre service. Ces requêtes aboutissent d'abord à notre couche HTTP et TLS, puis passent dans un système que nous appelons FL, et enfin dans Pingora, qui effectue des recherches dans le cache ou récupère des données à la source si nécessaire.
FL est le cerveau de Cloudflare. Une fois qu'une requête atteint FL, nous exécutons les différentes fonctionnalités de sécurité et de performance de notre réseau. Il applique la configuration et les paramètres uniques de chaque client, de l'application des règles WAF et de la protection DDoS au routage du trafic vers la plateforme de développement et R2.
Créé il y a plus de 15 ans, FL est au cœur du réseau Cloudflare. Il nous permet d'offrir un large éventail de fonctionnalités, mais au fil du temps, cette flexibilité est devenue un défi. À mesure que nous ajoutions des produits, FL devenait plus difficile à maintenir, plus lent à traiter les requêtes et plus difficile à étendre. Chaque nouvelle fonctionnalité nécessitait des vérifications minutieuses de la logique existante, et chaque ajout introduisait un peu plus de latence, ce qui rendait de plus en plus difficile le maintien des performances que nous souhaitions.
Vous pouvez voir à quel point FL est essentiel à notre système : nous l'avons souvent qualifié de « cerveau » de Cloudflare. C'est également l'une des parties les plus anciennes de notre système : le premier commit dans la base de code a été effectué par l'un de nos fondateurs, Lee Holloway, bien avant notre lancement initial. Nous célébrons notre 15e anniversaire cette semaine, mais ce système a vu le jour 9 mois avant cela !
Code : | Sélectionner tout |
1 2 3 4 5 | commit 39c72e5edc1f05ae4c04929eda4e4d125f86c5ce Author: Lee Holloway <q@t60.(none)> Date: Wed Jan 6 09:57:55 2010 -0800 nginx-fl initial configuration |
Comme l'indique le commit, la première version de FL a été mise en œuvre sur la base du serveur web NGINX, avec une logique produit implémentée en PHP. Au bout de trois ans, le système était devenu trop complexe pour être géré efficacement et trop lent pour répondre, et une réécriture presque complète du système en cours d'exécution a été effectuée. Cela a conduit à un autre commit important, cette fois-ci réalisé par Dane Knecht, qui est aujourd'hui notre directeur technique.
Code : | Sélectionner tout |
1 2 3 4 5 | commit bedf6e7080391683e46ab698aacdfa9b3126a75f Author: Dane Knecht Date: Thu Sep 19 19:31:15 2013 -0700 remove PHP. |
À partir de ce moment, FL a été implémenté à l'aide de NGINX, du framework OpenResty et de LuaJIT. Si cela a très bien fonctionné pendant longtemps, ces dernières années, le système a commencé à montrer des signes de vieillissement. Nous devions consacrer de plus en plus de temps à corriger ou à contourner des bogues obscurs dans LuaJIT. La nature hautement dynamique et non structurée de notre code Lua, qui était un avantage lorsque nous avons commencé à implémenter rapidement la logique, est devenue une source d'erreurs et de retards lorsque nous avons essayé d'intégrer de grandes quantités de logique produit complexe. Chaque fois qu'un nouveau produit était lancé, nous devions passer en revue tous les autres produits existants pour vérifier s'ils pouvaient être affectés par la nouvelle logique.
Il était clair que nous devions repenser notre approche. Ainsi, en juillet 2024, nous avons effectué un premier commit pour une implémentation entièrement nouvelle et radicalement différente. Pour gagner du temps dans la recherche d'un nouveau nom, nous l'avons simplement appelée « FL2 » et avons bien sûr commencé à désigner la FL originale sous le nom de « FL1 ».
Code : | Sélectionner tout |
1 2 3 4 5 | commit a72698fc7404a353a09a3b20ab92797ab4744ea8 Author: Maciej Lechowski Date: Wed Jul 10 15:19:28 2024 +0100 Create fl2 project |
Rust et la modularisation rigide
Nous ne partions pas de zéro. Nous avons déjà publié un article sur la façon dont nous avons remplacé un autre de nos anciens systèmes par Pingora, qui est construit dans le langage de programmation Rust, à l'aide du runtime Tokio. Nous avons également publié un article sur Oxy, notre framework interne pour la création de proxys dans Rust. Nous écrivons beaucoup en Rust et nous sommes devenus assez bons dans ce domaine.
Nous avons développé FL2 dans Rust, sur Oxy, et avons créé un cadre modulaire strict pour structurer toute la logique de FL2.
Pourquoi Oxy ?
Lorsque nous avons décidé de développer FL2, nous savions que nous ne remplacions pas simplement un ancien système, mais que nous reconstruisions les fondations mêmes de Cloudflare. Cela signifiait que nous avions besoin de plus qu'un simple proxy ; nous avions besoin d'un cadre capable d'évoluer avec nous, de gérer l'immense échelle de notre réseau et de permettre aux équipes d'avancer rapidement sans sacrifier la sécurité ou les performances.
Oxy nous offre une combinaison puissante de performances, de sécurité et de flexibilité. Construit en Rust, il élimine toute une série de bogues qui affectaient notre FL1 basé sur Nginx/LuaJIT, tels que les problèmes de sécurité de la mémoire et les conflits d'accès aux données, tout en offrant des performances de niveau C. À l'échelle de Cloudflare, ces garanties ne sont pas un luxe, elles sont essentielles. Chaque microseconde gagnée par requête se traduit par des améliorations tangibles de l'expérience utilisateur, et chaque crash ou cas limite évité permet à Internet de fonctionner sans heurts. Les garanties strictes de Rust en matière de compilation s'accordent également parfaitement avec l'architecture modulaire de FL2, où nous appliquons des contrats clairs entre les modules du produit et leurs entrées et sorties.
Mais le choix ne se limitait pas au langage. Oxy est le fruit d'années d'expérience dans la création de proxys haute performance. Il alimente déjà plusieurs services Cloudflare majeurs, de notre Zero Trust Gateway à l'iCloud Private Relay d'Apple, nous savions donc qu'il pouvait gérer les divers modèles de trafic et combinaisons de protocoles que FL2 allait rencontrer. Son modèle d'extensibilité nous permet d'intercepter, d'analyser et de manipuler le trafic de la couche 3 à la couche 7, et même de décapsuler et de retraiter le trafic à différentes couches. Cette flexibilité est essentielle à la conception de FL2, car elle nous permet de traiter de manière cohérente tout le trafic, du HTTP au trafic IP brut, et de faire évoluer la plateforme pour prendre en charge de nouveaux protocoles et fonctionnalités sans avoir à réécrire les éléments fondamentaux.
Oxy est également doté d'un ensemble complet de fonctionnalités intégrées qui nécessitaient auparavant de grandes quantités de code sur mesure. Des éléments tels que la surveillance, les rechargements logiciels, le chargement et l'échange de configurations dynamiques font tous partie du cadre. Cela permet aux équipes produit de se concentrer sur la logique métier unique de leur module plutôt que de réinventer la plomberie à chaque fois. Cette base solide nous permet d'apporter des modifications en toute confiance, de les livrer rapidement et d'être sûrs qu'elles se comporteront comme prévu une fois déployées.
Redémarrages en douceur - pour que l'Internet continue de fonctionner
L'une des améliorations les plus importantes apportées par Oxy concerne la gestion des redémarrages. Tout logiciel en développement et en amélioration continus devra tôt ou tard être mis à jour. Dans le cas des logiciels de bureau, c'est facile : il suffit de fermer le programme, d'installer la mise à jour et de le rouvrir. Sur le web, c'est beaucoup plus compliqué. Nos logiciels sont utilisés en permanence et ne peuvent pas simplement s'arrêter. Une requête HTTP interrompue peut empêcher le chargement d'une page, et une connexion interrompue peut vous exclure d'un appel vidéo. La fiabilité n'est pas facultative.
Dans FL1, les mises à niveau impliquaient le redémarrage du processus proxy. Redémarrer un proxy signifiait mettre fin au processus dans son intégralité, ce qui interrompait immédiatement toutes les connexions actives. Cela était particulièrement pénible pour les connexions de longue durée telles que les WebSockets, les sessions de streaming et les API en temps réel. Même les mises à niveau planifiées pouvaient entraîner des interruptions visibles pour les utilisateurs, et les redémarrages imprévus lors d'incidents pouvaient être encore pires.
Oxy change cela. Il comprend un mécanisme intégré de redémarrage en douceur qui nous permet de déployer de nouvelles versions sans interrompre les connexions, dans la mesure du possible. Lorsqu'une nouvelle instance d'un service basé sur Oxy démarre, l'ancienne cesse d'accepter de nouvelles connexions mais continue à servir celles qui existent déjà, permettant à ces sessions de se poursuivre sans interruption jusqu'à leur fin naturelle.
Cela signifie que si vous avez une session WebSocket en cours lorsque nous déployons une nouvelle version, cette session peut se poursuivre sans interruption jusqu'à ce qu'elle se termine naturellement, plutôt que d'être interrompue par le redémarrage. Dans l'ensemble du parc Cloudflare, les déploiements sont orchestrés sur plusieurs heures, de sorte que le déploiement global est fluide et presque invisible pour les utilisateurs finaux.
Nous allons encore plus loin en utilisant l'activation des sockets systemd. Au lieu de laisser chaque proxy gérer ses propres sockets, nous laissons systemd les créer et les posséder. Cela dissocie la durée de vie des sockets de celle de l'application Oxy elle-même. Si un processus Oxy redémarre ou plante, les sockets restent ouvertes et prêtes à accepter de nouvelles connexions, qui seront traitées dès que le nouveau processus sera en cours d'exécution. Cela élimine les erreurs de « connexion refusée » qui pouvaient se produire lors des redémarrages dans FL1 et améliore la disponibilité globale pendant les mises à niveau.
Nous avons également créé nos propres mécanismes de coordination dans Rust pour remplacer les bibliothèques Go telles que tableflip par shellflip. Cela utilise un socket de coordination de redémarrage qui valide la configuration, génère de nouvelles instances et s'assure que la nouvelle version est en bon état avant que l'ancienne ne s'arrête. Cela améliore les boucles de rétroaction et permet à nos outils d'automatisation de détecter et de réagir immédiatement aux pannes, plutôt que de s'appuyer sur des redémarrages aveugles basés sur des signaux.
Composition de FL2 à partir de modules
Pour éviter les problèmes rencontrés avec FL1, nous voulions une conception où toutes les interactions entre les logiques produit seraient explicites et faciles à comprendre.
Ainsi, en plus des fondations fournies par Oxy, nous avons construit une plateforme qui sépare toutes les logiques développées pour nos produits en modules bien définis. Après quelques expérimentations et recherches, nous avons conçu un système de modules qui applique des règles strictes :
- Aucune E/S (entrée ou sortie) ne peut être effectuée par le module.
- Le module fournit une liste de phases.
- Les phases sont évaluées dans un ordre strictement défini, qui est le même pour chaque requête.
- Chaque phase définit un ensemble d'entrées que la plateforme lui fournit et un ensemble de sorties qu'elle peut émettre.
Voici un exemple de définition d'une phase de module :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 | Phase { name: phases::SERVE_ERROR_PAGE, request_types_enabled: PHASE_ENABLED_FOR_REQUEST_TYPE, inputs: vec![ InputKind::IPInfo, InputKind::ModuleValue( MODULE_VALUE_CUSTOM_ERRORS_FETCH_WORKER_RESPONSE.as_str(), ), InputKind::ModuleValue(MODULE_VALUE_ORIGINAL_SERVE_RESPONSE.as_str()), InputKind::ModuleValue(MODULE_VALUE_RULESETS_CUSTOM_ERRORS_OUTPUT.as_str()), InputKind::ModuleValue(MODULE_VALUE_RULESETS_UPSTREAM_ERROR_DETAILS.as_str()), InputKind::RayId, InputKind::StatusCode, InputKind::Visitor, ], outputs: vec![OutputValue::ServeResponse], filters: vec![], func: phase_serve_error_page::callback, } |
Cette phase concerne notre produit de page d'erreur personnalisée. Elle nécessite plusieurs éléments en entrée : des informations sur l'adresse IP du visiteur, certaines informations d'en-tête et autres informations HTTP, ainsi que certaines « valeurs de module ». Les valeurs de module permettent à un module de transmettre des informations à un autre, et elles sont essentielles pour que les propriétés strictes du système de modules fonctionnent. Par exemple, ce module a besoin de certaines informations produites par la sortie de notre produit d'erreurs personnalisées basé sur des ensembles de règles (l'entrée « MODULE_VALUE_RULESETS_CUSTOM_ERRORS_OUTPUT »). Ces définitions d'entrée et de sortie sont appliquées au moment de la compilation.
Bien que ces règles soient strictes, nous avons constaté que nous pouvions implémenter toute la logique de nos produits dans ce cadre. L'avantage est que nous pouvons immédiatement déterminer quels autres produits sont susceptibles d'interagir entre eux.
Comment remplacer un système en cours d'exécution
Construire un cadre est une chose. Construire toute la logique du produit et la mettre au point de manière à ce que les clients ne remarquent rien d'autre qu'une amélioration des performances en est une autre.
La base de code FL prend en charge 15 ans de produits Cloudflare et évolue constamment. Nous ne pouvions pas arrêter le développement. L'une de nos premières tâches a donc consisté à trouver des moyens de rendre la migration plus facile et plus sûre.
- Étape 1 - Modules Rust dans OpenResty
La reconstruction de la logique produit dans Rust représente une distraction suffisante par rapport à la livraison des produits aux clients. Demander à toutes nos équipes de maintenir deux versions de la logique de leurs produits et de réimplémenter chaque modification une deuxième fois jusqu'à la fin de notre migration était trop demander.
Nous avons donc implémenté une couche dans notre ancien FL basé sur NGINX et OpenResty qui permettait d'exécuter les nouveaux modules. Au lieu de maintenir une implémentation parallèle, les équipes pouvaient implémenter leur logique dans Rust et remplacer leur ancienne logique Lua par celle-ci, sans attendre le remplacement complet de l'ancien système.
Voici, par exemple, une partie de l'implémentation du module de page d'erreur personnalisée défini précédemment (nous avons supprimé certains détails fastidieux, ce qui fait que le code ne compile pas tel quel) :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | pub(crate) fn callback(_services: &mut Services, input: &Input<'_>) -> Output { // Rulesets produced a response to serve - this can either come from a special // Cloudflare worker for serving custom errors, or be directly embedded in the rule. if let Some(rulesets_params) = input .get_module_value(MODULE_VALUE_RULESETS_CUSTOM_ERRORS_OUTPUT) .cloned() { // Select either the result from the special worker, or the parameters embedded // in the rule. let body = input .get_module_value(MODULE_VALUE_CUSTOM_ERRORS_FETCH_WORKER_RESPONSE) .and_then(|response| { handle_custom_errors_fetch_response("rulesets", response.to_owned()) }) .or(rulesets_params.body); // If we were able to load a body, serve it, otherwise let the next bit of logic // handle the response if let Some(body) = body { let final_body = replace_custom_error_tokens(input, &body); // Increment a metric recording number of custom error pages served custom_pages::pages_served("rulesets").inc(); // Return a phase output with one final action, causing an HTTP response to be served. return Output::from(TerminalAction::ServeResponse(ResponseAction::OriginError { rulesets_params.status, source: "rulesets http_custom_errors", headers: rulesets_params.headers, body: Some(Bytes::from(final_body)), })); } } } |
La logique interne de chaque module est clairement séparée du traitement des données, avec une gestion des erreurs très claire et explicite encouragée par la conception du langage Rust.
La plupart de nos modules les plus activement développés ont été traités de cette manière, ce qui a permis aux équipes de maintenir leur rythme de changement pendant notre migration.
- Étape 2 - Tests et déploiements automatisés
Il est essentiel de disposer d'un cadre de test très puissant pour couvrir une telle migration. Nous avons mis au point un système, baptisé en interne Flamingo, qui nous permet d'exécuter simultanément des milliers de requêtes de test de bout en bout sur nos systèmes de production et de préproduction. Les mêmes tests sont effectués sur FL1 et FL2, ce qui nous donne l'assurance que nous ne modifions pas les comportements.
Chaque fois que nous déployons une modification, celle-ci est mise en œuvre progressivement à travers plusieurs étapes, avec des volumes de trafic croissants. Chaque étape est automatiquement évaluée et n'est validée que lorsque l'ensemble des tests a été exécuté avec succès, et que les mesures globales de performance et d'utilisation des ressources se situent dans des limites acceptables. Ce système est entièrement automatisé et suspend ou annule les modifications si les tests échouent.
L'avantage est que nous sommes en mesure de créer et de livrer de nouvelles fonctionnalités de produit dans FL2 en moins de 48 heures, alors qu'il aurait fallu plusieurs semaines dans FL1. En fait, au moins une des annonces de cette semaine concernait un tel changement !...
La fin de cet article est réservée aux abonnés. Soutenez le Club Developpez.com en prenant un abonnement pour que nous puissions continuer à vous proposer des publications.