Diagnostiquer et corriger les fuites de mémoire en Python
7 mars 2017
0 minutes de lectureNote de la rédaction
Cet article est paru à l’origine sur fugue.co. Fugue a rejoint Snyk en 2022 et constitue un élément clé de Snyk IaC.
Fugue utilise Python à grande échelle dans son produit SaaS de sécurité cloud et ses outils de support, en raison de sa simplicité d’utilisation, de la sécurité Python, de sa vaste bibliothèque de packages et de ses puissants outils de langage. En développant des logiciels complexes pour le cloud, nous avons appris qu’un langage ne vaut que par ses outils de débogage et de profilage. Les erreurs de logique, les pics d’utilisation du processeur et les fuites de mémoire sont inévitables, mais un bon débogueur, un profileur de processeur et un profileur mémoire permettent de les repérer beaucoup plus facilement et rapidement. Nos développeurs peuvent ainsi reprendre la création du système dynamique d’orchestration et d’application des politiques cloud de Fugue. Voyons un exemple concret.
À l’automne, nos métriques ont révélé qu’un composant Python de Fugue, appelé reflector, redémarrait de façon aléatoire et devenait instable après quelques jours de fonctionnement. L’examen de l’utilisation de la mémoire a montré que l’empreinte mémoire du reflector augmentait continuellement, signe d’une fuite de mémoire. tracemalloc, un puissant outil de suivi de la mémoire inclus dans la bibliothèque standard Python, nous a permis de diagnostiquer et de corriger rapidement la fuite. Nous avons découvert qu’elle était liée à notre utilisation de requests, une bibliothèque HTTP tierce populaire pour Python. La réécriture du composant pour utiliser urllib, inclus dans la bibliothèque standard Python, a éliminé la fuite de mémoire. Dans cet article, nous allons examiner les détails.

Allocation de mémoire en Python
Dans la plupart des cas, il n’est pas nécessaire de comprendre la gestion de la mémoire en Python au-delà du fait que l’interpréteur s’en charge. Cependant, lorsqu’on écrit des programmes Python volumineux et complexes qui doivent être très stables, il est utile de regarder sous le capot pour comprendre comment écrire du code qui interagit correctement avec les algorithmes de gestion de la mémoire de Python. Python utilise notamment le comptage de références et le ramasse-miettes pour libérer des blocs de mémoire, et ne rend la mémoire au système que lorsque certaines conditions internes sont remplies. Un script Python pur ne peut jamais contrôler directement l’allocation de mémoire dans l’interpréteur. Pour contrôler directement cette allocation, on peut contourner le mécanisme de l’interpréteur en écrivant ou en utilisant une extension. Par exemple, numpy gère la mémoire des grands tableaux de données à l’aide de son propre allocateur de mémoire.
Fondamentalement, Python est un langage avec ramasse-miettes qui utilise le comptage de références. L’interpréteur alloue automatiquement de la mémoire aux objets lors de leur création et suit leur nombre de références dans une structure de données associée à chaque objet. Cette mémoire est libérée lorsque le compteur de références de l’objet atteint zéro. De plus, le ramasse-miettes détecte les références circulaires et supprime les objets auxquels seules ces références donnent accès. Ces deux mécanismes permettent de libérer chaque octet de mémoire alloué dans l’interpréteur Python, mais rien ne garantit la libération de la mémoire allouée dans les extensions.
Python gère son propre tas, distinct de celui du système. Dans l’interpréteur Python, la mémoire est allouée selon différentes méthodes en fonction du type d’objet à créer. Les types scalaires, tels que les nombres entiers et flottants, n’utilisent pas les mêmes méthodes d’allocation que les types composites, tels que les listes, les tuples et les dictionnaires. En général, la mémoire est allouée sur le tas Python en blocs de taille fixe, selon le type. Ces blocs sont regroupés en pools, eux-mêmes organisés en arènes. La mémoire est préallouée sous forme d’arènes, de pools et de blocs, puis utilisée pour stocker des données au fil de l’exécution du programme. Comme ces blocs, pools et arènes se trouvent dans le propre tas de Python, libérer un bloc de mémoire revient simplement à le marquer comme disponible pour une utilisation ultérieure dans l’interpréteur. La libération de mémoire en Python ne rend pas immédiatement cette mémoire au système. Lorsque toute une arène est marquée comme libre, l’interpréteur Python libère sa mémoire et la restitue au système. Cela peut toutefois se produire peu souvent en raison de la fragmentation de la mémoire.
En raison de ces abstractions, l’utilisation de la mémoire en Python suit souvent un profil de pic, où la consommation maximale détermine celle du reste de l’exécution, que cette mémoire soit activement utilisée ou non. De plus, le lien entre la mémoire « libérée » dans le code et sa restitution au système est flou et difficile à prévoir. Ces comportements rendent notoirement difficile la compréhension complète de l’utilisation de la mémoire dans les programmes Python complexes.
Profiler la mémoire avec tracemalloc
tracemalloc est un package inclus dans la bibliothèque standard Python (depuis la version 3.4). Il fournit des traces détaillées de l’allocation de mémoire, bloc par bloc, notamment la trace complète jusqu’à la ligne où l’allocation a eu lieu, ainsi que des statistiques sur le comportement global du programme en matière de mémoire. La documentation disponible ici présente bien ses fonctionnalités. La proposition d’amélioration Python (PEP) d’origine qui l’a introduit donne également un aperçu de sa conception.
tracemalloc permet de repérer les zones de code qui consomment beaucoup de mémoire de deux façons :
en examinant les statistiques cumulées d’utilisation de la mémoire afin de déterminer quelles allocations d’objets en consomment le plus ; et
en retraçant les frames d’exécution afin de repérer où ces objets sont alloués dans le code.
Utilisation de la mémoire au niveau du module
Nous commençons par suivre l’utilisation de la mémoire de l’ensemble du programme afin de repérer, à un niveau général, les objets qui en consomment le plus. Nous espérons ainsi obtenir suffisamment d’informations pour savoir où et comment examiner le problème plus en détail. Le wrapper suivant lance le suivi et affiche des statistiques lorsque vous appuyez sur Ctrl-C :
tracemalloc.start(10) lance le suivi de la mémoire en conservant 10 frames de trace pour chaque entrée. La valeur par défaut est 1, mais conserver davantage de frames est utile si vous prévoyez d’utiliser les traces pour localiser des fuites de mémoire, comme nous le verrons plus loin. tracemalloc.take_snapshot() prend un instantané de la mémoire actuellement allouée sur le tas Python. Il enregistre le nombre de blocs alloués, leur taille et les traces permettant de savoir quelles lignes de code ont alloué quels blocs de mémoire. Une fois l’instantané créé, nous pouvons calculer des statistiques d’utilisation de la mémoire, comparer des instantanés ou les enregistrer pour les analyser ultérieurement. top_n est une fonction utilitaire que j’ai écrite pour afficher joliment le résultat de tracemalloc. Ici, je demande les 25 allocations de mémoire les plus importantes de l’instantané, regroupées par nom de fichier. Après quelques minutes d’exécution, voici le résultat :
Ce résultat montre la quantité cumulée de mémoire allouée par le composant sur toute la durée d’exécution, regroupée par nom de fichier. À ce niveau de détail, il est difficile d’interpréter les résultats. La première ligne indique, par exemple, que 17 Mo d’objets collections ont été créés, mais cette vue ne nous dit pas précisément de quels objets il s’agit ni où ils sont utilisés. Il faut donc adopter une autre approche pour isoler le problème.
Comprendre les résultats de tracemalloc
tracemalloc affiche l’utilisation nette de la mémoire au moment où un instantané est pris. Lorsqu’on compare deux instantanés, il affiche l’utilisation nette de la mémoire entre les deux. Si de la mémoire est allouée puis libérée entre les instantanés, elle n’apparaît pas dans le résultat. Par conséquent, si les instantanés sont créés au même endroit dans une boucle, les allocations de mémoire visibles dans les différences entre deux instantanés contribuent à la quantité totale de mémoire utilisée à long terme, plutôt que de correspondre à une allocation temporaire pendant l’exécution.
Dans le cas des références circulaires nécessitant le ramasse-miettes, les cycles non collectés figurent dans le résultat, contrairement aux cycles collectés. Les blocs libérés par le ramasse-miettes pendant la période couverte par un instantané sont enregistrés comme de la mémoire libérée. Forcer le ramasse-miettes avec gc.collect() avant de prendre un instantané permet donc de réduire le bruit dans le résultat.
Utilisation de la mémoire à chaque itération
Puisque nous recherchons une fuite de mémoire, il est utile de comprendre comment l’utilisation de la mémoire de notre programme évolue au fil du temps. Nous pouvons instrumenter la boucle principale du composant pour mesurer la quantité de mémoire allouée à chaque itération, en appelant la méthode suivante depuis la boucle principale :
Ce code prend un instantané de la mémoire et l’enregistre, puis utilise snapshot.compare_to(other_snapshot, group_by='filename') pour comparer le nouvel instantané au précédent, en regroupant les résultats par nom de fichier. Après quelques itérations pour stabiliser l’utilisation de la mémoire, voici le résultat :
Les allocations de linecache (1) et de tracemalloc (2) font partie de l’instrumentation, mais nous voyons aussi des allocations de mémoire provenant du package HTTP requests (3), qui méritent un examen plus approfondi. Rappelons que tracemalloc suit l’utilisation nette de la mémoire : ces allocations s’accumulent donc à chaque itération. Bien que les allocations individuelles soient faibles et ne semblent pas problématiques, la fuite de mémoire ne devient apparente qu’au bout de quelques jours. Il s’agit probablement de petites pertes qui finissent par s’accumuler.
Filtrer les instantanés
Maintenant que nous savons où chercher, nous pouvons utiliser les fonctions de filtrage de tracemalloc pour n’afficher que les allocations de mémoire liées au package requests :
snapshot.filter_traces() prend une liste de Filters à appliquer à l’instantané. Ici, nous créons un Filter en mode inclusive, qui n’inclut que les traces correspondant à filename_pattern. Lorsque inclusive vaut False, le filtre exclut les traces correspondant à filename_pattern. filename_pattern utilise des jokers de type UNIX pour faire correspondre les noms de fichiers dans la trace. Dans cet exemple, les jokers de « requests » correspondent aux occurrences de « requests » au milieu d’un chemin, comme "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py".
Nous utilisons ensuite compare_to() pour comparer les résultats à l’instantané précédent. Voici le résultat filtré :
Avec le Filter en place, nous pouvons clairement voir comment requests utilise la mémoire. La ligne (4) montre qu’environ 50 Kio de mémoire sont perdus dans requests à chaque itération de la boucle principale. Notez que des allocations négatives, comme (5), apparaissent également dans le résultat. Elles correspondent à la libération de mémoire allouée lors d’itérations précédentes de la boucle.
Localiser les allocations de mémoire
Pour déterminer quelles utilisations de requests entraînent des fuites de mémoire, nous pouvons examiner en détail l’origine des allocations problématiques en appelant compare_to() avec traceback plutôt qu’avec filename, tout en utilisant un Filter pour limiter le résultat :
Pour chaque entrée du résultat, cela affiche 10 frames de trace (puisque nous avons lancé le suivi avec tracemalloc.start(10)). En voici un exemple tronqué :
La trace complète nous permet de remonter des allocations de mémoire jusqu’aux lignes de code de notre projet qui les génèrent. Dans ce composant, nos appels à requests provenaient d’une bibliothèque de stockage interne qui utilisait une API HTTP. La réécriture de cette bibliothèque pour utiliser directement urllib a éliminé la fuite de mémoire.

Profilage de la mémoire : art ou science ?
tracemalloc est un outil puissant pour comprendre l’utilisation de la mémoire par les programmes Python. Il nous a aidés à comprendre l’utilisation de la mémoire au niveau des modules, à repérer les objets alloués le plus souvent et à voir comment l’utilisation de la mémoire du reflector évoluait à chaque itération. Il offre des outils de filtrage utiles et permet d’afficher la trace complète de chaque allocation mémoire. Malgré toutes ces fonctionnalités, trouver des fuites de mémoire en Python peut encore relever davantage de l’art que de la science. Les profileurs mémoire nous permettent de voir comment la mémoire est utilisée, mais il est souvent difficile de trouver l’allocation précise à l’origine des problèmes. À nous de synthétiser les informations fournies par nos outils pour tirer une conclusion sur le comportement mémoire du programme, puis de décider des mesures à prendre.
Nous utilisons pratiquement tous les outils Python disponibles (frameworks de test, cProfile, etc.) pour rendre le système de Fugue fiable, performant et facile à maintenir. Le broker et le reflector tirent tous deux parti de l’introspection de Python pour prendre des décisions concernant les appels dynamiques à l’API AWS, ce qui nous permet de nous concentrer sur la logique plutôt que de coder des cas exhaustifs. Fugue exploite les atouts de Python là où cela est pertinent dans le système, ce qui se traduit au final par un produit plus stable et évolutif pour les utilisateurs finaux.
Une sécurité IaC pensée pour les développeurs
Snyk sécurise votre infrastructure en tant que code, du cycle de développement logiciel à l’exécution dans le cloud, grâce à un moteur unifié de politiques sous forme de code. Chaque équipe peut ainsi développer, déployer et exploiter ses applications en toute sécurité.
