Mettre une API ASP.NET Core dans un conteneur prend dix lignes de Dockerfile. Obtenir une image petite, rapide à construire, non-root et sans shell inutile demande un peu plus de méthode. Cet article passe en revue le Dockerfile multi-stage bien fait, le choix des images de base .NET 10 (dont les variantes chiseled), la publication sans Dockerfile avec dotnet publish /t:PublishContainer, puis l’exécution en conditions réelles : health checks, limites mémoire, scan de vulnérabilités et CI.
1. Le Dockerfile multi-stage
Le principe : une première étape construit l’application avec l’image SDK (lourde, pleine d’outils), une seconde étape ne récupère que le résultat du publish dans une image runtime minimale. Le SDK, les sources et les packages NuGet ne finissent jamais dans l’image livrée.
graph LR
subgraph Build["Étape build : sdk:10.0"]
A["COPY *.csproj"] --> B["dotnet restore<br/>(couche en cache)"]
B --> C["COPY src/"]
C --> D["dotnet publish<br/>--no-restore"]
end
subgraph Final["Étape finale : aspnet:10.0-noble-chiseled"]
E["COPY --from=build<br/>/app/publish"] --> F["USER $APP_UID<br/>ENTRYPOINT"]
end
D -->|artefacts publiés| E
style A fill:#4A90E2
style B fill:#F5A623
style C fill:#4A90E2
style D fill:#F5A623
style E fill:#7ED321
style F fill:#7ED321
# syntax=docker/dockerfile:1
# ---------- Étape 1 : build ----------
# $BUILDPLATFORM : le SDK tourne toujours dans l'architecture de la machine de build,
# même quand on produit une image arm64 (pas d'émulation QEMU pour la compilation).
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
# 1) On copie UNIQUEMENT les fichiers qui influencent le restore.
# Tant qu'ils ne changent pas, la couche "restore" est réutilisée depuis le cache.
# (À adapter : COPY échoue si un fichier est absent, retirer les Directory.*.props inutiles.)
COPY Directory.Build.props Directory.Packages.props ./
COPY src/Boutique.Api/Boutique.Api.csproj src/Boutique.Api/
COPY src/Boutique.Domain/Boutique.Domain.csproj src/Boutique.Domain/
# 2) Restore, avec un cache BuildKit pour les packages NuGet (partagé entre builds).
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore src/Boutique.Api/Boutique.Api.csproj -a $TARGETARCH
# 3) Le code source ensuite : une modification de .cs n'invalide que les couches suivantes.
COPY src/ src/
# 4) Publish sans refaire le restore (même architecture que le restore, sinon erreur).
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish src/Boutique.Api/Boutique.Api.csproj \
-c Release -a $TARGETARCH --no-restore \
-o /app/publish /p:UseAppHost=false
# ---------- Étape 2 : runtime ----------
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
USER $APP_UID
# Forme "exec" obligatoire : il n'y a pas de shell dans une image chiseled.
ENTRYPOINT ["dotnet", "Boutique.Api.dll"]
Les points qui font la différence :
- L’ordre des
COPY: Docker invalide une couche dès que ses entrées changent. Copier tout le dépôt avant le restore (❌COPY . .puisdotnet restore) relance le téléchargement de tous les packages à chaque modification d’un fichier.cs. Copier d’abord les.csproj(✅) rend le restore quasi instantané tant que les dépendances ne bougent pas. --no-restoresur lepublishévite un second restore implicite, et garantit qu’on utilise bien la couche mise en cache.--mount=type=cacheconserve le dossier~/.nuget/packagesentre deux builds, même quand la couche restore est invalidée (ajout d’un package) : seuls les nouveaux packages sont téléchargés.
2. Choisir l’image de base
Microsoft publie quatre familles d’images sur mcr.microsoft.com/dotnet/ :
| Image | Contenu | Usage |
|---|---|---|
sdk |
SDK complet, outils, shell | Étape de build uniquement |
aspnet |
Runtime .NET + ASP.NET Core | API, sites web (framework-dependent) |
runtime |
Runtime .NET seul | Console, Worker Service |
runtime-deps |
Uniquement les dépendances natives (libc, OpenSSL…) | App self-contained ou Native AOT |
Avec .NET 10, le tag par défaut (10.0, latest) est basé sur Ubuntu 24.04 « Noble » ; les images Debian ne sont plus publiées. Chaque famille se décline ensuite en variantes :
Tag (exemple pour aspnet) |
Particularités |
|---|---|
10.0 / 10.0-noble |
Ubuntu complet : shell, apt, ICU, tzdata |
10.0-noble-chiseled |
Ubuntu « ciselé » : ni shell, ni gestionnaire de paquets, non-root par défaut, sans ICU ni tzdata |
10.0-noble-chiseled-extra |
Chiseled + ICU + tzdata (globalisation et fuseaux horaires) |
10.0-noble-chiseled-composite |
Chiseled avec runtime précompilé en composite (démarrage plus rapide) |
10.0-alpine |
Alpine (musl) : très petite, avec shell ; variante -extra disponible |
10.0-azurelinux3.0-distroless |
Équivalent « distroless » sur Azure Linux |
Les images chiseled sont le bon défaut en production : la surface d’attaque est réduite au strict nécessaire (pas de bash, pas de apt, donc rien à exploiter pour un attaquant qui obtiendrait une exécution de commande), et elles sont nettement plus petites que l’Ubuntu complet.
Le piège à connaître : sans ICU ni tzdata, l’application tourne en globalisation invariante. Un TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris") ou un tri de chaînes sensible à la culture fr-FR ne se comportera pas comme sur un poste de développement.
<!-- ✅ Si l'app n'a pas besoin de cultures : on l'assume explicitement -->
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<!-- Sinon : utiliser la variante 10.0-noble-chiseled-extra -->
graph TD
Q1{"App self-contained<br/>ou Native AOT ?"}
Q1 -->|oui| RD["runtime-deps:10.0-noble-chiseled"]
Q1 -->|non| Q2{"Besoin de cultures<br/>ou fuseaux horaires ?"}
Q2 -->|non| CH["aspnet:10.0-noble-chiseled"]
Q2 -->|oui| EX["aspnet:10.0-noble-chiseled-extra"]
Q1 -.->|besoin d'un shell<br/>pour déboguer| FULL["aspnet:10.0 (Noble complet)"]
style RD fill:#7ED321
style CH fill:#7ED321
style EX fill:#4A90E2
style FULL fill:#F5A623
3. Non-root et port 8080
Depuis .NET 8, toutes les images Linux contiennent un utilisateur app (UID 1654), exposé dans la variable d’environnement APP_UID. Les images chiseled l’utilisent déjà par défaut ; pour les images Ubuntu complètes et Alpine, c’est à nous de l’activer :
# ✅ Le processus ne tourne pas en root
USER $APP_UID
Conséquence directe : un utilisateur non-root ne peut pas écouter sur un port inférieur à 1024. C’est pourquoi ASP.NET Core écoute par défaut sur le port 8080 dans les images officielles (variable ASPNETCORE_HTTP_PORTS=8080), et non plus sur 80 comme avant .NET 8. Les vieux docker run -p 80:80 doivent devenir :
docker run --rm -p 8080:8080 boutique-api:1.0
4. Sans Dockerfile : dotnet publish /t:PublishContainer
Le SDK .NET sait construire une image OCI tout seul, sans Docker pour le build (Docker ou Podman ne servent que pour charger l’image localement). Il choisit l’image de base, applique l’utilisateur non-root, déclare le port, et produit des couches identiques à celles d’un bon Dockerfile.
# Image locale "boutique-api:latest", poussée dans le démon Docker local
dotnet publish src/Boutique.Api -c Release --os linux --arch x64 /t:PublishContainer
Tout se configure par propriétés MSBuild dans le .csproj :
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- Nom de l'image (par défaut : AssemblyName) -->
<ContainerRepository>boutique/api</ContainerRepository>
<!-- Plusieurs tags séparés par ";" (par défaut : latest) -->
<ContainerImageTags>1.4.0;latest</ContainerImageTags>
<!-- Suffixe ajouté au tag de l'image de base inférée : aspnet:10.0-noble-chiseled -->
<ContainerFamily>noble-chiseled</ContainerFamily>
<!-- Ou bien l'image complète, qui rend ContainerFamily inopérant -->
<!-- <ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra</ContainerBaseImage> -->
<!-- Registre cible : sans cette propriété, l'image va dans le démon local -->
<!-- <ContainerRegistry>ghcr.io</ContainerRegistry> -->
</PropertyGroup>
<ItemGroup>
<ContainerEnvironmentVariable Include="DOTNET_gcServer" Value="1" />
<ContainerLabel Include="org.opencontainers.image.vendor" Value="Boutique" />
</ItemGroup>
Quelques règles d’inférence utiles :
- projet ASP.NET Core → image
aspnet, autre projet →runtime, projet self-contained →runtime-deps; - le tag de base suit le
TargetFramework(net10.0→10.0) ; - le port est déduit de
ASPNETCORE_HTTP_PORTS(donc 8080) ; - l’utilisateur est
app, référencé par son UID.
Pour un projet console qui n’est ni web ni Worker, il faut activer la fonctionnalité avec <EnableSdkContainerSupport>true</EnableSdkContainerSupport>. Et -p ContainerArchiveOutputPath=./images/ produit un .tar.gz au lieu de pousser l’image, pratique pour la scanner avant publication.
Dockerfile ou PublishContainer ? Le SDK ne sait pas exécuter d’instruction RUN : impossible d’installer un paquet système ou de lancer un script pendant le build. Dans ce cas, on garde un Dockerfile ou on prépare une image de base personnalisée référencée par ContainerBaseImage. Pour une API classique, PublishContainer suffit et supprime un fichier à maintenir.
5. Tailles d’image
Les chiffres ci-dessous sont des ordres de grandeur indicatifs (taille non compressée, architecture amd64, hors code applicatif sauf mention). Ils varient d’un patch à l’autre ; docker images donne la valeur exacte pour un cas donné.
| Image | Ordre de grandeur |
|---|---|
sdk:10.0 |
~ 800 Mo à 900 Mo |
aspnet:10.0 (Noble complet) |
~ 220 Mo à 230 Mo |
aspnet:10.0-noble-chiseled-extra |
~ 150 Mo |
aspnet:10.0-alpine |
~ 120 Mo |
aspnet:10.0-noble-chiseled |
~ 110 Mo à 120 Mo |
runtime-deps:10.0-noble-chiseled |
~ 10 Mo à 15 Mo |
App self-contained + trimming sur runtime-deps chiseled |
~ 40 Mo à 60 Mo |
App Native AOT sur runtime-deps chiseled |
~ 20 Mo à 30 Mo |
Le gain le plus visible vient du multi-stage (ne pas livrer le SDK : un facteur 4 à 8). Passer ensuite d’Ubuntu complet à chiseled divise encore la taille par deux environ.
6. À l’exécution
Variables d’environnement
La configuration ASP.NET Core lit les variables d’environnement ; le double underscore remplace le : des sections :
docker run --rm -p 8080:8080 \
-e ASPNETCORE_ENVIRONMENT=Staging \
-e ConnectionStrings__Default="Host=db;Database=boutique;Username=app;Password=secret" \
-e Logging__LogLevel__Default=Warning \
boutique-api:1.0
Les secrets n’ont rien à faire dans le Dockerfile (ENV) ni dans les propriétés ContainerEnvironmentVariable : ils seraient visibles dans les métadonnées de l’image. On les injecte au lancement, via l’orchestrateur ou un gestionnaire de secrets.
Health checks
Côté application, l’endpoint de santé standard :
// Program.cs (.NET 10, Microsoft.NET.Sdk.Web : aucun package supplémentaire)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapHealthChecks("/healthz");
app.Run();
Sous Kubernetes, les sondes livenessProbe / readinessProbe en httpGet interrogent cet endpoint depuis l’extérieur : rien à mettre dans l’image. L’instruction HEALTHCHECK de Docker, elle, exécute une commande dans le conteneur, et c’est là que ça coince : les images .NET n’embarquent ni curl ni wget, et une image chiseled n’a même pas de shell.
Une solution qui fonctionne partout : faire jouer à l’application elle-même le rôle de sonde.
// Program.cs : mode "sonde" déclenché par un argument
if (args.Contains("--healthcheck"))
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
try
{
using var response = await http.GetAsync("http://localhost:8080/healthz");
return response.IsSuccessStatusCode ? 0 : 1;
}
catch
{
return 1; // conteneur marqué "unhealthy"
}
}
var builder = WebApplication.CreateBuilder(args);
// ... configuration habituelle ...
var app = builder.Build();
app.MapHealthChecks("/healthz");
await app.RunAsync();
return 0;
# Forme exec : pas de shell nécessaire
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["dotnet", "Boutique.Api.dll", "--healthcheck"]
Chaque vérification démarre un processus .NET : on garde un intervalle raisonnable.
Limites mémoire, CPU et GC
Le runtime .NET lit les limites du cgroup. Avec docker run -m 512m --cpus 2, Environment.ProcessorCount vaut 2 et le GC plafonne par défaut le tas à 75 % de la limite mémoire. Le Server GC (un tas par cœur, choisi par défaut par ASP.NET Core) était autrefois réputé vorace en conteneur ; depuis .NET 9, le mode DATAS (Dynamic Adaptation To Application Sizes), activé par défaut, adapte le nombre de tas à la charge réelle et règle l’essentiel du problème. Pour le détail des générations et des modes, voir le Garbage Collector .NET.
# Plafond du tas à 70 % de la limite du conteneur (valeur HEXADÉCIMALE : 0x46 = 70)
docker run --rm -m 512m --cpus 2 \
-e DOTNET_GCHeapHardLimitPercent=0x46 \
boutique-api:1.0
❌ Un conteneur sans limite mémoire laisse le GC croire qu’il dispose de toute la RAM de l’hôte, jusqu’à l’OOM kill par l’orchestrateur. ✅ Toujours fixer une limite, et laisser le runtime s’y adapter.
7. .dockerignore et scan de vulnérabilités
Le contexte de build envoyé à Docker doit être minimal : des dossiers bin/ et obj/ copiés depuis Windows peuvent même casser le build Linux (fichiers project.assets.json incohérents).
# .dockerignore
**/bin/
**/obj/
**/.vs/
**/.vscode/
**/*.user
**/TestResults/
**/node_modules/
.git/
Dockerfile*
docker-compose*.yml
*.md
Une image n’est sûre qu’au moment où elle est construite : de nouvelles CVE sont publiées chaque semaine. Deux outils courants pour scanner une image :
# Trivy (Aqua Security) : vulnérabilités OS et packages NuGet de l'image
trivy image --severity HIGH,CRITICAL boutique-api:1.0
# Docker Scout, intégré à Docker Desktop
docker scout cves boutique-api:1.0
Côté code, dotnet list package --vulnerable --include-transitive vérifie les dépendances NuGet avant même de construire l’image. Et reconstruire régulièrement l’image (même sans changement de code) permet de récupérer les correctifs du tag 10.0-noble-chiseled, mis à jour à chaque patch .NET et Ubuntu.
8. Publier depuis GitHub Actions
Avec PublishContainer, la publication d’image vers le GitHub Container Registry (ghcr.io) tient dans un workflow complet de quelques étapes, déclenché par un tag de version (pour un pipeline de build et de tests plus complet, voir GitHub Actions pour un projet .NET) :
name: container
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write # nécessaire pour pousser sur ghcr.io
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Connexion à ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Construire et pousser l'image
env:
# Variable d'environnement plutôt que /p: : évite l'échappement du ";"
ContainerImageTags: ${{ github.ref_name }};latest
run: >
dotnet publish src/Boutique.Api/Boutique.Api.csproj
-c Release --os linux --arch x64
/t:PublishContainer
-p ContainerRegistry=ghcr.io
-p ContainerRepository=mon-org/boutique-api
Le SDK réutilise les identifiants écrits par docker login. Le nom de dépôt doit être en minuscules, ce que github.repository_owner ne garantit pas : d’où la valeur écrite en dur ici. Avec un Dockerfile, on remplace la dernière étape par docker/setup-buildx-action et docker/build-push-action, qui gèrent aussi le cache de couches entre runs. Dans les deux cas, on ajoute une étape de scan avant le push, et on épingle les actions tierces par SHA de commit plutôt que par tag mobile.
9. Docker Compose pour le développement local
Pour lancer l’API avec sa base de données sur un poste :
# compose.yaml
services:
api:
build: .
ports:
- "8080:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Default: "Host=db;Database=boutique;Username=postgres;Password=postgres"
depends_on:
db:
condition: service_healthy # attend que PostgreSQL réponde
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
docker compose up --build
Dès que l’application compte plusieurs services .NET, de l’observabilité et des dépendances à câbler, .NET Aspire offre une alternative plus intégrée : orchestration décrite en C#, chaînes de connexion injectées automatiquement et tableau de bord OpenTelemetry, sans fichier YAML à maintenir.
Résumé
| Sujet | À retenir |
|---|---|
| Multi-stage | sdk:10.0 pour construire, image runtime minimale pour exécuter |
| Cache | Copier les .csproj avant les sources, --mount=type=cache pour NuGet, --no-restore au publish |
| Image de base | aspnet:10.0-noble-chiseled par défaut, -extra pour ICU/tzdata, runtime-deps pour self-contained/AOT |
| Sécurité | USER $APP_UID, port 8080, pas de shell en chiseled, secrets injectés au runtime |
| Sans Dockerfile | dotnet publish /t:PublishContainer + ContainerRepository, ContainerImageTags, ContainerFamily, ContainerBaseImage |
| Tailles | Ordres de grandeur : ~ 850 Mo (SDK) → ~ 220 Mo (Noble) → ~ 115 Mo (chiseled) → ~ 25 Mo (AOT) |
| Health checks | Sondes HTTP de l’orchestrateur ; pour HEALTHCHECK, une sonde en forme exec (pas de curl) |
| Mémoire | Toujours fixer une limite ; le GC plafonne le tas à 75 %, ajustable via DOTNET_GCHeapHardLimitPercent (hexa) |
| Hygiène | .dockerignore strict, scan Trivy / Docker Scout, reconstruction régulière |
| CI et local | PublishContainer vers ghcr.io dans GitHub Actions ; Compose pour le local, Aspire en alternative |