Comment nous avons analysé et corrigé une fuite de mémoire en Golang

0
minutes de lecture
Comment nous avons analysé et corrigé une fuite de mémoire en Golang

Table des matières

Vous avez des questions ou souhaitez une démo ?

Nous sommes là pour vous aider ! Cliquez sur le bouton ci-dessous et nous vous recontacterons.

Demander une démo
Partager cet article :

Chez Glean, nous construisons une architecture moderne, 100 % cloud, pour résoudre certains problèmes complexes liés à la recherche d’entreprise et à la gestion des connaissances. L’optimisation des performances et des coûts en ressources est essentielle et nous amène souvent à relever des défis techniques intéressants. En déboguant l’un de ces défis, nous avons fait une découverte intéressante qui pourrait être utile à d’autres confrontés à des problématiques similaires.  ;

Chez Glean, nous utilisons Golang pour un service moyennement gourmand en mémoire. Nous utilisons aussi Google Cloud Platform (GCP) pour la majorité de nos déploiements. Nous exécutons ce service en tant qu’instance App Engine flexible, à l’aide d’une image de runtime personnalisé qui inclut Go 1.15. Nous avons observé le comportement intéressant suivant dans notre service Golang :

La mémoire augmentait lentement, atteignait la limite (nous utilisions 3 Go comme limite de ressources AppEngine dans ce cas) puis l’instance était arrêtée, probablement parce qu’elle dépassait la limite de mémoire. En regardant le graphique mémoire, cette montée régulière ressemblait fortement à une fuite mémoire :

Illustration du produit

Pas une fuite mémoire applicative

Heureusement, dans notre cas, une fuite mémoire côté application n’est pas difficile à déboguer puisque nous avons accès à des données de profiling en continu via le cloud profiler. Par le passé, nous avons déjà vu des connexions Google Remote Procedure Call (gRPC) non fermées provoquer ce type de problèmes, mais celles-ci étaient faciles à diagnostiquer grâce au profiler en continu. En particulier, le flame graph  ;dans l’interface du profiler montrait clairement une utilisation importante à un point d’appel précis dans ce genre de cas. Ici, ce n’était pas ce qui se produisait. En revanche, les profils ont révélé un point intéressant : la taille moyenne du heap (c’est-à-dire des objets en cours d’utilisation) était d’environ 1,5 Go (soit ~2 fois moins que l’empreinte mémoire observée par App Engine). Cela signifiait que la mémoire était retenue quelque part par le runtime Golang. Notre pensée suivante a été de nous demander s’il s’agissait d’un problème de fragmentation mémoire, car Golang est réputé mauvais sur cet aspect.

Pas un cas de fragmentation

Heureusement, il n’a pas été trop difficile de conclure que la fragmentation n’était pas non plus la cause. Nous avons ajouté un thread en arrière-plan qui consigne périodiquement les MemStats. Une borne supérieure de la mémoire fragmentée peut être obtenue facilement en soustrayant HeapAlloc de HeapInuse. En particulier, « HeapInuse minus HeapAlloc estimates the amount of memory that has been dedicated to particular size classes, but is not currently being used. » Cette quantité était assez faible : ~3 Mo dans notre cas.

Il était également intéressant de constater que les valeurs de HeapReleased étaient assez élevées. Nous avons approfondi et sommes tombés sur ce fil concernant des problèmes similaires.

{{richtext-banner-component}}

Problème d’interaction entre Golang et un environnement conteneurisé

La théorie possible dans le fil d’incident Golang est que Go a commencé à utiliser MADV_FREE par défaut à partir de la version 1.12. Cela signifiait qu’il pouvait ne pas rendre la mémoire immédiatement à l’OS, et que l’OS pouvait choisir de récupérer cette mémoire lorsqu’il ressentait une pression mémoire. Or, si l’on revient à la manière dont les conteneurs sont implémentés, il s’agit essentiellement de processus exécutés sous des Cgroups séparés. L’OS peut donc ne pas ressentir la pression mémoire et ne pas libérer la mémoire, même si le conteneur atteint sa limite mémoire et finit par être arrêté.  ;

Heureusement, il existe un flag de debug Golang permettant de modifier ce comportement et d’utiliser MADV_DONTNEED à la place, en définissant la variable d’environnement GODEBUG sur « madvdontneed=1 ». D’ailleurs, Go 1.16 a désormais rebasculé sur ce comportement par défaut. Après ce changement, le graphique mémoire est bien meilleur et se stabilise à 2 Go.

Illustration du produit

Points clés à retenir

  1. pprof et les flame graphs sont très utiles pour analyser les fuites mémoire côté application. Un profiler en continu peut vraiment aider à examiner plusieurs instantanés du profil et à identifier rapidement la cause des fuites. Cloud profiler est clairement un outil très pratique pour les workloads sur GCP.
  2. La journalisation des MemStats peut aider à analyser des causes potentielles à un niveau plus global. En particulier, « HeapInuse minus HeapAlloc » peut servir de borne supérieure pour estimer la quantité de mémoire perdue à cause de la fragmentation.
  3. Si vous utilisez Go entre les versions 1.12 et 1.15 dans des conteneurs, vous avez probablement intérêt à définir madvdontneed=1 in GODEBUG. :-)
Intégrer les LLMs et GPT dans les workflows d'entreprise

Intégrer les LLMs et GPT dans les workflows d'entreprise

Découvrez dans notre livre blanc comment les avancées des IA génératives les ont propulsées au premier plan de la transformation des environnements de travail modernes — et comment les intégrer au mieux dans plusieurs domaines clés de l'entreprise.

Intégrer les LLMs et GPT dans les workflows d'entreprise
L’IA au service de tous.
Demander une démo
CTA Section Background Shape

Work AI qui fonctionne.

CTA Background Gradient 3CTA Background Gradient 3CTA Background Mobile