La première tentative de l'équipe de Valoria a été un mlflow ui lancé sur le portable d'une développeuse, avec un dossier mlruns/ local. Deux semaines plus tard, ses collègues n'avaient rien vu de ses évaluations, le dossier pesait 400 Mo de traces, et un git clean l'a effacé. Un outil de suivi n'a de valeur que s'il est partagé, persistant, et qu'on sait le remettre à zéro sans rien oublier.
Le mécanisme
MLflow sépare deux stockages :
- le magasin de suivi (backend store) : expériences, runs, paramètres, métriques, prompts, traces, registre de modèles. C'est une base SQL.
- le magasin d'artefacts : fichiers produits par les runs (modèles empaquetés, tableaux d'évaluation). Avec
--serve-artifacts, le serveur sert de mandataire : les clients envoient et lisent les artefacts par HTTP, sans accès direct au volume ni identifiants de stockage.
flowchart LR
C["Clients Python<br/>lab.py, tests, CI"] -- "HTTP :5050" --> S["mlflow server<br/>2 workers"]
S -- "SQLAlchemy + psycopg 3" --> P[("PostgreSQL 16<br/>volume pgdata")]
S -- "--artifacts-destination" --> A[("/mlartifacts<br/>volume artifacts")]
U["Navigateur"] -- "interface" --> S
PostgreSQL ou SQLite ? SQLite suffit à un seul processus qui écrit : c'est ce qu'utilisent les tests (sqlite:///<tmp>/mlflow.db). Le serveur, lui, reçoit des écritures concurrentes : mlflow.genai.evaluate exécute les prédictions et les juges dans plusieurs fils, chacun exporte ses traces, et le serveur tourne avec deux workers. SQLite verrouille la base entière à chaque écriture ; sous cette charge, les erreurs database is locked apparaissent. PostgreSQL gère ces écritures concurrentes, et c'est la base que vous utiliserez en équipe ; autant développer dessus.
La mise en œuvre
L'image officielle ghcr.io/mlflow/mlflow:v3.17.1 ne contient aucun pilote PostgreSQL, et SQLAlchemy 2.1.4, qu'elle embarque, choisit psycopg (version 3) pour une URI postgresql://. Le Dockerfile du projet ajoute exactement ce pilote :
FROM ghcr.io/mlflow/mlflow:v3.17.1
RUN pip install --no-cache-dir "psycopg[binary]==3.2.10" mlflow:
build: ./docker
image: lab-mlflow-server:3.17.1
depends_on:
db:
condition: service_healthy
command: >
mlflow server --host 0.0.0.0 --port 5000
--backend-store-uri postgresql://mlflow:${POSTGRES_PASSWORD:-mlflow-lab}@db:5432/mlflow
--artifacts-destination /mlartifacts --serve-artifacts --workers 2
ports:
- "127.0.0.1:${MLFLOW_PORT:-5050}:5000"Le port n'est publié que sur 127.0.0.1 : le serveur n'a aucune authentification dans cette configuration, il ne doit pas être joignable depuis le réseau. Le port hôte est 5050 parce que 5000 est souvent pris (AirPlay sous macOS).
Le Makefile pilote la pile sous le nom de projet lab-mlflow, attend les contrôles de santé, puis vérifie l'arrêt :
COMPOSE := MLFLOW_PORT=$(MLFLOW_PORT) docker compose -p lab-$(SLUG)
up: ## Démarre MLflow + PostgreSQL et attend qu'il réponde
@$(COMPOSE) up -d --build --wait
@curl -sf $(MLFLOW_TRACKING_URI)/health >/dev/null && echo "MLflow prêt : $(MLFLOW_TRACKING_URI)"
down: ## Arrête la pile et supprime ses volumes (runs, traces, artefacts)
@$(COMPOSE) down -v --remove-orphans
@test -z "$$(docker ps -aq --filter label=com.docker.compose.project=lab-$(SLUG))" && \
test -z "$$(docker volume ls -q --filter label=com.docker.compose.project=lab-$(SLUG))" && \
echo "Vérifié : aucun conteneur ni volume lab-$(SLUG)."Le Makefile ne fait pas include .env suivi d'export : make up MLFLOW_PORT=5051 doit l'emporter sur .env. Il lit le port dans .env (défaut 5050) et le transmet explicitement à docker compose et, sous forme de MLFLOW_TRACKING_URI, à lab.py ; ceux-ci lisent le reste de .env eux-mêmes. Vérifié : make up MLFLOW_PORT=5051 puis curl -s localhost:5051/version répond 3.17.1.
Résultat obtenu (2026-10-16)
$ make up
Container lab-mlflow-db-1 Healthy
Container lab-mlflow-mlflow-1 Healthy
MLflow prêt : http://localhost:5050
$ docker ps --filter label=com.docker.compose.project=lab-mlflow
lab-mlflow-mlflow-1 lab-mlflow-server:3.17.1 Up 15 minutes (healthy) 127.0.0.1:5050->5000/tcp
lab-mlflow-db-1 postgres:16-alpine Up 15 minutes (healthy) 5432/tcp
$ curl -s localhost:5050/version
3.17.1
$ make down
Volume lab-mlflow_pgdata Removed
Volume lab-mlflow_artifacts Removed
Network lab-mlflow_default Removed
Vérifié : aucun conteneur ni volume lab-mlflow.Ce qui casse en production
- Pilote absent. Symptôme : le conteneur redémarre en boucle avec
ModuleNotFoundError: No module named 'psycopg'. Diagnostic :docker compose -p lab-mlflow logs mlflow. Parade : le Dockerfile ci-dessus ; installerpsycopg2ne sert à rien, ce n'est pas le pilote que choisit SQLAlchemy 2.1. - Serveur exposé. Un
ports: "5050:5000"publie le serveur sur toutes les interfaces, sans mot de passe : n'importe qui sur le réseau lit les traces, donc les questions des collaborateurs. Parade :127.0.0.1:en local ; en équipe, un mandataire authentifiant devant le serveur, ou l'authentification intégrée de MLflow. - Artefacts hors du serveur. Sans
--serve-artifacts, le client écrit les artefacts directement dans le chemin déclaré, qui n'existe que dans le conteneur :log_modeléchoue sur le poste. Parade : mode mandataire, ou stockage objet partagé (bucket) accessible des deux côtés. - Volume oublié.
docker compose downsans-vgarde 400 Mo de traces, et le prochainmake uprepart avec d'anciennes versions de prompts. Parade :make downfaitdown -vet vérifie qu'aucun volume ne reste.
La règle de décision
En local et en équipe : MLflow serveur, base PostgreSQL, artefacts servis par le serveur. SQLite pour les tests et les essais d'un seul processus. Ne publiez jamais le port d'un serveur sans authentification au-delà de 127.0.0.1. Surveillez la taille de la base : les traces y croissent avec chaque appel.
Pratique
Une collègue lance mlflow server avec --backend-store-uri sqlite:///mlflow.db et --workers 4, puis une évaluation parallèle. Une partie des traces manque et le journal du serveur montre des erreurs. Que changez-vous ?
SQLite verrouille toute la base à chaque écriture ; quatre workers et une évaluation parallèle écrivent en même temps. Un seul worker réduit le problème sans le supprimer (l'évaluation reste parallèle côté client) et ralentit l'interface. Désactiver le traçage retire précisément ce qu'on veut observer.
make down a affiché « Vérifié : aucun conteneur ni volume lab-mlflow », mais docker system df montre encore 1 Go d'images. Est-ce un défaut du nettoyage ?
Les données du lab (base, artefacts) sont dans les volumes, supprimés et vérifiés. Supprimer l'image à chaque down forcerait une reconstruction à chaque up. make reset retire l'image du lab ; un prune global toucherait aux images d'autres projets.
Exercice. Ajoutez à la cible up une vérification qui échoue si le serveur répond mais ne peut pas écrire en base (par exemple : créer une expérience de test puis la supprimer).
Voir la solution
@$(PY) -c "import mlflow; mlflow.set_tracking_uri('$(MLFLOW_TRACKING_URI)'); \
c = mlflow.MlflowClient(); e = c.create_experiment('lab-mlflow-sonde'); c.delete_experiment(e)" \
&& echo "Écriture en base vérifiée."/health répond dès que le processus HTTP est prêt, même si la base refuse les écritures (droits, disque plein). Une écriture réelle teste la chaîne complète. Une expérience supprimée reste dans la corbeille de MLflow : le nom lab-mlflow-sonde ne peut être réutilisé que si l'on supprime aussi la corbeille (mlflow gc), d'où un nom unique par exécution en pratique (lab-mlflow-sonde-<horodatage>).
- Je démarre et j'arrête la pile sous un nom de projet dédié, volumes compris.
- Je sais pourquoi le serveur utilise PostgreSQL et les tests SQLite.
- Je ne publie le port que sur
127.0.0.1. - Je vérifie après
make downqu'aucun conteneur ni volume ne reste.