Chez Glean, nous construisons une architecture moderne entièrement cloud pour résoudre certains problèmes complexes de recherche d'entreprise et de gestion des connaissances. L'optimisation des performances et des coûts de ressources est essentielle et nous confronte souvent à des défis techniques intéressants. Le débogage de l'un de ces défis nous a menés à une découverte intéressante qui pourrait être utile à d'autres personnes travaillant sur des problématiques similaires.
Chez Glean, nous utilisons Golang pour un service modérément gourmand en mémoire. Nous utilisons également Google Cloud Platform (GCP) pour la majeure partie de notre déploiement. Nous exécutons ce service comme une instance App Engine flexible à l'aide d'une image de runtime personnalisé incluant Go 1.15. Nous avons observé le comportement intéressant suivant dans notre service Golang :
La mémoire montait lentement, atteignait la limite (nous utilisions dans ce cas 3 Go comme limite de ressources AppEngine), puis l'instance était arrêtée, probablement parce qu'elle dépassait la limite de mémoire. En observant le graphe de mémoire, cette montée régulière avait tout d'une fuite de mémoire :

Pas une fuite de mémoire applicative
Heureusement, une fuite de mémoire au niveau de l'application n'est pas difficile à déboguer dans notre cas, car nous avons accès à des données de profilage continu grâce au cloud profiler. Par le passé, nous avons rencontré des cas de connexions gRPC (Google Remote Procedure Call) non fermées provoquant de tels problèmes, mais ceux-ci étaient faciles à déboguer avec le profileur continu. En particulier, le flame graph dans l'interface du profileur montrait clairement une utilisation importante à un point d'appel spécifique dans ce genre de cas. Ici, ce n'était pas le cas. Les profils ont toutefois révélé une chose intéressante : la taille moyenne du tas (c'est-à-dire par les objets en cours d'utilisation) tournait autour de 1,5 Go (soit environ 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 réflexe suivant a été de nous demander s'il s'agissait d'un problème de fragmentation de la mémoire, car Golang est réputé peu performant 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 le coupable. Nous avons ajouté un thread en arrière-plan qui enregistre périodiquement les MemStats. Une borne supérieure de la mémoire fragmentée peut facilement être obtenue en soustrayant HeapAlloc de HeapInuse. En particulier, « HeapInuse moins HeapAlloc estime la quantité de mémoire dédiée à des classes de taille particulières, mais qui n'est pas actuellement utilisée. » Cette quantité était assez faible, environ 3 Mo dans notre cas.
Il était également intéressant de constater que les valeurs de HeapReleased étaient assez élevées. Nous avons poussé nos recherches et sommes tombés sur ce fil de discussion traitant de problèmes similaires.
{{richtext-banner-component}}
Problème d'interaction entre Golang et l'environnement conteneurisé
La théorie avancée dans le fil de discussion du problème Golang est que Go a commencé à utiliser MADV_FREE par défaut à partir de Go 1.12. Cela signifiait qu'il ne rendait pas forcément la mémoire immédiatement au système d'exploitation, et que ce dernier pouvait choisir de récupérer cette mémoire lorsqu'il ressentait une pression mémoire. Cependant, si l'on revient à la façon dont les conteneurs sont implémentés, il s'agit essentiellement de processus s'exécutant sous des Cgroups distincts. Le système d'exploitation peut donc ne pas ressentir la pression mémoire et ne pas libérer la mémoire, même si le conteneur risque d'atteindre la limite de mémoire et d'être arrêté.
Heureusement, il existe un indicateur de débogage Golang permettant d'inverser ce comportement et d'utiliser MADV_DONTNEED à la place, en définissant la variable d'environnement GODEBUG sur « madvdontneed=1 ». De fait, Go 1.16 est revenu à l'utilisation de ce comportement par défaut. Le graphe de mémoire après ce changement est bien meilleur et se stabilise à 2 Go.

Points clés à retenir
- pprof et les flame graphs sont très utiles pour analyser les fuites de mémoire applicatives. Un profileur continu peut vraiment vous aider à examiner plusieurs instantanés du profil et à identifier rapidement la cause des fuites. Cloud profiler est assurément un outil pratique pour les charges de travail GCP.
- La journalisation des MemStats peut aider à analyser les causes potentielles à un niveau plus élevé. En particulier, « HeapInuse moins HeapAlloc » peut servir de borne supérieure pour estimer la quantité de mémoire gaspillée par la fragmentation.
- Si vous utilisez Go entre les versions 1.12 et 1.15 au sein de conteneurs, vous voudrez probablement définir madvdontneed=1 dans GODEBUG. :-)







