TP7 : Docker avancé et Docker Compose (multi-stage, non-root, PostgreSQL)

Vous savez déjà lancer un conteneur et écrire un Dockerfile simple (TP4, partie 4). Ce TP vous apprend à produire des images petites, reproductibles et sûres, puis à décrire une application complète (application + base de données + outil d’administration) avec Docker Compose.


Fichiers de départ : app-starter.zip

1. Objectifs pédagogiques

À l’issue du TP, vous serez capable de :

  • expliquer le rôle des couches et du cache dans un build Docker ;
  • écrire un Dockerfile multi-stage avec un .dockerignore, un utilisateur non-root et un HEALTHCHECK ;
  • mesurer et comparer la taille, le temps de build et le nombre de vulnérabilités de deux images ;
  • décrire une pile Compose avec réseaux, volume nommé, healthcheck, depends_on et fichier .env ;
  • construire une image multi-architecture (amd64 / arm64) avec Buildx.

Les images de cette page sont des schémas explicatifs, pas des captures d’écran.

2. Environnement du TP

  • Une VM Ubuntu Server 26.04 (la VM de lab des TP4–TP6 convient) avec Docker Engine et le plugin Compose :

    docker version
    docker compose version
    docker buildx version

    Si docker compose est absent : sudo apt install -y docker-compose-v2 (ou installez Docker depuis le dépôt officiel download.docker.com).

  • Node.js 24 et npm sur la VM (uniquement pour générer package-lock.json).

  • Copier le dossier app-starter/ sur la VM. Il contient une petite API TypeScript qui compte les visites dans PostgreSQL.

Fichier fourni Rôle
src/server.ts API Express : GET / (compteur) et GET /health
Dockerfile.naive Dockerfile « naïf » à ne pas reproduire (point de comparaison)
Dockerfile Squelette multi-stage avec des TODO
compose.yaml Squelette Compose avec des TODO
.env.example Variables attendues, sans vraie valeur

3. Partie 1 : Couches et cache

Notion Explication
Couche Chaque instruction RUN, COPY, ADD produit une couche en lecture seule, empilée sur la précédente.
Cache Si une instruction et ses fichiers d’entrée n’ont pas changé, Docker réutilise la couche existante.
Invalidation Dès qu’une couche change, toutes les suivantes sont reconstruites.
Contexte Le dossier envoyé au démon au début du build. Sans .dockerignore, il inclut node_modules/, .git/, .env…
  • Générer le fichier de verrouillage des dépendances (nécessaire à npm ci) :

    cd app-starter
    npm install
  • Construire l’image naïve en mesurant le temps :

    time docker build -f Dockerfile.naive -t visits-app:naive .
  • Modifier une ligne de src/server.ts (le message par exemple) et reconstruire. Observer que npm install est réexécuté : pourquoi ?

  • Afficher les couches et leur taille :

    docker history visits-app:naive
    docker image ls visits-app

4. Partie 2 : Mesurer l’image naïve

  • Relever les trois indicateurs de l’image naïve. Pour les vulnérabilités, utiliser Trivy sous forme de conteneur (aucune installation nécessaire) :

    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
      aquasec/trivy:latest image --severity HIGH,CRITICAL --quiet visits-app:naive
  • Vérifier sous quel utilisateur tourne le conteneur :

    docker run --rm visits-app:naive id

    Un processus root dans un conteneur est root sur le noyau de l’hôte si une faille permet de s’échapper du conteneur.

5. Partie 3 : Dockerfile multi-stage

Build multi-stage : seules les sorties utiles passent dans l’image finale Build multi-stage : seules les sorties utiles passent dans l’image finale

  • Créer un fichier .dockerignore qui exclut au minimum : node_modules, dist, .git, .env, Dockerfile*, compose.yaml, *.md.

  • Compléter les TODO du Dockerfile :

    • étape deps : copier uniquement package*.json puis npm ci ;
    • étape build : récupérer node_modules, copier les sources, compiler, puis npm prune --omit=dev ;
    • étape runtime : copier dist/, node_modules/ et package.json avec --chown=node:node, puis USER node ;
    • ajouter un HEALTHCHECK (--interval=10s --timeout=3s --retries=3) qui appelle /health.
  • Construire et comparer :

    time docker build -t visits-app:multistage .
    docker image ls visits-app
    docker run --rm visits-app:multistage id      # doit afficher uid=1000(node)
  • Modifier src/server.ts et reconstruire : l’étape npm ci doit maintenant venir du cache (CACHED).

  • Remplir le tableau comparatif (à reprendre dans le README.md de votre rendu) :

Indicateur naive multistage
Taille de l’image
Temps de build (à froid)
Temps de build (code modifié)
Vulnérabilités HIGH/CRITICAL
Utilisateur

6. Partie 4 : Pile Docker Compose

Pile Compose : app, PostgreSQL et Adminer sur deux réseaux, données dans un volume nommé Pile Compose : app, PostgreSQL et Adminer sur deux réseaux, données dans un volume nommé

  • Créer le fichier .env à partir de .env.example et choisir un mot de passe. Vérifier que .env est bien ignoré par Git.

  • Compléter les TODO de compose.yaml :

    • db : volume nommé pgdata, healthcheck avec pg_isready -U $${POSTGRES_USER}, aucun port publié, réseau backend ;
    • app : variables PGUSER, PGPASSWORD, PGDATABASE issues du .env, depends_on avec condition: service_healthy, réseaux frontend et backend ;
    • adminer : réseau backend.
  • Valider puis démarrer la pile :

    docker compose config          # affiche le fichier résolu (variables remplacées)
    docker compose up -d --build
    docker compose ps              # db doit être "healthy" avant que app démarre
    curl http://localhost:3000/
    curl http://localhost:3000/health
  • Ouvrir Adminer (http://<IP-VM>:8080, serveur db) et consulter la table visits.

  • Prouver la persistance : docker compose down puis docker compose up -d. Le compteur doit reprendre là où il s’était arrêté. Que fait docker compose down -v ?

  • Prouver l’isolation réseau : depuis la VM, psql -h localhost doit échouer (port non publié), alors que l’application joint db.

  • Mettre app à l’échelle : docker compose up -d --scale app=2. Quel problème apparaît avec ports: "3000:3000" ? Proposez une solution (défi plus bas).

7. Partie 5 : Buildx et images multi-architecture

Terme Explication
BuildKit Moteur de build par défaut de Docker : étapes parallèles, cache avancé, secrets de build.
Buildx Interface CLI de BuildKit : builders, plateformes multiples, export du cache.
Manifest list Index qui associe une même référence d’image à plusieurs architectures.
  • Activer l’émulation des autres architectures et créer un builder :

    docker run --privileged --rm tonistiigi/binfmt --install arm64
    docker buildx create --name multiarch --use
    docker buildx inspect --bootstrap
  • Construire et pousser une image amd64 + arm64 sur votre compte Docker Hub :

    docker login
    docker buildx build --platform linux/amd64,linux/arm64 \
      -t <utilisateur>/visits-app:1.0.0 --push .
    docker buildx imagetools inspect <utilisateur>/visits-app:1.0.0

Défi

  1. Ajouter un service Nginx reverse proxy devant app (seul port publié : 80) et lancer app avec --scale app=3. Vérifier avec curl que le champ host change d’une requête à l’autre.
  2. Utiliser un secret de build BuildKit (RUN --mount=type=secret,id=npmrc …) et vérifier avec docker history que le secret n’apparaît dans aucune couche.

8. Livrables

Captures à déposer dans captures/ :

Fichier Contenu attendu
image-comparison.png docker image ls visits-app montrant naive et multistage
non-root.png docker run --rm visits-app:multistage id
compose-ps.png docker compose ps avec db en état healthy
persistence.png Compteur de visites conservé après down / up
buildx-manifest.png docker buildx imagetools inspect listant amd64 et arm64
reverse-proxy.png (défi) réponses venant de trois conteneurs différents

Ainsi que : Dockerfile, .dockerignore, compose.yaml, .env.example (pas .env) et le tableau comparatif rempli.