🐳 Docker · Débutant

Conteneuriser une API Python avec Docker

⏱ 30 minutes 📦 Docker 24+ 🐍 Python 3.11 ⚡ FastAPI

Docker permet d'empaqueter une application et toutes ses dépendances dans un conteneur portable. Dans ce tutoriel, nous allons créer une API Python avec FastAPI, l'encapsuler dans une image Docker et l'exécuter en quelques commandes — le même résultat garanti sur toutes les machines.

Prérequis

📖 Terme : Docker

Définition : Plateforme de conteneurisation permettant d'empaqueter une application et toutes ses dépendances (runtime, librairies, code) dans une unité portable appelée conteneur.

But : Garantir que l'application fonctionne de manière identique peu importe où elle s'exécute (machine locale, serveur, cloud).

Pourquoi ici : Docker élimine le problème "ça marche sur ma machine" en isolant complètement l'environnement d'exécution.

1. Créer l'API FastAPI

On commence par une API simple avec deux routes. Créez le dossier du projet :

Terminal
mkdir mon-api && cd mon-api
Cette commande crée un répertoire de projet nommé "mon-api" et y entre. Tous les fichiers Docker seront placés ici.

Créez le fichier main.py :

main.py
# Imports : charger FastAPI, gestion d'erreurs HTTP et modèles Pydantic
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
import datetime

# Créer l'instance FastAPI avec métadonnées pour la documentation Swagger
app = FastAPI(
    title="Mon API",
    description="API exemple conteneurisée avec Docker",
    version="1.0.0"
)

# Définir le modèle de données Item avec validation Pydantic
class Item(BaseModel):
    name: str
    price: float
    in_stock: bool = True

# Simuler une base de données en mémoire pour ce tutoriel
items_db: List[Item] = [
    Item(name="Laptop", price=999.99),
    Item(name="Souris", price=29.99),
]

# Route GET racine : répondre avec un message et timestamp
@app.get("/")
def root():
    return {
        "message": "API opérationnelle 🚀",
        "timestamp": datetime.datetime.now().isoformat()
    }

# Route GET /items : retourner la liste complète des items
@app.get("/items", response_model=List[Item])
def get_items():
    """Retourne tous les articles disponibles."""
    return items_db

# Route GET /items/{item_id} : récupérer un item spécifique par ID
@app.get("/items/{item_id}", response_model=Item)
def get_item(item_id: int):
    if item_id < 0 or item_id >= len(items_db):
        raise HTTPException(status_code=404, detail="Article non trouvé")
    return items_db[item_id]

# Route POST /items : ajouter un nouvel item à la base de données
@app.post("/items", response_model=Item, status_code=201)
def create_item(item: Item):
    items_db.append(item)
    return item

# Route GET /health : vérifier que l'API est en fonctionnement
@app.get("/health")
def health_check():
    return {"status": "healthy"}
Ce fichier définit une API REST complète avec FastAPI : chaque route (@app.get/@app.post) répond à une requête HTTP. FastAPI génère automatiquement la documentation Swagger à partir de ces définitions.

Créez le fichier requirements.txt :

requirements.txt
# Framework web moderne pour construire les APIs REST
fastapi==0.111.0
# Serveur ASGI pour exécuter FastAPI (asynchrone, performant)
uvicorn[standard]==0.29.0
# Validation de données et sérialisation JSON
pydantic==2.7.0
Ce fichier liste toutes les dépendances Python avec des versions figées. Les versions figées assurent que tout le monde (vous, vos collègues, Docker) installe exactement les mêmes versions, éliminant les surprises en production.
Toujours figer les versions dans requirements.txt pour garantir la reproductibilité de votre image Docker.

2. Tester l'API localement (sans Docker)

Terminal
# Créer un environnement virtuel isolé pour ce projet
python -m venv venv
# Activer l'environnement (Windows : venv\Scripts\activate)
source venv/bin/activate

# Installer les dépendances listées dans requirements.txt
pip install -r requirements.txt

# Lancer l'API avec rechargement auto lors des modifications
uvicorn main:app --reload --port 8000
Ces commandes créent un environnement isolé, installent les dépendances, et démarrent le serveur Uvicorn qui expose l'API sur le port 8000. Le flag --reload redémarre le serveur à chaque sauvegarde de fichier, utile en développement.
Ouvrez http://localhost:8000/docs — vous verrez la documentation Swagger interactive de votre API.

3. Créer le Dockerfile

Le Dockerfile est la recette qui décrit comment construire l'image de votre application.

📖 Terme : Dockerfile

Définition : Fichier texte contenant une suite d'instructions pour assembler une image Docker. Chaque instruction crée une nouvelle couche (layer) dans l'image.

But : Automatiser la création d'une image de manière reproductible et documentée.

Pourquoi ici : Au lieu de créer manuellement des conteneurs et les configurer, le Dockerfile permet à n'importe qui de générer l'image identique simplement en exécutant "docker build".

Dockerfile
# ── Image de base officielle Python ──
# python:3.11-slim : variante légère (~150MB vs ~1GB pour l'image complète)
FROM python:3.11-slim

# ── Variables d'environnement pour Python ──
# PYTHONDONTWRITEBYTECODE=1 : ne pas générer les fichiers .pyc inutiles
# PYTHONUNBUFFERED=1 : envoyer les logs directement à stdout (temps réel)
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# ── Définir le répertoire de travail du conteneur ──
WORKDIR /app

# ── Copier les dépendances EN PREMIER ──
# Docker utilise le cache : si requirements.txt ne change pas, cette couche est réutilisée
# Les modifications du code source ne forcent pas une réinstallation des dépendances
COPY requirements.txt .
# --no-cache-dir : ne pas stocker le cache pip (économise de l'espace)
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt

# ── Copier le code source de l'application ──
COPY . .

# ── Port exposé par l'application (documentation) ──
# Note: EXPOSE ne publie pas le port, il faut utiliser -p lors du docker run
EXPOSE 8000

# ── Commande de démarrage ──
# 0.0.0.0 rend l'API accessible depuis l'extérieur du conteneur
# Par défaut, 127.0.0.1 ne serait accessible que depuis le conteneur lui-même
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Ce Dockerfile crée une image en couches successives : image de base → variables d'environnement → dépendances → code. Chaque couche peut être réutilisée par Docker si elle ne change pas, accélérant les builds futurs.
Nous copions requirements.txt avant le code source car les dépendances changent rarement. Si on copie le code en premier, chaque modification du code invaliderait le cache et forcerait une réinstallation de toutes les dépendances, ralentissant les builds.

Ajouter un .dockerignore

Comme .gitignore, le fichier .dockerignore empêche de copier des fichiers inutiles dans l'image :

📖 Terme : .dockerignore

Définition : Fichier listant les motifs de fichiers/dossiers à exclure lors de la copie dans le conteneur (instruction COPY dans le Dockerfile).

But : Réduire la taille de l'image en évitant de copier des fichiers non nécessaires à la production.

Pourquoi ici : Votre environnement virtuel local (venv/), les fichiers Python compilés (__pycache__/), et le historique Git (.git/) ne doivent jamais entrer dans l'image Docker.

.dockerignore
# Dossiers temporaires et environnements locaux
venv/
__pycache__/
*.pyc
*.pyo

# Secrets et configuration locale (ne jamais inclure dans l'image)
.env
.env.local

# Contrôle de version et documentation
.git
.gitignore
*.md

# Cache et fichiers de test
.pytest_cache/
.coverage
Sans .dockerignore, le "docker build" aurait copié votre dossier venv/ local (plusieurs centaines de Mo) et les cache Python, gonflant inutilement l'image. Avec .dockerignore, seul le code et requirements.txt sont copiés, puis Docker les réinstalle dans le conteneur (avec les bonnes versions et dépendances).
Excluire .env est critique pour la sécurité : les secrets ne doivent jamais être codés en dur dans l'image Docker, car les layers d'une image sont inspectables. On doit passer les secrets au runtime via les variables d'environnement.

4. Construire l'image Docker

📖 Terme : Image Docker

Définition : Ensemble immuable de couches empilées (layers) contenant un système d'exploitation minimal, des dépendances et du code. C'est un modèle ("blueprint") qui sert à créer des conteneurs.

But : Avoir un paquet portable et versionnné qui peut être exécuté sur n'importe quelle machine ayant Docker.

Pourquoi ici : Une image est à un conteneur ce qu'une classe est à une instance. On bâtit l'image une fois, puis on crée autant de conteneurs qu'on veut à partir de cette image.

📖 Terme : Layer (Couche)

Définition : Snapshot d'un changement de filesystem créé par une instruction Docker (FROM, COPY, RUN, etc.). Les layers sont empilées pour former l'image finale.

But : Permettre au Docker d'utiliser le cache : si une layer n'a pas changé, elle peut être réutilisée.

Pourquoi ici : Comprendre les layers est essentiel pour optimiser les builds. L'ordre des instructions dans le Dockerfile détermine l'efficacité du cache.

Terminal
# Construire l'image Docker
# -t mon-api:v1 : donne un nom et une étiquette (tag) à l'image
# . : utilise le Dockerfile du répertoire courant
docker build -t mon-api:v1 .

# Vérifier que l'image est créée et voir sa taille
docker images | grep mon-api
La commande "docker build" exécute les instructions du Dockerfile ligne par ligne, créant une couche pour chaque instruction. À la fin, toutes les couches sont fusionnées en une seule image que vous pouvez voir avec "docker images".
Sortie attendue : mon-api v1 abc123def456 2 minutes ago 180MB

5. Lancer le conteneur

📖 Terme : Conteneur

Définition : Instance en exécution d'une image Docker. C'est un processus isolé avec son propre système de fichiers, réseau et variables d'environnement.

But : Isoler complètement l'application : un conteneur ne voit que ses propres fichiers, ports et variables, sans interférer avec la machine hôte ou d'autres conteneurs.

Pourquoi ici : Contrairement à une machine virtuelle lourde, un conteneur Docker est léger (quelques secondes au démarrage) et peut tourner directement sur le kernel du système hôte.

Terminal
# Lancer un conteneur basé sur l'image mon-api:v1
# -d : détaché (mode background, libère le terminal)
# -p 8000:8000 : publie le port 8000 du conteneur sur le port 8000 de l'hôte
#        (sans cela, l'API serait inaccessible de l'extérieur du conteneur)
# --name api-container : donne un nom au conteneur (plus lisible que un ID aléatoire)
docker run -d -p 8000:8000 --name api-container mon-api:v1

# Lister les conteneurs en cours d'exécution
docker ps

# Afficher les logs du conteneur en temps réel (-f = follow)
docker logs -f api-container
Ces commandes créent et démarrent un conteneur à partir de l'image. Le flag -p établit un mappage de port : les requêtes vers http://localhost:8000 sont redirigées vers le port 8000 à l'intérieur du conteneur. Le flag -d permet au conteneur de s'exécuter en arrière-plan sans bloquer votre terminal.
Votre API est accessible sur http://localhost:8000 — exactement comme en local, mais dans un conteneur isolé.

6. Tester l'API conteneurisée

Terminal (curl)
# Test 1 : route racine
curl http://localhost:8000/
# Retour : {"message":"API opérationnelle 🚀","timestamp":"2025-01-15T10:30:00"}

# Test 2 : récupérer tous les items (GET /items)
curl http://localhost:8000/items
# Retour : [{"name":"Laptop","price":999.99,"in_stock":true},...]

# Test 3 : créer un nouvel item (POST /items)
curl -X POST http://localhost:8000/items \
  -H "Content-Type: application/json" \
  -d '{"name":"Clavier","price":79.99,"in_stock":true}'
# Retour : {"name":"Clavier","price":79.99,"in_stock":true}

# Test 4 : health check pour vérifier que l'API répond
curl http://localhost:8000/health
# Retour : {"status":"healthy"}
Ces requêtes curl testent chaque endpoint de l'API. L'API répond de manière identique en local et dans le conteneur, ce qui prouve que la conteneurisation n'a rien cassé. La route /health est particulièrement utile pour les orchestrateurs (Kubernetes, Docker Compose) qui doivent vérifier que l'API est prête.

7. Passer des variables d'environnement

En production, on ne code jamais les secrets en dur. On utilise les variables d'environnement :

main.py (avec config)
# Importer os pour lire les variables d'environnement
import os
from fastapi import FastAPI

# Lire les variables d'environnement, avec valeurs par défaut
# En production, ces valeurs seront surchargées par le déploiement
APP_ENV = os.getenv("APP_ENV", "development")
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db")

app = FastAPI()

# Endpoint qui expose l'environnement courant (utile pour déboguer)
@app.get("/")
def root():
    return {"env": APP_ENV, "message": "API opérationnelle"}
Au lieu de coder les secrets directement dans le code (dangereux et non flexible), on utilise os.getenv() pour lire depuis les variables d'environnement. Cela permet à chaque environnement (dev, staging, production) d'avoir sa propre configuration.
Pourquoi passer les secrets au runtime ? Si vous codiez le secret dans le code ou le Dockerfile, il resterait visible dans tous les layers de l'image Docker (inspectables), et vos logs Git le rendraient public. Les variables d'environnement restent externes à l'image, changeant selon où le conteneur est lancé, ce qui sépare la config du déploiement.
Terminal — lancer avec des variables
# Méthode 1 : Passer les variables directement avec -e
docker run -d -p 8000:8000 \
  -e APP_ENV=production \
  -e SECRET_KEY=mon-super-secret \
  -e DATABASE_URL=postgresql://user:pass@db:5432/mydb \
  --name api-prod \
  mon-api:v1

# Méthode 2 : Charger toutes les variables d'un fichier .env
# (plus pratique en production, moins de verbosité)
docker run -d -p 8000:8000 \
  --env-file .env \
  --name api-prod \
  mon-api:v1
La méthode 1 (-e) convient au développement, mais la méthode 2 (--env-file) est préférable pour la production : un seul fichier centralise toutes les variables, et il n'apparaît jamais dans l'historique des commandes. Attention : ne commitez jamais le fichier .env contenant des secrets sur Git !

8. Docker Compose : API + base de données

En pratique, votre API a besoin d'une base de données. Docker Compose orchestre plusieurs conteneurs ensemble :

📖 Terme : Docker Compose

Définition : Outil pour définir et exécuter plusieurs conteneurs Docker en une seule commande via un fichier YAML (docker-compose.yml).

But : Gérer des architectures multi-conteneurs : API + base de données + cache + queue, tout en local avant de déployer.

Pourquoi ici : Une API seule n'est pas utile. Docker Compose permet de tester l'intégration entre plusieurs services avant la production.

📖 Terme : Registry Docker

Définition : Serveur centralisé stockant des images Docker publiques ou privées. Docker Hub est le registry officiel gratuit.

But : Partager et télécharger des images préconstruites (postgres, redis, nginx, etc.) au lieu de les créer de zéro.

Pourquoi ici : Nous utilisons postgres:16-alpine et adminer depuis Docker Hub plutôt que de les créer nous-mêmes, gagnant du temps.

docker-compose.yml
# Version de la syntaxe Docker Compose
version: '3.9'

services:
  # ── Service API FastAPI ──
  api:
    # build: . va construire l'image à partir du Dockerfile
    build: .
    ports:
      # Mapper port 8000 hôte vers port 8000 du conteneur
      - "8000:8000"
    environment:
      # Variables d'environnement pour ce conteneur
      - APP_ENV=development
      # @db : le conteneur db est accessible par ce hostname (réseau Docker interne)
      - DATABASE_URL=postgresql://user:password@db:5432/mydb
    depends_on:
      # Attendre que le service db soit "healthy" avant de démarrer l'API
      db:
        condition: service_healthy
    volumes:
      # Monter le répertoire local /app du conteneur
      # Permet le hot reload : modifications du code = rechargement auto
      - .:/app
    # Commande override : lancer uvicorn avec --reload (watch mode)
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload

  # ── Service PostgreSQL ──
  db:
    # Utiliser l'image PostgreSQL 16 sur base Alpine (ultra-légère)
    image: postgres:16-alpine
    environment:
      # Identifiants de la base de données
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydb
    volumes:
      # Monter un volume pour persister les données entre les redémarrages
      - postgres_data:/var/lib/postgresql/data
    # Health check : Docker Compose teste si PostgreSQL est prêt
    healthcheck:
      # Commande pour vérifier la santé du service
      test: ["CMD-SHELL", "pg_isready -U user"]
      # Tester toutes les 5 secondes
      interval: 5s
      # Timeout de la commande : 5 secondes
      timeout: 5s
      # Nombre d'essais avant de considérer le service unhealthy
      retries: 5

  # ── Service Adminer : interface web pour PostgreSQL ──
  adminer:
    # Image officielle pour gérer les bases de données graphiquement
    image: adminer
    ports:
      - "8080:8080"
    # Attendre que PostgreSQL soit prêt
    depends_on:
      - db

# ── Volumes nommés ──
# postgres_data : crée un volume Docker pour persister les données PostgreSQL
# Survit même si le conteneur est supprimé
volumes:
  postgres_data:
Ce fichier docker-compose.yml déclare trois services : api (l'application), db (PostgreSQL), et adminer (interface admin). Docker Compose crée un réseau interne où ces services peuvent communiquer par hostname (api parle à db via "db:5432"). Le flag "condition: service_healthy" garantit que l'API n'essaie pas de se connecter à PostgreSQL tant qu'il n'est pas prêt.
Nous utilisons postgres:16-alpine (image préexistante) au lieu de créer notre propre base de données. De même, adminer est une image publique. Réutiliser des images standards est plus rapide et plus sûr qu'écrire un Dockerfile custom pour chacun.
Terminal
# Démarrer tous les services définis dans docker-compose.yml
# -d : mode détaché (background)
docker compose up -d

# Voir les logs en direct de tous les services
# -f : follow (continuez à regarder les nouveaux logs)
docker compose logs -f

# Arrêter et supprimer tous les conteneurs (données du volume persistent)
docker compose down

# Arrêter ET supprimer les volumes (réinitialise complètement la DB)
# ⚠️ À utiliser avec prudence : supprime les données !
docker compose down -v
Ces commandes gèrent le cycle de vie de tous les services : up crée et démarre les conteneurs, down les arrête et les supprime. Le flag -d permet à Compose de retourner le contrôle du terminal sans afficher les logs (idéal pour la production). Le flag -v supprime aussi les volumes, utile pour un reset complet en développement.

9. Commandes Docker essentielles

Terminal — aide-mémoire
# ── Gérer les conteneurs ──
# Lister les conteneurs en cours d'exécution
docker ps

# Lister tous les conteneurs (actifs et arrêtés)
docker ps -a

# Arrêter proprement un conteneur (SIGTERM)
docker stop mon-conteneur

# Supprimer un conteneur arrêté
docker rm mon-conteneur

# Forcer la suppression immédiate d'un conteneur (même s'il tourne)
docker rm -f mon-conteneur

# ── Entrer dans un conteneur (debug interactif) ──
# Ouvrir un shell Bash dans le conteneur
docker exec -it mon-conteneur bash

# Exécuter une commande sans interaction
docker exec mon-conteneur ls /app

# ── Gérer les images ──
# Lister toutes les images téléchargées/construites
docker images

# Supprimer une image spécifique
docker rmi mon-api:v1

# Supprimer les images non utilisées par aucun conteneur
docker image prune

# ── Nettoyage complet ──
# Supprimer conteneurs, images, réseaux et caches non utilisés
# ⚠️ Radical : à utiliser avec prudence !
docker system prune -a
Ces commandes permettent d'inspecter et gérer vos conteneurs et images. "docker ps" affiche toujours les conteneurs actuels ; "docker exec" permet d'entrer dans un conteneur en cours pour déboguer ; "docker rmi" et "docker image prune" nettoient les images inutilisées pour économiser de l'espace disque.

Structure finale du projet

Arborescence
mon-api/
├── main.py              # Code de l'API FastAPI
├── requirements.txt     # Dépendances Python
├── Dockerfile           # Recette de l'image Docker
├── docker-compose.yml   # Orchestration multi-services
├── .dockerignore        # Fichiers exclus de l'image
└── .env                 # Variables d'environnement (ne pas committer !)
Ne commitez jamais votre fichier .env contenant des secrets sur Git. Ajoutez-le dans votre .gitignore.

Récapitulatif

Vous savez maintenant :

La prochaine étape logique est d'optimiser l'image pour la production avec les multi-stage builds, ou de déployer sur Cloud Run en quelques minutes.