Native AOT en .NET 10 : pourquoi et quand l’utiliser
Native AOT (Ahead-Of-Time) compile votre application .NET en code natif au moment du dotnet publish, sans JIT à l’exécution. Le binaire produit est autonome (self-contained), démarre en quelques dizaines de millisecondes, consomme deux à cinq fois moins de mémoire qu’un équivalent JIT, et s’exécute dans des environnements où le JIT est interdit (iOS App Store, certains conteneurs durcis). Microsoft documente ce mode de déploiement sur learn.microsoft.com et le considère comme la cible privilégiée pour les services cloud massivement déployés en .NET 10.
Cette série pratique vous guide depuis l’installation des prérequis système jusqu’à un binaire AOT optimisé, en couvrant les pièges classiques : trimming agressif qui supprime du code utilisé par reflection, sérialisation JSON dépendante de la dynamique, dépendances tierces non annotées. Vous apprendrez aussi à mesurer concrètement les gains et à diagnostiquer ce qui ne fonctionne pas.
Étape 1 : Installer les prérequis natifs selon votre plateforme
Contrairement à un publish .NET classique, Native AOT exige un compilateur natif (clang ou MSVC) et des bibliothèques système. Sur Ubuntu 22.04 LTS ou plus récent :
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev
Sur Fedora 39+ : sudo dnf install clang zlib-devel zlib-ng-devel. Sur macOS, installez Xcode Command Line Tools via xcode-select --install. Sur Windows, ouvrez Visual Studio Installer et ajoutez la charge de travail « Développement Desktop avec C++ ». La doc officielle liste les versions minimales par distribution Linux. Signal de réussite : clang --version retourne une version >= 14 sur Linux ou >= 15 sur macOS.
Étape 2 : Créer un projet console minimal
Dans un dossier vide :
dotnet new console -n AotDemo --aot
cd AotDemo
L’option --aot, ajoutée au template console en .NET 8 et conservée en .NET 10, active automatiquement les bonnes propriétés du .csproj :
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
La propriété PublishAot est l’interrupteur principal ; elle active aussi automatiquement IsTrimmable, EnableTrimAnalyzer, EnableSingleFileAnalyzer et EnableAotAnalyzer, qui repèrent à la compilation les usages risqués de reflection. InvariantGlobalization remplace la base ICU complète par un fallback minimal, ce qui économise environ 30 Mo dans le binaire final mais limite le formatage de dates et nombres à la culture invariant.
Étape 3 : Publier et observer le résultat
Compilez en mode Release pour la plateforme cible :
dotnet publish -c Release -r linux-x64
Sur Linux x64, le binaire produit dans bin/Release/net10.0/linux-x64/publish/AotDemo pèse environ 1,5 à 4 Mo selon les dépendances. Exécutez-le :
./bin/Release/net10.0/linux-x64/publish/AotDemo
Mesurez le démarrage avec time :
time ./AotDemo
Sur un serveur cloud standard, un Hello World affiche une valeur réelle proche de 5-15 millisecondes, contre 80-150 ms pour le même programme en framework-dependent JIT. La consommation mémoire mesurée via ps -o rss tombe autour de 4-8 Mo (RSS). Signal de réussite : la commande file AotDemo indique ELF 64-bit LSB executable et non plus un assembly managé.
Étape 4 : Le piège de la reflection et la sérialisation JSON
Native AOT impose le trimming agressif : tout code qui n’est pas statiquement référencé peut être supprimé. La conséquence majeure concerne System.Text.Json en mode dynamique. Le code suivant compile mais lève une exception à l’exécution :
using System.Text.Json;
var json = JsonSerializer.Serialize(new { Nom = "Mariama", Age = 32 });
Console.WriteLine(json);
Le compilateur émettra l’avertissement IL3050 ou IL2026 selon le cas. La solution : utiliser le source generator de System.Text.Json. Définissez d’abord un record et un contexte :
using System.Text.Json;
using System.Text.Json.Serialization;
public record Personne(string Nom, int Age);
[JsonSerializable(typeof(Personne))]
internal partial class AppJsonContext : JsonSerializerContext { }
Puis sérialisez en passant explicitement le contexte :
var p = new Personne("Mariama", 32);
string json = JsonSerializer.Serialize(p, AppJsonContext.Default.Personne);
Console.WriteLine(json);
Le source generator produit à la compilation tout le code de sérialisation, sans appel à reflection. Le binaire reste compatible AOT et plus rapide qu’en mode dynamique. Signal de réussite : recompilation sans aucun avertissement IL2xxx ou IL3xxx.
Étape 5 : Activer AOT sur une API ASP.NET Core
Depuis .NET 8, ASP.NET Core supporte officiellement Native AOT pour les Minimal APIs, gRPC et les Worker Services. En .NET 10, SignalR est passé en support partiel (il n’était pas supporté en .NET 8). MVC et Blazor Server restent non supportés à cette date. Créez un projet web AOT :
dotnet new webapiaot -n CatalogueAot
cd CatalogueAot
dotnet publish -c Release -r linux-x64
Le template webapiaot, introduit en .NET 8, applique les bons réglages : PublishAot=true, JsonSerializerContext pré-généré, et un Program.cs qui utilise WebApplication.CreateSlimBuilder() au lieu de CreateBuilder(). La variante Slim active moins de services par défaut, ce qui réduit encore l’empreinte. Le binaire publié pèse autour de 18-25 Mo, démarre en moins de 50 ms, et accepte le premier requête HTTP en moins de 100 ms après lancement.
Étape 6 : Vérifier la compatibilité d’une dépendance tierce
Toute bibliothèque NuGet n’est pas compatible AOT. En .NET 10, Microsoft a introduit la métadonnée IsAotCompatible que les auteurs de packages déclarent dans leur .csproj. Vous pouvez forcer l’analyzer à vérifier que toutes vos dépendances la déclarent en ajoutant :
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>
</PropertyGroup>
Si une dépendance n’est pas annotée, vous obtenez l’avertissement IL3058 avec le nom de la bibliothèque incriminée. Avant d’écarter la dépendance, consultez son issue tracker GitHub : beaucoup de packages fonctionnent en réalité avec AOT mais leurs auteurs n’ont pas encore ajouté l’attribut. Signal de réussite : dotnet publish termine sans aucun IL3058 ni warning de trimming.
Étape 7 : Optimiser la taille du binaire
Un binaire AOT par défaut peut surprendre par sa taille. Plusieurs leviers existent. D’abord, InvariantGlobalization économise environ 30 Mo en évitant ICU. Ensuite, StackTraceSupport=false réduit encore quelques mégaoctets en supprimant les métadonnées de stack trace symboliques (vous gardez les noms de méthodes mais perdez les numéros de ligne). Enfin, ajoutez :
<PropertyGroup>
<OptimizationPreference>Size</OptimizationPreference>
<IlcDisableReflection>true</IlcDisableReflection>
</PropertyGroup>
Attention : IlcDisableReflection est extrême — il interdit toute reflection même nominale. Beaucoup de bibliothèques s’effondrent. Testez sur un binaire de production complet avant d’activer ce drapeau. Pour des cas d’usage cloud, l’objectif raisonnable est 15-25 Mo pour une API REST avec base de données. Signal de réussite : ls -lh ./publish/CatalogueAot affiche moins de 25 Mo et la commande s’exécute correctement.
Étape 8 : Conteneuriser un binaire AOT
Native AOT brille dans les images Docker. Le binaire étant autonome, vous n’avez plus besoin de l’image mcr.microsoft.com/dotnet/aspnet:10.0 de 200+ Mo. Utilisez plutôt :
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -r linux-x64 -o /app
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS final
WORKDIR /app
COPY --from=build /app/CatalogueAot .
ENTRYPOINT ["./CatalogueAot"]
L’image finale basée sur runtime-deps:10.0-alpine pèse environ 30 Mo au total, contre 220 Mo pour un déploiement framework-dependent. Sur un cluster Kubernetes facturant la bande passante, l’économie est tangible. Pour un déploiement à 50 pods, le pull initial passe de 11 Go à 1,5 Go. Signal de réussite : docker images | grep catalogue-aot indique une taille proche de 30 Mo.
Étape 9 : Diagnostiquer un crash AOT
Les binaires AOT n’ont pas de stack trace managée par défaut. Pour comprendre un crash, conservez les symboles de debug à côté du binaire :
<PropertyGroup>
<StripSymbols>false</StripSymbols>
</PropertyGroup>
Sur Linux, l’extension .dbg contient les symboles. Utilisez gdb ou lldb pour analyser un dump :
gdb ./CatalogueAot core.dump
(gdb) bt
Pour la production, l’export OpenTelemetry vers Application Insights ou Grafana capture exceptions et métriques même sans symboles locaux. Le runtime .NET 10 améliore aussi le format des stack traces AOT pour qu’elles soient plus lisibles que dans les versions précédentes. Signal de réussite : un throw new Exception("test") dans le code produit un message lisible incluant le nom de la méthode appelante.
Quand ne pas utiliser Native AOT
Malgré ses avantages, Native AOT n’est pas universel. Si votre application utilise massivement reflection, plugins dynamiques (Assembly.LoadFile), ORM avec proxy dynamique (Entity Framework Core fonctionne avec AOT en .NET 10, mais certains scénarios avancés non), ou si vous publiez sur plusieurs plateformes depuis une seule machine de build, le mode framework-dependent classique reste plus pragmatique. Pour les bibliothèques internes, gardez le JIT et ne migrez que les endpoints les plus chauds — typiquement les API gateway ou les fonctions serverless où la cold start coûte cher en facturation. Bien dimensionné, Native AOT divise par cinq le coût d’un AWS Lambda C# au profil typique d’un trafic en rafales.