Retour au cours

backend / fastapi

Déploiement — Uvicorn, Gunicorn et Docker

Leçon 231 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi combiner Gunicorn et Uvicorn en production plutôt qu'Uvicorn seul
  • Construire une image Docker multi-stage minimale et exécutée sans privilèges root
  • Distinguer un endpoint de liveness et un endpoint de readiness
  • Configurer un docker-compose.yml avec API, base de données et cache
  • Comprendre le rôle de max_requests pour limiter l'impact de fuites mémoire lentes

Dans quel contexte ?

L'équipe déploie l'API en production avec uvicorn app.main:app --reload, exactement comme en développement. Après quelques jours, un worker plante silencieusement suite à une fuite mémoire progressive et le service devient indisponible jusqu'à un redémarrage manuel, en pleine nuit, découvert seulement par les alertes clients. Migrer vers Gunicorn avec UvicornWorker, max_requests pour recycler périodiquement les workers, et un vrai endpoint /ready que l'orchestrateur peut surveiller, élimine ce genre d'incident.

Le fossé entre "ça marche chez moi" et la production

uvicorn --reload, vu dès la première leçon, est parfait pour développer. Mais il est totalement inadapté à un environnement de production.

Il ne redémarre pas automatiquement un worker qui plante, ni ne répartit la charge sur plusieurs cœurs CPU de façon robuste. Cette leçon comble ce fossé, en commençant par comprendre pourquoi combiner Gunicorn et Uvicorn.

Uvicorn sait exécuter du code ASGI, le protocole asynchrone que parle FastAPI. Mais Gunicorn est un gestionnaire de PROCESSUS bien plus mature.

Il surveille des workers, les redémarre automatiquement s'ils plantent, et gère un redémarrage progressif sans interrompre le trafic en cours. La combinaison — Gunicorn en chef d'orchestre, Uvicorn comme type de worker — cumule les forces des deux.

Une fois les processus supervisés, il reste à bien construire l'image qui les fait tourner : pourquoi une image Docker multi-stage ? Construire une image en une seule étape inclut souvent des outils de compilation inutiles une fois l'application construite.

Ça gonfle inutilement la taille de l'image finale, donc son temps de déploiement. Une construction "multi-stage" sépare l'étape de build de l'image finale, qui ne garde que le strict nécessaire.

Une fois l'image allégée, un réflexe de sécurité s'impose : ne jamais exécuter en root. Faire tourner un conteneur avec l'utilisateur root par défaut est une mauvaise pratique.

Si un attaquant parvient à exécuter du code arbitraire dans le conteneur, il hérite alors des pleins privilèges à l'intérieur de celui-ci. Créer un utilisateur dédié non privilégié limite les dégâts possibles.

Il reste une dernière distinction utile à connaître pour l'orchestration : liveness contre readiness. Un orchestrateur a besoin de savoir deux choses différentes.

EndpointQuestion poséeVérifie les dépendances (DB, cache) ?
/health (liveness)Le process est-il vivant ?Non
/ready (readiness)L'application peut-elle vraiment servir du trafic ?Oui

Piège fréquent

Confondre le PROCESS vivant (/health, liveness) et l'application VRAIMENT prête à recevoir du trafic, dépendances comprises (/ready, readiness) peut amener l'orchestrateur à router du trafic vers une instance pas encore réellement opérationnelle, par exemple avant que sa connexion à la base de données ne soit établie.

Commandes & code

Déploiement — Uvicorn, Gunicorn et Docker

bash
# Développement : un seul process, rechargement à chaud
uvicorn app.main:app --reload --port 8000

# Production simple : plusieurs workers Uvicorn
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
python
# gunicorn_conf.py — Gunicorn comme process manager, Uvicorn comme worker ASGI
# C'est la combinaison recommandée en production : Gunicorn gère le cycle de vie
# des workers (redémarrage automatique, graceful reload), Uvicorn exécute l'ASGI.

bind = "0.0.0.0:8000"
workers = 4                                  # généralement (2 x nb_cores) + 1
worker_class = "uvicorn.workers.UvicornWorker"
timeout = 30
graceful_timeout = 30
max_requests = 1000                            # redémarre un worker après N requêtes (évite les fuites mémoire lentes)
max_requests_jitter = 50                        # évite que tous les workers redémarrent en même temps
accesslog = "-"
errorlog = "-"
bash
gunicorn app.main:app -c gunicorn_conf.py
dockerfile
# Dockerfile multi-stage — image finale minimale
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home appuser  # ne JAMAIS tourner en root en production
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
RUN chown -R appuser:appuser /app
USER appuser

ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"

CMD ["gunicorn", "app.main:app", "-c", "gunicorn_conf.py"]
yaml
# docker-compose.yml — API + PostgreSQL + Redis
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/mydb
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 512M

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 10s
      retries: 5

  redis:
    image: redis:7-alpine

volumes:
  pgdata:
python
# Endpoint de health check — distinguer "liveness" (process vivant) et "readiness" (prêt à servir du trafic)
@app.get("/health")
async def health():
    return {"status": "ok"}


@app.get("/ready")
async def readiness(db: Annotated[AsyncSession, Depends(get_db)]):
    try:
        await db.execute(select(1))  # vérifie que la DB répond réellement
        return {"status": "ready"}
    except Exception:
        raise HTTPException(status_code=503, detail="Base de données indisponible")

Résumé

  • Gunicorn + UvicornWorker est la combinaison de production standard : gestion robuste du cycle de vie des workers.
  • max_requests avec jitter redémarre périodiquement les workers, limitant l'impact de fuites mémoire lentes.
  • Une image Docker multi-stage minimise la taille finale et exécute l'app avec un utilisateur non-root.
  • Distinguer /health (liveness) et /ready (readiness, teste les dépendances) aide l'orchestrateur à router le trafic correctement.

Exercices pratiques

1 disponible
1

Mission : le pod Kubernetes qui reçoit du trafic avant d'être prêt

Objectif : Corriger une confusion entre liveness et readiness qui provoque des erreurs 500 lors des déploiements, et sécuriser l'image Docker.

Contexte

L'application est déployée sur Kubernetes avec un seul endpoint /health référencé à la fois comme sonde de liveness ET de readiness. La connexion à la base de données met environ 3 secondes à s'établir au démarrage d'un nouveau pod. À chaque déploiement, les utilisateurs rencontrent des erreurs 500 pendant quelques secondes, précisément le temps que la connexion DB s'établisse sur le nouveau pod qui reçoit déjà du trafic.

Résoudre l’exercice →