Graphique montrant une courbe RSS en hausse constante à côté d'une courbe plate du tas GC, entourée d'une grille de blocs mémoire de 3400 octets perdus
Retour au blog
.NET 10Fuite mémoireLinux

3,4 Ko à la fois : comment nous avons trouvé une fuite mémoire native dans .NET 10 sous Linux

Sven HennessenDéveloppement

TL;DR

  • Symptôme : le RSS de chaque pod augmente de façon linéaire d'environ 22-35 Mio par jour, indépendamment de la charge, tandis que le tas GC managé reste petit et stable.
  • Cause : sous Linux, le runtime .NET 10 perd environ 3,4 Ko chaque fois qu'il relance depuis le code natif une TypeInitializationException mise en cache.
  • Déclencheur : Npgsql 10 utilise par défaut GssEncryptionMode=Prefer. Si libgssapi_krb5.so.2 est absente de l'image, chaque nouvelle connexion physique PostgreSQL provoque une telle relance.
  • Contournement : définir GssEncryptionMode=Disable dans la chaîne de connexion ou PGGSSENCMODE=disable, ou ajouter libgssapi_krb5.so.2 à l'image (p. ex. apk add krb5-libs). Un correctif du runtime est en revue (dotnet/runtime#135290).

Certains de nos services ASP.NET Core sur Kubernetes consommaient chaque jour davantage de mémoire, alors que le tas managé restait petit. Cet article montre étape par étape comment nous avons ramené cette croissance à une fuite mémoire native dans le runtime .NET 10 sous Linux, pourquoi une valeur par défaut de Npgsql la déclenche et trois façons de l'arrêter.

Certains de nos services ASP.NET Core sur Kubernetes consommaient chaque jour un peu plus de mémoire. Le tas managé restait petit. Un redémarrage faisait disparaître le problème pendant quelques semaines.

Cet article montre comment nous avons trouvé la cause. La cause est une fuite mémoire native dans le runtime .NET 10 sous Linux. Un paramètre du pilote de base de données déclenche la fuite, et une seule valeur de configuration ou l'ajout de la bibliothèque native manquante l'arrête.

Pourquoi c'est important pour l'exploitation et les coûts

Une fuite lente ne provoque pas d'incident le premier jour. Elle provoque ces problèmes :

  • Les memory requests deviennent trop grandes. Les équipes dimensionnent la memory request d'après la taille du pod au bout de quelques semaines, pas d'après le working set réel. Dans chaque namespace, cela consomme du quota dont d'autres workloads ont besoin.
  • Des redémarrages que personne ne planifie. Quand un pod atteint sa limite mémoire, Kubernetes l'arrête (OOMKill). Les requêtes en cours échouent.
  • Le problème reste caché. Chaque déploiement redémarre les pods. Les équipes qui déploient souvent ne voient jamais le problème. Celles qui déploient moins souvent subissent les OOMKills.
  • Des alertes sans cause claire. Les alertes mémoire se déclenchent, mais les outils .NET habituels montrent un tas managé en bonne santé. L'équipe doit alors décider s'il faut enquêter ou planifier des redémarrages.

Dans notre cas, un pod limité à 2 Gio aurait atteint sa limite au bout de quelques semaines.

Étape 1 : détecter le schéma

Notre monitoring envoyait des alertes mémoire pour un service en production. Nous avons examiné le RSS de tous les pods sur 14 jours :

ObservationValeur
RSS de chaque podCroissance linéaire, 22–35 Mio/jour (la plupart des pods environ 28–29 Mio/jour)
Pods concernésTous les pods de production du service
Lien avec la chargeAucun. Les pods inactifs grossissaient au même rythme que les pods très sollicités.
Tas managé (Gen2, LOH)Petit et stable
Nombre de threadsStable
Autres services sur la même plateformeCertains grossissaient au même rythme. D'autres restaient stables.

Deux faits étaient déterminants :

  1. La croissance est linéaire et ne dépend pas de la charge. Une fuite liée à la charge grossit plus vite sur les pods sollicités. Celle-ci grossissait au même rythme sur tous les pods. Un timer ou une tâche de fond périodique en est donc probablement la cause.
  2. Certains services restent stables. Ces services utilisent la même image de base, la même plateforme et le même chart. Nous les avons utilisés comme services témoins à chaque étape suivante.

Étape 2 : prouver que le tas managé n'est pas la cause

La première étape habituelle est une analyse du tas GC. Mais il fallait d'abord savoir si le tas managé pouvait expliquer la croissance.

Notre monitoring n'affichait que quelques valeurs du runtime .NET pour ces pods. Nous avons donc activé les métriques du runtime .NET dans le service et les avons envoyées à Prometheus. Elles comprennent la taille du tas GC par génération, le nombre de GC, le thread pool, le nombre d'exceptions et la mémoire du processus (process_working_set_bytes, process_private_memory_bytes).

Résultat :

  • Le tas GC restait petit et stable.
  • Le RSS continuait à augmenter.
  • L'écart entre le RSS et le tas GC augmentait de façon linéaire.

Cet écart est la métrique décisive. Si le tas GC est stable et que le RSS augmente, un profileur de mémoire managée ne trouvera pas la fuite. La mémoire se trouve dans des allocations natives.

Astuce : affichez process_working_set_bytes et la taille du tas GC sur le même graphique. Si l'écart entre les deux augmente, arrêtez l'analyse du tas managé et examinez la mémoire native.

Étape 3 : identifier les régions mémoire qui grossissent

Sous Linux, /proc/<pid>/smaps affiche chaque mapping mémoire d'un processus avec son RSS. Vous pouvez le lire avec kubectl exec. Aucun débogueur n'est nécessaire, et le pod continue de tourner.

Nous avons regroupé les mappings par catégorie avec un petit script awk. Un instantané d'un pod après 34,5 heures :

ConsommateurRSSPart
Arènes de threads glibc malloc136,5 Mio24,8 %
Arène principale glibc ([heap])97,8 Mio17,8 %
Total glibc234,3 Mio42,5 %
Mappings de code JIT (/memfd:doublemapper, W^X)113,6 Mio20,6 %
Autre mémoire anonyme (tas managé, piles)67,0 Mio12,2 %
Autres bibliothèques et assemblies98,0 Mio17,8 %

Le tas managé ne pesait qu'environ 14 Mio. Le plus gros consommateur était la mémoire allouée par du code natif avec malloc.

Le service témoin a changé notre conclusion. Une première hypothèse était « la fragmentation des arènes glibc ». C'est un problème connu de .NET sous Linux, et MALLOC_ARENA_MAX est la réponse habituelle. Mais le service témoin stable avait le même nombre d'arènes (15). Sa part glibc dans le RSS était aussi identique (42,6 %). Le nombre d'arènes n'expliquait donc pas la croissance. La fragmentation explique pourquoi glibc conserve la mémoire. Elle n'explique pas pourquoi le processus continue d'en allouer davantage.

Leçon : MALLOC_ARENA_MAX et DOTNET_EnableWriteXorExecute=0 peuvent réduire le RSS. Ils ne corrigent pas une fuite. Comparez avec un service témoin avant de modifier ces paramètres.

Il fallait donc savoir ce que contenaient les blocs malloc qui s'accumulaient.

Étape 4 : récupérer un dump d'un pod en cours d'exécution

Nous avons exécuté dotnet-dump collect --type Heap dans un pod de développement après cinq jours d'uptime.

Quelques remarques pratiques :

  • Utilisez --type Heap, pas Full. Le dump du tas a pris environ 13 secondes. La liveness probe tolérait environ 30 secondes. Un dump complet prend plus de temps et peut provoquer un redémarrage.
  • Copiez le binaire single-file de dotnet-dump dans le pod. Téléchargez-le depuis https://aka.ms/dotnet-dump/linux-x64. Notre image runtime ne le contenait pas.
  • Définissez le répertoire d'extraction avec DOTNET_BUNDLE_EXTRACT_BASE_DIR vers un chemin accessible en écriture. Si l'image fonctionne en mode invariant globalization, définissez aussi DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.
  • Ne faites pas confiance à kubectl exec ... cat pour les gros fichiers. Le flux a été tronqué sans erreur. Nous avons compressé le dump, l'avons découpé en parties de 20 Mo et avons copié chaque partie en boucle jusqu'à ce que sa somme SHA-256 corresponde.

Résultat : un fichier core de 1,34 Go (122 Mo compressé). Le pod n'a pas redémarré.

Étape 5 : examiner le tas natif

dotnet-dump analyze (SOS) affiche le tas managé. Pour le tas glibc, nous avons utilisé chap. chap lit un fichier core Linux et liste chaque allocation malloc. Il indique aussi si une allocation est encore référencée ou si elle a fuité.

Attention : notre image utilisait glibc 2.44. chap ne prenait pas entièrement en charge cette version et affichait des avertissements sur la structure des arènes. Nous n'avons donc utilisé les chiffres de chap que comme point de départ et avons vérifié les valeurs importantes avec nos propres outils.

Le résultat était très net :

ConstatValeur
Allocations d'exactement 3400 octets (0xd48)environ 44 600
Dont non référencées par aucune mémoire (fuitées)42 231
Taille totaleenviron 152 Mo
Croissance calculéeenviron 28 Mio/jour

Le rythme calculé correspondait à la croissance du RSS dans notre monitoring. Un seul type d'allocation expliquait donc toute la croissance.

Que contient un bloc de 3400 octets ?

chap ne parvenait pas à identifier les blocs. Nous avons donc écrit un petit script Python qui lit le fichier core via ses segments ELF PT_LOAD. Le script a calculé des statistiques pour chaque champ de 8 octets sur l'ensemble des 42 231 blocs.

Certains champs avaient la même valeur dans tous les blocs :

OffsetValeurSignification
0x300x10000FContextFlags (x64 CONTEXT_FULL)
0x380x33SegCs : le segment de code utilisateur 64 bits
0x440x246EFlags : une valeur de flags typique
0x1000x37FMot de contrôle de la FPU x87 (valeur par défaut)
0xF8une adresseRip : le pointeur d'instruction

C'est la structure d'un CONTEXT Windows x64, c'est-à-dire un instantané des registres du CPU. Le runtime .NET utilise aussi cette structure sous Linux (dans le PAL). Avec les champs AVX-512, un CONTEXT et un EXCEPTION_RECORD tiennent dans 3400 octets.

Le champ Rsp (pointeur de pile) avait 82 valeurs différentes. De nombreux threads différents avaient donc créé les blocs. Mais Rip était identique dans tous les blocs. Un seul et même endroit du code les avait donc tous créés.

Quel code a créé les blocs ?

Nous avons trouvé l'adresse de chargement de libcoreclr.so dans le dump et l'avons soustraite de Rip pour obtenir un offset dans la bibliothèque. Nous avons ensuite désassemblé la bibliothèque à cet offset, en utilisant le même fichier libcoreclr.so que le pod, après avoir vérifié son SHA-256.

L'adresse était l'adresse de retour après un appel à RtlCaptureContext. Nous avons comparé la fonction avec le code source de .NET 10 et l'avons trouvée : DispatchManagedException(PAL_SEHException& ex, bool isHardwareException) dans vm/exceptionhandling.cpp.

Cette fonction fait passer une exception du code natif du runtime vers la gestion des exceptions managée. Chaque bloc fuité représente donc une exception que le runtime a levée depuis le code natif vers le code managé.

Étape 6 : identifier l'exception

Il restait à trouver l'exception. Nous avons ouvert le même dump avec dotnet-dump analyze et SOS.

  • La mémoire committed du GC s'élevait à 55,7 Mo. Cela confirmait une fois de plus que le tas managé n'était pas la fuite.
  • Le tas contenait une TypeInitializationException mise en cache pour le type interne Interop+NetSecurityNative. L'exception interne provenait du constructeur statique de GssInitializer. La bibliothèque native GSSAPI n'était pas présente dans notre image de conteneur, son initialisation échouait donc.
  • La pile de l'exception était la suivante :
InitHelpers.InitClassSlow
NetSecurityNative.ImportPrincipalName
SafeGssNameHandle.CreateTarget
UnixNegotiateAuthenticationPal.InitializeSecurityContext
NegotiateAuthentication.GetOutgoingBlob
Npgsql.Internal.NpgsqlConnector.GSSEncrypt

La TypeInitializationException se comporte ainsi : si un constructeur statique échoue, le runtime conserve l'exception. Chaque accès ultérieur au type relance la même exception. Le runtime effectue cette relance depuis le code natif (InitClassSlow). Chaque accès passe donc par DispatchManagedException.

Étape 7 : relier les deux côtés

Côté Npgsql. Dans Npgsql 10, la valeur par défaut de GssEncryptionMode est Prefer. Lorsque SSL est actif, Npgsql tente d'abord le chiffrement GSS à chaque nouvelle connexion physique. Il appelle NegotiateAuthentication.GetOutgoingBlob, intercepte la TypeInitializationException et poursuit avec TLS. La connexion fonctionne, et l'application ne voit aucune erreur. Mais chaque nouvelle connexion physique provoque une relance native.

Notre service ouvre régulièrement de nouvelles connexions physiques. Certaines data sources n'ont pas de taille de pool minimale, et Npgsql ferme les connexions inactives.

Côté runtime. Dans .NET 10 sous Linux, la macro UNINSTALL_MANAGED_EXCEPTION_DISPATCHER_EX (dans vm/exceptmacros.h) intercepte la PAL_SEHException native. Elle déplace l'exception dans une copie locale et appelle DispatchManagedException. Cet appel ne retourne jamais. La gestion des exceptions managée se poursuit dans le bloc catch managé. Le destructeur de la copie locale ne s'exécute donc pas, et les enregistrements d'exception (CONTEXT + EXCEPTION_RECORD) ne sont jamais libérés. Le runtime les avait alloués avec posix_memalign. Chaque relance perd un bloc de 3400 octets.

Étape 8 : isoler le problème avec un repro

Une hypothèse tirée d'un dump n'est pas une preuve. Nous avons donc écrit deux petites applications console et les avons exécutées sur .NET 10.0.12 dans une image de conteneur sans bibliothèque GSSAPI. Nous avons utilisé l'image Microsoft mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled, afin de vérifier si le problème était propre à notre image.

Repro 1 : runtime seul, sans base de données

L'application appelle NegotiateAuthentication.GetOutgoingBlob en boucle et intercepte l'exception.

ImageAppelsCroissance du RSSPar appel
mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled40 000152 Moenviron 3,9 Ko
Témoin : throw/catch managé40 00014–15 Mo, non linéairen/a

Repro 2 : de bout en bout avec Npgsql et PostgreSQL

L'application ouvre des connexions Npgsql 10.0.3 vers PostgreSQL 17 avec SslMode=Require et Pooling=false. Nous avons comparé Prefer et Disable.

ImageGssEncryptionModeConnexionsCroissance du RSSTypeInitializationExceptions
Microsoft chiseledPrefer30 000103 Mo30 000
Microsoft chiseledDisable30 0005 Mo0

Résultats :

  • Avec Prefer, chaque connexion provoque exactement une exception. Le RSS augmente de façon linéaire d'environ 3,4 Ko par connexion.
  • Avec Disable, aucune exception ne se produit. Le RSS reste stable.
  • La fuite se produit avec l'image Microsoft non modifiée. Elle n'est donc pas propre à notre image.

Le contournement

Il existe trois façons de supprimer le déclencheur. Une seule suffit.

Option 1 : désactiver le chiffrement GSS dans la chaîne de connexion. C'est ce que nous avons fait. Nous avons rendu GssEncryptionMode configurable dans le service et l'avons réglé sur Disable via une variable d'environnement dans les values Helm :

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

Option 2 : désactiver le chiffrement GSS via une variable d'environnement. Si vous ne pouvez pas modifier le code, Npgsql lit aussi la variable standard de libpq :

PGGSSENCMODE=disable

Option 3 : ajouter la bibliothèque native GSSAPI à l'image. Si libgssapi_krb5.so.2 est présente, le constructeur statique de GssInitializer réussit. Aucune TypeInitializationException n'est donc mise en cache, et la relance native n'a pas lieu. Sur une image basée sur Alpine, une ligne dans le Dockerfile suffit :

RUN apk add --no-cache krb5-libs

Sur une image basée sur Debian ou Ubuntu, le paquet s'appelle libgssapi-krb5-2 (apt-get install -y --no-install-recommends libgssapi-krb5-2). Les images chiseled n'ont pas de gestionnaire de paquets. Il faut alors copier la bibliothèque depuis un build stage, ou utiliser l'option 1 ou 2.

Avant d'appliquer l'une de ces options, examinez ces effets :

  • Aucun changement fonctionnel chez nous. Le chiffrement GSS n'a jamais fonctionné dans nos conteneurs, car la bibliothèque GSSAPI n'était pas dans l'image.
  • TLS reste actif. SslMode ne change pas.
  • Les options 1 et 2 désactivent Kerberos pour PostgreSQL. Si vous avez besoin de l'authentification Kerberos ou GSS, choisissez l'option 3.
  • L'option 3 alourdit l'image et conserve la tentative GSS. Npgsql tente toujours le chiffrement GSS à chaque nouvelle connexion physique, mais la tentative ne passe plus par la relance native qui fuit.
  • Le bug du runtime demeure. Tout autre code qui relance depuis le code natif une TypeInitializationException mise en cache peut aussi perdre de la mémoire. Ces options ne suppriment que le déclencheur que nous avons trouvé.

Résultat

Nous avons d'abord déployé le contournement dans notre environnement de développement. Le lendemain matin, le RSS du service était stable. Avant le changement, il augmentait d'environ 28 Mio par jour.

Comment vérifier si vos services sont concernés

  1. Examinez l'évolution du RSS sur plusieurs jours. Cherchez une croissance linéaire indépendante de la charge, alors que le tas GC reste stable.
  2. Comptez les exceptions. Avec les métriques du runtime .NET ou dotnet-counters monitor System.Runtime, cherchez un rythme constant d'exceptions lorsque le service est inactif.
  3. Trouvez le déclencheur. Utilisez dotnet-trace ou un gestionnaire d'exceptions first-chance pour journaliser les TypeInitializationException. Ou récupérez un dump du tas et exécutez dumpheap -type TypeInitializationException dans SOS.
  4. Vérifiez l'image. Votre image utilise-t-elle Npgsql 10 avec SSL et sans libgssapi_krb5.so.2 ? Alors vous avez probablement le déclencheur.

État upstream

  • Runtime .NET : nous avons signalé la fuite avec le repro dans dotnet/runtime#135234. Un correctif est en revue dans dotnet/runtime#135290. Il confie la propriété des enregistrements d'exception au dispatch des exceptions et ajoute un test de non-régression. Au moment de la rédaction, le correctif visait la branche main. Aucun backport vers .NET 10 n'avait été annoncé.
  • Npgsql : npgsql/npgsql#6416 décrit la même avalanche d'exceptions et la même croissance mémoire dans des conteneurs. Npgsql 10.0.2 intercepte l'exception, mais celle-ci se produit toujours à chaque nouvelle connexion. npgsql/npgsql#6593 arrête les tentatives GSS après le premier échec, ce qui supprime le déclencheur sans changement de configuration.

Leçons

  1. Mesurez d'abord l'écart entre le RSS et le tas GC. Il vous indique si le problème concerne la mémoire managée ou native.
  2. Utilisez toujours un service témoin. Notre première hypothèse (les arènes glibc) était fausse. C'est la comparaison avec un service stable qui l'a montré.
  3. Une fuite indépendante de la charge indique un timer ou une tâche périodique. Trouvez la période et rapprochez-la du code.
  4. Regroupez les fuites natives par taille d'allocation. Une taille exacte qui apparaît des dizaines de milliers de fois est une empreinte.
  5. Le contenu d'un bloc fuité révèle qui l'a alloué. Les valeurs de champs constantes identifient la structure. Le pointeur d'instruction identifie le code.
  6. Une exception interceptée par l'application n'est pas gratuite. Elle coûte du CPU. Ici, elle coûtait aussi de la mémoire.
  7. Confirmez l'hypothèse avec un repro minimal, en incluant l'image de l'éditeur. Le rapport upstream est ainsi rapidement accepté.

Les outils que nous avons utilisés

  • Prometheus et les métriques du runtime .NET
  • /proc/<pid>/smaps avec awk
  • dotnet-dump (collect et analyze, SOS)
  • chap pour le tas glibc
  • Un petit script Python qui lit les fichiers core ELF
  • objdump pour désassembler libcoreclr.so
  • Le code source du runtime .NET et de Npgsql sur GitHub
  • Podman pour les conteneurs de repro

Sources

Besoin d'aide ?

La fuite ne venait pas de notre code, mais d'une valeur par défaut que personne n'avait choisie consciemment : le paramètre GSS de Npgsql, combiné à une bibliothèque native absente de l'image. C'est précisément ce que nous examinons lors de la revue de code de services .NET : chaînes de connexion et valeurs par défaut des pilotes, images de base et Dockerfiles, limites de ressources et tout ce qui n'apparaît qu'après des semaines en production. Une revue ciblée de votre service vous montre quelles valeurs par défaut s'exécutent en silence chez vous.

Faire relire votre service