PI-GROUPE
MLOpsIAKubernetesObservabilitéSREArchitecture

KServe : servir un modèle sur mesure qui surveille son propre cluster

Descente à zéro réplique, déploiement canari, détection de dérive intégrée : KServe apporte au service d'inférence ce que Knative apporte aux applications. Mise en œuvre complète sur un modèle maison.

Kubeflow orchestre l'entraînement. Reste à servir le modèle — et c'est là que la plupart des chaînes MLOps se délitent. Un Deployment avec une API Flask fonctionne en démonstration ; il tient mal la production.

KServe répond à trois besoins que le déploiement classique ne couvre pas : la mise à l'échelle jusqu'à zéro quand le modèle ne sert pas, le déploiement progressif entre versions, et la capture des requêtes pour la détection de dérive.

Ce que KServe apporte réellement

Le manifeste InferenceService décrit un service d'inférence complet. Derrière, KServe assemble Knative Serving pour l'élasticité, Istio pour le routage, et un runtime adapté au format du modèle.

BesoinSans KServeAvec KServe
Descente à zéroImpossible avec un DeploymentNative, via Knative
Canari entre versionsDeux services et un aiguillage manuelcanaryTrafficPercent
Prétraitement et postraitementDans le code du modèleTransformateur séparé
Capture des requêtesÀ écrirelogger intégré
Format du modèleServeur à écrireRuntimes fournis

La séparation entre transformateur et prédicteur mérite qu'on s'y arrête : elle permet de faire évoluer la préparation des données sans retoucher au modèle, et inversement.

Le runtime sur mesure

Pour un modèle scikit-learn standard, le runtime fourni suffit. Notre détecteur d'anomalies demande davantage : il doit interroger Prometheus, construire le vecteur d'entrée et normaliser avant de prédire.

python
"""Serveur d'inférence KServe pour le détecteur d'anomalies du cluster."""
import argparse
from typing import Any

import joblib
import kserve
import numpy as np
from prometheus_api_client import PrometheusConnect

VARIABLES = ["cpu_utilisation", "memoire_octets", "redemarrages",
             "latence_p99", "taux_erreur", "attente_ordonnanceur"]


class DetecteurAnomalies(kserve.Model):
    """Prédit un score d'anomalie à partir de l'identifiant d'un pod."""

    def __init__(self, nom: str, chemin_modele: str, url_prometheus: str):
        super().__init__(nom)
        self.chemin = chemin_modele
        self.prometheus = PrometheusConnect(url=url_prometheus, disable_ssl=False)
        self.modele = None
        self.echelle = None
        self.ready = False

    def load(self) -> bool:
        """Chargé une seule fois au démarrage du conteneur."""
        artefact = joblib.load(f"{self.chemin}/modele.joblib")
        self.modele = artefact["detecteur"]
        self.echelle = artefact["echelle"]
        self.ready = True
        return self.ready

    def preprocess(self, charge: dict[str, Any], headers=None) -> dict[str, Any]:
        """Récupère l'état courant du pod depuis Prometheus."""
        vecteurs = []
        for entree in charge["instances"]:
            pod = entree["pod"]
            mesures = []
            for variable in VARIABLES:
                requete = f'{variable}{{pod="{pod}"}}'
                resultat = self.prometheus.custom_query(query=requete)
                mesures.append(float(resultat[0]["value"][1]) if resultat else 0.0)
            vecteurs.append(mesures)
        return {"vecteurs": vecteurs, "pods": [e["pod"] for e in charge["instances"]]}

    def predict(self, entrees: dict[str, Any], headers=None) -> dict[str, Any]:
        X = self.echelle.transform(np.array(entrees["vecteurs"]))
        scores = self.modele.decision_function(X)
        verdicts = self.modele.predict(X)          # -1 anomalie, 1 normal

        return {"predictions": [
            {
                "pod": pod,
                "score": round(float(s), 4),
                "anomalie": bool(v == -1),
                # Le score brut d'Isolation Forest n'est pas une probabilité :
                # on le ramène dans [0,1] pour rester lisible côté exploitation.
                "confiance": round(float(min(1.0, abs(s) * 2)), 3),
            }
            for pod, s, v in zip(entrees["pods"], scores, verdicts)
        ]}


if __name__ == "__main__":
    analyseur = argparse.ArgumentParser(parents=[kserve.model_server.parser])
    analyseur.add_argument("--model_dir", required=True)
    analyseur.add_argument("--prometheus_url", required=True)
    args, _ = analyseur.parse_known_args()

    modele = DetecteurAnomalies("detecteur-anomalies", args.model_dir, args.prometheus_url)
    modele.load()
    kserve.ModelServer().start([modele])

Le commentaire sur le score mérite attention. decision_function d'Isolation Forest renvoie une distance signée, pas une probabilité. L'exposer telle quelle conduit les équipes d'exploitation à l'interpréter comme un pourcentage — et à se tromper.

Le manifeste d'inférence

yaml
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: detecteur-anomalies
  namespace: mlops
  annotations:
    serving.kserve.io/deploymentMode: Serverless
spec:
  predictor:
    minReplicas: 1              # pas de zéro : le chargement du modèle coûte 20 s
    maxReplicas: 8
    scaleTarget: 30             # requêtes simultanées par réplique
    scaleMetric: concurrency
    containerConcurrency: 30
    serviceAccountName: kserve-mlops
    logger:
      mode: all                 # capture entrées et sorties pour la dérive
      url: http://collecteur-derive.mlops.svc/journal
    containers:
      - name: kserve-container
        image: registry.interne/detecteur-anomalies:2.3.0
        args:
          - --model_dir=/mnt/modeles/detecteur
          - --prometheus_url=http://prometheus.observabilite.svc:9090
        resources:
          requests: { cpu: 500m, memory: 1Gi }
          limits:   { cpu: "2",  memory: 3Gi }
        readinessProbe:
          httpGet: { path: /v1/models/detecteur-anomalies, port: 8080 }
          initialDelaySeconds: 25
    volumes:
      - name: modeles
        persistentVolumeClaim: { claimName: registre-modeles }

Deux choix méritent d'être justifiés.

minReplicas: 1 plutôt que zéro. La descente à zéro séduit sur le papier. En pratique, le premier appel après une période creuse paie le démarrage du conteneur plus le chargement du modèle — vingt secondes ici. Pour un service de détection appelé toutes les minutes, l'économie ne compense pas la latence. Gardez le zéro pour les modèles réellement sporadiques.

scaleMetric: concurrency plutôt que rps. Une requête d'inférence qui interroge Prometheus passe l'essentiel de son temps à attendre. Mesurer les requêtes par seconde sous-estime la charge réelle ; mesurer la concurrence reflète le nombre de requêtes en vol.

Le déploiement canari

C'est l'argument le plus fort de KServe. Faire cohabiter deux versions et basculer le trafic progressivement tient en une ligne.

yaml
spec:
  predictor:
    canaryTrafficPercent: 10
    containers:
      - name: kserve-container
        image: registry.interne/detecteur-anomalies:2.4.0   # nouvelle version

Dix pour cent du trafic part vers la 2.4.0, le reste reste sur la 2.3.0. Les deux versions exposent leurs métriques séparément, ce qui permet de comparer avant de basculer.

promql
# Comparaison des deux révisions sur le taux d'anomalies signalées
sum by (revision) (
  rate(kserve_anomalies_detectees_total{service="detecteur-anomalies"}[15m])
)
/
sum by (revision) (
  rate(kserve_predictions_total{service="detecteur-anomalies"}[15m])
)

Si la nouvelle version signale trois fois plus d'anomalies que l'ancienne sur le même trafic, le problème vient du modèle, pas du cluster. Vous remettez canaryTrafficPercent à zéro sans avoir cassé quoi que ce soit.

La détection de dérive branchée au bon endroit

Le logger de KServe envoie chaque couple entrée-sortie vers un collecteur. C'est lui qui alimente l'analyse de dérive — sans instrumenter le code du modèle.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: collecteur-derive
  namespace: mlops
spec:
  replicas: 2
  selector:
    matchLabels: { app: collecteur-derive }
  template:
    metadata:
      labels: { app: collecteur-derive }
    spec:
      containers:
        - name: collecteur
          image: registry.interne/collecteur-derive:1.2.0
          env:
            - name: FENETRE_REFERENCE
              value: "7d"           # comparaison glissante sur sept jours
            - name: SEUIL_ALERTE
              value: "0.3"
          ports:
            - { name: http, containerPort: 8080 }
            - { name: metriques, containerPort: 9090 }

Le collecteur accumule les requêtes, compare la distribution courante à la référence des sept jours précédents, et publie le résultat en métriques Prometheus. L'alerte se configure ensuite exactement comme n'importe quelle alerte d'infrastructure.

KServe ou Kubeflow : la question ne se pose pas

Les deux se complètent. Kubeflow Pipelines entraîne et publie dans le registre MLflow ; KServe sert la version portant l'alias production. Le lien se fait par une simple annotation.

yaml
metadata:
  annotations:
    mlflow.org/model-uri: "models:/detecteur-anomalies-cluster@production"

Un opérateur maison surveille l'alias dans le registre et met à jour l'image de l'InferenceService quand il change. Le réentraînement du lundi matin se propage ainsi jusqu'au service d'inférence sans intervention.

Les erreurs que j'ai vues

Charger le modèle à chaque requête. Le load() existe pour être appelé une fois. Le placer dans predict() multiplie la latence par cinquante.

Oublier la sonde de disponibilité. Sans elle, Knative route du trafic vers un pod dont le modèle n'est pas encore chargé. Le premier appel échoue, et Knative considère le pod en bonne santé.

Dimensionner la mémoire au poids du modèle. Un modèle de 200 Mio en charge en mémoire trois à quatre fois plus une fois désérialisé, sans compter le tampon des requêtes. Mesurez en charge, ne calculez pas.

Traiter la dérive comme un sujet de recherche. Elle se surveille avec les mêmes outils que le reste : une métrique, un seuil, une alerte. Un tableau de bord dédié que personne ne regarde ne sert à rien.