Skip to content

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 + workers uvicorn, ou uvicorn seul 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.

dockerfile
# ❌ 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 . . avant pip 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)

dockerfile
# ---- 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.yml

Pourquoi c'est mieux, en une phrase chacun :

ChoixRaison 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-stageLes build tools restent dans le builder ; l'image finale ne contient que le venv + le code.
COPY manifestes avant codeCache Docker : on ne réinstalle pas les deps quand seul le code change.
uv sync --frozenBuild 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=1Les 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) + 1

Cette 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 :

python
# 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 :

dockerfile
# 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 16 dans 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).

python
# 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 :

python
# ❌ 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 descriptors

Pourquoi 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 : liveready (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.
python
# 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) :

yaml
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 panneSymptômeCause / Correctif
Stream coupé au redéploiementLes clients SSE reçoivent une erreur réseau pendant un deployPas 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 streamStream interrompu après ~30s sans raison apparentetimeout 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 exploseOOMKilled, pods qui redémarrentTrop 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 chargep99 dégradé après quelques minutesClient AsyncAnthropic recréé par requête → handshakes TLS répétés. Singleton via app.state.
429 en cascadeErreurs en pic de traficTu 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'imageClé 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_tokens et 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 jamais datetime.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ètre effort (lowmax) module le coût à modèle constant : commence à high, descends si une tâche est correcte mais trop lente.
  • Concurrence bornée : un asyncio.Semaphore global protège ton TPM Anthropic et évite que 500 streams simultanés ne saturent ta RAM (chaque stream garde du contexte en mémoire).
python
# 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 text

Sé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_000 sur 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 un content vide. Ne lis jamais response.content[0] aveuglément.
python
# 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, via get_final_message()) ou resp.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.
python
# 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

  1. gunicorn+uvicorn vs uvicorn seul : sous orchestrateur (k8s), uvicorn seul + 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 recyclage max_requests sans tuer le pod.
  2. 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.
  3. Image slim vs distroless : distroless va plus loin (pas de shell → surface d'attaque minimale) mais complique le debug (kubectl exec sans 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-slim en builder ET runtime, sépare l'install des deps (sur les manifestes seuls) du COPY du code, crée un appuser non-root, ajoute le .dockerignore. Vérifie avec docker images avant/après et docker history <image> pour voir les couches. Confirme le non-root avec docker run --rm <image> whoami → doit afficher appuser.

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=10 dans gunicorn_conf.py et un endpoint qui stream une tâche longue (effort: "high", gros prompt). Observe le worker tué (WORKER TIMEOUT dans les logs) et le client coupé. Corrige en montant timeout à 120s. Question piège à te poser : pourquoi --reload n'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) ou graceful_timeout (gunicorn). Dans l'endpoint, gère asyncio.CancelledError pour fermer le stream Anthropic proprement. Teste : lance un stream, fais docker 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 script asyncio.gather) avec et sans le sémaphore, compare la RAM (docker stats) et le taux de 429. Pousse plus loin : renvoie un 429 propre 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.usage via get_final_message() en fin de stream. Calcule le coût avec les prix de claude-opus-4-8 (5 $/Mtok input, 25 $/Mtok output ; cache read ≈ 0.1× input). Ajoute un compteur Prometheus llm_tokens_total{type="output"}. Vérifie que cache_read_input_tokens > 0 quand tu réutilises un gros system prompt avec cache_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 CMD doit être en exec form avec le chemin complet de l'interpréteur, le HEALTHCHECK basé sur python -c (pas curl/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.

Bibliothèque tech perso — Achref