Skip to main content

Speicherlecks in Python erkennen und beheben

Artikel von
blog hero python code purple

7. März 2017

0 Min. Lesezeit

 

Fugue setzt Python umfassend in seinem Cloud-Security-SaaS-Produkt und seinen Support-Tools ein – dank der einfachen Handhabung, Python-Sicherheit, umfangreichen Paketbibliothek und leistungsstarken Sprachwerkzeugen. Beim Entwickeln komplexer Cloud-Software haben wir gelernt: Eine Programmiersprache ist nur so gut wie ihre Debugging- und Profiling-Tools. Logikfehler, CPU-Spitzen und Speicherlecks sind unvermeidlich. Ein guter Debugger sowie CPU- und Speicherprofiler können das Auffinden dieser Fehler jedoch erheblich erleichtern und beschleunigen. So können sich unsere Entwickler wieder der Entwicklung von Fugues dynamischem Cloud-Orchestrierungs- und Durchsetzungssystem widmen. Sehen wir uns ein konkretes Beispiel an.

Im Herbst meldeten unsere Metriken, dass eine Python-Komponente von Fugue namens Reflector nach einigen Tagen Laufzeit zufällige Neustarts und Instabilität aufwies. Ein Blick auf die Speichernutzung zeigte, dass der Speicherbedarf des Reflectors stetig und kontinuierlich zunahm – ein Hinweis auf ein Speicherleck. tracemalloc, ein leistungsstarkes Tool zur Speicherüberwachung aus der Python-Standardbibliothek, ermöglichte es, das Leck schnell zu diagnostizieren und zu beheben. Wir stellten fest, dass das Speicherleck mit unserer Verwendung von requests, einer beliebten HTTP-Bibliothek eines Drittanbieters für Python, zusammenhing. Durch die Umstellung der Komponente auf urllib aus der Python-Standardbibliothek wurde das Speicherleck beseitigt. In diesem Blogbeitrag erfahren Sie mehr über die Details.

Liniendiagramm: Die Speicherauslastung des Reflectors steigt von etwa 8 % an Tag 1 auf 20 % an Tag 4.
Metriken zeigen das Problem: Prozentsatz des gesamten vom Reflector verwendeten Systemspeichers unter Verwendung der Requests-Bibliothek.

Speicherverwaltung in Python

In den meisten Fällen müssen Sie die Speicherverwaltung in Python nicht verstehen. Es genügt zu wissen, dass der Interpreter den Speicher für Sie verwaltet. Beim Schreiben großer, komplexer Python-Programme mit hohen Anforderungen an die Stabilität lohnt sich jedoch ein Blick hinter die Kulissen. So verstehen Sie, wie Ihr Code mit den Speicherverwaltungsalgorithmen von Python zusammenspielt. Python verwendet insbesondere Referenzzählung und Garbage Collection, um Speicherblöcke freizugeben. Speicher wird jedoch erst dann an das System zurückgegeben, wenn bestimmte interne Bedingungen erfüllt sind. Ein reines Python-Skript hat nie direkte Kontrolle über die Speicherzuweisung im Interpreter. Wenn Sie die Speicherzuweisung direkt steuern möchten, können Sie die Speicherverwaltung des Interpreters umgehen, indem Sie eine Erweiterung schreiben oder verwenden. Beispielsweise verwaltet numpy den Speicher großer Daten-Arrays mit einem eigenen Speicherzuweiser.

Grundsätzlich ist Python eine Garbage-Collected-Sprache, die Referenzzählung verwendet. Der Interpreter weist Objekten beim Erstellen automatisch Speicher zu und erfasst die Anzahl der Verweise auf diese Objekte in einer dem Objekt zugeordneten Datenstruktur. Dieser Speicher wird freigegeben, sobald der Referenzzähler für das Objekt den Wert null erreicht. Zusätzlich erkennt die Garbage Collection Referenzzyklen und entfernt Objekte, auf die nur innerhalb solcher Zyklen verwiesen wird. Jeder im Python-Interpreter zugewiesene Speicherbereich kann durch diese beiden Mechanismen freigegeben werden. Für Speicher, der in Erweiterungen zugewiesen wird, gilt das jedoch nicht unbedingt.

Python verwaltet einen eigenen Heap, der vom System-Heap getrennt ist. Der Python-Interpreter weist Speicher je nach Typ des zu erstellenden Objekts mit unterschiedlichen Verfahren zu. Skalare Typen wie Ganzzahlen und Gleitkommazahlen nutzen andere Speicherzuweisungsverfahren als zusammengesetzte Typen wie Listen, Tupel und Dictionaries. Im Allgemeinen wird Speicher auf dem Python-Heap abhängig vom Typ in Blöcken fester Größe zugewiesen. Diese Blöcke werden in Pools organisiert, die wiederum in Arenen zusammengefasst sind. Speicher wird mithilfe von Arenen, Pools und Blöcken vorab zugewiesen. Diese dienen dann im Verlauf der Programmausführung bei Bedarf zum Speichern von Daten. Da diese Blöcke, Pools und Arenen zum eigenen Heap von Python gehören, wird ein freigegebener Speicherblock lediglich als verfügbar für die künftige Verwendung im Interpreter markiert. Durch das Freigeben von Speicher in Python wird dieser nicht sofort auf Systemebene freigegeben. Erst wenn eine ganze Arena als frei markiert ist, gibt der Python-Interpreter ihren Speicher frei und führt ihn an das System zurück. Aufgrund der Speicherfragmentierung kommt dies jedoch möglicherweise nur selten vor.

Aufgrund dieser Abstraktionen weist die Speichernutzung in Python häufig ein Verhalten mit Spitzenwerten auf: Der Spitzenverbrauch bestimmt die Speichernutzung für den Rest der Ausführung – unabhängig davon, ob dieser Speicher tatsächlich verwendet wird. Außerdem ist der Zusammenhang zwischen dem Freigeben von Speicher im Code und seiner Rückgabe an das System unscharf und schwer vorherzusagen. Deshalb ist es bekanntermaßen schwierig, die Speichernutzung komplexer Python-Programme vollständig zu verstehen.

Speicherprofiling mit tracemalloc

tracemalloc ist ein Paket der Python-Standardbibliothek (seit Version 3.4). Es liefert detaillierte Speicherzuweisungsspuren auf Blockebene, einschließlich des vollständigen Stack-Traces bis zu der Zeile, in der die Speicherzuweisung erfolgt ist, sowie Statistiken zum allgemeinen Speicherverhalten eines Programms. Die Dokumentation finden Sie hier. Sie bietet eine gute Einführung in die Funktionen des Pakets. Auch der ursprüngliche Python Enhancement Proposal (PEP), mit dem es eingeführt wurde, gibt Einblicke in sein Design.

Mit tracemalloc lassen sich Bereiche mit hohem Speicherverbrauch auf zwei Arten ermitteln:

  • Anhand kumulativer Statistiken zur Speichernutzung erkennen, welche Objektzuweisungen den meisten Speicher beanspruchen, und

  • Ausführungs-Frames nachverfolgen, um herauszufinden, wo diese Objekte im Code zugewiesen werden.

Speichernutzung auf Modulebene

Zunächst verfolgen wir die Speichernutzung des gesamten Programms, um auf hoher Ebene zu erkennen, welche Objekte den meisten Speicher beanspruchen. Das liefert hoffentlich genügend Hinweise darauf, wo und wie wir genauer nachsehen sollten. Der folgende Wrapper startet die Nachverfolgung und gibt Statistiken aus, wenn Sie Strg-C drücken:

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

tracemalloc.start(10) startet die Speicherüberwachung und speichert für jeden Eintrag 10 Stack-Trace-Frames. Standardmäßig ist der Wert 1. Wenn Sie Stack-Traces zum Auffinden von Speicherlecks verwenden möchten, ist es jedoch hilfreich, mehr Frames zu speichern. Darauf gehen wir später ein. tracemalloc.take_snapshot() erstellt eine Momentaufnahme des aktuell auf dem Python-Heap zugewiesenen Speichers. Darin werden die Anzahl der zugewiesenen Blöcke, ihre Größe und die Stack-Traces gespeichert. So lässt sich erkennen, welche Codezeilen welche Speicherblöcke zugewiesen haben. Nach dem Erstellen einer Momentaufnahme können wir Statistiken zur Speichernutzung berechnen, Momentaufnahmen vergleichen oder sie für spätere Analysen speichern. top_n ist eine Hilfsfunktion, die ich geschrieben habe, um die Ausgabe von tracemalloc übersichtlich darzustellen. Hier lasse ich mir die 25 größten Speicherzuweisungen in der Momentaufnahme anzeigen, gruppiert nach Dateiname. Nach einigen Minuten Laufzeit sieht die Ausgabe so aus:

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

Hier sehen Sie die kumulative Speichermenge, die die Komponente während der gesamten Laufzeit zugewiesen hat, gruppiert nach Dateiname. Auf dieser Detailebene sind die Ergebnisse schwer zu interpretieren. Die erste Zeile zeigt beispielsweise, dass 17 MB an collections-Objekten erstellt werden. Diese Ansicht enthält jedoch nicht genügend Details, um zu erkennen, um welche Objekte es sich handelt oder wo sie verwendet werden. Um das Problem einzugrenzen, ist ein anderer Ansatz erforderlich.

Die Ausgabe von tracemalloc verstehen

tracemalloc zeigt die Netto-Speichernutzung zum Zeitpunkt der Erstellung einer Momentaufnahme. Beim Vergleich zweier Momentaufnahmen wird die Netto-Speichernutzung zwischen ihnen angezeigt. Wird zwischen den Momentaufnahmen Speicher zugewiesen und wieder freigegeben, erscheint er nicht in der Ausgabe. Werden Momentaufnahmen daher jeweils an derselben Stelle einer Schleife erstellt, tragen die in den Unterschieden zwischen zwei Momentaufnahmen sichtbaren Speicherzuweisungen zur langfristig genutzten Gesamtspeichermenge bei. Es handelt sich nicht um vorübergehende Zuweisungen im Verlauf der Ausführung.

Bei Referenzzyklen, die eine Garbage Collection erfordern, erscheinen nicht bereinigte Zyklen in der Ausgabe, bereinigte hingegen nicht. Alle Speicherblöcke, die der Garbage Collector während des von einer Momentaufnahme erfassten Zeitraums freigibt, werden als freigegebener Speicher aufgeführt. Wenn Sie vor dem Erstellen einer Momentaufnahme mit gc.collect() eine Garbage Collection erzwingen, verringert das daher störende Einträge in der Ausgabe.

Speichernutzung pro Iteration

Da wir nach einem Speicherleck suchen, ist es hilfreich zu verstehen, wie sich die Speichernutzung unseres Programms im Laufe der Zeit verändert. Wir können die Hauptschleife der Komponente instrumentieren, um zu ermitteln, wie viel Speicher in jeder Iteration zugewiesen wird. Rufen Sie dazu die folgende Methode in der Hauptschleife auf:

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)

Dieser Code erstellt eine Momentaufnahme des Speichers und speichert sie. Anschließend vergleicht er mit snapshot.compare_to(other_snapshot, group_by='filename') die neueste Momentaufnahme mit der vorherigen und gruppiert die Ergebnisse nach Dateiname. Nach einigen Aufwärmiterationen sieht die Ausgabe so aus:

[ 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

Die Zuweisungen von linecache (1) und tracemalloc (2) gehören zur Instrumentierung. Daneben erkennen wir aber auch einige Speicherzuweisungen des HTTP-Pakets requests (3), die genauer untersucht werden sollten. Zur Erinnerung: tracemalloc erfasst die Netto-Speichernutzung. Diese Speicherzuweisungen summieren sich also mit jeder Iteration. Die einzelnen Zuweisungen sind zwar klein und fallen nicht als problematisch auf, doch das Speicherleck wird erst im Laufe einiger Tage sichtbar. Wahrscheinlich addieren sich also viele kleine Verluste.

Momentaufnahmen filtern

Jetzt wissen wir ungefähr, wo wir suchen müssen. Mit den Filterfunktionen von tracemalloc können wir uns nur die Speicherzuweisungen anzeigen lassen, die mit dem requests-Paket zusammenhängen:

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() übernimmt eine Liste von Filters, die auf die Momentaufnahme angewendet werden sollen. Hier erstellen wir einen Filter im Modus inclusive, der nur die zum filename_pattern passenden Spuren einschließt. Ist inclusive auf False gesetzt, schließt der Filter Spuren aus, die zum filename_pattern passen. Für die Übereinstimmung mit Dateinamen im Stack-Trace verwendet filename_pattern Platzhalter im UNIX-Stil. In diesem Beispiel stimmen die Platzhalter in „requests“ mit Vorkommen von „requests“ mitten in einem Pfad überein, etwa mit "/Users/mike/.pyenv/versions/venv/lib/python3.4/site-packages/requests/sessions.py".

Anschließend vergleichen wir mit compare_to() die Ergebnisse mit der vorherigen Momentaufnahme. Die gefilterte Ausgabe sehen Sie unten:

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

Mit dem Filter sehen wir deutlich, wie requests Speicher verwendet. Zeile (4) zeigt, dass requests bei jeder Iteration der Hauptschleife rund 50 KiB Speicher verliert. Beachten Sie, dass in dieser Ausgabe auch negative Speicherzuweisungen wie (5) sichtbar sind. Dabei wird Speicher freigegeben, der in früheren Iterationen der Schleife zugewiesen wurde.

Speicherzuweisungen nachverfolgen

Um herauszufinden, welche Verwendungen von requests Speicherlecks verursachen, können wir mit compare_to() und traceback statt filename genau untersuchen, wo problematische Speicherzuweisungen stattfinden. Mit einem Filter grenzen wir die Ausgabe weiter ein:

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

Für jeden Eintrag werden 10 Stack-Trace-Frames ausgegeben (da wir die Nachverfolgung mit tracemalloc.start(10) gestartet haben). Ein gekürztes Beispiel sehen Sie unten:

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)

Anhand des vollständigen Stack-Traces können wir von den Speicherzuweisungen ausgehend die Codezeilen in unserem Projekt zurückverfolgen, die diese verursachen. In dieser Komponente stammten unsere requests-Aufrufe aus einer internen Speicherbibliothek, die eine HTTP-API verwendete. Durch die Umstellung der Bibliothek auf die direkte Verwendung von urllib wurde das Speicherleck beseitigt.

Liniendiagramm zur Speicherauslastung des Reflectors, die über vier Tage weitgehend konstant zwischen 8,5 % und 9,3 % liegt
Die Kennzahlen zeigen, dass das Problem gelöst ist: Prozentsatz des vom Reflektor verwendeten gesamten Systemspeichers nach dem Entfernen der Requests und dem Wechsel zu urllib.

Speicherprofiling: Kunst oder Wissenschaft?

tracemalloc ist ein leistungsstarkes Tool, um den Speicherverbrauch von Python-Programmen zu verstehen. Es half uns dabei, den Speicherverbrauch auf Modulebene nachzuvollziehen, herauszufinden, welche Objekte am häufigsten zugewiesen werden, und zu erkennen, wie sich der Speicherverbrauch des Reflectors pro Iteration verändert. Das Tool bietet nützliche Filterfunktionen und ermöglicht es uns, den vollständigen Stacktrace für jede Speicherzuweisung einzusehen. Trotz all dieser Funktionen kann die Suche nach Speicherlecks in Python immer noch eher wie eine Kunst als wie eine Wissenschaft wirken. Mit Speicherprofilern können wir nachvollziehen, wie der Speicher genutzt wird. Häufig ist es jedoch schwierig, genau die Speicherzuweisung zu finden, die die Probleme verursacht. Es liegt an uns, die Informationen aus unseren Tools zu einer Schlussfolgerung über das Speicherverhalten des Programms zusammenzuführen und anschließend zu entscheiden, welche Maßnahmen wir ergreifen.

Wir nutzen praktisch alle verfügbaren Python-Tools (Test-Frameworks, cProfile usw.), um Fugues System zuverlässig, performant und einfach wartbar zu machen. Broker und Reflector nutzen beide die Introspektionsfunktionen von Python, um dynamische Aufrufe der AWS-API zu beurteilen. So können wir uns auf die Logik konzentrieren, statt alle möglichen Fälle umfassend zu programmieren. Fugue nutzt die Stärken von Python dort, wo es im System sinnvoll ist. Das sorgt letztlich für mehr Produktstabilität und Erweiterbarkeit für Endnutzer.

IaC-Sicherheit für Entwickler

Snyk schützt Ihre Infrastructure as Code vom SDLC bis zur Laufzeit in der Cloud mit einer einheitlichen Policy-as-Code-Engine, damit jedes Team sicher entwickeln, bereitstellen und betreiben kann.

Gepostet in: