In der Welt der modernen Softwareentwicklung ist Go eine beliebte Wahl für die Implementierung von hochperformanten und skalierbaren Anwendungen. Besonders in Kombination mit komplexen Backend-Systemen wie Kafka findet Go zunehmend Einsatz. Ein häufiges Szenario besteht darin, dass Go-Anwendungen auf bewährte C-Bibliotheken zurückgreifen, um von deren Funktionen und Stabilität zu profitieren. Doch dieser Vorteil birgt auch Herausforderungen, insbesondere wenn es um die Verwaltung von Speicher geht. Bei Zendesk wurde ein solcher Fall sichtbar, als ein mit Go entwickelter Kafka-Consumer auf Basis der C-Bibliothek librdkafka unerwartet stetig wachsenden Speicherverbrauch zeigte – ein klassischer Fall eines Speicherlecks.
Die Zusammenarbeit zwischen Go und C bringt aufgrund der Unterschiede in der Speicherverwaltung gewisse Risiken mit sich. Während Go über eine automatische Speicherbereinigung verfügt, verlangt C die explizite Freigabe von allokiertem Speicher. Wenn dadurch Fehler entstehen, schlägt sich das in zunehmend größerem Speicherverbrauch nieder, welcher oft schwer zu erkennen und zu beheben ist. Zendesk beobachtete bei ihrem Kafka-Consumer, der stark frequentierte OffsetCommitResponses verarbeitete, eine stetig ansteigende Resident Set Size (RSS). Auffällig war jedoch, dass die internen Metriken von Go keinerlei Anstieg der Go-Allocated-Heap-Größe anzeigten.
Der Verdacht fiel daraufhin auf die C-Seite der Anwendung. Zur genaueren Analyse entschied sich das Team, jemalloc einzusetzen – ein alternativer Speicher-Allocator, der ausführliche Metriken über Speicherzuweisungen bereitstellen kann. Die Integration von jemalloc in den Go-Container ermöglichte eine präzise Überwachung der tatsächlich von der C-Bibliothek verwendeten allokierten Speicherbereiche. Die erhobenen Metriken bestätigten schließlich den Verdacht: Die zunehmenden Speicherressourcen stammten von malloc-Aufrufen innerhalb der C-Bibliothek und wurden nicht ausreichend freigegeben. Ein klassischer Schritt bei der Fehlersuche in Speicherverwaltung ist die Nutzung von Valgrind.
Dieses mächtige Werkzeug bietet detaillierte Berichte über Speicherlecks und -fehler, indem es die Speicherallokation und -freigabe überwacht. Bei der Ausführung auf dem Staging-Container lieferte Valgrind zwar umfangreiche Outputs, jedoch wurden die für das Problem relevanten Speicherlecks nicht erkannt. Stattdessen wurden hauptsächlich statische Speicherbereiche im Kontext von OpenSSL oder dynamischen Linkern bemängelt, die nicht zu einem prozessübergreifenden Wachstum führten. Dieses Ergebnis eröffnete zwei mögliche Szenarien. Entweder erfolgt die Speicherallokation auf Wege, die Valgrind nicht überwachen kann, wie z.
B. Anfragen per mmap, oder die Anwendung gab tatsächlich alle Speicherbereiche vor Programmende frei, doch die Gesamtmenge der zwischenzeitlich allozierten Speicherbereiche wuchs ohne Beschränkung an. Die erste Option schien für diesen speziellen Fall unwahrscheinlich, da weder die Anwendung noch librdkafka direkte mmap-Aufrufe tätigten. Um weitere Erkenntnisse zu gewinnen, wandte sich das Team an neue, moderner Mittel der Systembeobachtung: eBPF und die Nutzerwerkzeuge darum wie bpftrace. Diese Technologien erlauben es, den Kernel bei der Laufzeit zu instrumentieren und nahezu jede Systemressource oder Funktionsaufruf zu überwachen – ohne den Code signifikant zu beeinträchtigen.
Dadurch kann man zu jedem Zeitpunkt herausfinden, welche Speicherbereiche alloziert und freigegeben werden. Für die Zwecke der Speicherleckerkennung wurde librdkafka modifiziert, um Benutzerraum-Dynamische-Tracing (USDT)-Probes in die zentralen Speicherverwaltungsfunktionen wie rd_malloc und rd_free einzubetten. Diese USDT-Probes erzeugen nahezu keine Overhead, wenn sie nicht verwendet werden, ermöglichen bei aktiviertem Tracing allerdings die exakte Überwachung von malloc/free-Aufrufen. Ergänzend wurde beim Kompilieren die Option -fno-omit-frame-pointer gesetzt, damit präzise Backtraces der Speicherallokationen erfasst werden konnten. Nach der Bereitstellung der angepassten Anwendung mit eingebetteten Probes in der Staging-Umgebung konnten mit bpftrace eigenhändig verschiedene Skripte ausgeführt werden, welche die Zuordnung der allozierten Speicherbereiche mit deren Aufruf-Stack verfolgten.
Diese Vorgehensweise löste das zentrale Problem, dass Valgrind nur am Programmende Speicherlecks erkennt, während eBPF skripte laufzeitaktiv die aktuellen Speicherlecks sichtbar machten. Die Herausforderung bei der Nutzung von bpftrace innerhalb von Kubernetes-Containern liegt in den nötigen Berechtigungen und der Verfügbarkeit der Kernel-Header. Zendesk bewältigte diese Hürden durch das Laden des kheaders-Kernelsmoduls auf den Nodes und das Starten des Containers im privilegierten Modus, wodurch die erforderlichen Systemrechte gewährt wurden. Dies ist zwar für produktive Systeme keine dauerhafte Lösung, erlaubt aber weitreichende Analysen in Staging-Umgebungen. Die mit bpftrace gewonnenen Daten wurden anschließend mit einem Ruby-Skript automatisiert nachverarbeitet, indem die vermeintlich kryptischen Speicheradressen in lesbare Funktionsnamen und Quellcodezeilen übersetzt wurden.
Dank dieser Auswertung wurde schnell klar, welche Codepfade die unfreigegebenen Speicherbereiche verursachten. Die Analyse enthüllte, dass der Speicherverbrauch durch eine intern gesteuerte Ereigniswarteschlange innerhalb von librdkafka zustande kam. Jeder OffsetCommitResponse führte zu einem Ereignis, das in diese Warteschlange eingereiht wurde. Da die Anwendung jedoch keine Ereignisse von dieser Warteschlange konsumierte, wuchs die Struktur unbegrenzt an und verursachte folglich den Speicheranstieg. Der eigentliche Fix erwies sich als denkbar einfach: Durch das aktive Konsumieren und Entsorgen jener Ereignisse konnte der Speicherverbrauch gestoppt und die Warteschlange geleert werden.
Ein minimaler Codeeingriff reichte aus, um den Speicherverbrauch zu stabilisieren und den Leak endgültig zu beseitigen. Das Beispiel von Zendesk zeigt eindrucksvoll, wie komplexe Interaktionen zwischen Go und C sowie tiefgreifende Speicherprobleme identifiziert und behoben werden können. Der Einsatz moderner System-Trace-Technologien wie eBPF in Kombination mit bewährten Tools wie jemalloc und Valgrind eröffnet dabei völlig neue Möglichkeiten für die Analyse und Optimierung in produktiven Umgebungen. Darüber hinaus illustriert der Fall die Risiken unkontrollierter Datenströme innerhalb von Anwendungen. Unbegrenzte Warteschlangen oder Puffer ohne Dimensionierung können schnell zu Folgeproblemen führen, die sich erst spät bemerkbar machen und schwer zu lokalisieren sind.