L'API d'inférence et son image
Le prototype de Ferrand Médical tenait en 30 lignes : une route /ask, un appel Gemini, un print. Sur une VM, ça suffit. Sur Kubernetes, le cluster doit pouvoir demander au processus s'il vit, s'il peut recevoir du trafic, combien de requêtes il porte, et le tuer proprement. Un service qui ne répond pas à ces questions sera redémarré au mauvais moment, recevra du trafic avant d'être prêt et coupera les requêtes en cours à chaque déploiement. Le contrat se signe dans le code, avant d'écrire la moindre ligne de YAML.
Le contrat entre le service et Kubernetes
flowchart TB K["kubelet"] -->|"GET /health/live toutes les 10 s"| L["Vivant ? (processus qui répond)"] K -->|"GET /health/ready toutes les 5 s"| R["Prêt ? (configuration complète)"] P["Prometheus / HPA"] -->|"GET /metrics"| M["requêtes, latence, requêtes en cours"] C["Client via Ingress"] -->|"POST /v1/ask"| A["Délestage si > MAX_INFLIGHT"] A --> G["Gemini (3 tentatives sur 429/500/503)"] K -->|"SIGTERM"| S["uvicorn finit les requêtes en cours (20 s)"]
Quatre décisions structurent l'API :
| Question du cluster | Réponse du service | Erreur classique évitée |
|---|---|---|
| Le processus est-il bloqué ? | /health/live répond sans rien vérifier d'autre | redémarrage en boucle quand Gemini a une panne |
| Peut-il servir ? | /health/ready vérifie la configuration (clé présente, backend connu) | trafic envoyé à un pod sans clé : 100 % d'erreurs |
| Est-il saturé ? | /metrics expose inference_inflight_requests | autoscaling aveugle (leçon 5) |
| Peut-il s'arrêter ? | arrêt gracieux de 20 s après SIGTERM | requêtes coupées à chaque déploiement |
La mise en œuvre
La configuration est lue une fois, depuis l'environnement. Elle sait dire ce qui l'empêche d'être prête :
def problems(self) -> list[str]:
"""Ce qui empêche le service d'être prêt (sert à /health/ready)."""
out = []
if self.backend not in {"gemini", "fake"}:
out.append(f"LLM_BACKEND inconnu : {self.backend}")
if self.backend == "gemini" and not self.use_vertex and not self.api_key:
out.append("GEMINI_API_KEY absente (Secret non monté ?)")
if self.backend == "gemini" and self.use_vertex and not self.project:
out.append("GOOGLE_CLOUD_PROJECT absent en mode Vertex AI")
return outDeux backends partagent la même interface : gemini (le vrai, par clé d'API sur kind ou par Vertex AI sur GKE) et fake, qui consomme du CPU pendant FAKE_CPU_MS millisecondes et renvoie une réponse fixe. Le second sert aux tests et au test de charge : on mesure Kubernetes, pas le quota Gemini.
Le client Gemini est créé une fois et gardé pour toute la vie du processus, avec trois tentatives espacées (1 s, 2 s) sur les codes transitoires :
except errors.APIError as e:
if e.code not in self.RETRYABLE or attempt == 3:
raise LLMError(f"Gemini a répondu {e.code}") from e
log.warning("gemini %s, nouvelle tentative %d dans %.0fs", e.code, attempt, delay)La route principale refuse le travail qu'elle ne peut pas absorber au lieu de l'empiler :
if request.app.state.inflight >= s.max_inflight:
# Délestage : mieux vaut un 503 immédiat (le client réessaie ailleurs)
# qu'une file d'attente qui fait exploser la latence de tout le monde.
REQUESTS.labels("503").inc()
raise HTTPException(503, "surcharge", headers={"Retry-After": "1"})Chaque réponse porte le nom du pod (POD_NAME, injecté par Kubernetes) et la version : c'est ce qui permettra de voir la répartition de charge et la bascule de version dans les leçons suivantes. Code complet : main.py, llm.py, config.py.
L'image
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /srv
COPY requirements.txt .
RUN pip install -r requirements.txt && useradd --uid 10001 --no-create-home app
COPY app/ app/
COPY web/ web/
USER 10001
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--timeout-graceful-shutdown", "20", "--timeout-keep-alive", "75", "--no-access-log"]USER 10001 numérique (et non USER app) : Kubernetes ne peut vérifier runAsNonRoot que sur un UID numérique. PYTHONDONTWRITEBYTECODE évite d'écrire des .pyc, ce qui rend possible un système de fichiers racine en lecture seule. L'option --timeout-keep-alive 75 a une histoire, racontée en leçon 5 : elle a supprimé des 502 mesurés sous charge.
cd files
make setup # .venv + .env
make test # 18 tests, sans clé ni réseau
make run # http://localhost:8000 (avec la clé de .env)Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Tous les pods redémarrent pendant une panne Gemini de 10 minutes | la sonde liveness appelle Gemini | liveness = processus vivant, rien d'autre |
Erreurs client has been closed aléatoires | client genai.Client() recréé puis libéré à chaque requête | un client par processus, créé au démarrage |
| Latence p95 qui passe de 4 s à 40 s sous pic | les requêtes s'empilent dans la boucle asynchrone sans limite | délestage MAX_INFLIGHT + Retry-After |
CreateContainerConfigError: container has runAsNonRoot and image has non-numeric user | USER app dans le Dockerfile | UID numérique |
| 429 en rafale après un redémarrage de flotte | toutes les instances réessaient au même rythme | attente exponentielle, 3 tentatives maximum, puis 502 explicite |
La règle de décision
- Sonde liveness : ne vérifie que le processus. Readiness : vérifie ce que le pod contrôle (configuration, ressources locales), jamais une dépendance partagée par tous les pods.
- Délestez quand la latence d'un appel au modèle se compte en secondes : une file non bornée transforme un pic en panne générale.
- Gardez un backend
fakedans le code de production : il coûte 20 lignes et rend les tests de charge gratuits et reproductibles.
Pratique
L'API Gemini renvoie des 503 pendant 8 minutes. Votre sonde liveness appelle Gemini avec un timeout de 2 s et failureThreshold: 3. Que se passe-t-il sur le cluster ?
La liveness ne connaît pas la cause : trois échecs, le kubelet tue le conteneur. Tous les pods échouent en même temps, donc tous redémarrent, et le retour de Gemini trouve un service en plein redémarrage. Le retrait du Service sans redémarrage décrit la readiness, pas la liveness. L'HPA ne réagit pas aux sondes.
Sous un pic, 200 utilisateurs posent une question en même temps à un service de 2 pods. Chaque appel Gemini dure 4 s. Avec MAX_INFLIGHT=32, que reçoit le 70e utilisateur ?
Deux pods à 32 requêtes en cours absorbent 64 requêtes. Au-delà, le service répond tout de suite 503 : le client (ou l'Ingress) réessaie au lieu d'attendre derrière une file. Sans délestage, la réponse arriverait après plusieurs vagues de 4 s, et tous les utilisateurs verraient leur latence monter. Le 422 sert aux questions invalides.
Exercice
Ajoutez une route GET /v1/info qui renvoie la version, le backend et le modèle, sans appeler le modèle, et un test sans réseau qui la vérifie. Ensuite, décidez si cette route doit être exposée par l'Ingress.
Voir la solution
@app.get("/v1/info")
async def info(request: Request) -> dict:
be = request.app.state.backend
return {"version": s.version, "backend": be.name if be else None, "model": s.model}def test_info_does_not_call_model():
with client(settings()) as c:
d = c.get("/v1/info").json()
assert d == {"version": "test", "backend": "fake", "model": BASE.model}Exposition : la route ne divulgue rien de sensible, mais elle révèle la version exacte, utile à un attaquant qui cherche une faille connue. Gardez-la derrière l'Ingress seulement si le support en a besoin ; sinon, consultez-la par kubectl port-forward.
Résultat obtenu
Exécution du 2026-10-15 :
$ make test
.................. [100%]
18 passed in 0.65sLes 9 tests de l'API couvrent la réponse, les questions vides ou trop longues, la readiness sans clé, le mode Vertex sans clé, l'erreur modèle traduite en 502, le délestage, l'injection de pannes, les métriques et la consommation CPU du backend fake. Les 9 tests du chart sont présentés en leçon 3.
Je sais faire
- Séparer ce que vérifie une sonde liveness de ce que vérifie une readiness
- Borner les requêtes en cours et répondre 503 avec
Retry-After - Construire une image Python non-root à UID numérique
- Tester l'API sans clé grâce à un backend
fake