Files
ENI-projet-piscine/docs/architecture/10-infra.md
T

18 KiB

Infrastructure

Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.

Topologie Sert à Statut
Docker Compose Développer et recetter sur le poste Fait
Docker Compose plus reverse proxy Déployer sur la machine on-premise Fait
k3s single-node Cible à terme En cours

Poste de développement

Statut : Fait. Défini par docker-compose.yml, projet enervision.

flowchart TB
  subgraph poste["Poste de développement"]
    ng["ng serve<br/>:4200"]
    api["uvicorn --reload<br/>:8000"]
  end

  subgraph compose["docker compose"]
    back["service backend<br/>image construite depuis apps/backend"]
    db[("service db<br/>timescale/timescaledb-ha:pg17")]
  end

  ng -.->|"proxy /api"| api
  api -->|"hôte :5433 vers conteneur :5432"| db
  back -->|"réseau interne, db:5432"| db
Service Image Points notables
db timescale/timescaledb-ha:pg17 Publié sur 5433 côté hôte, 5432 souvent déjà pris. healthcheck pg_isready, 12 tentatives, start_period 40s
backend Construite depuis apps/backend depends_on: db, condition: service_healthy. N'embarque pas le source : toute modification impose docker compose up -d --build backend

La boucle de développement n'utilise pas le service backend. make db-up puis make dev : seule la base tourne en conteneur, l'API et ng serve tournent sur le poste avec le rechargement à chaud, lancés ensemble par make dev (make dev-backend/make dev-frontend pour lancer l'un des deux seul). Le service backend sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se lancent donc pas ensemble.

Trois pièges sont documentés en tête du docker-compose.yml, ils ne se devinent pas :

  • PGDATA vaut /home/postgres/pgdata/data pour l'image -ha, et non le chemin habituel de l'image postgres. Monté ailleurs, le volume ne retient rien, sans le moindre message.
  • db/init est monté fichier par fichier. Monter le dossier masquerait les scripts d'init de l'image, dont timescaledb-tune. Ajouter un fichier dans db/init/ impose donc une ligne dans le compose. Voir db/README.md.
  • LocalExecutor exécute les tâches comme sous-processus du scheduler, jamais du webserver : c'est le scheduler qui a besoin du volume airflow_ml_state (modèle, magasin MLflow).

Airflow (issues #115, #116 et #119)

Trois services Airflow sont définis dans docker-compose.yml. Les docker compose profiles ne sont pas utilisés : le démarrage reste explicite via make airflow-up et Airflow ne fait pas partie de la boucle make dev.

Service Rôle Points notables
airflow-init Migre la base de métadonnées et crée le compte admin Conteneur jetable (restart: "no"). webserver et scheduler attendent qu'il se termine avec succès
airflow-webserver Interface Airflow sur le port 8080 Avec LocalExecutor, il n'exécute aucune tâche lui-même
airflow-scheduler Planifie et exécute les tâches Les DAGs tournent en sous-processus avec LocalExecutor

LocalExecutor exécute les tâches dans le scheduler et non dans le webserver.

Les services sont construits depuis :

etl/airflow/Dockerfile

avec la racine du dépôt comme contexte Docker.

Airflow 2.10.4 fonctionne avec Python 3.12, tandis que le pipeline ML et le backend utilisent des dépendances Python 3.14.

L'image Airflow embarque donc deux environnements distincts :

/opt/ml/.venv
/opt/backend/.venv

Le premier contient le pipeline Machine Learning.

Le second contient le backend EnerVision utilisé par les DAGs alertes et historical_import.

Cette séparation évite d'installer directement LightGBM, MLflow ou les dépendances SQLAlchemy du backend dans l'environnement Python utilisé par Airflow.

Le choix est décrit dans l'ADR 0008.

Les DAGs actuellement présents sont :

DAG Planification Ce qu'il exécute
ml_train manuelle enervision_ml.train dans /opt/ml/.venv
ml_score 0 * * * * enervision_ml.score dans /opt/ml/.venv
alertes 15 * * * * app.detection.internal_alerts puis app.cli generate-recommendations dans /opt/backend/.venv
historical_import manuelle app.etl.historical_import dans /opt/backend/.venv

Import historique

Le DAG :

historical_import

est défini dans :

etl/airflow/dags/historical_import.py

Il ne contient aucune logique d'import propre.

Il utilise un BashOperator pour exécuter le module backend existant :

app.etl.historical_import

dans l'environnement :

/opt/backend/.venv

Le flux est donc :

Airflow scheduler
      |
      v
historical_import
      |
      v
BashOperator
      |
      v
app.etl.historical_import
      |
      v
PostgreSQL / TimescaleDB

Les fichiers historiques locaux sont montés en lecture seule dans les services Airflow :

./data/raw:/opt/data/raw:ro

Les chemins utilisés depuis le conteneur sont :

/opt/data/raw/all_sites_combined.csv
/opt/data/raw/dataset_metadata.json

Le montage en lecture seule empêche les traitements Airflow de modifier les fichiers sources.

Le dataset historique sert uniquement à initialiser les données de l'environnement.

Le DAG utilise donc :

schedule = None
catchup = False
max_active_runs = 1

Il est déclenché manuellement.

max_active_runs = 1 empêche deux imports du même dataset de s'exécuter simultanément.

La tâche possède également :

retries = 1
retry_delay = 2 minutes
execution_timeout = 30 minutes

Le traitement historique étant idempotent, une reprise après une erreur transitoire ne doit pas créer de doublons.

Deux exécutions manuelles successives ont été validées avec succès.

Après les deux exécutions, PostgreSQL/TimescaleDB contenait toujours :

122647

lectures avec :

source = "csv"

La deuxième exécution n'a donc pas dupliqué les lectures historiques.

DAG alertes

Le DAG alertes est planifié à la quinzième minute de chaque heure.

La règle anomaly compare une lecture à la prediction du même instant, que ml_score écrit à l'heure pile. Le décalage laisse donc du temps au scoring pour terminer.

Aucune dépendance Airflow explicite n'est cependant déclarée entre ml_score et alertes. Quatre règles de détection sur cinq ne dépendent pas du modèle, et l'absence d'un modèle entraîné ne doit pas empêcher les autres alertes d'être produites.

Les deux tâches du DAG alertes s'enchaînent :

detection
    |
    v
recommandations

recommendation.alert_id étant une clé étrangère NOT NULL, la génération des recommandations est exécutée après la détection.

Les traitements sont idempotents en base grâce aux contraintes :

uq_alert_source_reference
uq_recommendation_alert_rule

Chaque tâche possède deux tentatives, deux minutes d'attente entre les tentatives et un plafond de cinq minutes par tentative.

DAGs ML

ml_train reste manuel.

Réentraîner le modèle est coûteux et train.py remplace actuellement le modèle existant sans comparer automatiquement les métriques du nouveau modèle avec celles du précédent.

Tant que ce mécanisme de sélection n'existe pas, le réentraînement reste déclenché humainement.

ml_score est planifié toutes les heures et réutilise le modèle produit par ml_train.

Il utilise :

max_active_runs = 1
retries = 2
execution_timeout = 30 minutes

Deux scorings ne peuvent donc pas s'exécuter simultanément sur les mêmes données.

Configuration Airflow

airflow-init s'appuie sur l'entrypoint de l'image Airflow avec :

_AIRFLOW_DB_MIGRATE
_AIRFLOW_WWW_USER_*

Une migration de la base Airflow qui échoue fait échouer airflow-init.

Le webserver et le scheduler dépendent du succès de ce service et ne démarrent donc pas sur une base de métadonnées non initialisée.

Les variables Airflow sont fournies depuis le fichier .env.

Les secrets ne sont pas passés dans les arguments des processus.

Les variables AIRFLOW_* ne sont volontairement pas déclarées avec ${VAR:?} dans le bloc commun de Docker Compose : Compose interpole le fichier complet même lorsqu'un seul service est démarré.

La validation des secrets nécessaires est réalisée par airflow-init.

Le scheduler reçoit également les variables nécessaires aux traitements backend :

DATABASE_URL
APP_SECRET_KEY

APP_SECRET_KEY est alimentée par :

AIRFLOW_APP_SECRET_KEY

Cette clé est distincte de celle utilisée par l'API EnerVision.

Base de métadonnées Airflow

Airflow utilise une base PostgreSQL dédiée :

airflow

Elle est créée lors de l'initialisation de PostgreSQL par :

db/init/120-airflow-database.sql

Sur un volume pgdata déjà existant, les scripts de docker-entrypoint-initdb.d ne sont pas rejoués automatiquement.

Dans ce cas, la base peut être créée manuellement une fois :

docker compose exec db psql -U enervision -d enervision -c "CREATE DATABASE airflow;"

CI Airflow

Le workflow :

.github/workflows/airflow.yml

utilise Python 3.12 via :

etl/airflow/.python-version

Il vérifie :

formatage Ruff
analyse statique Ruff
tests d'intégrité des DAGs
construction de l'image Airflow

La construction de l'image embarque :

ml/
apps/backend/

Une modification de ces composants peut donc casser l'image Airflow.

La CI vérifie également sans accès réseau que les commandes utilisées par les DAGs sont importables depuis leurs environnements respectifs.

Pour l'import historique, elle exécute notamment :

python -m app.etl.historical_import --help

depuis /opt/backend.

Cette vérification permet de détecter une dépendance backend manquante ou un environnement Docker incomplet sans avoir besoin de démarrer PostgreSQL.

Dépendance système LightGBM

libgomp1 est installé explicitement dans l'image Airflow.

LightGBM dépend de cette bibliothèque OpenMP.

Sans elle, l'image Docker pourrait être construite correctement mais l'import de LightGBM échouerait au moment de l'exécution avec une erreur liée à :

libgomp.so.1

Machine cible, exécution Docker

Statut : Fait. Défini par l'overlay docker-compose.prod.yml, appliqué par-dessus le docker-compose.yml. Écrit et validé sur le poste, jamais encore lancé sur le serveur de l'école. Décision et motifs dans l'ADR 0007.

flowchart LR
  navigateur["Navigateur"]

  subgraph machine["Machine on-premise"]
    proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
    front["service frontend<br/>nginx statique :3000"]
    api["service backend<br/>uvicorn :8000"]
    db[("service db<br/>:5432")]
    mail["service mailpit"]
  end

  navigateur -->|"HTTPS"| proxy
  proxy -->|"/"| front
  proxy -->|"/api/"| api
  api --> db
  api --> mail

Le proxy est le seul service à publier des ports sur le réseau. Backend et frontend ne sont plus publiés du tout, la base et l'interface Mailpit sont ramenées sur 127.0.0.1, donc joignables par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et la commande de validation hors exécution sont dans infra/proxy/README.md.

Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas :

  • Servir le SPA et l'API sous la même origine est ce qui rend le cookie __Secure-ev_refresh utilisable. Sans cela, apiUrl: '/api/v1' ne mène nulle part une fois en conteneur.
  • APP_TRUST_PROXY_HEADERS passe à vrai en même temps, sinon la limitation de débit par IP compte sur l'IP du proxy et devient globale.

Cible à terme, k3s

Statut : En cours. Le module infra/terraform/modules/k3s/ installe le cluster. Il n'a jamais été appliqué.

flowchart LR
  poste["Poste<br/>terraform apply"]
  kube["kubeconfig local"]

  subgraph serveur["Serveur on-premise"]
    k3s["k3s server single-node<br/>Traefik désactivé"]
    charges["Charges de travail<br/>aucune déclarée"]
  end

  poste -->|"SSH, get.k3s.io"| k3s
  k3s -->|"cat /etc/rancher/k3s/k3s.yaml"| kube
  k3s -.-> charges

Ce que le Terraform fait

sequenceDiagram
  participant TF as terraform apply
  participant SRV as Serveur on-premise
  participant L as Poste local

  TF->>SRV: SSH, curl get.k3s.io puis install server
  TF->>SRV: attend /etc/rancher/k3s/k3s.yaml
  TF->>SRV: ssh cat k3s.yaml
  SRV-->>L: kubeconfig, 127.0.0.1 réécrit en ssh_host

Ce que le Terraform ne fait pas

Il déclare le provider null et lui seul : ni kubernetes, ni helm. Aucun namespace, aucun déploiement, aucun service, aucun ingress. À l'issue d'un apply, on dispose d'un cluster vide et d'un kubeconfig, rien de plus.

Décisions figées

Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de code et des description de variables, c'est-à-dire qu'ils ne survivaient pas au premier remaniement.

Décision Raison Où elle est appliquée
k3s single-node plutôt que Kubernetes complet Une seule machine on-premise, pas de plan de contrôle à répartir modules/k3s/main.tf
k3s_version obligatoire, valeur vide refusée Sans épinglage, get.k3s.io installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible validation dans modules/k3s/variables.tf
Traefik désactivé Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut k3s_disable_components, défaut ["traefik"]
Kubeconfig laissé en 600/root, lu par sudo --write-kubeconfig-mode 644 exposerait cluster-admin à tout utilisateur local de la machine Commentaire et fetch_kubeconfig dans modules/k3s/main.tf
State Terraform en backend local Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant environments/dev/versions.tf
.terraform.lock.hcl versionné Fige les versions de provider entre contributeurs et future CI Commentaire dans .gitignore
*.tfvars ignoré, *.tfvars.example versionné Les tfvars portent l'adresse du serveur et le chemin de la clé .gitignore
Désinstallation gérée au destroy k3s-uninstall.sh en on_failure = continue : un serveur injoignable ne bloque pas le destroy modules/k3s/main.tf
Deux racines, dev et prod Séparation des états et des variables par environnement environments/
Terminaison TLS par un reverse proxy Nginx en Compose L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu docker-compose.prod.yml, ADR 0007
Certificat auto-signé par défaut, chemin ACME câblé Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir scripts/tls-selfsigned.sh, infra/proxy/acme-deploy-hook.sh

Ports et noms

Quoi Valeur Remarque
PostgreSQL, côté hôte 5433 Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris
PostgreSQL, côté réseau Compose db:5432 Nom de service, utilisé par DATABASE_URL du service backend
API 8000 Identique en conteneur et hors conteneur
Frontend, ng serve 4200 Boucle de développement. Valeur par défaut d'APP_CORS_ORIGINS
Frontend en conteneur 3000 Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte
Reverse proxy 80 et 443 Les seuls ports publiés par docker-compose.prod.yml. 80 ne sert que la redirection et le défi ACME
SSH du serveur 22 par défaut ssh_port, redéfinissable
Base applicative enervision Variable POSTGRES_DB
Base de test enervision_test Créée par db/init/110-test-database.sql, nom attendu en dur par apps/backend/tests/conftest.py
Base de métadonnées Airflow airflow Créée par db/init/120-airflow-database.sql, même conteneur db
Webserver Airflow 8080 make airflow-up. Scheduler et webserver ne publient que ce port ; les tâches (LocalExecutor) tournent côté scheduler, sans port propre

Le trou vers k3s

Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première question à trancher, avant toute ressource Kubernetes.

Questions ouvertes

  • Quel ingress remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par l'ADR 0007, mais la réponse vaut pour la topologie Compose, pas pour Kubernetes.
  • Quel nom de domaine public, sans lequel Let's Encrypt reste hors d'atteinte et le certificat reste auto-signé.
  • Quel registre d'images, et comment il est alimenté sans CI.
  • Quel stockage persistant côté Kubernetes pour PostgreSQL, et si la base tourne dans le cluster ou à côté.
  • Quelle stratégie de sauvegarde et de restauration des données de mesure.
  • Que devient environments/prod/, aujourd'hui réduit à un .gitkeep.