CI/CD & Ops

Docker

Images, multi-stage, compose, bonnes pratiques .NET et Angular.

Vérifié en septembre 2026 · outils aux versions citées dans le cours · environ 14 min

Une image Docker est un système de fichiers figé, accompagné de la commande qui le fait vivre : tout ce qu'il faut pour lancer l'application, et rien qui dépende de la machine qui l'exécute. On s'en sert pour livrer une API .NET ou un front Angular sous une forme qui tourne à l'identique sur un poste, une chaîne d'intégration et un serveur. Écrire un Dockerfile qui fonctionne prend dix minutes ; les difficultés viennent après, quand un build met quatre minutes pour une ligne changée, quand l'image pèse un gigaoctet ou quand un docker stop coupe des requêtes en cours. Elles s'expliquent toutes par trois mécanismes : le cache des couches, la séparation entre construire et exécuter, et la place du processus principal dans le conteneur.

Images, couches et cache

Une image est une pile de couches en lecture seule. Chaque instruction qui touche au système de fichiers — RUN, COPY, ADD — en produit une, qui ne contient que la différence avec la précédente. Le conteneur ajoute au sommet une couche inscriptible, jetée avec lui. Deux images bâties sur la même base partagent ses couches sur le disque comme sur le registre : c'est ce qui rend un docker pull rapide quand seule la dernière couche a changé.

Au build, chaque instruction est comparée à celle du build précédent. Pour un RUN, la clé est le texte de la commande, et les ARG qu'elle utilise : un RUN apt-get update reste en cache tant que son texte ne change pas, même quand les dépôts ont bougé. Pour un COPY, la clé est une empreinte calculée sur le contenu et les métadonnées des fichiers copiés, date de modification exclue — un git checkout qui ne change rien au contenu n'invalide donc rien. Et dès qu'une instruction manque le cache, toutes celles qui la suivent sont rejouées, qu'elles aient changé ou non.

Cette dernière règle fait de l'ordre des instructions la première décision de performance d'un Dockerfile. Ce qui change rarement doit venir en haut, ce qui change à chaque commit en bas. Le fichier suivant fait l'inverse : il copie tout le dépôt avant de restaurer les paquets, si bien que modifier un commentaire dans un contrôleur relance le téléchargement de toutes les dépendances NuGet.

# A la racine du depot : src/Boutique.Api, src/Boutique.Domaine, web/...
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /src

# Tout le depot en une seule couche : la moindre ligne modifiee, dans
# n'importe quel fichier, change l'empreinte de ce COPY.
COPY . .

# Cette etape ne depend que des .csproj, mais elle suit une couche invalidee :
# elle est rejouee a chaque build, et tous les paquets NuGet avec elle.
RUN dotnet restore src/Boutique.Api/Boutique.Api.csproj
RUN dotnet publish src/Boutique.Api/Boutique.Api.csproj -c Release -o /app --no-restore

# L'image livree embarque le SDK, les sources et le cache NuGet.
WORKDIR /app
ENTRYPOINT ["dotnet", "Boutique.Api.dll"]

Un Dockerfile .NET qui n'est pas naïf

La correction tient en deux idées. D'abord, copier seuls les fichiers qui déterminent les paquets — les .csproj, et à côté d'eux Directory.Build.props, Directory.Packages.props ou NuGet.config s'ils existent — puis restaurer, puis seulement copier le code. dotnet restore n'a besoin de rien d'autre : il lit les références et écrit obj/project.assets.json. Ensuite, séparer l'image qui compile de celle qui s'exécute.

# syntax=docker/dockerfile:1
# Fichier src/Boutique.Api/Dockerfile, construit depuis la racine du depot :
#   docker build -f src/Boutique.Api/Dockerfile .

# --- Etape 1 : compiler, avec le SDK ---------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Tout ce qui decide des paquets, et rien d'autre. Ces fichiers changent
# rarement : la couche du restore reste en cache d'un commit a l'autre.
COPY Directory.Build.props Directory.Packages.props ./
COPY src/Boutique.Api/Boutique.Api.csproj src/Boutique.Api/
COPY src/Boutique.Domaine/Boutique.Domaine.csproj src/Boutique.Domaine/
RUN dotnet restore src/Boutique.Api/Boutique.Api.csproj

# Le code, qui change a chaque commit, n'invalide plus que ce qui le suit.
COPY src/ src/
RUN dotnet publish src/Boutique.Api/Boutique.Api.csproj \
    -c Release -o /app/publish --no-restore

# --- Etape 2 : executer, avec le seul runtime ASP.NET Core -----------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

# Utilisateur non root fourni par les images .NET depuis la version 8.
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Boutique.Api.dll"]

Chaque FROM ouvre une étape. Seule la dernière devient l'image livrée ; les précédentes ne servent qu'à fabriquer ce que COPY --from vient y prendre. Le SDK, les sources, les fichiers obj et le cache NuGet restent dans l'étape build, et l'image finale ne contient que le runtime ASP.NET Core et le résultat de dotnet publish. Le gain n'est pas seulement de taille : ce qui n'est pas dans l'image ne peut pas être exploité, et le code source n'en fait plus partie.

--no-restore sur publish n'est pas un détail : il garantit que la compilation utilise exactement la restauration mise en cache plus haut. Si un projet manque à la liste des .csproj copiés, dotnet restore se contente d'un avertissement — le projet est ignoré, car introuvable — et c'est publish qui échoue sur NETSDK1004, faute de project.assets.json. Sans l'option, il restaurerait en silence, dans une couche rejouée à chaque commit. La liste est d'ailleurs le prix de ce Dockerfile : un projet ajouté à la solution demande une ligne ici.

Un Dockerfile Angular

Un front Angular compilé n'est plus une application Node : c'est un dossier de fichiers statiques. Node sert à le produire, puis disparaît. La première étape installe les dépendances avec npm ci, qui suit le package-lock.json à la lettre, là où npm install peut le réécrire ; la seconde copie la sortie dans une image nginx.

# syntax=docker/dockerfile:1
# Fichier web/Dockerfile, construit depuis la racine du depot :
#   docker build -f web/Dockerfile .

# --- Etape 1 : construire, avec Node ----------------------------------------
FROM node:24-alpine AS build
WORKDIR /app

# Meme logique que le restore : les dependances avant le code.
COPY web/package.json web/package-lock.json ./
RUN npm ci

COPY web/ ./
RUN npm run build

# --- Etape 2 : servir, avec nginx -------------------------------------------
FROM nginx:stable-alpine
COPY web/nginx.conf /etc/nginx/conf.d/default.conf

# Le builder application d'Angular ecrit dans dist/<projet>/browser.
COPY --from=build /app/dist/web/browser /usr/share/nginx/html
EXPOSE 80

# Ni ENTRYPOINT ni CMD : ceux de l'image nginx lancent deja nginx
# au premier plan, avec l'arret propre sur SIGQUIT.
# Son processus maitre tourne en root ; pour un front non root,
# nginxinc/nginx-unprivileged ecoute sur 8080 (listen 8080 dans nginx.conf).

Le chemin de sortie est un piège de migration. Le builder application, par défaut depuis Angular 17, écrit dans dist/web/browser et non plus dans dist/web : un Dockerfile écrit pour l'ancien builder copie un dossier qui contient browser/ et sert un 403 à la racine du site.

Reste la configuration du serveur. Le routeur d'Angular gère des adresses comme /commandes/42 dans le navigateur, mais un rechargement de la page ou un lien ouvert depuis un courriel envoie cette adresse au serveur, qui ne possède aucun fichier de ce nom et répond 404. nginx doit donc renvoyer index.html pour toute route qu'il ne connaît pas, et laisser l'application trancher.

# web/nginx.conf, copie sur /etc/nginx/conf.d/default.conf
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # Les .js et .css produits par ng build portent un hash dans leur nom :
    # un contenu nouveau a un nom nouveau, on peut donc les garder un an.
    # Un fichier absent reste un 404 : il ne doit jamais devenir index.html.
    location ~* \.(?:js|css)$ {
        try_files $uri =404;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # index.html n'a pas de hash : c'est lui qui designe les fichiers du jour.
    location = /index.html {
        add_header Cache-Control "no-cache";
    }

    # Tout le reste : le fichier s'il existe, sinon une route Angular.
    location / {
        try_files $uri $uri/ /index.html;
    }
}

try_files essaie chaque candidat dans l'ordre et, si aucun n'existe, fait une redirection interne vers le dernier. La règle séparée pour les .js et .css évite un défaut sournois du repli : sans elle, un fichier de l'ancienne version demandé après un déploiement recevrait index.html avec un statut 200, et le navigateur refuserait d'exécuter du HTML comme un module JavaScript.

Taille et sécurité de l'image

L'image de base décide de presque tout. sdk contient le compilateur et n'a sa place que dans une étape de build ; aspnet et runtime ne contiennent que ce qui exécute ; les variantes -chiseled retirent en plus le shell et le gestionnaire de paquets. Moins de fichiers, c'est moins de vulnérabilités signalées par les scanners et moins d'outils à disposition d'un attaquant — au prix d'un docker exec ... sh devenu impossible. Depuis .NET 10, les étiquettes sans distribution explicite désignent Ubuntu 24.04, et Debian n'est plus publiée. En production, une étiquette flottante comme 10.0 se fige par un digest @sha256:... pour que deux builds partent de la même base.

Ce qui entre dans l'image dépend aussi de ce qui entre dans le contexte. Le builder a accès à tout le dossier désigné par le dernier argument de docker build ; les COPY y prennent ce qu'ils désignent, moins ce qu'exclut .dockerignore. Sans lui, COPY src/ src/ emporte les bin et obj de la machine hôte, qui écrasent la restauration faite dans l'image et invalident la couche à chaque compilation locale, et COPY web/ ./ recopie un node_modules installé pour un autre système.

# .dockerignore, a la racine du contexte (ici, la racine du depot).
# Les motifs partent de cette racine : "bin" seul ne viserait que ./bin.

# Sorties de compilation .NET, ou qu'elles soient
**/bin
**/obj
**/TestResults

# Angular et Node
**/node_modules
**/dist
**/.angular

# Outils et poste de travail
.git
.vs
.vscode
.idea
**/*.user

# Secrets et configuration locale : rien de tout cela n'entre dans une image
**/.env
**/appsettings.Development.json
**/*.pfx

# Les fichiers Docker eux-memes : les retoucher ne doit pas invalider COPY src/ src/
**/Dockerfile
**/.dockerignore
compose.yaml

Par défaut, le processus d'un conteneur tourne en root. Les espaces de noms l'isolent, mais une faille dans le noyau ou un volume monté trop largement lui donne alors les droits de root sur ce qu'il atteint. Les images .NET fournissent depuis la version 8 un utilisateur app, désigné par la variable APP_UID : USER $APP_UID suffit. Sur une base qui ne l'a pas, on crée un compte dédié.

# Une base generique, sans l'utilisateur app des images .NET, pour une application
# publiee en autonome : dotnet publish -c Release -r linux-x64 --self-contained -o publish
# Depuis la 23.04, l'image ubuntu contient un utilisateur ubuntu (UID 1000) ;
# on lui prefere un compte dedie, a l'UID choisi.
FROM ubuntu:24.04

# Les dependances natives de .NET que l'image de base n'a pas.
RUN apt-get update \
 && apt-get install -y --no-install-recommends libicu74 ca-certificates \
 && rm -rf /var/lib/apt/lists/*

# UID et GID fixes : un volume ou un orchestrateur raisonne en nombres.
RUN groupadd --gid 10001 boutique \
 && useradd --uid 10001 --gid boutique --no-create-home \
      --shell /usr/sbin/nologin boutique

WORKDIR /app
# Sans --chown, les fichiers copies appartiennent a root : l'application
# pourra les lire et les executer, pas les reecrire. C'est voulu.
COPY publish/ .

# Kestrel n'ecoute par defaut que http://localhost:5000, injoignable de
# l'exterieur ; 8080 parce qu'un port sous 1024 exige des privileges dans
# beaucoup d'environnements.
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080

# Tout ce qui suit, et le processus du conteneur, tournent sous cet utilisateur.
USER 10001:10001
ENTRYPOINT ["./Boutique.Api"]

Deux choix de ce fichier ne sont pas cosmétiques. USER reçoit un UID numérique et non un nom : Kubernetes, avec runAsNonRoot, ne sait vérifier qu'un nombre et refuse de démarrer une image dont l'utilisateur est un nom, sauf si le pod fixe lui-même runAsUser. Et les fichiers de l'application restent la propriété de root : un processus compromis ne peut pas réécrire son propre code. Seuls les dossiers où l'application doit écrire lui sont donnés, par un chown ciblé ou un volume.

Compose

Compose décrit une pile entière dans un fichier : les services, le réseau qui les relie, les volumes qui leur survivent. Tous les services d'un projet rejoignent un réseau commun où chacun est joignable par son nom : l'API parle à db:5432, le port du conteneur, et le port publié sur l'hôte ne sert qu'à ce qui vient de l'extérieur.

# compose.yaml, a la racine du depot. DB_PASSWORD vient du fichier .env
# voisin, que Compose lit de lui-meme et qui ne doit jamais etre commite.
name: boutique

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: boutique
      POSTGRES_DB: boutique
      POSTGRES_PASSWORD: ${DB_PASSWORD:?definir DB_PASSWORD dans .env}
    volumes:
      - donnees-db:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U boutique -d boutique"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  api:
    build:
      context: .
      dockerfile: src/Boutique.Api/Dockerfile
    environment:
      # "__" remplace ":" : la variable devient ConnectionStrings:Boutique.
      # "db" est le nom du service, resolu par le DNS du reseau de Compose.
      ConnectionStrings__Boutique: "Host=db;Database=boutique;Username=boutique;Password=${DB_PASSWORD}"
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:8080"
    stop_grace_period: 30s

  front:
    build:
      context: .
      dockerfile: web/Dockerfile
    depends_on:
      - api
    ports:
      - "4200:80"

volumes:
  donnees-db:

depends_on dans sa forme courte n'attend pas qu'un service soit prêt : il attend que son conteneur ait démarré. Une base PostgreSQL démarrée n'accepte pas encore de connexions, et une API qui migre son schéma au démarrage échoue ou réussit selon qui gagne la course. La forme longue avec condition: service_healthy attend que le healthcheck du service réussisse. La forme courte garde un usage : un docker compose up front démarre aussi api et db, dans l'ordre.

Un volume nommé vit indépendamment des conteneurs : docker compose down le conserve, seul down -v le supprime. Cela explique un piège de l'image PostgreSQL : POSTGRES_PASSWORD et les scripts d'initialisation ne s'appliquent qu'à un dossier de données vide. Changer le mot de passe dans .env ne change donc rien tant que le volume existe. Au passage, le chemin du volume dépend de la version : ce fichier suit PostgreSQL 18, dont l'image attend un montage sur /var/lib/postgresql et range ses données dans /var/lib/postgresql/18/docker ; jusqu'à la 17, elle l'attendait sur /var/lib/postgresql/data.

Les variables obéissent à deux mécanismes qu'il ne faut pas confondre. ${DB_PASSWORD} est une interpolation : Compose la remplace en lisant le fichier, à partir de l'environnement du shell puis du fichier .env voisin, et :? fait échouer la commande si la variable manque, plutôt que de démarrer une base au mot de passe vide. La section environment, elle, définit ce que le processus verra dans son conteneur ; une valeur présente dans .env n'y entre que si elle y est référencée.

# Construire depuis la racine du depot (le contexte), le Dockerfile etant ailleurs
docker build -t boutique-api:1.4.0 -f src/Boutique.Api/Dockerfile .

# Voir chaque etape en clair, et lesquelles sortent du cache (CACHED)
docker build --progress=plain -t boutique-api:dev -f src/Boutique.Api/Dockerfile .

# S'arreter a une etape nommee, pour inspecter l'etape de compilation
docker build --target build -t boutique-api:build -f src/Boutique.Api/Dockerfile .

# Les couches d'une image, avec l'instruction qui a produit chacune et sa taille
docker history boutique-api:1.4.0

# Lancer : --rm supprime le conteneur a l'arret, -p publie hote:conteneur
docker run --rm -d --name api -p 8080:8080 \
  -e ConnectionStrings__Boutique="Host=hote-db;Database=boutique" boutique-api:1.4.0
docker logs -f api
docker exec -it api sh       # un shell dans le conteneur (absent des images chiseled)
docker stop api              # SIGTERM, puis SIGKILL au bout de 10 s

# Toute la pile
docker compose up -d --build # reconstruit ce qui a change, puis demarre en arriere-plan
docker compose up --wait     # rend la main quand tout tourne, et est sain s'il a un healthcheck
docker compose ps
docker compose logs -f api
docker compose down          # supprime conteneurs et reseau ; le volume nomme reste
docker compose down -v       # ... et les volumes nommes : la base repart de zero

Ce qui se passe au démarrage

Un conteneur vit exactement aussi longtemps que son processus principal, celui qui porte le numéro 1 dans son espace de noms. ENTRYPOINT désigne ce processus, CMD ses arguments par défaut ; docker run image arg1 arg2 remplace CMD et laisse ENTRYPOINT en place. La combinaison n'a de sens qu'en forme exec, un tableau JSON lancé tel quel. En forme shell, Docker lance /bin/sh -c, ignore CMD et les arguments de la ligne de commande, et selon sa documentation, l'exécutable n'est alors plus le processus 1 et ne reçoit pas les signaux Unix.

# Un outil en ligne de commande : migrations, imports, taches ponctuelles.
# Publie hors de Docker : dotnet publish src/Boutique.Outils -c Release -o publish
FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY publish/ .
USER $APP_UID

# Forme exec : un tableau JSON, sans shell. dotnet est le processus 1 :
# le SIGTERM de docker stop lui arrive directement, s'il a un gestionnaire
# (hote generique ou PosixSignalRegistration, voir plus bas). Sinon, le
# noyau l'ignore et docker stop finit en SIGKILL.
ENTRYPOINT ["dotnet", "Boutique.Outils.dll"]

# Arguments par defaut, ajoutes apres l'ENTRYPOINT, et remplaces en bloc
# par tout ce qui suit le nom de l'image dans docker run.
CMD ["migrer", "--jusqua", "derniere"]

# docker run boutique-outils                  -> dotnet Boutique.Outils.dll migrer --jusqua derniere
# docker run boutique-outils importer a.csv   -> dotnet Boutique.Outils.dll importer a.csv
# docker run --entrypoint ls boutique-outils  -> ls : --entrypoint efface aussi le CMD

# Le piege, en forme shell :
#   ENTRYPOINT dotnet Boutique.Outils.dll
# s'execute comme /bin/sh -c "dotnet Boutique.Outils.dll" : le CMD et les
# arguments de docker run sont ignores, et dotnet n'est plus le processus 1.

docker stop envoie SIGTERM, attend dix secondes, puis envoie SIGKILL, qui ne se négocie pas : le conteneur sort avec le code 137. Le noyau ajoute une règle propre au processus 1 : il ne lui délivre que les signaux pour lesquels il a installé un gestionnaire. Un processus 1 sans gestionnaire de SIGTERM ne meurt donc pas, il ignore le signal, et chaque arrêt dure dix secondes avant de finir en coupure brutale. C'est le symptôme d'un script d'entrée qui lance l'application sans exec, ou d'un programme qui ne gère pas les signaux ; docker run --init, ou init: true dans Compose, intercale un petit processus 1 qui relaie les signaux et récupère les processus orphelins.

ASP.NET Core est du bon côté : l'hôte générique écoute SIGTERM, cesse d'accepter des connexions, laisse finir les requêtes en cours et arrête les services hébergés, ce que la section « Travail en arrière-plan et arrêt propre » du cours « Résilience et performance » détaille. Ce n'est plus vrai de toute application .NET : depuis .NET 10, le runtime n'installe plus de gestionnaire de SIGTERM par défaut, et un outil console qui n'utilise pas l'hôte générique doit en enregistrer un avec PosixSignalRegistration. Reste un écart de délais : HostOptions.ShutdownTimeout vaut trente secondes, Docker n'en accorde que dix. Une requête longue est coupée au milieu de l'arrêt que .NET croyait avoir le temps de mener. On aligne les deux, par docker stop -t 30 ou stop_grace_period dans Compose, ou en réduisant le délai de l'hôte.

Ce cours vous a servi ? Offrir un café Signaler une erreur