Aller au contenu

Lab : conteneuriser l'application Signalements

200 · Pratiquer ⏱ 2 h dockerdocker-composepostgresqlpython

Prérequis

Testé avec compose 5.5.1 docker 29.8.1 postgres 18.6 python 3.14 , vérifié le 1 octobre 2026

Ce lab rassemble tout le cours Docker : les fondamentaux. Vous partez du code d'une petite application et vous devez livrer un projet Compose qui passe toutes les vérifications du script fourni à la fin. Il valide le niveau 200 des compétences « Exécuter des conteneurs » et « Construire des images de conteneurs ».

Comptez environ deux heures si vous avez suivi les leçons. Essayez d'écrire le Dockerfile et le fichier Compose vous-même avant de regarder les solutions repliées.

L'application

« Signalements » est une API web en Python (Flask, servie par Gunicorn) : les habitants d'une commune y signalent un problème de voirie. Elle stocke ses données dans PostgreSQL si la variable DATABASE_URL est définie, en mémoire sinon.

Créez un répertoire signalements et placez-y les deux fichiers suivants.

app.py :

"""Signalements : une petite application de démonstration.

Les habitants d'une commune signalent un problème (lampadaire en panne,
nid-de-poule...). Sans DATABASE_URL, les signalements sont gardés en mémoire.
"""
import os
import socket

from flask import Flask, jsonify, request

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


def connexion():
    import psycopg
    return psycopg.connect(DATABASE_URL, connect_timeout=3)


def initialiser():
    if DATABASE_URL:
        with connexion() as cnx:
            cnx.execute(
                "CREATE TABLE IF NOT EXISTS signalements ("
                " id serial PRIMARY KEY,"
                " lieu text NOT NULL,"
                " description text NOT NULL,"
                " cree_le timestamptz NOT NULL DEFAULT now())"
            )


@app.get("/")
def accueil():
    return jsonify(
        application="signalements",
        version=os.environ.get("APP_VERSION", "dev"),
        conteneur=socket.gethostname(),
        stockage="postgresql" if DATABASE_URL else "memoire",
    )


@app.get("/signalements")
def lister():
    if not DATABASE_URL:
        return jsonify(_memoire)
    with connexion() as cnx:
        lignes = cnx.execute(
            "SELECT id, lieu, description FROM signalements ORDER BY id"
        ).fetchall()
    return jsonify([{"id": i, "lieu": l, "description": d} for i, l, d in lignes])


@app.post("/signalements")
def creer():
    donnees = request.get_json(force=True)
    lieu, description = donnees["lieu"], donnees["description"]
    if not DATABASE_URL:
        _memoire.append({"id": len(_memoire) + 1, "lieu": lieu, "description": description})
        return jsonify(_memoire[-1]), 201
    with connexion() as cnx:
        (ident,) = cnx.execute(
            "INSERT INTO signalements (lieu, description) VALUES (%s, %s) RETURNING id",
            (lieu, description),
        ).fetchone()
    return jsonify(id=ident, lieu=lieu, description=description), 201


@app.get("/sante")
def sante():
    if DATABASE_URL:
        try:
            with connexion() as cnx:
                cnx.execute("SELECT 1")
        except Exception as erreur:  # la base est injoignable
            return jsonify(etat="degrade", erreur=str(erreur)), 503
    return jsonify(etat="ok")


initialiser()

requirements.txt :

flask==3.1.3
gunicorn==26.2.0
psycopg[binary]==3.3.6

Les exigences

Votre projet doit respecter les exigences suivantes. Chacune renvoie à la leçon qui l'explique.

ExigenceLeçon
L'image tourne sous un utilisateur non-root, avec un UID numérique8
CMD en forme exec : le PID 1 n'est pas un shell, l'arrêt propre prend moins de 5 secondes avec le code 06, 8
Le contexte de construction exclut .git, .env, secrets/8
La base PostgreSQL 18 garde ses données dans un volume nommé, monté au bon chemin9
La base n'a aucun port publié et se trouve uniquement sur un réseau interne10
L'application est publiée sur 127.0.0.1:8000 seulement10
L'application ne démarre que lorsque la base est saine ; les deux services ont une vérification de santé honnête6, 11
Aucun mot de passe dans compose.yaml ; Compose refuse de démarrer sans POSTGRES_PASSWORD11
L'application a une racine en lecture seule, toutes ses capabilities retirées, no-new-privileges et des limites de ressources9, 12
Un fichier de surcharge fournit un mode développement avec rechargement à chaud11

Étape 1 : le Dockerfile et le .dockerignore

Écrivez un Dockerfile qui installe les dépendances avant de copier le code, crée un utilisateur système d'UID 10001, et lance Gunicorn avec deux workers sur le port 8000.

Solution

Dockerfile :

# syntax=docker/dockerfile:1
FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore

RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app.py .

USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]

.dockerignore :

# Tout ce qui n'a rien à faire dans l'image
.git
.venv
__pycache__/
*.pyc
.env
Dockerfile*
.dockerignore
secrets/
compose*.yaml
verifier.sh

L'option --no-control-socket évite une erreur de Gunicorn 26, qui veut créer un socket dans le répertoire personnel d'un utilisateur qui n'en a pas (leçon 8).

Construisez et testez l'image seule, en mode mémoire :

$ docker build -t signalements:1.0 .
$ docker run --rm -d --name essai -p 127.0.0.1:8000:8000 signalements:1.0
$ curl -s localhost:8000/
$ docker stop essai

Étape 2 : le fichier Compose

Écrivez compose.yaml avec deux services, app et db, deux réseaux (frontal, et donnees en interne) et un volume nommé. Le mot de passe vient d'un fichier .env que vous créez à côté, avec les droits 600 :

$ printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -base64 24)" > .env
$ chmod 600 .env
Solution
name: signalements

services:
  app:
    build: .
    image: signalements:1.0
    environment:
      DATABASE_URL: postgresql://signalements:${POSTGRES_PASSWORD:?à définir dans .env}@db:5432/signalements
      APP_VERSION: "1.0"
    ports:
      - "127.0.0.1:8000:8000"
    networks: [frontal, donnees]
    depends_on:
      db:
        condition: service_healthy
    read_only: true
    tmpfs:
      - /tmp:size=16m
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 256M
          pids: 100
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/sante', timeout=2)"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  db:
    image: postgres:18.6
    environment:
      POSTGRES_USER: signalements
      POSTGRES_DB: signalements
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?à définir dans .env}
    volumes:
      - pgdata:/var/lib/postgresql
    shm_size: 128m
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
          pids: 200
    networks: [donnees]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U signalements -d signalements -h 127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 30s
    restart: unless-stopped

networks:
  frontal:
  donnees:
    internal: true

volumes:
  pgdata:

Étape 3 : le mode développement

Écrivez un compose.override.yaml qui remplace Gunicorn par le serveur de développement de Flask avec rechargement à chaud, monte le code, et ajoute une interface Adminer dans un profil outils.

Solution
# Réglages du poste de développement, chargés automatiquement par « docker compose ».
services:
  app:
    command: ["flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "8000", "--debug"]
    environment:
      APP_VERSION: "dev"
    volumes:
      - type: bind
        source: .
        target: /app
        read_only: true

  adminer:
    image: adminer:5
    profiles: [outils]
    ports:
      - "127.0.0.1:8081:8080"
    networks: [donnees, frontal]

On monte le répertoire et non le seul fichier app.py : un montage de fichier unique ne suit pas les éditeurs qui remplacent le fichier (leçon 11).

Étape 4 : vérifier

Placez ce script dans le répertoire du projet, rendez-le exécutable (chmod +x verifier.sh) et lancez-le. Il démarre le projet sans la surcharge de développement, contrôle chaque exigence, puis teste la persistance par un down suivi d'un up.

#!/usr/bin/env bash
# Vérifie que le projet Signalements respecte les exigences du lab.
# Usage : ./verifier.sh   (depuis le répertoire du projet, avec un fichier .env)
set -uo pipefail
projet=signalements
ok=0; ko=0
verifier() {  # verifier "description" commande...
  local description=$1; shift
  if "$@" >/dev/null 2>&1; then echo "  OK  $description"; ok=$((ok+1))
  else echo "  KO  $description"; ko=$((ko+1)); fi
}
app() { docker compose ps -q app; }
db()  { docker compose ps -q db; }

echo "Démarrage du projet..."
docker compose -f compose.yaml up -d --wait --build >/dev/null 2>&1 || { echo "échec du démarrage"; exit 1; }

echo "Image et exécution"
verifier "l'application ne tourne pas en root" \
  bash -c '[ "$(docker exec '"$(app)"' id -u)" != "0" ]'
verifier "toutes les capabilities retirées (cap_drop: ALL)" \
  bash -c 'docker inspect -f "{{.HostConfig.CapDrop}}" '"$(app)"' | grep -q ALL'
verifier "no-new-privileges actif" \
  bash -c 'docker exec '"$(app)"' grep -q "NoNewPrivs:\s*1" /proc/1/status'
verifier "racine en lecture seule" \
  bash -c '[ "$(docker inspect -f "{{.HostConfig.ReadonlyRootfs}}" '"$(app)"')" = true ]'
verifier "limite mémoire définie" \
  bash -c '[ "$(docker inspect -f "{{.HostConfig.Memory}}" '"$(app)"')" -gt 0 ]'
verifier "CMD en forme exec (PID 1 n'est pas un shell)" \
  bash -c '! docker top '"$(app)"' -o pid,cmd | sed -n 2p | grep -q "/bin/sh -c"'

echo "Réseau"
verifier "la base n'a aucun port publié" \
  bash -c '[ -z "$(docker port '"$(db)"')" ]'
verifier "l'application est publiée sur 127.0.0.1 seulement" \
  bash -c 'docker port '"$(app)"' | grep -qx "8000/tcp -> 127.0.0.1:8000"'
verifier "la base est sur un réseau interne uniquement" \
  bash -c 'for n in $(docker inspect -f "{{range \$k,\$v := .NetworkSettings.Networks}}{{\$k}} {{end}}" '"$(db)"'); do [ "$(docker network inspect -f "{{.Internal}}" $n)" = true ] || exit 1; done'

echo "Fonctionnement"
verifier "vérification de santé : healthy" \
  bash -c '[ "$(docker inspect -f "{{.State.Health.Status}}" '"$(app)"')" = healthy ]'
verifier "création d'un signalement" \
  curl -sf -X POST localhost:8000/signalements -H 'Content-Type: application/json' -d '{"lieu":"Lab","description":"vérification"}'
verifier "arrêt propre en moins de 5 secondes" \
  bash -c 'debut=$(date +%s); docker compose stop app; fin=$(date +%s); [ $((fin-debut)) -lt 5 ] && [ "$(docker inspect -f "{{.State.ExitCode}}" '"$(app)"')" = 0 ]'

echo "Persistance"
docker compose -f compose.yaml down >/dev/null 2>&1
docker compose -f compose.yaml up -d --wait >/dev/null 2>&1
verifier "les données survivent à docker compose down / up" \
  bash -c 'curl -sf localhost:8000/signalements | grep -q "\"lieu\":\"Lab\""'

echo
echo "Résultat : $ok vérification(s) réussie(s), $ko échouée(s)."
[ "$ko" -eq 0 ]

Sur le projet de référence, le résultat est :

Démarrage du projet...
Image et exécution
  OK  l'application ne tourne pas en root
  OK  toutes les capabilities retirées (cap_drop: ALL)
  OK  no-new-privileges actif
  OK  racine en lecture seule
  OK  limite mémoire définie
  OK  CMD en forme exec (PID 1 n'est pas un shell)
Réseau
  OK  la base n'a aucun port publié
  OK  l'application est publiée sur 127.0.0.1 seulement
  OK  la base est sur un réseau interne uniquement
Fonctionnement
  OK  vérification de santé : healthy
  OK  création d'un signalement
  OK  arrêt propre en moins de 5 secondes
Persistance
  OK  les données survivent à docker compose down / up

Résultat : 13 vérification(s) réussie(s), 0 échouée(s).

Chaque ligne KO renvoie à une exigence du tableau. Par exemple, sans cap_drop: [ALL], le script affiche :

  KO  toutes les capabilities retirées (cap_drop: ALL)
Résultat : 12 vérification(s) réussie(s), 1 échouée(s).

Remarquez que la vérification porte sur la configuration (HostConfig.CapDrop) et non sur les capabilities effectives du processus : l'application tournant sous un UID non-root, ses capabilities effectives sont nulles de toute façon. Le retrait explicite protège le jour où quelqu'un passerait le service en root dans le fichier Compose (user: "0").

Pour aller plus loin

  • Ajoutez un service de sauvegarde dans un profil maintenance (leçon 11, exercice 3) et une vérification qui restaure la dernière sauvegarde dans un conteneur jetable.
  • Remplacez la variable POSTGRES_PASSWORD de la base par un secret Compose et POSTGRES_PASSWORD_FILE (leçon 11).
  • Analysez l'image avec Trivy (leçon 12) et reconstruisez-la avec --pull pour intégrer les correctifs de l'image de base.
  • Nettoyez à la fin : docker compose down -v supprime conteneurs, réseaux et données du projet.

Plan du cours