Diagramm mit einer stetig steigenden RSS-Linie neben einer flachen GC-Heap-Linie, umgeben von einem Raster geleakter 3400-Byte-Speicherblöcke
Zurück zum Blog
.NET 10SpeicherleckLinux

3,4 KB auf einmal: Wie wir ein natives Speicherleck in .NET 10 unter Linux gefunden haben

Sven HennessenEntwicklung

TL;DR

  • Symptom: Der RSS jedes Pods wächst linear um etwa 22-35 MiB pro Tag, unabhängig von der Last, während der Managed GC-Heap klein und flach bleibt.
  • Ursache: Unter Linux verliert die .NET-10-Runtime jedes Mal etwa 3,4 KB, wenn sie eine gecachte TypeInitializationException aus nativem Code erneut wirft.
  • Auslöser: Npgsql 10 nutzt standardmäßig GssEncryptionMode=Prefer. Fehlt libgssapi_krb5.so.2 im Image, verursacht jede neue physische PostgreSQL-Verbindung genau einen solchen Rethrow.
  • Abhilfe: GssEncryptionMode=Disable im Connection String oder PGGSSENCMODE=disable setzen, oder libgssapi_krb5.so.2 ins Image aufnehmen (z. B. apk add krb5-libs). Ein Runtime-Fix ist im Review (dotnet/runtime#135290).

Einige unserer ASP.NET-Core-Services auf Kubernetes haben jeden Tag mehr Speicher belegt, obwohl der Managed Heap klein blieb. Dieser Beitrag zeigt Schritt für Schritt, wie wir das Wachstum auf ein natives Speicherleck in der .NET-10-Runtime unter Linux zurückgeführt haben, warum ein Npgsql-Default es auslöst und mit welchen drei Wegen du es stoppst.

Einige unserer ASP.NET-Core-Services auf Kubernetes haben jeden Tag mehr Speicher belegt. Der Managed Heap blieb klein. Ein Neustart hat das Problem für ein paar Wochen verschwinden lassen.

Dieser Beitrag zeigt, wie wir die Ursache gefunden haben. Die Ursache ist ein natives Speicherleck in der .NET-10-Runtime unter Linux. Eine Einstellung im Datenbanktreiber löst das Leck aus, und entweder ein einziger Konfigurationswert oder das Nachinstallieren der fehlenden nativen Bibliothek stoppt es.

Warum das für Betrieb und Kosten relevant ist

Ein langsames Leck verursacht am ersten Tag keinen Incident. Es verursacht diese Probleme:

  • Die Memory Requests werden zu groß. Teams dimensionieren den Memory Request nach der Größe des Pods nach einigen Wochen, nicht nach dem echten Working Set. In jedem Namespace belegt das Quota, das andere Workloads brauchen.
  • Neustarts, die niemand plant. Erreicht ein Pod sein Memory Limit, beendet Kubernetes ihn (OOMKill). Laufende Requests schlagen fehl.
  • Das Problem bleibt unsichtbar. Jedes Deployment startet die Pods neu. Teams, die oft deployen, sehen das Problem nie. Teams, die seltener deployen, bekommen die OOMKills.
  • Alerts ohne klare Ursache. Die Speicher-Alerts schlagen an, aber die üblichen .NET-Tools zeigen einen gesunden Managed Heap. Das Team muss dann entscheiden, ob es nachforscht oder Neustarts einplant.

In unserem Fall hätte ein Pod mit 2 GiB Limit diese Grenze nach einigen Wochen erreicht.

Schritt 1: Das Muster erkennen

Unser Monitoring meldete Speicher-Alerts für einen Service in Produktion. Wir haben den RSS aller Pods über 14 Tage untersucht:

BeobachtungWert
RSS pro PodLineares Wachstum, 22–35 MiB/Tag (die meisten Pods etwa 28–29 MiB/Tag)
Betroffene PodsAlle Produktions-Pods des Service
Zusammenhang mit der LastKeiner. Pods im Leerlauf wuchsen genauso schnell wie ausgelastete Pods.
Managed Heap (Gen2, LOH)Klein und stabil
Thread-AnzahlStabil
Andere Services auf derselben PlattformManche wuchsen genauso schnell. Andere blieben flach.

Zwei Fakten waren entscheidend:

  1. Das Wachstum ist linear und hängt nicht von der Last ab. Ein lastgetriebenes Leck wächst auf ausgelasteten Pods schneller. Dieses Leck wuchs auf allen Pods gleich schnell. Vermutlich verursacht es also ein Timer oder eine periodische Hintergrundaufgabe.
  2. Manche Services bleiben flach. Diese Services nutzen dasselbe Base Image, dieselbe Plattform und dasselbe Chart. Wir haben sie in jedem weiteren Schritt als Kontroll-Services genutzt.

Schritt 2: Beweisen, dass der Managed Heap nicht die Ursache ist

Der übliche erste Schritt ist eine Analyse des GC-Heaps. Vorher mussten wir aber wissen, ob der Managed Heap das Wachstum überhaupt erklären kann.

Unser Monitoring zeigte für diese Pods nur wenige .NET-Runtime-Werte. Deshalb haben wir die .NET-Runtime-Metriken im Service aktiviert und an Prometheus geschickt. Die Metriken umfassen die GC-Heap-Größe pro Generation, die Anzahl der GCs, den Thread Pool, die Anzahl der Exceptions und den Prozessspeicher (process_working_set_bytes, process_private_memory_bytes).

Ergebnis:

  • Der GC-Heap blieb klein und stabil.
  • Der RSS wuchs weiter.
  • Die Differenz zwischen RSS und GC-Heap wuchs linear.

Diese Differenz ist die entscheidende Metrik. Ist der GC-Heap flach und wächst der RSS, findet ein Managed Memory Profiler das Leck nicht. Der Speicher steckt in nativen Allokationen.

Tipp: Leg process_working_set_bytes und die GC-Heap-Größe in dasselbe Diagramm. Wächst die Lücke zwischen beiden, hör mit der Analyse des Managed Heaps auf und untersuch den nativen Speicher.

Schritt 3: Herausfinden, welche Speicherbereiche wachsen

Unter Linux zeigt /proc/<pid>/smaps jedes Memory Mapping eines Prozesses mit seinem RSS. Du kannst die Datei per kubectl exec lesen. Du brauchst keinen Debugger, und der Pod läuft weiter.

Wir haben die Mappings mit einem kleinen awk-Skript in Kategorien gruppiert. Ein Snapshot eines Pods nach 34,5 Stunden:

VerbraucherRSSAnteil
glibc-malloc-Thread-Arenas136,5 MiB24,8 %
glibc-Main-Arena ([heap])97,8 MiB17,8 %
glibc gesamt234,3 MiB42,5 %
JIT-Code-Mappings (/memfd:doublemapper, W^X)113,6 MiB20,6 %
Sonstiger anonymer Speicher (Managed Heap, Stacks)67,0 MiB12,2 %
Sonstige Bibliotheken und Assemblies98,0 MiB17,8 %

Der Managed Heap war nur etwa 14 MiB groß. Der größte Verbraucher war Speicher, den nativer Code mit malloc angefordert hatte.

Der Kontroll-Service hat unsere Schlussfolgerung verändert. Eine erste Theorie war „Fragmentierung der glibc-Arenas“. Das ist ein bekanntes Problem von .NET unter Linux, und MALLOC_ARENA_MAX ist die übliche Antwort darauf. Der flache Kontroll-Service hatte aber dieselbe Anzahl an Arenas (15). Auch sein glibc-Anteil am RSS war gleich (42,6 %). Die Anzahl der Arenas war also nicht der Grund für das Wachstum. Fragmentierung erklärt, warum glibc den Speicher behält. Sie erklärt nicht, warum der Prozess immer mehr anfordert.

Lektion: MALLOC_ARENA_MAX und DOTNET_EnableWriteXorExecute=0 können den RSS verkleinern. Ein Leck beheben sie nicht. Vergleich mit einem Kontroll-Service, bevor du diese Einstellungen änderst.

Wir mussten also wissen, was in den wachsenden malloc-Blöcken steckt.

Schritt 4: Einen Dump aus einem laufenden Pod ziehen

Wir haben dotnet-dump collect --type Heap in einem Dev-Pod nach fünf Tagen Uptime ausgeführt.

Ein paar praktische Hinweise:

  • Nutz --type Heap, nicht Full. Der Heap-Dump dauerte etwa 13 Sekunden. Die Liveness Probe erlaubte etwa 30 Sekunden. Ein Full Dump dauert länger und kann einen Neustart auslösen.
  • Kopier das Single-File-Binary von dotnet-dump in den Pod. Du kannst es unter https://aka.ms/dotnet-dump/linux-x64 herunterladen. Unser Runtime Image enthielt es nicht.
  • Setz das Extract-Verzeichnis per DOTNET_BUNDLE_EXTRACT_BASE_DIR auf einen beschreibbaren Pfad. Läuft das Image im Invariant-Globalization-Modus, setz zusätzlich DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.
  • Trau kubectl exec ... cat bei großen Dateien nicht. Der Stream wurde ohne Fehlermeldung abgeschnitten. Wir haben den Dump komprimiert, in Teile zu 20 MB aufgeteilt und jeden Teil in einer Schleife kopiert, bis seine SHA-256-Prüfsumme stimmte.

Das Ergebnis war eine Core-Datei mit 1,34 GB (122 MB komprimiert). Der Pod wurde nicht neu gestartet.

Schritt 5: Den nativen Heap untersuchen

dotnet-dump analyze (SOS) zeigt den Managed Heap. Für den glibc-Heap haben wir chap genutzt. chap liest eine Linux-Core-Datei und listet jede malloc-Allokation auf. Außerdem zeigt es, ob eine Allokation noch referenziert wird oder geleakt ist.

Achtung: Unser Image nutzte glibc 2.44. chap unterstützte diese Version nicht vollständig und zeigte Warnungen zum Arena-Layout. Wir haben die Zahlen von chap deshalb nur als Ausgangspunkt genutzt und die wichtigen Werte mit eigenen Tools geprüft.

Das Ergebnis war sehr eindeutig:

BefundWert
Allokationen mit genau 3400 Bytes (0xd48)etwa 44.600
Davon von keinem Speicher referenziert (geleakt)42.231
Gesamtgrößeetwa 152 MB
Berechnetes Wachstumetwa 28 MiB/Tag

Die berechnete Rate stimmte mit dem RSS-Wachstum in unserem Monitoring überein. Eine einzige Art von Allokation verursachte also das gesamte Wachstum.

Was steckt in einem 3400-Byte-Block?

chap konnte die Blöcke nicht identifizieren. Also haben wir ein kleines Python-Skript geschrieben, das die Core-Datei über ihre ELF-PT_LOAD-Segmente liest. Das Skript hat für jedes 8-Byte-Feld Statistiken über alle 42.231 Blöcke berechnet.

Einige Felder hatten in allen Blöcken denselben Wert:

OffsetWertBedeutung
0x300x10000FContextFlags (x64 CONTEXT_FULL)
0x380x33SegCs: das 64-Bit-User-Code-Segment
0x440x246EFlags: ein typischer Flag-Wert
0x1000x37Fx87-FPU-Control-Word (Standardwert)
0xF8eine AdresseRip: der Instruction Pointer

Das ist das Layout einer Windows-x64-CONTEXT-Struktur, also eines Snapshots der CPU-Register. Die .NET-Runtime nutzt dieses Layout auch unter Linux (im PAL). Mit den AVX-512-Feldern passen ein CONTEXT und ein EXCEPTION_RECORD in 3400 Bytes.

Das Feld Rsp (Stack Pointer) hatte 82 verschiedene Werte. Viele verschiedene Threads haben die Blöcke also erzeugt. Rip war aber in allen Blöcken gleich. Eine einzige Stelle im Code hat sie also alle erzeugt.

Welcher Code hat die Blöcke erzeugt?

Wir haben die Ladeadresse von libcoreclr.so im Dump ermittelt, sie von Rip abgezogen und so einen Offset innerhalb der Bibliothek erhalten. Dann haben wir die Bibliothek an diesem Offset disassembliert. Dabei haben wir dieselbe libcoreclr.so wie im Pod verwendet und ihren SHA-256 geprüft.

Die Adresse war die Rücksprungadresse nach einem Aufruf von RtlCaptureContext. Wir haben die Funktion mit dem .NET-10-Quellcode verglichen und sie gefunden: DispatchManagedException(PAL_SEHException& ex, bool isHardwareException) in vm/exceptionhandling.cpp.

Diese Funktion überführt eine Exception aus nativem Runtime-Code in das Managed Exception Handling. Jeder geleakte Block steht also für eine Exception, die die Runtime aus nativem Code in Managed Code geworfen hat.

Schritt 6: Herausfinden, welche Exception

Jetzt mussten wir die Exception finden. Wir haben denselben Dump mit dotnet-dump analyze und SOS geöffnet.

  • Der GC Committed Memory lag bei 55,7 MB. Das hat noch einmal bestätigt, dass der Managed Heap nicht das Leck war.
  • Der Heap enthielt eine gecachte TypeInitializationException für den internen Typ Interop+NetSecurityNative. Die Inner Exception stammte aus dem statischen Konstruktor von GssInitializer. Die native GSSAPI-Bibliothek fehlte in unserem Container Image, deshalb schlug ihre Initialisierung fehl.
  • Der Stack der Exception sah so aus:
InitHelpers.InitClassSlow
NetSecurityNative.ImportPrincipalName
SafeGssNameHandle.CreateTarget
UnixNegotiateAuthenticationPal.InitializeSecurityContext
NegotiateAuthentication.GetOutgoingBlob
Npgsql.Internal.NpgsqlConnector.GSSEncrypt

Die TypeInitializationException verhält sich so: Schlägt ein statischer Konstruktor fehl, merkt sich die Runtime die Exception. Jeder spätere Zugriff auf den Typ wirft dieselbe Exception erneut. Diesen Rethrow führt die Runtime aus nativem Code aus (InitClassSlow). Jeder Zugriff läuft also durch DispatchManagedException.

Schritt 7: Beide Seiten verbinden

Npgsql-Seite. In Npgsql 10 ist der Default für GssEncryptionMode gleich Prefer. Ist SSL aktiv, versucht Npgsql bei jeder neuen physischen Verbindung zuerst GSS-Verschlüsselung. Es ruft NegotiateAuthentication.GetOutgoingBlob auf, fängt die TypeInitializationException und macht mit TLS weiter. Die Verbindung funktioniert, und die Anwendung sieht keinen Fehler. Jede neue physische Verbindung verursacht aber einen nativen Rethrow.

Unser Service öffnet regelmäßig neue physische Verbindungen. Einige Data Sources haben keine minimale Pool-Größe, und Npgsql schließt Idle-Verbindungen.

Runtime-Seite. In .NET 10 unter Linux fängt das Makro UNINSTALL_MANAGED_EXCEPTION_DISPATCHER_EX (in vm/exceptmacros.h) die native PAL_SEHException. Es verschiebt die Exception in eine lokale Kopie und ruft DispatchManagedException auf. Dieser Aufruf kehrt nicht zurück. Das Managed Exception Handling läuft im Managed catch-Block weiter. Der Destruktor der lokalen Kopie läuft deshalb nie, und damit gibt auch niemand die Exception Records (CONTEXT + EXCEPTION_RECORD) frei. Die Runtime hat diese Records mit posix_memalign angelegt. Jeder Rethrow verliert einen 3400-Byte-Block.

Schritt 8: Das Problem mit einem Repro isolieren

Eine Theorie aus einem Dump ist kein Beweis. Also haben wir zwei kleine Konsolenanwendungen geschrieben und sie auf .NET 10.0.12 in einem Container Image ohne GSSAPI-Bibliothek ausgeführt. Wir haben das Microsoft-Image mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled genutzt. So konnten wir prüfen, ob das Problem nur unser eigenes Image betrifft.

Repro 1: nur Runtime, keine Datenbank

Die Anwendung ruft NegotiateAuthentication.GetOutgoingBlob in einer Schleife auf und fängt die Exception.

ImageAufrufeRSS-WachstumPro Aufruf
mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled40.000152 MBetwa 3,9 KB
Kontrolle: Managed throw/catch40.00014–15 MB, nicht linearn. a.

Repro 2: End-to-End mit Npgsql und PostgreSQL

Die Anwendung öffnet Npgsql-10.0.3-Verbindungen zu PostgreSQL 17 mit SslMode=Require und Pooling=false. Wir haben Prefer und Disable verglichen.

ImageGssEncryptionModeVerbindungenRSS-WachstumTypeInitializationExceptions
Microsoft chiseledPrefer30.000103 MB30.000
Microsoft chiseledDisable30.0005 MB0

Ergebnisse:

  • Mit Prefer verursacht jede Verbindung genau eine Exception. Der RSS wächst linear um etwa 3,4 KB pro Verbindung.
  • Mit Disable tritt keine Exception auf. Der RSS bleibt flach.
  • Das Leck tritt im unveränderten Microsoft-Image auf. Es betrifft also nicht nur unser eigenes Image.

Die Abhilfe

Es gibt drei Wege, den Auslöser zu entfernen. Du brauchst nur einen davon.

Option 1: GSS-Verschlüsselung im Connection String abschalten. Das haben wir gemacht. Wir haben GssEncryptionMode im Service konfigurierbar gemacht und per Umgebungsvariable in den Helm Values auf Disable gesetzt:

Host=db;Database=app;SslMode=Require;GssEncryptionMode=Disable

Option 2: GSS-Verschlüsselung per Umgebungsvariable abschalten. Kannst du den Code nicht ändern, liest Npgsql auch die Standard-Variable von libpq:

PGGSSENCMODE=disable

Option 3: Die native GSSAPI-Bibliothek ins Image aufnehmen. Ist libgssapi_krb5.so.2 vorhanden, läuft der statische Konstruktor von GssInitializer erfolgreich durch. Es wird also keine TypeInitializationException gecacht, und der native Rethrow findet nicht statt. Bei einem Alpine-basierten Image reicht eine Zeile im Dockerfile:

RUN apk add --no-cache krb5-libs

Bei einem Debian- oder Ubuntu-basierten Image heißt das Paket libgssapi-krb5-2 (apt-get install -y --no-install-recommends libgssapi-krb5-2). Chiseled Images haben keinen Paketmanager. Dort musst du die Bibliothek aus einer Build Stage hineinkopieren oder Option 1 oder 2 nutzen.

Bevor du eine dieser Optionen umsetzt, prüf diese Auswirkungen:

  • Keine funktionale Änderung bei uns. GSS-Verschlüsselung hat in unseren Containern nie funktioniert, weil die GSSAPI-Bibliothek nicht im Image war.
  • TLS bleibt aktiv. SslMode ändert sich nicht.
  • Option 1 und 2 schalten Kerberos für PostgreSQL ab. Brauchst du Kerberos- oder GSS-Authentifizierung, nimm Option 3.
  • Option 3 macht das Image größer und behält den GSS-Versuch bei. Npgsql versucht weiterhin bei jeder neuen physischen Verbindung GSS-Verschlüsselung, der Versuch läuft aber nicht mehr über den leckenden nativen Rethrow.
  • Der Runtime-Bug bleibt. Anderer Code, der eine gecachte TypeInitializationException aus nativem Code erneut wirft, kann ebenfalls Speicher verlieren. Diese Optionen entfernen nur den Auslöser, den wir gefunden haben.

Ergebnis

Wir haben die Abhilfe zuerst in unserer Entwicklungsumgebung ausgerollt. Am nächsten Morgen war der RSS des Service flach. Vor der Änderung wuchs er um etwa 28 MiB pro Tag.

So prüfst du, ob deine Services betroffen sind

  1. Schau dir den RSS-Verlauf über einige Tage an. Achte auf lineares Wachstum, das nicht von der Last abhängt, während der GC-Heap flach bleibt.
  2. Zähl die Exceptions. Achte mit .NET-Runtime-Metriken oder dotnet-counters monitor System.Runtime auf eine konstante Exception-Rate, während der Service idle ist.
  3. Finde den Auslöser. Nutz dotnet-trace oder einen First-Chance-Exception-Handler, um TypeInitializationException zu loggen. Oder zieh einen Heap-Dump und führ in SOS dumpheap -type TypeInitializationException aus.
  4. Prüf das Image. Nutzt dein Image Npgsql 10 mit SSL und ohne libgssapi_krb5.so.2? Dann hast du wahrscheinlich den Auslöser.

Upstream-Status

  • .NET-Runtime: Wir haben das Leck mit dem Repro in dotnet/runtime#135234 gemeldet. Ein Fix ist in dotnet/runtime#135290 im Review. Der Fix lässt den Exception Dispatch die Ownership der Exception Records übernehmen und ergänzt einen Regressionstest. Als wir diesen Beitrag geschrieben haben, zielte der Fix auf den Main Branch. Ein Backport nach .NET 10 war nicht angekündigt.
  • Npgsql: npgsql/npgsql#6416 beschreibt denselben Exception-Sturm und dasselbe Speicherwachstum in Containern. Npgsql 10.0.2 fängt die Exception, sie tritt aber weiterhin bei jeder neuen Verbindung auf. npgsql/npgsql#6593 stellt die GSS-Versuche nach dem ersten Fehlschlag ein. Das entfernt den Auslöser ohne Konfigurationsänderung.

Lektionen

  1. Miss zuerst die Lücke zwischen RSS und GC-Heap. Sie zeigt dir, ob das Problem im Managed oder im nativen Speicher liegt.
  2. Nutz immer einen Kontroll-Service. Unsere erste Theorie (glibc-Arenas) war falsch. Das hat erst der Vergleich mit einem flachen Service gezeigt.
  3. Ein lastunabhängiges Leck deutet auf einen Timer oder eine periodische Aufgabe hin. Finde die Periode und ordne sie dem Code zu.
  4. Gruppier native Lecks nach Allokationsgröße. Eine exakte Größe, die zehntausendfach vorkommt, ist ein Fingerabdruck.
  5. Der Inhalt eines geleakten Blocks verrät, wer ihn angelegt hat. Konstante Feldwerte identifizieren die Struktur. Der Instruction Pointer identifiziert den Code.
  6. Eine Exception, die die Anwendung fängt, ist nicht kostenlos. Sie kostet CPU. In diesem Fall kostete sie auch Speicher.
  7. Bestätige die Theorie mit einem minimalen Repro und nimm das Image des Herstellers mit dazu. So wird der Upstream-Report schnell akzeptiert.

Tools, die wir genutzt haben

  • Prometheus und .NET-Runtime-Metriken
  • /proc/<pid>/smaps mit awk
  • dotnet-dump (collect und analyze, SOS)
  • chap für den glibc-Heap
  • Ein kleines Python-Skript, das ELF-Core-Dateien liest
  • objdump zum Disassemblieren von libcoreclr.so
  • Der Quellcode von .NET-Runtime und Npgsql auf GitHub
  • Podman für die Repro-Container

Quellen

Brauchst du Unterstützung?

Das Leck steckte nicht in unserem Code, sondern in einem Default, den niemand bewusst gewählt hatte: Npgsqls GSS-Einstellung, kombiniert mit einer fehlenden nativen Bibliothek im Image. Genau solche Stellen prüfen wir in Code Reviews von .NET-Services: Connection Strings und Treiber-Defaults, Base-Images und Dockerfiles, Ressourcen-Limits und alles, was erst nach Wochen in Produktion auffällt. Ein fokussiertes Review deines Services zeigt dir, welche Defaults bei dir still mitlaufen.

Service reviewen lassen