Fastify, mature et performant, un moyen d’augmenter les performances ?
Résumé
Notre objectif est d’étudier si l’utilisation de Fastify à la place d’Express.js (nommé Express dans ce texte) comme framework Node.js permet d’améliorer les performances, de réduire la mémoire utilisée, de s’abstraire certaines librairies et simplifier l’écriture de nouvelles API.
Dans cet article, nous allons analyser les résultats de performance obtenus suite à nos différents tests.
- Migration d’une API Gateway de Express à Fastify, dans le contexte d’un projet nécessitant beaucoup de performance
- Migration d’un POC NestJS de Express à Fastify, dans le contexte d’un POC créé spécifiquement pour ce test
Introduction
Fastify et Express sont tous deux des frameworks pour Node.js qui facilitent la création d’applications Web. Les différences clés incluent :
- Vitesse : Fastify est conçu pour être plus rapide que Express.
- Taille plus petite : Fastify a une plus petite empreinte mémoire que Express.
- Simplicité : Fastify est conçu pour être plus simple à utiliser que Express.
- Architecture modulaire : Fastify utilise une architecture modulaire pour les plugins, tandis qu’Express utilise des middlewares.
Suite aux différents benchmarks que nous avons réalisés, nous avons constaté que Fastify est toujours plus performant que Express.
Mais qu’en est-il lorsqu’un projet nécessite un processus de migration qui peut prendre du temps ? Le changement offre-t-il un retour sur investissement (ROI) intéressant ?
L’objectif de cette étude est de se familiariser avec une telle migration, d’analyser les performances résultantes, de définir un ROI permettant de valider la viabilité du projet.
API Gateway
Methodologie
Contexte
Nous souhaitons étudier les avantages et inconvénients de la migration d’Express vers Fastify sur une API Gateway, mesurer son efficacité sur nos APIs, et estimer le coût de cette migration. À titre d’information cette API Gateway est composée d’un serveur express ainsi que d’un cache varnish en back pour la gestion du cache avec les backend.
Dans notre cas nous avons environ 116 APIs à migrer. Comme cela peut prendre un certain temps, nous allons procéder en deux étapes.
Tout d’abord nous allons migrer l’ensemble des API en utilisant une couche de compatibilité temporaire et vérifier le surcoût de performance.
Ensuite, nous allons extraire deux API representatives, l’une ayant très peu de traitement et l’autre étant une API très sollicitée au sein de notre application.
Étapes
Couche de compatibilité temporaire
- Implémenter le plugin Fastify @fastify/express qui ajoute une compatibilité complète de Express (nommé dans la suite de l’article fastify/express) afin de migrer rapidement l’ensemble de la base de code sur Fastify.
- Mesurer les impacts sur les performances pour au moins 4 APIs
- Documenter les résultats
Migration de l’API A
- Extraire l’API A du plugin de compatibilité
- Adapter les middlewares, la validation de schéma
- Mesurer l’amélioration des performances (entre Express, Express avec Fastify et Fastify seulement)
Migration de l’API B
- Extraire l’API B du plugin de compatibilité
- Adapter le schéma et les middlewares de l’API sous Fastify
- Mesurer et comparer les performances Express vs Fastify
L’ensemble des tests de performance ont été réalisés en utilisant k6
Réalisation & limites
Lors de la migration de l’API B nous avons rencontré certaines difficultés lors de l’extraction de la couche de compatibilité.
Nous avons dressé une liste de ces problèmes à titre d’information, en énonçant les solutions adoptées.
Les obstacles rencontrés :
Middleware d’autorisation incompatible
- Il a fallu adapter le schéma de la route (
.options({ allowUnknown: true
}) à utiliser pour garder les headers custom) - Solution : nouveau middleware à développer pour Fastify
Adapter la gestion des erreurs custom
- une vérification spécifique avec un traitement spécifique est occultée par le traitement de Fastify, entraînant un retour d’erreur par défaut sans personnalisation.
- il a fallu revoir le traitement spécifique sur cette API
- Solution: utilisation de
setErrorHandler
et filtrer sur le code d'erreur FST_ERR_VALIDATION afin de mettre en place le traitement voulu
Déclaration de route incompatible
- Find my way (module de résolution de route du serveur) précise qu’avoir plusieurs paramètres optionnels dans une route peut affecter les performances
Having a route with multiple parameters may affect negatively the performance, so prefer single parameter approach whenever possible, especially on routes which are on the hot path of your application.
- la route ne peut pas être utilisée directement car /:param1?/:param2? est incompatible avec la résolution de route de Fastify
- Solution: grâce au fait que cette API a des pathParams optionnels qui sont utilisés soit avec les deux définis, soit aucun, cela revient à avoir uniquement ces deux routes pour l’API
fastify.get('/:param1/:param2?', inputScheme, handler)
fastify.get('/', inputScheme, handler)
Résultats
Couche de compatibilité temporaire
En local
Deux tests de performance ont été lancés en local (sur MacBook Pro M1, 10 coeur, 16 Go de RAM). Le premier avec le scénario suivant:
- API A, 10 iter/s durant 2 min
- API B, 12 iter/s durant 2 min
- API C, 20 iter/s durant 2 min
- API D, 10 iter/s durant 2 min
- API E, 20 iter/s durant 2 min
Les résultats du premier test sont les suivants:
Test de performance en local (durée 2 min):
Conclusion : on observe un overhead de 1 à 2 ms.
Un second test, avec le même scénario mais des temps de 1 minute pour chaque API, a été lancé pour confirmer ce comportement.
Test de performance en local (durée 1 min):
On observe un overhead de 1 à 2 ms.
Dans les deux cas un overhead de 1 à 2 ms a été observé.
Sur CI
Un test supplémentaire de performance a été lancé sur la CI (instance AWS r4.xlarge, 4vCPU) afin de visualiser dans Datadog l’overhead en temps de réponse ainsi que l’impact sur le CPU et la mémoire que la surcouche Fastify/Express peut avoir en utilisant le scénario suivant :
- API A, 100 iter/s durant 2 min
- API B, 20 iter/s durant 5min
- API C, 100 iter/s durant 3min
- API D, 20 iter/s durant 3min
- API E, 15 iter/s durant 3min
Lancement
- Le 04/10 à 11:45pm -> 12:06pm fc211ee8 (avec Fastify/Express)
- Le 04/10 à 12:50pm -> 13:11pm e1f2e1a9 (avec express)
Résultats obtenus
On constate un léger overhead de 0–1ms en général.
Variance: Deux APIs A et C ont une différence plus importante pour le p90 entre Express et Fastify/Express. Cette différence s’explique par le temps de mis en cache par varnish lors des premiers appels
La mémoire et le CPU sont visibles sur les images. On constate une augmentation du CPU de (10% en pic on passe de 31.88% à 34.49%)
Migration de l’API A
Scenario K6
{
iterations: 30,
duration: '5m',
preAllocatedVUs: 500,
isConstantArrivalRate: true,
}
CPU
Courbe CPU Express en violet, courbe CPU Fastify en bleu
On remarque un pic CPU à 8% pour Fastify et à 9% pour Express qui correspond à la mise en cache. Les consommations moyennes sont assez similaires mais avec un léger avantage pour Fastify.
Mémoire
Courbe mémoire Express en violet, courbe CPU Fastify en bleu
Comme pour la consommation CPU, les deux courbes sont relativement similaires, mais Fastify présente de nouveau un léger avantage.
Temps de réponse
On peut conclure que, concernant les tests faits sur l’API A, Fastify permet un gain d’environ 10% du temps de réponse, ainsi qu’un léger gain en utilisation CPU et mémoire.
À noter aussi que toutes les préconisations de Fastify n’ont pas été mises en œuvre, on peut s’attendre à un gain de performance supplémentaire grâce aux actions suivantes :
- Remplacer notre outil de validation de schéma Joi
- Ajouter un outil de sérialisation et de schéma de sortie des APIs
- Changer le logger
Migration de l’API B
Scenario K6
{
iterations: 50,
duration: '10m',
preAllocatedVUs: 500,
isConstantArrivalRate: true,
}
CPU
Courbe CPU Express en jaune à gauche, courbe CPU Fastify en bleu à droite
On remarque un pic CPU pour chacun à environ 42%, cela correspond aux premiers appels avant la mise en cache des réponses dans Varnish.
On note que la consommation CPU est en moyenne plus élevée avec Express, et se stabilise autour de 35% CPU contre 30% CPU pour Fastify.
Mémoire
Courbe mémoire Express en jaune à gauche, courbe mémoire Fastify en bleu à droite
Un pic d’utilisation mémoire de plus de 2,5 % est observé pour Express, légèrement inférieur pour Fastify.
La consommation mémoire est en moyenne plus élevée avec Express, se stabilisant autour de 2,2 % contre 2 % pour Fastify.
On peut conclure que concernant les tests faits sur l’API B, Fastify permet un gain d’environ 20% du temps de réponse et un léger gain d’utilisation CPU.
À noter également que toutes les préconisations de Fastify n’ont pas été implémentées, et nous pouvons nous attendre à un gain de performance supplémentaire grâce aux actions suivantes :
- Remplacer notre outils de validation de schéma Joi
- Ajouter un outils de sérialisation et de schéma de sortie des APIs
- Changer le logger
Discussion
Dans cette section, nous allons évaluer le retour sur investissement (ROI) potentiel de la migration, en mettant en évidence les gains de performance attendus.
ROI
L’objectif du ROI est d’évaluer la viabilité financière d’un projet en projetant le temps nécessaire pour récupérer l’investissement initial et atteindre le point de rentabilité.
Il nous faut pour cela estimer les coûts vs les gains et définir le point de neutralité dans le temps.
Ces données sont basées sur les informations dont j’ai connaissance.
Données de référence
Les données de référence ont été prises sur Datadog lors d’un pic d’utilisation :
Mardi 28 novembre — 18h00 22h00
270 instances — 29% utilisation processeur
Prod instance: c6i.xlarge (prix: 0,17 USD)
Coût de la migration
Actuellement, le projet compte 116 APIS à migrer ce qui devrait prendre approximativement une durée de 150 jours (dev, review, tests inclus)
Ceci est une estimation en prenant en compte les données suivantes:
- Nombre d’APIs: 116 APIS
- Temps de dev/review/tests: 1 jour/H par API
- Total: 116 jours
- Deviation: 150 jours (deviation de 30%) suite au différents problèmes pouvant être rencontrés (proxy, validation spécifiques, middlewares ..)
Perte pendant la migration
Lors de la migration, comme observé dans les résultats précédents, on constate en moyenne un temps de réponse supérieur de 1 à 2 ms et une consommation en pic du CPU supérieure de 10 %.
Pour maintenir une équivalence avec les pourcentages actuels du CPU, il faudrait augmenter le nombre d’instances de 10 %, ce qui représenterait un coût approximatif de 6700 € par an.
Ceci est basé sur les données suivantes:
- Projection (nb_instances *instance_price* active_hour_by_day * nb_jours): 27 * 0,17 * 4 * 365 = 6700 euros par an
Gain potentiel estimé
Le gain potentiel estimé, basé sur les différents résultats obtenus précédemment, est le suivant :
CPU
- API A: On passe en moyenne de 9% pour express à 8% d’utilisation du CPU pour Fastify soit un gain de 10%
- API B: On passe en moyenne de 35% pour express à 30% d’utilisation du CPU pour Fastify soit un gain de 14%
En supposant que le gain soit identique pour l’ensemble des APIs.
En se basant sur les données précédentes avec 270 instances on pourrait économiser 32 instances (12%) soit un économie de 7942€ par an
Formule utilisée:
Projection (nb_instances *instance_price* active_hour_by_day * nb_jours): 32 * 0,17 * 4 * 365 = 7942
En prenant en compte le temps de développement: 150 days * TJM = xx
En prenant un TJM faible (au minimum 300€)
Le retour sur investissement (ROI) final est estimé à environ 5 ans et 8 mois
POC NestJS
Methodologie
Contexte
Réalisation d’un POC en NestJS, avec comme seule modification la sous-couche de traitement entre Express et Fastify.
En effet, NestJS supporte nativement deux frameworks HTTP grâce à son abstraction : Express (par défaut) et Fastify, simplement en modifiant les paramètres de configuration.
Réalisation & Limites
Le POC comporte deux routes :
[GET] / : qui retourne simplement la chaîne Hello world!
[GET] /remote :
- qui fait une requête HTTP sur un autre service et retourne la valeur
- ce serveur simule une latence de traitement sur un service appelé, de manière reproductible: délai de manière cyclique la réponse de 1ms, 10ms, 3ms, 20ms
- il permet de voir le comportement des deux frameworks en cas de montée en charge des requêtes entrantes et d’avoir une temporisation de la réponse
Résultats
Conditions des tests
Les données ont été récupérées au travers de l’outil autocannon et de clinic.js sur un MacBook Pro M2 de 2023 avec 16Go de RAM.
Avec autocannon
Analyse
Sur ce test, en traitant uniquement des données locales, Fastify a pu gérer près de 3.3x plus de requêtes que Express, tout en maintenant des temps de réponses extrêmement faibles, près de 3x plus faible.
Cependant, dans un monde en microservices, où le serveur doit appeler d’autres endpoints, les données restent quasiment les mêmes.
On observe néanmoins un gain de performance en utilisant Fastify sous un trafic élevé.
$ autocannon http://127.0.0.1:3000/remote --pipelining 10 --connections 100
De plus, avec le framework Express, il y a une montée en charge plus difficile, on voit qu’il y a déjà des requêtes en erreur qui commencent à apparaître.
Tests en charge avec k6
Analyse
On voit ici que Fastify permet de traiter d’avantages de requêtes qu’Express. Cependant hormis cet écart, qui n’est somme toute pas très probant, la principale différence réside dans le temps moyen nécessaire pour traiter les requêtes, même si le serveur est sous charge.
Investigation avec Clinic.js Doctor
Analyse
Pour effectuer la même tâche, il existe par contre de nombreuses différences entre les deux frameworks:
Les différences de fonctionnement entre les deux frameworks peuvent s’expliquer par le fait qu’Express alloue de nouvelles ressources à chaque nouvelle connexion, laissant le garbage collector traiter la mémoire. Cela explique les pics de mémoire, ainsi que le CPU et l’utilisation de l’EventLoop élevé: toute allocation/suppression de mémoire sollicite le CPU et doivent être gérée par le ramasse miette de Node.js.
D’un autre côté, Fastify a été conçu pour ne pas réallouer de nouveaux objets pour chaque nouvelle connexion entrante [1].
De plus, comme il n’y a pas d’allocation mémoire, le compilation JIT de JavaScript optimise les fonctions au fur et à mesure de leur utilisation. Ce qui entraine une diminution de la consommation des ressources, car le code est de plus en plus optimisé au fur et à mesure de son utilisation.
Conclusion
La migration vers Fastify amène bien une amélioration des performances, de l’utilisation du CPU, en passant par l’utilisation de la mémoire, au temps de réponse. Sur nos cas de tests le gain a été de 10 à 20 % du temps de réponse, en sachant que la latence des appels de nos APIs vers les autres services est très faible (cache). Ce gain peut varier en fonction des APIs car Fastify n’est responsable que d’une partie du traitement.
Dans le cas où le serveur web utilise un framework tel que NestJS qui crée un niveau d’abstraction au-dessus du serveur HTTP (Express ou Fastify), il peut être intéressant d’envisager un changement. Cela implique un coût de migration assez faible. Le gain peut cependant varier en fonction de l’architecture du projet, par exemple, dans le cadre d’un projet en microservice, si les latences entre les services sont importantes le gain sera minimisé.
Dans le cas ou le serveur web utilise un framework Express il peut être interessant d’envisager une migration en fonction du trafic, de l’architecture et du coût de la migration. Pour l’API Gateway, il semble que le coût de migration reste actuellement relativement élevé, et la baisse temporaire de performance (dépendant du temps nécessaire pour migrer l’ensemble des APIs) peut avoir un impact non négligeable.
En complément de cette étude, il aurait été intéressant de :
- Explorer les aspects inconnus en testant la migration sur d’autres APIs pour identifier et résoudre les éventuels problèmes de compatibilité.
- Optimiser le temps de migration
- Utiliser l’ensemble des recommandations Fastify (ajv pour la validation au lieu de joi, fournir le schéma de nos api pour accélérer la sérialisation,.. )
Remerciement
Merci à ekino et particulièrement à l’équipe Node.js pour nous avoir permis de mener cette étude. Un remerciement particulier à Bruno Sinteff, Florian Robert et Laurent Baysse pour leur temps et la motivation qu’ils ont démontrés tout au long de ce projet.