Skip to main content

Diagnostiquer et corriger les fuites de mémoire en Python

Écrit par
blog hero python code purple

7 mars 2017

0 minutes de lecture

Note 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.

Graphique linéaire montrant l’utilisation de la mémoire par le réflecteur, qui passe d’environ 8 % au jour 1 à 20 % au jour 4.
Les métriques montrent le problème : pourcentage de la mémoire totale du système utilisée par le reflector, à l’aide de la bibliothèque requests.

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 :

import tracemalloctracemalloc.start(10)
try:    
	run_reflector()
except:    
	snapshot = tracemalloc.take_snapshot()    
	top_n(25, snapshot, trace_type='filename')

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 :

[ Top 25 with filename tracebacks ]
197618 blocks 17.02311134338379 MB/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/collections/__init__.py:0: size=17.0 MiB,
 count=197618,
 average=90 B105364 blocks 11.34091567993164 MB frozen importlib._bootstrap:0: 
size=11.3 MiB, 
count=105364, 
average=113 B60339 blocks 9.233230590820312 MB/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/json/decoder.py:0:
size=9455 KiB, 
count=60339, 
average=160 B...

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 :

def collect_stats(self):        
self.snapshots.append(tracemalloc.take_snapshot())        
if len(self.snapshots)  1: 

stats = self.snapshots[-1].filter_traces(filters).compare_to(self.snapshots[-2], 'filename')    

for stat in stats[:10]:                
print("{} new KiB {} total KiB {} new {} total memory blocks: ".format(stat.size_diff/1024, stat.size / 1024, stat.count_diff ,stat.count))                
for line in stat.traceback.format():                    
print(line)

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 :

[ Top 5 with filename tracebacks ]190.7421875 
new KiB 1356.5634765625 total KiB 1930 
new 13574 total memory blocks:      
(1)  File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/linecache.py", 

line 02.1328125 
new KiB 12.375 total KiB 32 
new 86 total memory blocks:             

(2)  File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/tracemalloc.py", 
line 01.859375 
new KiB 18.7001953125 total KiB 3 
new 53 total memory blocks:         

(3)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connection.py", 
line 0-1.71875 
new KiB 34.5224609375 total KiB -2 
new 91 total memory blocks:   File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connectionpool.py", 
line 01.66015625 new KiB 61.662109375 total KiB 18 new 260 total memory blocks:   
File "/Users/mike/.pyenv/versions/3.4.2/lib/python3.4/urllib/parse.py", line 0

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 :

from tracemalloc 
import Filter    
filters = [Filter(inclusive=True, filename_pattern="*requests*")]    
filtered_stats = snapshot.filter_traces(filters).compare_to(old_snapshot.filter_traces(filters), 'traceback')    
for stat in stats[:10]:        
	print("{} 
	new KiB {} 
	total KiB {} 
	new {} 
	total memory blocks: ".format(stat.size_diff/1024, stat.size / 1024, stat.count_diff ,stat.count))        

	for line in stat.traceback.format():            
	print(line)

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é :

48.7890625 
new KiB 373.974609375 total KiB 4 
new 1440 total memory blocks:                 

(4)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/structures.py", 
line 01.46875 
new KiB 16.2939453125 total KiB 2 
new 49 total memory blocks:   

File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 0 -1.4453125

new KiB 34.2802734375 total KiB -2 
new 96 total memory blocks:                 

(5)  File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 0-0.859375 
new KiB 31.8505859375 total KiB -1 
new 85 total memory blocks:   

File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connectionpool.py", 
line 00.6484375 
new KiB 20.8330078125 total KiB 1 
new 56 total memory blocks:   
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/packages/urllib3/connection.py", line 0

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 :

   stats = snapshot.filter_traces(filters).compare_to(old_snapshot.filter_traces(filters), 'traceback')

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é :

5 memory blocks: 4.4921875 KiB  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 585    
r = adapter.send(request, **kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py", 
line 475    
resp = self.send(prep, **send_kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 46    
return session.request(method=method, url=url, **kwargs)  
File "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests_unixsocket/__init__.py", 
line 60    
return request('post', url, data=data, json=json, **kwargs)

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.

Graphique linéaire montrant une utilisation de la mémoire du réflecteur globalement stable, entre 8,5 % et 9,3 %, sur quatre jours
Les métriques indiquent que le problème est résolu : pourcentage de la mémoire totale du système utilisé par le réflecteur, après suppression des requêtes et passage à urllib.

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é.

Publié dans: