backend / fastapi
Déploiement — Uvicorn, Gunicorn et Docker
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.ymlavec API, base de données et cache - Comprendre le rôle de
max_requestspour 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.
| Endpoint | Question posée | Vé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
# 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# 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 = "-"gunicorn app.main:app -c gunicorn_conf.py# 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"]# 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:# 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 +
UvicornWorkerest la combinaison de production standard : gestion robuste du cycle de vie des workers. max_requestsavec 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
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.