Production (Docker, uvicorn workers)
TL;DR — En prod, ton app FastAPI n'est pas
uvicorn main:app --reload. C'est une image Docker reproductible (multi-stage, non-root,.dockerignore), lancée par un process manager (gunicorn+ workersuvicorn, ouuvicornseul derrière l'orchestrateur) avec un nombre de workers calé sur les cœurs et le profil de charge (I/O-bound vs CPU-bound). Pour un service qui sert un agent LLM, c'est un cas massivement I/O-bound : un seul worker async tient des centaines de connexions SSE ouvertes pendant que l'agent stream des tokens depuis l'API Anthropic. Le piège n°1 du dev TS/PHP : croire que « plus de workers = plus de débit ». Faux pour de l'I/O — tu fais surtout exploser ta RAM et tu casses tes connexions long-lived. Le reste de cette leçon : Dockerfile best-in-class, calcul des workers, signaux/graceful shutdown (vital pour ne pas couper un stream en plein milieu), healthchecks qui distinguent live de ready, et l'observabilité d'un endpoint qui appelle un LLM.
🧠 Mental model
Tu viens de NestJS/Node. Là-bas, tu as un process Node mono-thread avec une event loop, et tu scales avec PM2 (cluster mode) ou des réplicas Kubernetes. Le modèle FastAPI en prod est exactement le même mental model, juste avec un vocabulaire différent :
NestJS / Node FastAPI / Python
┌──────────────────────┐ ┌──────────────────────────┐
│ PM2 cluster mode │ │ gunicorn (master) │
│ ├─ node worker (loop)│ ≈ │ ├─ uvicorn worker (loop) │
│ ├─ node worker (loop)│ │ ├─ uvicorn worker (loop) │
│ └─ node worker (loop)│ │ └─ uvicorn worker (loop) │
└──────────────────────┘ └──────────────────────────┘
1 loop / worker 1 loop / worker
(le GIL n'autorise qu'un (le GIL n'autorise qu'un
thread Python actif à la fois) thread Python actif à la fois)L'analogie qui débloque tout : un worker uvicorn = un cuisinier dans une cuisine. La cuisine a une seule plaque active à la fois (le GIL). Tant que le cuisinier attend qu'un plat mijote (= un appel réseau, ton await client.messages.stream(...)), il peut prendre une autre commande et lancer un autre plat. C'est de l'async I/O : un seul cuisinier sert beaucoup de tables tant qu'il attend plus qu'il ne travaille.
Mais si une commande exige de hacher des légumes pendant 3 secondes sans s'arrêter (= un calcul CPU pur, du JSON géant à parser, un pdf2image), le cuisinier est bloqué : toutes les autres tables attendent, plat froid. Là, et seulement là, tu veux plusieurs cuisiniers (plusieurs workers) — ou tu sors le travail CPU de la cuisine (worker queue, run_in_executor).
Un service qui sert un agent Anthropic est 99% de l'attente : tu attends que le modèle réfléchisse et stream ses tokens. C'est le scénario async idéal. Le danger n'est pas le CPU, c'est la mémoire (chaque worker recharge tout le code + le SDK) et les connexions coupées quand tu redéploies sans graceful shutdown.
Requête SSE vers /agent/stream :
client ──HTTP──> [uvicorn worker, event loop]
│
│ await client.messages.stream(...) ← le worker ATTEND ici
│ (pendant ce temps il sert d'autres clients)
▼
API Anthropic (claude-opus-4-8)
│
token ◀── token ◀── token ◀── token (stream, ~minutes pour une tâche dure)Le Dockerfile : la version naïve (à NE PAS faire)
Commençons par ce que tout le monde écrit en premier — et pourquoi c'est mauvais en prod.
# ❌ MAUVAIS — ne fais pas ça en production
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]Tout est cassé, point par point :
FROM python:3.12→ l'image complète (~1 Go), avec gcc, build tools, etc. Tu déploies une distro entière pour faire tourner un seul process.COPY . .avantpip install→ le moindre changement de code invalide le cache Docker et réinstalle toutes les deps à chaque build. Sans.dockerignore, tu copies aussi.git,.venv,__pycache__, tes secrets.env.- Tourne en root → faille de sécurité majeure : un RCE dans ton app = root dans le conteneur.
--reload→ le watcher de fichiers en prod, c'est de la CPU gaspillée et un comportement non déterministe.- Aucune version épinglée, pas de
HEALTHCHECK, pas de gestion des signaux.
Le Dockerfile : la version idiomatique (multi-stage, non-root)
# ---- Stage 1 : builder ----
# On installe les dépendances dans un venv jetable, avec les build tools.
FROM python:3.12-slim AS builder
# uv : installeur de deps ultra-rapide (remplace pip). Sinon, garde pip.
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Copier UNIQUEMENT les manifestes d'abord → maximise le cache Docker.
# Tant que pyproject.toml / uv.lock ne changent pas, cette couche est cachée.
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv==0.5.* \
&& uv sync --frozen --no-dev --no-install-project
# ---- Stage 2 : runtime ----
# Image finale minimale : pas de build tools, pas de cache pip.
FROM python:3.12-slim AS runtime
# Variables d'environnement Python pour un comportement prod sain :
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONHASHSEED=random \
PATH="/app/.venv/bin:$PATH"
# Créer un utilisateur non-privilégié AVANT de copier le code.
RUN groupadd --gid 1001 appuser \
&& useradd --uid 1001 --gid 1001 --no-create-home --shell /usr/sbin/nologin appuser
WORKDIR /app
# Récupérer le venv depuis le builder (et rien d'autre du builder).
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
# Copier le code applicatif en dernier (la couche qui change le plus souvent).
COPY --chown=appuser:appuser ./app ./app
USER appuser
EXPOSE 8000
# HEALTHCHECK natif Docker. En orchestrateur (k8s) tu utiliseras plutôt
# les probes, mais c'est utile pour docker compose / debug local.
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz/live', timeout=2).status==200 else 1)"
# Lancement : voir la section suivante pour gunicorn vs uvicorn.
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--config", "/app/app/gunicorn_conf.py"]Le .dockerignore qui va avec (sans lui, le multi-stage ne sert à rien) :
.git
.gitignore
.venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
.env
.env.*
tests/
*.md
Dockerfile
docker-compose.ymlPourquoi c'est mieux, en une phrase chacun :
| Choix | Raison senior |
|---|---|
python:3.12-slim | ~150 Mo vs ~1 Go ; moins de surface d'attaque CVE. (alpine tente mais musl casse souvent les wheels compilées — évite sauf besoin précis.) |
| Multi-stage | Les build tools restent dans le builder ; l'image finale ne contient que le venv + le code. |
COPY manifestes avant code | Cache Docker : on ne réinstalle pas les deps quand seul le code change. |
uv sync --frozen | Build déterministe depuis le lockfile (l'équivalent de npm ci, pas npm install). |
USER appuser (non-root) | Principe du moindre privilège. Beaucoup de clusters refusent les conteneurs root. |
--chown au COPY | Évite une couche RUN chown qui double la taille des fichiers copiés. |
PYTHONUNBUFFERED=1 | Les logs partent direct dans stdout (sinon Python bufferise et tu perds des logs au crash). |
Combien de workers ? Le calcul qui compte
La formule que tu liras partout vient de la doc gunicorn :
workers = (2 × nombre_de_cœurs) + 1Cette formule est faite pour du WSGI synchrone (Flask, Django classique), où chaque worker bloque sur l'I/O et où il faut sur-provisionner pour masquer la latence. Pour un service async I/O-bound comme un proxy d'agent LLM, c'est souvent le mauvais réflexe.
Le raisonnement correct, en arbre de décision :
Ton endpoint est-il majoritairement bloqué sur de l'I/O réseau
(appels LLM, DB, autres APIs) avec du code 100% async (await partout) ?
│
├── OUI (cas d'un service d'agent Anthropic)
│ └── 1 worker async sature déjà beaucoup de connexions concurrentes.
│ Scale par RÉPLIQUES (pods/conteneurs), pas par workers internes.
│ workers = 1 à 2 par conteneur, puis horizontal scaling.
│ Raison : chaque worker recharge tout en RAM ; multiplier les
│ workers pour de l'I/O ne fait que gonfler la mémoire.
│
└── NON — tu as du CPU-bound (parsing lourd, embeddings locaux, images)
└── Là, plusieurs workers aident car le GIL bloque un seul thread.
workers = nombre de cœurs (commence là, mesure, ajuste).
Mieux : sors le CPU dans une worker queue (Celery, ARQ, Dramatiq).Le gunicorn_conf.py que référence le Dockerfile, qui rend tout ça explicite et observable :
# app/gunicorn_conf.py
import multiprocessing
import os
# --- Workers ---
# Pour un service I/O-bound (agent LLM), on NE multiplie PAS par les cœurs.
# On lit WEB_CONCURRENCY (convention 12-factor) ou on tombe sur un défaut sobre.
# Le scaling se fait par réplicas de conteneur, géré par l'orchestrateur.
_default_workers = min(multiprocessing.cpu_count(), 2)
workers = int(os.environ.get("WEB_CONCURRENCY", _default_workers))
worker_class = "uvicorn.workers.UvicornWorker"
# --- Binding ---
bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"
# --- Timeouts ---
# ATTENTION : le timeout gunicorn tue un worker s'il ne "pingue" pas le master
# dans le délai. Un stream LLM long (plusieurs minutes pour une tâche dure sur
# claude-opus-4-8) DOIT avoir un timeout généreux, sinon gunicorn tue le worker
# en plein stream. UvicornWorker envoie des heartbeats, mais reste large.
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "120"))
graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "30"))
keepalive = 5
# --- Recyclage des workers (anti memory-leak) ---
# Redémarre un worker après N requêtes (avec jitter pour ne pas tous les
# recycler en même temps). Utile contre les fuites mémoire insidieuses.
max_requests = int(os.environ.get("GUNICORN_MAX_REQUESTS", "2000"))
max_requests_jitter = 200
# --- Logs sur stdout/stderr (12-factor) ---
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("LOG_LEVEL", "info")
# --- Hooks pour l'observabilité ---
def when_ready(server):
server.log.info("gunicorn ready — workers=%s class=%s", workers, worker_class)
def worker_int(worker):
# Appelé sur SIGINT/SIGQUIT d'un worker : trace pour debug des shutdowns.
worker.log.info("worker %s received interrupt", worker.pid)Et la variante uvicorn seul, valable quand tu es derrière un orchestrateur (Kubernetes, ECS) qui gère déjà les réplicas — tu n'as alors pas besoin de la couche master gunicorn :
# Alternative : 1 process uvicorn par conteneur, scaling par l'orchestrateur.
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", "--port", "8000", \
"--workers", "1", \
"--no-access-log", \
"--timeout-graceful-shutdown", "30"]Règle de pouce senior : un worker async par cœur disponible au maximum, puis tu scales horizontalement. Si tu te retrouves à mettre
--workers 16dans un conteneur pour « plus de débit » sur de l'I/O, tu te trompes de levier — augmente les réplicas.
L'app FastAPI : lifespan, client partagé, et l'endpoint qui stream l'agent
Voici le cœur applicatif. Trois choses comptent pour la prod : un AsyncAnthropic partagé créé une seule fois (pas par requête), un lifespan qui ouvre/ferme proprement les ressources, et un endpoint SSE qui gère le graceful shutdown (un client qui se déconnecte ne doit pas faire planter le worker).
# app/main.py
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import anthropic
from anthropic import AsyncAnthropic
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
logger = logging.getLogger("agent-service")
MODEL = "claude-opus-4-8"
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# --- Startup : créer les ressources coûteuses UNE fois ---
# Le client réutilise un pool de connexions httpx. En créer un par requête
# détruit le keep-alive et écroule les perfs sous charge.
client = AsyncAnthropic(
# La clé vient de l'env (ANTHROPIC_API_KEY). Ne JAMAIS la hardcoder.
max_retries=4, # retries SDK : 429 / 5xx / 529 avec backoff
timeout=600.0, # large : une tâche dure peut streamer des minutes
)
app.state.anthropic = client
logger.info("anthropic client initialized")
try:
yield
finally:
# --- Shutdown : fermer proprement (libère le pool httpx) ---
await client.close()
logger.info("anthropic client closed")
app = FastAPI(title="Agent Service", lifespan=lifespan)
def get_client(request: Request) -> AsyncAnthropic:
"""Dépendance FastAPI : injecte le client partagé via app.state."""
return request.app.state.anthropic
class AgentRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=20_000)
effort: str = Field(default="high", pattern="^(low|medium|high|xhigh|max)$")
router = APIRouter(prefix="/agent", tags=["agent"])
@router.post("/stream")
async def stream_agent(
body: AgentRequest,
request: Request,
client: AsyncAnthropic = Depends(get_client),
) -> StreamingResponse:
"""Stream les tokens de l'agent en SSE.
Points prod clés :
- on STREAM (pas de réponse bloquante) → pas de timeout HTTP côté SDK,
et l'utilisateur voit les tokens arriver.
- on surveille la déconnexion client (await request.is_disconnected())
pour annuler proprement et ne pas brûler des tokens dans le vide.
"""
async def event_source() -> AsyncIterator[str]:
try:
async with client.messages.stream(
model=MODEL,
max_tokens=64_000, # streaming → on peut viser large
thinking={"type": "adaptive"}, # adaptive : le modèle dose son raisonnement
output_config={"effort": body.effort}, # JAMAIS budget_tokens sur opus-4-8
messages=[{"role": "user", "content": body.prompt}],
) as stream:
async for text in stream.text_stream:
# Si le client a coupé la connexion, on arrête tout.
if await request.is_disconnected():
logger.info("client disconnected — aborting stream")
break
# Format SSE : "data: <payload>\n\n"
yield f"data: {text}\n\n"
final = await stream.get_final_message()
# Émettre l'usage pour l'observabilité côté client si besoin.
yield (
f"event: done\n"
f"data: {{\"output_tokens\": {final.usage.output_tokens}}}\n\n"
)
except anthropic.RateLimitError:
# 429 : le SDK a déjà retenté max_retries fois. On informe le client.
yield "event: error\ndata: rate_limited\n\n"
except anthropic.APIStatusError as exc:
logger.warning("anthropic api error: status=%s type=%s", exc.status_code, exc.type)
yield "event: error\ndata: upstream_error\n\n"
except asyncio.CancelledError:
# Annulation propre (shutdown ou déconnexion) — on laisse remonter.
logger.info("stream cancelled")
raise
return StreamingResponse(
event_source(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # désactive le buffering nginx → tokens en temps réel
},
)
app.include_router(router)Et le piège classique qu'un dev venu de Node fait souvent — recréer le client par requête :
# ❌ MAUVAIS — un nouveau client (et un nouveau pool httpx) à CHAQUE requête.
@router.post("/stream-bad")
async def stream_bad(body: AgentRequest):
client = AsyncAnthropic() # ← détruit le keep-alive, fuite des connexions sous charge
async with client.messages.stream(model=MODEL, max_tokens=1024,
messages=[{"role": "user", "content": body.prompt}]) as s:
async for text in s.text_stream:
yield text
# ... et on oublie de fermer le client → fuite de file descriptorsPourquoi c'est faux : AsyncAnthropic encapsule un client httpx avec un pool de connexions et du keep-alive TCP/TLS vers l'API Anthropic. Le recréer par requête, c'est refaire un handshake TLS à chaque fois (latence + CPU) et accumuler des connexions non fermées. Un seul client pour toute la durée de vie du process, injecté via app.state + Depends.
Healthchecks : live ≠ ready (la distinction qui sauve un déploiement)
Erreur fréquente : un seul /health qui retourne 200. En prod orchestrée, il te faut deux sémantiques distinctes :
- Liveness (« le process est-il vivant ? ») → si ça échoue, l'orchestrateur tue et redémarre le conteneur. Doit être ultra-léger et ne dépendre d'aucune dépendance externe. Surtout, ne jamais pinger l'API Anthropic ici : un incident côté Anthropic ferait redémarrer en boucle tes pods sains.
- Readiness (« peut-il prendre du trafic maintenant ? ») → si ça échoue, l'orchestrateur retire le pod du load balancer sans le tuer (ex. au démarrage, ou si une dépendance critique est down). Peut vérifier des dépendances.
# app/health.py
from fastapi import APIRouter, Response, status
health = APIRouter(tags=["health"])
@health.get("/healthz/live")
async def liveness() -> dict[str, str]:
# Aucune dépendance externe. "Si tu peux répondre, tu es vivant."
return {"status": "ok"}
@health.get("/healthz/ready")
async def readiness(response: Response) -> dict[str, str]:
# Vérifie que les ressources critiques au DÉMARRAGE sont prêtes.
# Exemple : la clé API est-elle configurée ? Le client est-il créé ?
# On NE fait PAS d'appel réseau vers Anthropic ici (coûteux + faux négatifs
# lors d'un incident transitoire — utilise plutôt un circuit breaker côté code).
import os
if not os.environ.get("ANTHROPIC_API_KEY"):
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "missing_api_key"}
return {"status": "ready"}Côté Kubernetes, ça se câble ainsi (référence) :
livenessProbe:
httpGet: { path: /healthz/live, port: 8000 }
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet: { path: /healthz/ready, port: 8000 }
initialDelaySeconds: 5
periodSeconds: 10⚙️ En production
Failure modes (et comment chacun te mord)
| Mode de panne | Symptôme | Cause / Correctif |
|---|---|---|
| Stream coupé au redéploiement | Les clients SSE reçoivent une erreur réseau pendant un deploy | Pas de graceful shutdown. Configure graceful_timeout/--timeout-graceful-shutdown ≥ durée d'un stream raisonnable, et terminationGracePeriodSeconds (k8s) cohérent. Le worker doit finir/annuler proprement ses streams avant de mourir. |
| gunicorn tue un worker en plein stream | Stream interrompu après ~30s sans raison apparente | timeout gunicorn trop bas. Pour des streams LLM longs, monte-le (120s+). C'est un timeout de heartbeat worker, pas de requête HTTP — mais un worker bloqué le déclenche. |
| RAM qui explose | OOMKilled, pods qui redémarrent | Trop de workers pour de l'I/O. Réduis WEB_CONCURRENCY, scale par réplicas. Vérifie aussi max_requests (recyclage anti-leak). |
| Latence qui grimpe sous charge | p99 dégradé après quelques minutes | Client AsyncAnthropic recréé par requête → handshakes TLS répétés. Singleton via app.state. |
| 429 en cascade | Erreurs en pic de trafic | Tu dépasses ton TPM/RPM Anthropic. Le SDK retente (max_retries) mais ce n'est pas une file d'attente. Mets une vraie limite de concurrence (asyncio.Semaphore) et/ou une queue. |
refusal non géré | Code qui crash sur response.content[0] | Sur certaines requêtes, stop_reason == "refusal" avec un content vide. Toujours tester stop_reason avant de lire le contenu (voir section sécurité). |
| Secrets dans l'image | Clé API exfiltrable depuis l'image | .env copié par COPY . .. Utilise .dockerignore + injection au runtime (env de l'orchestrateur, secrets manager). Jamais ENV ANTHROPIC_API_KEY=... dans le Dockerfile. |
Performance
- Le levier perf n°1 pour un service d'agent, c'est le streaming + le client partagé, pas le nombre de workers. Stream toujours les réponses LLM : ça évite les timeouts HTTP côté SDK sur les gros
max_tokenset améliore la latence perçue (premier token rapide). - Prompt caching : si tu réutilises un gros system prompt ou un contexte stable entre requêtes, ajoute un
cache_control: {"type": "ephemeral"}sur le dernier bloc stable. Lecture cache ≈ 0.1× le prix input. Pour un proxy d'agent multi-tenant, c'est souvent la plus grosse économie possible. (Le moindre octet qui change dans le préfixe invalide le cache — garde le system prompt figé, n'y injecte jamaisdatetime.now().) - Choix de modèle = levier coût/latence :
claude-opus-4-8(5/25 $ par Mtok) pour la qualité max et l'agentique long-horizon ;claude-haiku-4-5(1/5 $) pour de la classification ou des sous-tâches simples. Le paramètreeffort(low→max) module le coût à modèle constant : commence àhigh, descends si une tâche est correcte mais trop lente. - Concurrence bornée : un
asyncio.Semaphoreglobal protège ton TPM Anthropic et évite que 500 streams simultanés ne saturent ta RAM (chaque stream garde du contexte en mémoire).
# Limiter la concurrence des appels LLM (protège ton quota + ta RAM)
LLM_SEMAPHORE = asyncio.Semaphore(int(os.environ.get("MAX_CONCURRENT_LLM", "25")))
async def guarded_stream(client, **kwargs):
async with LLM_SEMAPHORE:
async with client.messages.stream(**kwargs) as stream:
async for text in stream.text_stream:
yield textSécurité
- Non-root, image slim, deps épinglées via lockfile : déjà dans le Dockerfile. Ajoute un scan de vulnérabilités au CI (
trivy image,grype). - La clé API ne touche jamais l'image ni les logs : injection au runtime. Si tu logges les requêtes, redige
Authorization/x-api-key. - Valide les entrées avec Pydantic (le
max_length=20_000sur le prompt n'est pas cosmétique : il borne ton coût input et bloque les abus). En tant que proxy d'agent, traite tout prompt utilisateur comme hostile (prompt injection) — ne lui donne jamais d'outils à effet de bord sans validation/gating côté serveur. - Gère le
stop_reason: "refusal": sur des sujets sensibles, le modèle peut décliner avec un HTTP 200 et uncontentvide. Ne lis jamaisresponse.content[0]aveuglément.
# Lecture sûre d'une réponse non-streamée
resp = await client.messages.create(model=MODEL, max_tokens=1024,
messages=[{"role": "user", "content": prompt}])
if resp.stop_reason == "refusal":
raise HTTPException(status_code=422, detail="request_declined")
text = next((b.text for b in resp.content if b.type == "text"), "")Observabilité
Un endpoint qui appelle un LLM a trois métriques propres que tu dois exposer en plus des métriques HTTP habituelles : tokens (input/output/cache), coût (dérivé des tokens × prix du modèle) et latence time-to-first-token (pas juste la latence totale, qui inclut la génération).
- Logs structurés (JSON) sur stdout : l'orchestrateur les collecte. Inclus
request_id,model,effort,input_tokens,output_tokens,cache_read_input_tokens,duration_ms. - L'usage est dans la réponse :
final.usage(en streaming, viaget_final_message()) ouresp.usage. C'est ta source de vérité pour le coût — calcule-le, ne l'estime pas avec un tokenizer tiers. - Métriques Prometheus : un middleware ASGI (ou
prometheus-fastapi-instrumentator) pour latence/débit/erreurs HTTP, plus des compteurs custom pour les tokens. PYTHONUNBUFFERED=1(déjà dans le Dockerfile) : sans ça, Python bufferise stdout et tu perds les derniers logs lors d'un crash.
# Middleware minimal : log structuré + en-tête de latence
import time, uuid, json
from starlette.middleware.base import BaseHTTPMiddleware
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
rid = request.headers.get("x-request-id", str(uuid.uuid4()))
start = time.perf_counter()
response = await call_next(request)
dur_ms = (time.perf_counter() - start) * 1000
logger.info(json.dumps({
"request_id": rid,
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round(dur_ms, 1),
}))
response.headers["x-request-id"] = rid
return response
app.add_middleware(AccessLogMiddleware)Les tradeoffs senior à assumer
- gunicorn+uvicorn vs uvicorn seul : sous orchestrateur (k8s),
uvicornseul + scaling par réplicas est plus simple et plus observable (un process = un pod = des métriques claires). gunicorn ajoute un master qui supervise/recycle les workers — utile hors k8s (VM, docker compose) ou si tu veux le recyclagemax_requestssans tuer le pod. - Workers internes vs réplicas : pour de l'I/O, les réplicas gagnent (scaling fin, isolation des pannes, pas de RAM dupliquée à l'excès dans un seul pod). Les workers internes ne valent le coup que pour absorber du CPU-bound résiduel.
- Image slim vs distroless :
distrolessva plus loin (pas de shell → surface d'attaque minimale) mais complique le debug (kubectl execsans shell). Slim est un bon défaut ; distroless quand la sécurité prime sur le confort de debug.
🏋️ Exercices
Exercice 1 — Dockerize proprement (implement)
Objectif : transformer le Dockerfile naïf (single-stage, root) de la section « version naïve » en multi-stage non-root, et faire chuter la taille de l'image d'au moins 60%.
Indice/Solution : pars de
python:3.12-slimen builder ET runtime, sépare l'install des deps (sur les manifestes seuls) duCOPYdu code, crée unappusernon-root, ajoute le.dockerignore. Vérifie avecdocker imagesavant/après etdocker history <image>pour voir les couches. Confirme le non-root avecdocker run --rm <image> whoami→ doit afficherappuser.
Exercice 2 — Le worker tueur de stream (break-then-fix)
Objectif : reproduire puis corriger le bug où gunicorn tue un worker en plein stream LLM long.
Indice/Solution : lance avec
timeout=10dansgunicorn_conf.pyet un endpoint qui stream une tâche longue (effort: "high", gros prompt). Observe le worker tué (WORKER TIMEOUTdans les logs) et le client coupé. Corrige en montanttimeoutà 120s. Question piège à te poser : pourquoi--reloadn'a-t-il rien à voir, et pourquoi le timeout est-il un heartbeat et pas un timeout HTTP ?
Exercice 3 — Graceful shutdown qui ne coupe pas les clients (production-grade)
Objectif : faire en sorte qu'un docker stop (SIGTERM) pendant un stream actif laisse les streams en cours se terminer (ou s'annuler proprement) au lieu de couper brutalement.
Indice/Solution : règle
--timeout-graceful-shutdown 30(uvicorn) ougraceful_timeout(gunicorn). Dans l'endpoint, gèreasyncio.CancelledErrorpour fermer le stream Anthropic proprement. Teste : lance un stream, faisdocker stop(envoie SIGTERM puis SIGKILL après le grace period), vérifie que le worker attend la fin du stream actif et refuse les nouvelles connexions entre-temps. Bonus : ajoute un endpoint readiness qui passe en 503 dès réception du SIGTERM (pour que le LB retire le pod avant la fin du grace period).
Exercice 4 — Borne le coût (production-grade)
Objectif : empêcher qu'un pic de trafic ne génère 500 streams concurrents et ne fasse exploser ton quota TPM Anthropic et ta RAM.
Indice/Solution : ajoute un
asyncio.Semaphore(N)global autour des appels LLM (cf.guarded_stream). Mesure : lance 100 requêtes concurrentes (hey,wrk, ou un scriptasyncio.gather) avec et sans le sémaphore, compare la RAM (docker stats) et le taux de 429. Pousse plus loin : renvoie un429propre côté FastAPI quand le sémaphore est saturé au-delà d'un seuil d'attente, au lieu de laisser les requêtes s'empiler.
Exercice 5 — Observabilité du coût (production-grade)
Objectif : exposer, pour chaque requête d'agent, un log structuré JSON contenant input_tokens, output_tokens, cache_read_input_tokens et le coût calculé en dollars.
Indice/Solution : récupère
final.usageviaget_final_message()en fin de stream. Calcule le coût avec les prix declaude-opus-4-8(5 $/Mtok input, 25 $/Mtok output ; cache read ≈ 0.1× input). Ajoute un compteur Prometheusllm_tokens_total{type="output"}. Vérifie quecache_read_input_tokens > 0quand tu réutilises un gros system prompt aveccache_control— sinon, un invalidateur silencieux (timestamp, JSON non trié) casse ton cache.
Exercice 6 — Slim → distroless (break-then-fix)
Objectif : migrer l'image runtime vers gcr.io/distroless/python3-debian12, constater ce qui casse, et décider si ça vaut le coup.
Indice/Solution : distroless n'a pas de shell ni de package manager. Le
CMDdoit être en exec form avec le chemin complet de l'interpréteur, leHEALTHCHECKbasé surpython -c(pascurl/sh), et le debug se fait via un sidecar ou une image debug. Conclus dans tes notes : qu'as-tu gagné en surface d'attaque, qu'as-tu perdu en confort de debug ? Pour quel type de service ce tradeoff est-il justifié ?
🎤 En entretien
Q : Combien de workers uvicorn mets-tu pour un service qui ne fait que proxifier des appels LLM, et pourquoi ? R : Très peu — 1 à 2 par conteneur — puis je scale par réplicas, parce que c'est du pur I/O-bound : un worker async sature déjà des centaines de connexions concurrentes pendant qu'il attend le LLM ; multiplier les workers ne ferait que dupliquer la RAM sans gagner de débit.
Q : Pourquoi le client AsyncAnthropic doit-il être un singleton et pas recréé par requête ? R : Il encapsule un pool de connexions httpx avec keep-alive TCP/TLS vers l'API ; le recréer par requête force un handshake TLS à chaque appel (latence + CPU) et fuit des file descriptors sous charge — on le crée une fois dans le lifespan et on l'injecte via app.state.
Q : Quelle est la différence entre une liveness probe et une readiness probe, et quel piège faut-il éviter dans chacune pour un service LLM ? R : La liveness dit « tue-moi et redémarre si je réponds plus » et ne doit dépendre d'aucune ressource externe (ne jamais pinger Anthropic, sinon un incident upstream fait redémarrer tes pods sains) ; la readiness dit « retire-moi du LB si je ne peux pas servir » et peut vérifier des dépendances de démarrage — l'erreur classique est de n'avoir qu'un seul /health qui mélange les deux.
Q : Que se passe-t-il si tu redéploies pendant qu'un stream SSE est actif, et comment l'évites-tu ? R : Sans graceful shutdown, le SIGTERM coupe le worker et le client reçoit une erreur réseau en plein stream ; je configure un graceful_timeout (gunicorn) ou --timeout-graceful-shutdown (uvicorn) suffisant, je gère asyncio.CancelledError dans l'endpoint pour fermer le stream proprement, et je bascule la readiness en 503 dès le SIGTERM pour que le load balancer retire le pod avant la fin du grace period.