Lección 1 — Le service

L'API d'inférence et son image

7 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

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 clusterRéponse du serviceErreur classique évitée
Le processus est-il bloqué ?/health/live répond sans rien vérifier d'autreredé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_requestsautoscaling aveugle (leçon 5)
Peut-il s'arrêter ?arrêt gracieux de 20 s après SIGTERMrequê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 :

files/app/config.py
    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 out

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

files/app/llm.py
            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 :

files/app/main.py
        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

files/Dockerfile
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.

terminal
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ômeCauseParade
Tous les pods redémarrent pendant une panne Gemini de 10 minutesla sonde liveness appelle Geminiliveness = processus vivant, rien d'autre
Erreurs client has been closed aléatoiresclient genai.Client() recréé puis libéré à chaque requêteun client par processus, créé au démarrage
Latence p95 qui passe de 4 s à 40 s sous picles requêtes s'empilent dans la boucle asynchrone sans limitedélestage MAX_INFLIGHT + Retry-After
CreateContainerConfigError: container has runAsNonRoot and image has non-numeric userUSER app dans le DockerfileUID numérique
429 en rafale après un redémarrage de flottetoutes les instances réessaient au même rythmeattente 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 fake dans 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 ?

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 ?

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
files/app/main.py (dans create_app)
    @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}
files/tests/test_app.py
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.65s

Les 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
Siguiente