Speicherlecks in Python erkennen und beheben
7. März 2017
0 Min. LesezeitAnmerkung der Redaktion
Dieser Blogbeitrag erschien ursprünglich auf fugue.co. Fugue wurde 2022 Teil von Snyk und ist ein wichtiger Bestandteil von Snyk IaC.
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.

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

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.
