Rust en production : pourquoi nous avons réécrit notre processeur de signaux
Nous avons réécrit notre moteur de traitement de signaux de Go vers Rust. Voici pourquoi, ce que nous avons appris et l'amélioration de 4x du débit que nous avons obtenue.
Il y a six mois, nous avons pris la décision de réécrire notre moteur de traitement de signaux — le composant qui transforme les signaux bruts du navigateur en vecteurs de caractéristiques normalisés et hachables — de Go vers Rust. Ce n'est pas une décision que nous avons prise à la légère. Notre implémentation en Go fonctionnait. Elle était testée. Elle était déployée. Mais elle était devenue le goulot d'étranglement de notre pipeline, et nous avions besoin d'une amélioration en marche d'escalier du débit. Voici ce qui s'est passé.
Pourquoi Go ne suffisait plus
Notre processeur de signaux effectue un travail intensif en calcul : analyser des payloads JSON, appliquer des fonctions de normalisation à plus de 130 signaux, calculer des hachages propriétaires et construire des vecteurs de hachage d'identification. En Go, ce travail était limité par le CPU, et le ramasse-miettes de Go est devenu un problème à l'échelle. Chaque cycle de traitement de signaux allouait des objets intermédiaires — nœuds JSON analysés, valeurs de chaînes normalisées, tampons de hachage — qui créaient une pression sur le GC.
À 30K événements/seconde, notre processeur de signaux en Go affichait des pauses GC de 2-5ms toutes les quelques secondes. Ces pauses étaient acceptables. À 50K événements/seconde, les pauses GC atteignaient 8-15ms et se produisaient plus fréquemment. À 80K événements/seconde — notre charge projetée pour le T3 — les pauses GC auraient fait dépasser à la latence p99 notre SLA. Il nous fallait soit plus de serveurs (coûteux), soit une implémentation plus efficace.
Pourquoi Rust
Nous avons évalué trois options : optimiser l'implémentation Go (sync.Pool, allocation par arène, réglage de GOGC), réécrire en C++ et réécrire en Rust. L'optimisation de Go a apporté une amélioration de 30% mais n'a pas résolu fondamentalement le problème du GC. Le C++ a été écarté en raison de préoccupations de sécurité mémoire dans un système critique pour la sécurité. Rust offrait des abstractions à coût nul, aucun ramasse-miettes et des garanties de sécurité mémoire imposées à la compilation.
L'écosystème Rust disposait aussi de bibliothèques matures pour tout ce dont nous avions besoin : serde pour l'analyse JSON, des crates de hachage haute performance et tokio pour l'I/O asynchrone. La courbe d'apprentissage était réelle — notre équipe avait une expérience approfondie de Go mais limitée de Rust — mais les caractéristiques de performance étaient exactement ce dont nous avions besoin.
Le processus de réécriture
Nous avons réécrit le processeur de signaux comme un service autonome qui communique avec le reste de notre pipeline via gRPC. Cela nous a permis de le déployer aux côtés de l'implémentation Go et de basculer progressivement le trafic. La réécriture a pris trois ingénieurs quatre semaines — deux semaines pour l'implémentation centrale et deux semaines pour les tests, le benchmarking et la gestion des cas limites.
L'aspect le plus difficile n'était pas le langage lui-même, mais garantir la parité de comportement avec l'implémentation Go. Nous avons construit un harnais de comparaison qui exécutait les deux implémentations sur la même entrée et vérifiait qu'elles produisaient une sortie identique. Nous avons découvert 14 différences subtiles au cours de ce processus — la plupart liées à la gestion des nombres à virgule flottante, à la normalisation Unicode et aux cas limites de l'analyse JSON.
Résultats de performance
L'implémentation Rust traite les signaux en 0.8ms en moyenne contre 3.2ms pour Go — une amélioration de 4x. L'utilisation de la mémoire a chuté de 2.1GB à 340MB pour la même charge de travail. Il n'y a aucune pause GC parce qu'il n'y a pas de ramasse-miettes. L'utilisation du CPU a diminué de 60% au même débit, ce qui signifie que chaque serveur gère 4x plus de trafic.
À 80K événements/seconde, l'implémentation Rust maintient un temps de traitement p99 de 1.4ms sans aucune pause. Cette marge signifie que nous n'aurons pas à revoir les performances du traitement de signaux dans un avenir prévisible. La réduction de l'utilisation du CPU et de la mémoire se traduit aussi directement par des coûts d'infrastructure plus bas — nous avons mis hors service 8 serveurs de traitement de signaux sur 12.
Leçons apprises
Réécrire en Rust en valait la peine pour notre cas spécifique — une charge de travail limitée par le CPU, gourmande en allocations et sensible à la latence. Nous ne réécririons pas notre couche d'ingestion HTTP ni notre service de requêtes ClickHouse en Rust, car ces composants sont limités par l'I/O et Go les gère efficacement. La leçon n'est pas « tout réécrire en Rust », mais « utiliser Rust là où ses abstractions à coût nul et ses performances déterministes comptent le plus ».
La plus grande surprise a été tout ce que le compilateur Rust a détecté pendant la réécriture. Plusieurs bugs latents de notre implémentation Go — conditions de course sur des tampons partagés, débordement d'entiers dans le calcul de hachage et accès hors limites sur des entrées malformées — ont été détectés comme des erreurs à la compilation en Rust. Le compilateur est exigeant, mais il se rentabilise en justesse.
Honnêtement, la première semaine a été pénible. Sarah tenait au tableau blanc un décompte des « bagarres avec le borrow checker » — nous avons atteint 47 avant que l'équipe cesse de compter. Mais dès la troisième semaine, le code qui compilait fonctionnait tout simplement. Aucun panic mystérieux en production, aucune course de données sous charge. Ce compromis en vaut la peine pour tout ce qui se trouve sur le chemin critique.