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.
| Besoin | Sans KServe | Avec KServe |
|---|---|---|
| Descente à zéro | Impossible avec un Deployment | Native, via Knative |
| Canari entre versions | Deux services et un aiguillage manuel | canaryTrafficPercent |
| Prétraitement et postraitement | Dans le code du modèle | Transformateur séparé |
| Capture des requêtes | À écrire | logger intégré |
| Format du modèle | Serveur à écrire | Runtimes 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.
"""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
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.
spec:
predictor:
canaryTrafficPercent: 10
containers:
- name: kserve-container
image: registry.interne/detecteur-anomalies:2.4.0 # nouvelle versionDix 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.
# 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.
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.
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.