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 :
| Observation | Valeur |
|---|---|
| RSS de chaque pod | Croissance linéaire, 22–35 Mio/jour (la plupart des pods environ 28–29 Mio/jour) |
| Pods concernés | Tous les pods de production du service |
| Lien avec la charge | Aucun. Les pods inactifs grossissaient au même rythme que les pods très sollicités. |
| Tas managé (Gen2, LOH) | Petit et stable |
| Nombre de threads | Stable |
| Autres services sur la même plateforme | Certains grossissaient au même rythme. D'autres restaient stables. |
Deux faits étaient déterminants :
- 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.
- 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_byteset 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 :
| Consommateur | RSS | Part |
|---|---|---|
| Arènes de threads glibc malloc | 136,5 Mio | 24,8 % |
Arène principale glibc ([heap]) | 97,8 Mio | 17,8 % |
| Total glibc | 234,3 Mio | 42,5 % |
Mappings de code JIT (/memfd:doublemapper, W^X) | 113,6 Mio | 20,6 % |
| Autre mémoire anonyme (tas managé, piles) | 67,0 Mio | 12,2 % |
| Autres bibliothèques et assemblies | 98,0 Mio | 17,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_MAXetDOTNET_EnableWriteXorExecute=0peuvent 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, pasFull. 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-dumpdans le pod. Téléchargez-le depuishttps://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_DIRvers un chemin accessible en écriture. Si l'image fonctionne en mode invariant globalization, définissez aussiDOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. - Ne faites pas confiance à
kubectl exec ... catpour 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 :
| Constat | Valeur |
|---|---|
Allocations d'exactement 3400 octets (0xd48) | environ 44 600 |
| Dont non référencées par aucune mémoire (fuitées) | 42 231 |
| Taille totale | environ 152 Mo |
| Croissance calculée | environ 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 :
| Offset | Valeur | Signification |
|---|---|---|
0x30 | 0x10000F | ContextFlags (x64 CONTEXT_FULL) |
0x38 | 0x33 | SegCs : le segment de code utilisateur 64 bits |
0x44 | 0x246 | EFlags : une valeur de flags typique |
0x100 | 0x37F | Mot de contrôle de la FPU x87 (valeur par défaut) |
0xF8 | une adresse | Rip : 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
TypeInitializationExceptionmise en cache pour le type interneInterop+NetSecurityNative. L'exception interne provenait du constructeur statique deGssInitializer. 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.
| Image | Appels | Croissance du RSS | Par appel |
|---|---|---|---|
mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled | 40 000 | 152 Mo | environ 3,9 Ko |
Témoin : throw/catch managé | 40 000 | 14–15 Mo, non linéaire | n/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.
| Image | GssEncryptionMode | Connexions | Croissance du RSS | TypeInitializationExceptions |
|---|---|---|---|---|
| Microsoft chiseled | Prefer | 30 000 | 103 Mo | 30 000 |
| Microsoft chiseled | Disable | 30 000 | 5 Mo | 0 |
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.
SslModene 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
TypeInitializationExceptionmise 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
- 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.
- 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. - Trouvez le déclencheur. Utilisez
dotnet-traceou un gestionnaire d'exceptions first-chance pour journaliser lesTypeInitializationException. Ou récupérez un dump du tas et exécutezdumpheap -type TypeInitializationExceptiondans SOS. - 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
- 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.
- 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é.
- 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.
- Regroupez les fuites natives par taille d'allocation. Une taille exacte qui apparaît des dizaines de milliers de fois est une empreinte.
- 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.
- 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.
- 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>/smapsavecawkdotnet-dump(collect et analyze, SOS)- chap pour le tas glibc
- Un petit script Python qui lit les fichiers core ELF
objdumppour désassemblerlibcoreclr.so- Le code source du runtime .NET et de Npgsql sur GitHub
- Podman pour les conteneurs de repro
