TP Docker – Des premiers conteneurs à Docker Compose

Niveau : débutant, aucun prérequis Docker Durée estimée : 4 h à 5 h Prérequis : savoir ouvrir un terminal, connaître les commandes de base (cd, ls, cat), avoir Docker Engine ou Docker Desktop installé.

Objectifs pédagogiques

À la fin de ce TP, vous devez être capable de :

  1. Expliquer la différence entre une image et un conteneur, et entre un conteneur et une machine virtuelle.
  2. Lancer, arrêter, inspecter et supprimer des conteneurs.
  3. Publier un port et rendre un service accessible depuis votre machine.
  4. Persister des données avec un bind mount et avec un volume nommé.
  5. Configurer un conteneur par variables d'environnement.
  6. Écrire un Dockerfile, construire une image et comprendre le cache de build.
  7. Faire communiquer plusieurs conteneurs sur un réseau Docker.
  8. Décrire une application multi-services dans un fichier compose.yaml.

Comment travailler

Chaque module suit la même structure :

  • Concept : ce qu'il faut comprendre avant de taper des commandes.
  • Manipulation : les commandes à exécuter, dans l'ordre.
  • Questions : à rédiger dans un fichier reponses.md que vous rendrez.
  • Point de contrôle : ce qui doit fonctionner avant de passer au module suivant.

Créez d'abord votre répertoire de travail :

mkdir -p ~/tp-docker && cd ~/tp-docker
touch reponses.md

Les réponses attendues sont en annexe B. Ne les consultez qu'après avoir cherché.


Module 0 – Vérifier l'environnement

Manipulation

docker version
docker info
docker run --rm hello-world

Si docker version affiche uniquement la partie Client avec une erreur de connexion, le démon n'est pas démarré ou votre utilisateur n'a pas les droits.

Sous Linux :

sudo systemctl status docker
sudo systemctl start docker
sudo usermod -aG docker $USER   # puis se déconnecter / reconnecter

Questions

  • Q0.1 Quelle version du client et quelle version du serveur (démon) avez-vous ?
  • Q0.2 Dans la sortie de docker info, relevez le Storage Driver et le nombre d'images présentes.

Point de contrôle

docker run --rm hello-world affiche le message de bienvenue.


Module 1 – Les concepts de base

Concept

Docker manipule quatre objets que vous allez retrouver en permanence.

L'image est un modèle en lecture seule. Elle contient un système de fichiers (les binaires, les bibliothèques, votre code) et des métadonnées (la commande à lancer, les variables d'environnement par défaut, les ports déclarés). Une image ne s'exécute pas, elle sert de patron.

Le conteneur est une instance en cours d'exécution d'une image. Docker ajoute au-dessus de l'image une couche en écriture, propre au conteneur. À partir d'une même image on peut lancer autant de conteneurs que l'on veut, ils sont indépendants les uns des autres.

Le registry est le dépôt d'images. Docker Hub est le registry public par défaut. Quand vous écrivez nginx:1.27-alpine, vous demandez le dépôt nginx avec le tag 1.27-alpine sur Docker Hub.

Les couches (layers) : une image est un empilement de couches, chacune correspondant à une étape de construction. Les couches sont partagées entre images, ce qui explique qu'une deuxième image basée sur debian:12 se télécharge beaucoup plus vite que la première.

Différence avec une machine virtuelle : une VM embarque un noyau complet et un système d'exploitation entier, gérés par un hyperviseur. Un conteneur partage le noyau de la machine hôte et n'isole que l'espace utilisateur, à l'aide de deux mécanismes du noyau Linux : les namespaces (isolation de la vue : processus, réseau, points de montage, nom de machine) et les cgroups (limitation des ressources : CPU, mémoire). Un conteneur démarre donc en quelques dizaines de millisecondes et pèse quelques dizaines de mégaoctets, contre plusieurs secondes et plusieurs gigaoctets pour une VM.

Conséquence importante : un conteneur Linux a besoin d'un noyau Linux. Sur Windows et macOS, Docker Desktop fait tourner une petite VM Linux en arrière-plan, et vos conteneurs s'exécutent dedans.

Manipulation

docker image ls
docker pull debian:12
docker pull nginx:1.27-alpine
docker image ls
docker image inspect nginx:1.27-alpine | head -n 40

Questions

  • Q1.1 Quelle est la taille de l'image debian:12 ? Et celle de nginx:1.27-alpine ? Comment expliquez-vous l'écart avec une VM Debian classique ?
  • Q1.2 Que se passe-t-il si vous relancez docker pull debian:12 ? Pourquoi ?
  • Q1.3 Reformulez en une phrase la différence entre nginx (le dépôt), 1.27-alpine (le tag) et l'identifiant sha256:... (le digest).

Module 2 – Premier conteneur, cycle de vie

Concept

docker run enchaîne trois opérations : télécharger l'image si elle est absente, créer un conteneur à partir de cette image, puis le démarrer. Un conteneur vit tant que son processus principal (PID 1) tourne. Quand ce processus se termine, le conteneur s'arrête. Il n'est pas supprimé pour autant : il reste en état Exited et occupe de l'espace disque jusqu'à ce que vous le supprimiez.

Manipulation

# Premier plan : la sortie du conteneur s'affiche dans votre terminal
docker run debian:12 echo "bonjour depuis un conteneur"

# Le conteneur existe encore, à l'arrêt
docker ps
docker ps -a

# Arrière-plan (detached) avec un nom explicite
docker run -d --name veilleur debian:12 sleep 300

docker ps
docker logs veilleur
docker inspect veilleur | head -n 30
docker stats --no-stream veilleur

# Cycle de vie
docker stop veilleur
docker ps -a
docker start veilleur
docker restart veilleur
docker stop veilleur
docker rm veilleur

Testez maintenant la suppression automatique :

docker run --rm debian:12 echo "je disparais tout de suite"
docker ps -a

Questions

  • Q2.1 Pourquoi docker ps ne montre pas le conteneur créé par docker run debian:12 echo ..., alors que docker ps -a le montre ?
  • Q2.2 À quoi sert l'option --rm ? Dans quels cas est-elle une mauvaise idée ?
  • Q2.3 Lancez docker run -d --name test debian:12. Le conteneur s'arrête immédiatement. Expliquez pourquoi (indice : quelle est la commande par défaut de l'image debian ?).
  • Q2.4 Quelle est la différence entre docker stop et docker kill ? Regardez la documentation sur les signaux SIGTERM et SIGKILL.

Point de contrôle

docker ps -a ne doit plus contenir aucun conteneur du module. Nettoyez avec docker rm <nom>.


Module 3 – Entrer dans un conteneur et observer l'isolation

Concept

Deux options changent tout pour travailler en interactif :

  • -i (--interactive) garde l'entrée standard ouverte,
  • -t (--tty) alloue un pseudo-terminal.

On les combine presque toujours en -it.

Attention à ne pas confondre :

  • docker run -it <image> <commande> crée un nouveau conteneur,
  • docker exec -it <conteneur> <commande> exécute une commande dans un conteneur déjà démarré.

Manipulation

docker run -it --name bac-a-sable debian:12 bash

Vous êtes maintenant dans le conteneur. Exécutez :

hostname
ps aux
ls /
cat /etc/os-release
ip addr || (apt-get update && apt-get install -y iproute2 && ip addr)
touch /fichier-temoin
echo "modification" > /root/note.txt
exit

De retour sur l'hôte :

docker start bac-a-sable
docker exec -it bac-a-sable bash
ls /fichier-temoin && cat /root/note.txt
exit

docker rm -f bac-a-sable
docker run -it --rm debian:12 bash -c "ls /fichier-temoin"

Questions

  • Q3.1 Combien de processus voyez-vous avec ps aux dans le conteneur ? Comparez avec ps aux | wc -l sur votre machine. Quel namespace explique cette différence ?
  • Q3.2 Le fichier /fichier-temoin existe-t-il après docker start + docker exec ? Existe-t-il dans un nouveau conteneur créé depuis la même image ? Qu'en déduisez-vous sur la couche d'écriture du conteneur ?
  • Q3.3 Si vous supprimez le conteneur, que deviennent les données écrites dedans ?
  • Q3.4 Le hostname du conteneur correspond à quoi ?

Module 4 – Publier un service sur un port

Concept

Chaque conteneur reçoit sa propre pile réseau et sa propre adresse IP sur un réseau interne. Cette adresse n'est pas routable depuis l'extérieur de la machine hôte. Pour rendre un service accessible, on publie un port avec -p <port_hote>:<port_conteneur>.

L'instruction EXPOSE présente dans une image est purement documentaire : elle indique quel port le service écoute, elle n'ouvre rien.

Manipulation

docker run -d --name web -p 8080:80 nginx:1.27-alpine

docker ps
curl -I http://localhost:8080
docker logs web
docker logs -f web    # Ctrl+C pour quitter le suivi

Ouvrez http://localhost:8080 dans un navigateur, rechargez plusieurs fois, puis regardez les logs.

Testez un second conteneur sur le même port hôte :

docker run -d --name web2 -p 8080:80 nginx:1.27-alpine

Puis corrigez :

docker rm web2
docker run -d --name web2 -p 8081:80 nginx:1.27-alpine
curl -I http://localhost:8081

Questions

  • Q4.1 Quelle erreur exacte obtenez-vous en publiant deux fois le port hôte 8080 ? Pourquoi les deux conteneurs peuvent-ils en revanche écouter tous les deux sur le port 80 en interne ?
  • Q4.2 Que donne docker port web ?
  • Q4.3 Que se passe-t-il avec docker run -d --name web3 nginx:1.27-alpine (sans -p) puis curl http://localhost:80 ? Le serveur tourne-t-il pour autant ?
  • Q4.4 Quelle différence entre -p 8080:80 et -p 127.0.0.1:8080:80 ? Laquelle choisiriez-vous sur un serveur exposé à Internet ?

Point de contrôle

docker rm -f web web2 web3 2>/dev/null

Module 5 – Persister les données

Concept

La couche d'écriture d'un conteneur disparaît avec lui. Docker propose deux mécanismes pour sortir les données du cycle de vie du conteneur.

Le bind mount monte un répertoire de votre machine dans le conteneur : -v /chemin/hote:/chemin/conteneur. Le contenu de l'hôte masque celui du conteneur. C'est l'outil du développement, pour éditer du code sans reconstruire l'image.

Le volume nommé est un espace géré par Docker (sous /var/lib/docker/volumes/ sous Linux) : -v mon-volume:/chemin/conteneur. Vous ne gérez pas le chemin hôte, Docker s'en charge. C'est l'outil de la production, pour les données de bases de données notamment.

Manipulation, partie A : bind mount

mkdir -p ~/tp-docker/site
cat > ~/tp-docker/site/index.html <<'EOF'
<!doctype html>
<html lang="fr">
  <head><meta charset="utf-8"><title>TP Docker</title></head>
  <body><h1>Version 1</h1></body>
</html>
EOF

docker run -d --name site \
  -p 8080:80 \
  -v ~/tp-docker/site:/usr/share/nginx/html:ro \
  nginx:1.27-alpine

curl http://localhost:8080

Modifiez le fichier sur l'hôte, sans toucher au conteneur :

sed -i 's/Version 1/Version 2/' ~/tp-docker/site/index.html
curl http://localhost:8080

Testez l'option :ro (lecture seule) :

docker exec -it site sh -c "echo test > /usr/share/nginx/html/test.html"

Manipulation, partie B : volume nommé

docker volume create donnees
docker volume ls
docker volume inspect donnees

docker run --rm -v donnees:/data debian:12 sh -c "echo 'écrit par le conteneur A' > /data/message.txt"
docker run --rm -v donnees:/data debian:12 cat /data/message.txt

Le premier conteneur a été supprimé, la donnée est toujours là.

Questions

  • Q5.1 Quelle erreur obtenez-vous en écrivant dans un montage :ro ?
  • Q5.2 Où se trouve physiquement le volume donnees sur l'hôte (utilisez docker volume inspect) ?
  • Q5.3 Citez deux cas où un bind mount est préférable, et deux cas où un volume nommé est préférable.
  • Q5.4 Que se passe-t-il si vous montez un volume vide sur un répertoire du conteneur qui contenait déjà des fichiers ? Et avec un bind mount vide ? Testez avec -v vide:/etc/nginx sur l'image nginx.

Point de contrôle

docker rm -f site

Module 6 – Configurer par variables d'environnement

Concept

Une image doit être générique et une configuration doit être injectée au démarrage, pas cuite dans l'image. Le vecteur standard est la variable d'environnement, avec -e CLE=valeur ou --env-file fichier.

C'est ainsi que se configurent la plupart des images officielles : mots de passe, noms de bases, options de démarrage.

Manipulation

docker run -d --name postgres \
  -e POSTGRES_USER=tp \
  -e POSTGRES_PASSWORD=motdepasse \
  -e POSTGRES_DB=tpdb \
  -v pgdata:/var/lib/postgresql/data \
  -p 5432:5432 \
  postgres:16-alpine

docker logs postgres | tail -n 20
docker exec -it postgres psql -U tp -d tpdb

Dans psql :

CREATE TABLE visites (id serial PRIMARY KEY, vu_le timestamptz DEFAULT now());
INSERT INTO visites DEFAULT VALUES;
INSERT INTO visites DEFAULT VALUES;
SELECT * FROM visites;
\q

Détruisez le conteneur, recréez-le avec le même volume :

docker rm -f postgres

docker run -d --name postgres \
  -e POSTGRES_USER=tp \
  -e POSTGRES_PASSWORD=motdepasse \
  -e POSTGRES_DB=tpdb \
  -v pgdata:/var/lib/postgresql/data \
  -p 5432:5432 \
  postgres:16-alpine

docker exec -it postgres psql -U tp -d tpdb -c "SELECT count(*) FROM visites;"

Regardez aussi les variables vues par le conteneur :

docker exec postgres env

Questions

  • Q6.1 Vos deux lignes sont-elles toujours présentes après suppression et recréation du conteneur ? Pourquoi ?
  • Q6.2 Que se passe-t-il si vous omettez POSTGRES_PASSWORD ? Lisez le message d'erreur dans docker logs.
  • Q6.3 Pourquoi est-il déconseillé d'écrire un mot de passe directement dans un docker run ou dans un Dockerfile ? Quelles alternatives connaissez-vous ?
  • Q6.4 Que trouve-t-on dans la sortie de docker exec postgres env en plus de vos variables ? D'où viennent les autres ?

Point de contrôle

Gardez le conteneur postgres en marche, il resservira au module 9.


Module 7 – Construire sa propre image

Concept

Un Dockerfile est une recette. Chaque instruction produit une couche. Les principales :

Instruction Rôle
FROM image de base
WORKDIR répertoire de travail des instructions suivantes
COPY copie des fichiers du contexte de build vers l'image
RUN exécute une commande au moment du build
ENV variable d'environnement par défaut
EXPOSE documente le port écouté
CMD commande par défaut au démarrage du conteneur
ENTRYPOINT exécutable fixe, CMD en devient les arguments

Le contexte de build est le répertoire passé en dernier argument de docker build (souvent .). Il est envoyé au démon avant la construction, d'où l'intérêt du fichier .dockerignore.

Le cache : Docker réutilise une couche si l'instruction et son entrée n'ont pas changé. Dès qu'une couche est invalidée, toutes les suivantes le sont. D'où la règle : ce qui change rarement se place en haut du Dockerfile, ce qui change souvent en bas.

Manipulation

mkdir -p ~/tp-docker/app && cd ~/tp-docker/app

app.py :

import os
from flask import Flask

app = Flask(__name__)
MESSAGE = os.environ.get("MESSAGE", "Bonjour depuis mon image")

@app.route("/")
def index():
    return f"{MESSAGE}\n"

@app.route("/sante")
def sante():
    return {"statut": "ok"}

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

requirements.txt :

flask==3.0.3

Dockerfile :

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

ENV MESSAGE="Bonjour depuis mon image"
EXPOSE 8000

CMD ["python", "app.py"]

.dockerignore :

.git
__pycache__
*.pyc
.venv
reponses.md

Construisez et lancez :

docker build -t monapp:1.0 .
docker image ls monapp
docker history monapp:1.0

docker run -d --name monapp -p 8000:8000 monapp:1.0
curl http://localhost:8000
curl http://localhost:8000/sante

docker run -d --name monapp-fr -p 8001:8000 -e MESSAGE="Message surchargé" monapp:1.0
curl http://localhost:8001

Observez le cache. Modifiez uniquement app.py :

sed -i 's/Bonjour depuis mon image/Bonjour version 2/' app.py
docker build -t monapp:2.0 .

Puis inversez volontairement l'ordre des instructions (mettez COPY . . avant le RUN pip install) et reconstruisez pour comparer.

Questions

  • Q7.1 Combien d'étapes sont marquées CACHED lors du second build ? Lesquelles et pourquoi ?
  • Q7.2 Pourquoi copie-t-on requirements.txt seul avant de copier le code applicatif ?
  • Q7.3 Quelle est la différence entre RUN et CMD ? Et entre CMD et ENTRYPOINT ?
  • Q7.4 À quoi sert --no-cache-dir dans le pip install ? Quel est l'effet sur la taille de l'image ?
  • Q7.5 Comparez la taille de monapp:1.0 avec celle d'une image basée sur python:3.12 (sans -slim). Refaites le build pour mesurer.
  • Q7.6 Que contient docker history monapp:1.0 ? Quelle instruction pèse le plus lourd ?

Point de contrôle

curl http://localhost:8000 renvoie votre message, curl http://localhost:8001 renvoie le message surchargé.


Module 8 – Les réseaux Docker

Concept

Docker crée par défaut trois réseaux : bridge (celui utilisé quand vous ne précisez rien), host et none.

Point clé pour la suite : sur le réseau bridge par défaut, les conteneurs ne se résolvent pas par leur nom. Sur un réseau défini par l'utilisateur, Docker fournit un DNS interne, et le nom du conteneur devient un nom d'hôte utilisable. C'est la raison pour laquelle on crée toujours un réseau dédié à une application.

Manipulation

docker network ls
docker network create tp-net
docker network inspect tp-net

# Un serveur et un client sur le réseau dédié
docker run -d --name api --network tp-net monapp:1.0
docker run --rm -it --network tp-net curlimages/curl:8.10.1 curl http://api:8000/

# Comparaison sur le bridge par défaut
docker run -d --name api-defaut monapp:1.0
docker run --rm -it curlimages/curl:8.10.1 curl --max-time 5 http://api-defaut:8000/

Connectez le conteneur postgres du module 6 au réseau :

docker network connect tp-net postgres
docker run --rm -it --network tp-net postgres:16-alpine \
  psql "postgresql://tp:motdepasse@postgres:5432/tpdb" -c "SELECT count(*) FROM visites;"

Questions

  • Q8.1 Le curl http://api:8000/ fonctionne-t-il sur tp-net ? Et sur le bridge par défaut avec api-defaut ? Expliquez.
  • Q8.2 Le conteneur api a été lancé sans -p. Pourquoi est-il quand même joignable depuis l'autre conteneur ?
  • Q8.3 Sur tp-net, quel port utilise-t-on pour joindre l'API : le port hôte ou le port du conteneur ?
  • Q8.4 Quel est l'intérêt, en production, de ne pas publier le port d'une base de données sur l'hôte ?

Point de contrôle

docker rm -f api api-defaut monapp monapp-fr postgres

Module 9 – Docker Compose

Concept

Reproduire à la main une pile de trois conteneurs avec leurs réseaux, volumes et variables devient vite ingérable. Compose décrit l'ensemble dans un fichier YAML versionnable, et pilote le tout avec docker compose up / docker compose down.

Compose crée automatiquement un réseau dédié au projet. Les services s'y résolvent par leur nom de service.

Manipulation

mkdir -p ~/tp-docker/compose && cd ~/tp-docker/compose
mkdir -p app

app/requirements.txt :

flask==3.0.3
psycopg[binary]==3.2.3

app/app.py :

import os
import time
import psycopg
from flask import Flask

app = Flask(__name__)
DSN = os.environ["DATABASE_URL"]

def connexion():
    derniere = None
    for _ in range(15):
        try:
            return psycopg.connect(DSN)
        except psycopg.OperationalError as exc:
            derniere = exc
            time.sleep(2)
    raise derniere

def init():
    with connexion() as conn:
        conn.execute(
            "CREATE TABLE IF NOT EXISTS compteur ("
            "id int PRIMARY KEY, valeur int NOT NULL)"
        )
        conn.execute(
            "INSERT INTO compteur (id, valeur) VALUES (1, 0) "
            "ON CONFLICT (id) DO NOTHING"
        )
        conn.commit()

@app.route("/")
def index():
    with connexion() as conn:
        cur = conn.execute(
            "UPDATE compteur SET valeur = valeur + 1 WHERE id = 1 RETURNING valeur"
        )
        valeur = cur.fetchone()[0]
        conn.commit()
    return f"Nombre de visites : {valeur}\n"

init()

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

app/Dockerfile :

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 8000
CMD ["python", "app.py"]

compose.yaml :

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: tp
      POSTGRES_PASSWORD: motdepasse
      POSTGRES_DB: tpdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tp -d tpdb"]
      interval: 5s
      timeout: 3s
      retries: 10

  web:
    build: ./app
    environment:
      DATABASE_URL: postgresql://tp:motdepasse@db:5432/tpdb
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

Lancez la pile :

docker compose up -d --build
docker compose ps
docker compose logs -f web     # Ctrl+C pour quitter

curl http://localhost:8000
curl http://localhost:8000
curl http://localhost:8000

docker compose exec db psql -U tp -d tpdb -c "SELECT * FROM compteur;"

Testez la persistance :

docker compose down
docker compose up -d
curl http://localhost:8000     # le compteur repart-il de zéro ?

docker compose down -v
docker compose up -d
curl http://localhost:8000

Questions

  • Q9.1 Quel nom porte le réseau créé par Compose ? Utilisez docker network ls. D'où vient ce nom ?
  • Q9.2 Dans DATABASE_URL, l'hôte est db. À quoi correspond ce nom ?
  • Q9.3 Quelle différence entre docker compose down et docker compose down -v ? Constatez l'effet sur le compteur.
  • Q9.4 À quoi sert le healthcheck combiné à depends_on: condition: service_healthy ? Que se passerait-il sans, au premier démarrage ?
  • Q9.5 Le service db n'a pas de section ports. Est-il joignable depuis votre machine ? Depuis web ? Pourquoi ?
  • Q9.6 Quelle est la différence entre image: et build: dans un service Compose ?

Point de contrôle

curl http://localhost:8000 incrémente le compteur à chaque appel, et le compteur survit à un docker compose down suivi d'un up.


Module 10 – Nettoyage et bonnes pratiques

Concept

Les images, conteneurs arrêtés, volumes orphelins et caches de build s'accumulent. Sur un poste de développement, plusieurs dizaines de gigaoctets sont vite atteints.

Manipulation

docker system df
docker system df -v

docker container prune
docker image prune          # images "dangling" seulement
docker image prune -a       # toutes les images non utilisées, prudence
docker builder prune
docker volume ls -f dangling=true
docker volume prune

docker system prune -a --volumes   # à ne jamais lancer sans savoir ce que l'on supprime

Bonnes pratiques à retenir

  1. Toujours épingler une version d'image (postgres:16-alpine, pas postgres:latest).
  2. Ne jamais stocker de secret dans une image ni dans un Dockerfile.
  3. Un conteneur, un rôle. Ne pas empiler application et base dans le même conteneur.
  4. Écrire les logs sur la sortie standard, pas dans un fichier interne au conteneur.
  5. Traiter les conteneurs comme jetables. Toute donnée à conserver va dans un volume.
  6. Ordonner le Dockerfile du plus stable au plus volatil pour exploiter le cache.
  7. Utiliser un .dockerignore pour réduire le contexte de build.
  8. Ne pas exécuter le processus en root dans l'image finale.

Questions

  • Q10.1 Combien d'espace récupérez-vous avec docker system df avant et après nettoyage ?
  • Q10.2 Que signifie une image « dangling » ?
  • Q10.3 Pourquoi docker volume prune est-il la commande la plus risquée de la liste ?

Module 11 – Pour aller plus loin (optionnel)

A. Build multi-étapes

Objectif : ne livrer que le nécessaire dans l'image finale. Adaptez ce modèle à l'application du module 9.

FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt

FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PYTHONPATH=/deps
COPY --from=build /deps /deps
COPY app.py .
RUN useradd --create-home --uid 10001 appuser
USER appuser
EXPOSE 8000
CMD ["python", "app.py"]
  • Q11.1 Quelle taille fait l'image multi-étapes par rapport à l'image du module 9 ?
  • Q11.2 Vérifiez avec docker exec <conteneur> id que le processus ne tourne pas en root.

B. Limiter les ressources

docker run -d --name limite --memory=256m --cpus=0.5 monapp:1.0
docker stats --no-stream limite
  • Q11.3 Quel mécanisme du noyau applique ces limites ?

C. Publier une image

docker login
docker tag monapp:1.0 <votre-compte>/monapp:1.0
docker push <votre-compte>/monapp:1.0

Module 12 – Mini-projet de synthèse

À rendre dans un dossier projet/ contenant tout le nécessaire pour qu'un tiers lance l'application avec une seule commande.

Cahier des charges

Réalisez une petite application de prise de notes composée de trois services :

  1. api : une application Python (Flask ou FastAPI) que vous construisez avec votre propre Dockerfile. Elle expose :
    • POST /notes avec un corps JSON {"texte": "..."} qui enregistre une note,
    • GET /notes qui renvoie la liste des notes,
    • GET /sante qui renvoie {"statut": "ok"}.
  2. db : PostgreSQL, avec un volume nommé pour les données, aucun port publié sur l'hôte.
  3. proxy : nginx qui écoute sur le port 8080 de l'hôte et transmet les requêtes à api.

Contraintes

  • Toute la pile démarre avec docker compose up -d.
  • La configuration de la base passe par des variables d'environnement, lues depuis un fichier .env non versionné. Un .env.example est fourni.
  • Les données survivent à docker compose down puis up.
  • L'image de l'API est construite en multi-étapes et le processus ne tourne pas en root.
  • Un healthcheck est défini sur db et sur api.
  • Un fichier README.md explique le lancement, l'arrêt, la consultation des logs et la remise à zéro.

Grille d'évaluation indicative

Critère Points
La pile démarre en une commande 4
Persistance des données vérifiée 3
Dockerfile correct et cache exploité 3
Aucun secret en dur, .env utilisé 3
Réseau : db non exposé, résolution par nom de service 3
Healthchecks fonctionnels 2
Utilisateur non root, image allégée 2
README clair 2
Réponses aux questions des modules 0 à 10 8

Annexe A – Aide-mémoire

# Images
docker image ls                       # lister
docker pull <image>:<tag>             # télécharger
docker build -t <nom>:<tag> .         # construire
docker image rm <image>               # supprimer
docker history <image>                # couches

# Conteneurs
docker run -d --name <nom> -p 8080:80 <image>
docker ps / docker ps -a
docker logs -f <conteneur>
docker exec -it <conteneur> sh
docker inspect <conteneur>
docker stop / start / restart / rm <conteneur>
docker rm -f <conteneur>              # forcer
docker stats

# Volumes
docker volume create <nom>
docker volume ls / inspect / rm

# Réseaux
docker network create <nom>
docker network ls / inspect
docker network connect <reseau> <conteneur>

# Compose
docker compose up -d --build
docker compose ps
docker compose logs -f <service>
docker compose exec <service> sh
docker compose down [-v]

# Nettoyage
docker system df
docker system prune -a --volumes

Annexe B – Réponses attendues

Q0.2 Le storage driver est généralement overlay2 sous Linux.

Q1.1 debian:12 fait environ 120 Mo, nginx:1.27-alpine environ 50 Mo. Une image ne contient pas de noyau ni les services système d'un OS complet, seulement l'espace utilisateur nécessaire.

Q1.2 Rien n'est retéléchargé, les couches sont déjà présentes localement.

Q1.3 Le dépôt identifie le projet, le tag est une étiquette mobile pointant vers une version, le digest est l'empreinte immuable du contenu exact de l'image.

Q2.1 Le conteneur s'est arrêté dès la fin de echo. docker ps ne montre que les conteneurs en cours d'exécution.

Q2.2 --rm supprime le conteneur à son arrêt. À éviter quand on veut consulter les logs ou l'état après l'arrêt, notamment pour diagnostiquer un plantage.

Q2.3 La commande par défaut de l'image debian est bash. Sans -it, bash ne trouve pas d'entrée standard et se termine immédiatement, donc le conteneur s'arrête.

Q2.4 docker stop envoie SIGTERM puis, après un délai de grâce (10 s par défaut), SIGKILL. docker kill envoie directement SIGKILL, sans laisser le processus se terminer proprement.

Q3.1 Très peu de processus, souvent un seul plus ps. C'est le namespace PID qui isole la table des processus.

Q3.2 Le fichier survit à stop/start du même conteneur car il est dans la couche d'écriture de ce conteneur. Il est absent d'un nouveau conteneur, qui repart de l'image d'origine.

Q3.3 Elles sont perdues avec la couche d'écriture.

Q3.4 Au début de l'identifiant du conteneur, sauf si --hostname est précisé.

Q4.1 Une erreur de type « port is already allocated ». Les ports 80 internes n'entrent pas en conflit car chaque conteneur a son propre namespace réseau, donc sa propre pile de ports.

Q4.3 Aucune réponse sur l'hôte, mais nginx tourne bien dans le conteneur. Il est joignable depuis un autre conteneur du même réseau, ou via l'IP du conteneur.

Q4.4 -p 8080:80 écoute sur toutes les interfaces de l'hôte, -p 127.0.0.1:8080:80 seulement en local. Sur un serveur exposé, la seconde forme, avec un reverse proxy devant.

Q5.1 Un refus d'écriture, le système de fichiers étant monté en lecture seule.

Q5.3 Bind mount : développement avec rechargement à chaud, injection d'un fichier de configuration depuis le dépôt. Volume nommé : données d'une base, données applicatives d'un service en production.

Q5.4 Avec un volume nommé vide, Docker recopie le contenu initial du répertoire de l'image dans le volume au premier montage. Avec un bind mount, aucune recopie : le répertoire de l'hôte masque le contenu de l'image, et nginx ne trouve plus sa configuration.

Q6.1 Oui, les données sont dans le volume pgdata, indépendant du conteneur.

Q6.2 Postgres refuse de démarrer et exige POSTGRES_PASSWORD ou POSTGRES_HOST_AUTH_METHOD=trust.

Q6.3 Le mot de passe apparaît dans l'historique du shell, dans docker inspect et dans les couches de l'image. Alternatives : fichier .env non versionné, secrets Docker Swarm ou Kubernetes, gestionnaire de secrets externe.

Q6.4 On y trouve PATH, HOSTNAME, et les variables définies par les instructions ENV de l'image de base.

Q7.1 Les étapes jusqu'au pip install sont en cache, car ni requirements.txt ni les instructions précédentes n'ont changé. Seules la copie de app.py et les étapes suivantes sont rejouées.

Q7.2 Pour que la couche d'installation des dépendances, longue, ne soit invalidée que lorsque les dépendances changent, et non à chaque modification du code.

Q7.3 RUN s'exécute pendant le build et produit une couche. CMD définit la commande de démarrage du conteneur, remplaçable en ligne de commande. ENTRYPOINT définit l'exécutable fixe, CMD fournissant alors ses arguments par défaut.

Q7.4 Il évite de conserver le cache de téléchargement de pip dans la couche, ce qui réduit la taille de l'image.

Q7.5 L'image basée sur python:3.12 complet est environ trois à quatre fois plus lourde.

Q8.1 La résolution par nom fonctionne sur tp-net grâce au DNS embarqué des réseaux définis par l'utilisateur. Sur le bridge par défaut, le nom n'est pas résolu et curl échoue.

Q8.2 La publication de port ne concerne que l'accès depuis l'hôte. Entre conteneurs d'un même réseau, tous les ports sont joignables directement.

Q8.3 Le port du conteneur, ici 8000.

Q8.4 Cela réduit la surface d'attaque : la base n'est accessible que depuis les services de son réseau.

Q9.1 Il s'appelle <nom_du_dossier>_default, le nom de projet étant par défaut celui du répertoire.

Q9.2 Au nom du service Compose, résolu par le DNS interne du réseau du projet.

Q9.3 down supprime conteneurs et réseau mais conserve les volumes nommés. down -v supprime aussi les volumes, donc le compteur repart de zéro.

Q9.4 Il retarde le démarrage de web jusqu'à ce que Postgres accepte réellement les connexions. Sans lui, web peut démarrer avant que la base soit prête, d'où l'intérêt aussi de la boucle de reconnexion dans le code.

Q9.5 Non depuis l'hôte, oui depuis web via le réseau du projet.

Q9.6 image: utilise une image existante, build: construit l'image à partir d'un Dockerfile local.

Q10.2 Une image sans tag, remplacée par un nouveau build portant le même nom.

Q10.3 Elle supprime les volumes non référencés par un conteneur, donc potentiellement des données de production dont le conteneur a été supprimé temporairement.

Q11.3 Les cgroups.