Attrape-moi si tu peux — Fuites de mémoire

Dec 01 2022
Une rétrospective sur une fuite de mémoire
Introduction Les fuites de mémoire sont l'une de ces choses qui, lorsqu'elles se produisent, peuvent vraiment vous plonger dans le vif du sujet. Les diagnostiquer semble être une tâche difficile au début.
Ingénieurs Elli contre fuite de mémoire (illustré par Jane Kim)

Introduction

Les fuites de mémoire sont l'une de ces choses qui, lorsqu'elles se produisent, peuvent vraiment vous plonger dans les profondeurs. Les diagnostiquer semble être une tâche difficile au début. Ils nécessitent une plongée approfondie dans les outils et les composants sur lesquels votre service s'appuie. Cet examen approfondi non seulement approfondit votre compréhension de votre paysage de services, mais donne également un aperçu de la façon dont les choses fonctionnent sous le capot. Bien que décourageantes à première vue, les fuites de mémoire sont essentiellement une bénédiction déguisée.

Chez Elli, nous faisons de notre mieux pour minimiser la dette technique au strict minimum. Cependant, des incidents se produisent toujours et notre approche consiste à apprendre et à partager les connaissances en résolvant ces problèmes.

Donc, cet article vise à faire exactement cela. Dans cet article, nous vous expliquons notre approche d'identification d'une fuite de mémoire et partageons nos apprentissages en cours de route.

Le contexte

Avant de plonger dans la réparation de la fuite de mémoire, nous avons besoin d'un peu de contexte sur l'infrastructure d'Elli et où la fuite de mémoire s'est produite en premier lieu.

Elli, entre autres, est un opérateur de bornes de recharge. Nous sommes responsables de connecter les bornes de recharge (CS) à notre backend et de les contrôler via le protocole OCPP . Ergo, nos clients peuvent recharger leurs VE dans des stations privées ou publiques. Les CS sont connectés à nos systèmes via WebSockets. En ce qui concerne l'authentification, nous prenons en charge les connexions via TLS ou Mutual TLS (mTLS). Pendant TLS, un CS vérifiera notre certificat de serveur et s'assurera qu'il se connecte à un backend Elli. Avec mTLS, nous vérifions également que le CS dispose d'un certificat client émis par nous.

Côté connectivité, un serveur écrit en Node.js, se charge de prendre en charge la logique UPGRADE de HTTP vers WebSockets et de conserver l'état des connexions. Il est déployé dans un cluster Kubernetes et géré par un Horizontal Pod Autoscaler (HPA). Idéalement, le HPA suit la charge de trafic et augmente ou diminue les pods en conséquence.

Nous maintenons simultanément des dizaines de milliers de connexions TCP persistantes et de longue durée à partir des bornes de recharge . Cela introduit de la complexité et diffère considérablement des services RESTful typiques. Une métrique proxy qui suit la charge est l'utilisation de la mémoire car elle reflète le nombre de connexions établies et la logique de l'application ne nécessite pas beaucoup de calculs. Nos pods ont une longue durée de vie, et la mise à l'échelle via la mémoire nous a conduit à constater que le nombre de pods augmente lentement pour un nombre constant de connexions. Pour faire court, nous avons repéré une fuite de mémoire.

Évaluation de l'impact

Face à tout type de problème de production, l'équipe d'ingénierie d'Elli évalue immédiatement les implications de cet incident sur nos clients et l'entreprise. Ainsi, après avoir découvert cette fuite de mémoire, nous avons fait l'évaluation suivante :

L'application perd de la mémoire en quelques jours. Cela signifie que sans recevoir de trafic supplémentaire, notre infrastructure continue de croître.

Lorsqu'un pod ne peut pas gérer de trafic supplémentaire, grâce à la sonde de préparation de Kubernetes, il cesse de recevoir du trafic supplémentaire mais continue de servir les connexions établies. Un pod qui desservirait X connexions pourrait finir par ne desservir qu'une fraction de ses capacités en raison de la fuite, sans causer de perturbation du côté client. Cela signifie que nous pouvons facilement absorber l'impact en faisant simplement tourner plus de pods.

L'enquête

Passons maintenant à la véritable plongée technique dans la fuite de mémoire.

Nous expliquons ici les outils et les méthodes que nous avons utilisés pour découvrir la source de la fuite de mémoire, ce que nous nous attendions à voir de notre expérience et ce que nous avons réellement observé. Nous avons inclus des liens vers les ressources que nous avons utilisées dans notre enquête pour votre référence.

Une introduction rapide à la mémoire JS

Les variables en JavaScript (et dans la plupart des autres langages de programmation) sont stockées à deux endroits : la pile et le tas. Une pile est généralement une région continue de mémoire allouant un contexte local pour chaque fonction en cours d'exécution. Le tas est une région beaucoup plus grande stockant tout ce qui est alloué dynamiquement. Cette séparation est utile pour rendre l'exécution plus sûre contre la corruption (la pile est mieux protégée) et plus rapide (pas besoin de ramasse-miettes dynamique des trames de la pile, allocation rapide de nouvelles trames).

Seuls les types primitifs passés par valeur (Number, Boolean, références à des objets) sont stockés sur la pile. Tout le reste est alloué dynamiquement à partir du pool de mémoire partagé appelé tas. En JavaScript, vous n'avez pas à vous soucier de désallouer des objets à l'intérieur du tas. Le ramasse-miettes les libère chaque fois que personne ne les référence. Bien sûr, la création d'un grand nombre d'objets a un impact sur les performances (quelqu'un doit tenir toute la comptabilité) et provoque une fragmentation de la mémoire.

La source:https://glebbahmutov.com/blog/javascript-stack-size/

Prendre un instantané de tas à partir d'un pod de production | Instantanés de tas et profilage

Attentes

Nous avons collecté régulièrement des instantanés de tas de notre application pour voir une accumulation d'objets au fil du temps. En raison de la nature de l'application, qui contient principalement des connexions WebSocket, nous nous attendions à ce que les objets TLSSocket correspondent au nombre de connexions dans l'application. Nous avons émis l'hypothèse que lorsqu'une station était déconnectée, l'objet était en quelque sorte toujours référencé. La collecte des ordures fonctionne en nettoyant les objets inaccessibles, donc dans ce cas, les objets seraient laissés intacts.

Résultats

L'obtention d'un vidage de tas à partir d'un pod utilisé à 90 % a entraîné une plage de 100 Mo. Chaque pod demande environ 1,5 Go de RAM et le tas représentait moins de 10 % de la mémoire allouée. Cela parait suspect…

Où le reste de la mémoire a-t-il été alloué ? Néanmoins, nous avons poursuivi l'analyse. Prendre trois instantanés à intervalles et observer l'évolution de la mémoire au fil du temps n'a rien révélé. Nous n'avons pas remarqué d'accumulation d'objets ni de problèmes de récupération de place. Le vidage de tas semblait plutôt sain.

Image 1 : Prise d'instantanés de tas via les outils de développement Chrome à partir d'un module de production. Le nombre d'objets TLSSocket s'aligne sur la connexion du pod actuel contrairement aux résultats attendus.

Les objets TLSSocket correspondaient à l'état de l'application. Pour en revenir à la première observation, le vidage de tas est d'un ordre de grandeur inférieur à l'utilisation de la mémoire. Nous avons pensé : « Cela ne peut pas être vrai. Nous cherchons au mauvais endroit. Nous devons prendre du recul. »

De plus, nous avons profilé l'application via le Cloud Profiler proposé par GCP. Nous voulions voir comment les objets sont alloués au fil du temps et identifier potentiellement la fuite de mémoire.

L'obtention d'un vidage de tas bloque le thread principal et peut potentiellement tuer l'application, contrairement au profileur qui peut être maintenu en production avec peu de frais généraux.

Cloud Profiler est un outil de profilage continu conçu pour les applications exécutées sur Google Cloud. Il s'agit d'un profileur statistique ou d'échantillonnage à faible surcharge et adapté aux environnements de production.

Bien que le profileur ait contribué à notre compréhension des locataires du tas, il ne nous a toujours pas donné de pistes sur l'enquête. Au contraire, cela nous a éloignés d'aller dans la bonne direction.

Alerte spoiler : le profileur nous a cependant fourni des informations assez précieuses lors d'un incident en production où nous avons identifié et corrigé une fuite de mémoire agressive, mais c'est une histoire pour une autre fois.

Statistiques d'utilisation de la mémoire

Nous avions besoin de plus d'informations sur l'utilisation de la mémoire. Nous avons créé des tableaux de bord pour toutes les métriques que process.memoryUsage() avait à offrir.

heapTotal et heapUsed font référence à l'utilisation de la mémoire de V8.

L' externe fait référence à l'utilisation de la mémoire des objets C++ liés aux objets JavaScript gérés par V8.

Le rss, Resident Set Size , est la quantité d'espace occupé dans le périphérique de mémoire principal (c'est-à-dire un sous-ensemble de la mémoire totale allouée) pour le processus, y compris tous les objets et codes C++ et JavaScript.

Les arrayBuffers font référence à la mémoire allouée pour ArrayBuffers et SharedArrayBuffers , y compris tous les Node.js Buffers . Ceci est également inclus dans la valeur externe. Lorsque Node.js est utilisé en tant que bibliothèque intégrée, cette valeur peut être 0 car les allocations pour ArrayBuffers peuvent ne pas être suivies dans ce cas.

Image 2 : Une visualisation du contenu RSS. Il n'y a pas de modèle officiel à jour de la mémoire du V8 car il change assez fréquemment. C'est notre meilleur effort pour décrire ce qui vit sous le RSS afin que nous puissions avoir une image plus claire des composants de mémoire potentiels qui fuient de la mémoire. Si vous souhaitez en savoir plus sur le ramasse-miettes, nous vous suggérons https://v8.dev/blog/trash-talk. Merci à @mlippautz pour la clarification.

Comme nous l'avons vu précédemment, nous obtenions des instantanés de tas d'environ 100 Mo à partir d'un conteneur qui avait plus de 1 Go d'utilisation de la mémoire. Où est le reste de la mémoire allouée ? Regardons.

Image 3 : Utilisation de la mémoire par pod (95e centile). Il grandit avec le temps. Rien de nouveau ici, nous sommes conscients de la fuite de mémoire.
Image 4 : Le nombre de connexions par pod au fil du temps (95e centile) ; les pods gèrent de moins en moins de connexions.
Image 5 : Mémoire utilisée par le tas (95e centile). Le tas est aligné sur la taille des instantanés que nous avons collectés et est stable dans le temps.
Image 6 : Mémoire externe (95e centile) : de petite taille et stable.
Image 7 : Utilisation de la mémoire et Resident Set Size (RSS) (95e centile). Il y a une corrélation - RSS suit le modèle.

Que savons-nous jusqu'ici? RSS se développe, le tas et les externes sont stables ce qui nous amène à la pile. Cela pourrait signifier une méthode qui est appelée et ne se termine jamais, entraînant ainsi un débordement de pile. Cependant, la pile ne peut pas contenir des centaines de Mo. À ce stade, nous avons déjà testé dans un environnement hors production avec plusieurs milliers de stations, mais nous n'avons obtenu aucun résultat.

Alloueur de mémoire

Pendant le brainstorming, nous avons considéré la fragmentation de la mémoire : des morceaux de mémoire sont alloués de manière non séquentielle, ce qui conduit à de petits morceaux de mémoire qui ne peuvent pas être utilisés pour de nouvelles allocations. Dans notre cas, l'application est de longue durée et effectue beaucoup d'allocations et de libérations. La fragmentation de la mémoire est une préoccupation valable dans ces cas. Une recherche approfondie sur Google nous a conduits à un problème GitHub où les gens ont été confrontés à la même situation que nous. Le même schéma de fuite de mémoire a été observé et correspondait à notre hypothèse.

Nous avons décidé de tester un autre répartiteur de mémoire et nous sommes passés de musl à jemalloc . Nous n'avons trouvé aucun résultat significatif. À ce stade, nous savions que nous devions faire une pause. Nous avons dû repenser entièrement l'approche.

Se pourrait-il que la fuite n'apparaisse que sur les connexions mTLS ?

Lors de nos premiers tests, nous avons essayé de reproduire le problème dans un environnement hors production, mais sans succès. Nous avons effectué des tests de charge avec des milliers de stations simulant différents scénarios, connectant/déconnectant des stations pendant des jours, mais ils n'ont produit aucun résultat significatif. Cependant, nous avons commencé à soupçonner de plus en plus qu'il y avait quelque chose que nous avions manqué lors de l'exécution de ces tests.

Nous n'avons pas tenu compte du fait que nos stations peuvent se connecter via TLS ou mTLS. Notre premier test incluait des stations TLS, mais pas mTLS, et la raison en est simple : nous ne pouvions pas facilement créer des stations mTLS et les certificats clients respectifs. Un incident récent nous a incités à minimiser le rayon d'explosion et à diviser les responsabilités de l'application afin que chaque déploiement gère le trafic TLS et mTLS séparément. Eurêka ! La fuite de mémoire n'apparaît que sur nos pods mTLS, alors que sur TLS la mémoire est stable.

Où allons-nous à partir d'ici?

Nous avons décidé qu'il y avait deux options : (1) Passer à nos prochains suspects - une bibliothèque qui gère toutes les tâches de l'infrastructure à clé publique ainsi qu'une récursivité potentielle quelque part dans ce chemin de code, (2) ou vivre avec jusqu'à ce que nous retravaillions entièrement notre service.

Au cours de l'enquête sur la fuite de mémoire, de nombreux sujets imprévus liés au service concerné ont été portés à notre attention. Compte tenu de la fuite de mémoire et de tout ce que nous avons découvert, nous avons décidé d'améliorer notre paysage de services et de diviser les responsabilités du service . Le flux d'authentification et d'autorisation CS, entre autres, serait délégué au nouveau service et nous utiliserions les bons outils pour gérer les tâches PKI.

Sommaire

L'amélioration de notre mise à l'échelle a révélé que nous avions une fuite de mémoire qui aurait pu passer inaperçue pendant une période indéfinie. Donner la priorité au client et évaluer l'impact de la fuite étaient d'abord et avant tout. Ce n'est qu'alors que nous avons pu accélérer notre enquête puisque nous nous sommes rendu compte qu'il n'y avait pas d'impact sur les clients. Nous avons commencé par l'endroit le plus évident à regarder lors du diagnostic d'une fuite de mémoire - le tas. Cependant, l'analyse du tas nous a montré que nous regardions au mauvais endroit. D'autres indices étaient nécessaires et l' API de processus de V8 nous a donné exactement cela. Dans les premiers résultats que nous avons obtenus, la fuite de mémoire est apparue dans RSS. Enfin, en analysant toutes les informations recueillies, nous avons suspecté une fragmentation de la mémoire.

Changer l'allocateur de mémoire n'a pas amélioré la situation. Au lieu de cela, changer notre approche et diviser la charge de travail entre TLS et mTLS nous a aidés à réduire le chemin de code affecté.

Quels ont été les résultats finaux de notre enquête?

Nos projets d'amélioration de l'évolutivité et de résolution de la fuite de mémoire nous ont incités à diviser le service et à en écrire un nouveau pour prendre en charge le flux de connectivité CS séparément des autres spécificités CS.

Avons-nous réparé notre fuite de mémoire ?

Le temps nous le dira, mais je dirais que l'enquête était bien plus que cela. L'expérience de sonder la fuite nous a aidés à grandir en tant que développeurs et notre service à adopter une architecture plus résiliente et évolutive.

Points clés et apprentissages

  • Des défis d'ingénierie difficiles rassemblent les gens ; nous avons joué au ping-pong sur des idées avec des ingénieurs extérieurs à notre équipe.
  • Nous a donné la motivation de repenser le service, ce qui a conduit à une architecture plus évolutive.
  • Si ça fait mal, cela nécessite votre attention; ne l'ignorez pas.
  • https://nodejs.org/en/docs/guides/diagnostics/memory/using-heap-snapshot/
  • Activez le débogage à distance vers un pod via le transfert de port :https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/
  • https://developers.google.com/cast/docs/debugging/remote_debugger
Suivez-nous sur Médium ! (illustré par Jane Kim)

Si vous souhaitez en savoir plus sur notre fonctionnement, abonnez-vous au blog Elli Medium et visitez le site Web de notre entreprise sur elli.eco ! À la prochaine!

A propos de l'auteur

Thanos Amoutzias est ingénieur logiciel, il développe le système de gestion des bornes de recharge d'Elli et anime les sujets SRE. Il est passionné par la création de services fiables et la fourniture de produits percutants. Vous pouvez le retrouver sur LinkedIn et dans le ️.

Crédits : Merci à tous mes collègues qui ont révisé et donné leur avis sur l'article !