Lab : conteneuriser l'application Signalements
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.6Les exigences
Votre projet doit respecter les exigences suivantes. Chacune renvoie à la leçon qui l'explique.
| Exigence | Leçon |
|---|---|
| L'image tourne sous un utilisateur non-root, avec un UID numérique | 8 |
CMD en forme exec : le PID 1 n'est pas un shell, l'arrêt propre prend moins de 5 secondes avec le code 0 | 6, 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 chemin | 9 |
| La base n'a aucun port publié et se trouve uniquement sur un réseau interne | 10 |
L'application est publiée sur 127.0.0.1:8000 seulement | 10 |
| L'application ne démarre que lorsque la base est saine ; les deux services ont une vérification de santé honnête | 6, 11 |
Aucun mot de passe dans compose.yaml ; Compose refuse de démarrer sans POSTGRES_PASSWORD | 11 |
L'application a une racine en lecture seule, toutes ses capabilities retirées, no-new-privileges et des limites de ressources | 9, 12 |
| Un fichier de surcharge fournit un mode développement avec rechargement à chaud | 11 |
É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.shL'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_PASSWORDde la base par un secret Compose etPOSTGRES_PASSWORD_FILE(leçon 11). - Analysez l'image avec Trivy (leçon 12) et reconstruisez-la avec
--pullpour intégrer les correctifs de l'image de base. - Nettoyez à la fin :
docker compose down -vsupprime conteneurs, réseaux et données du projet.