From a577356198d7fbba3ac61e5483996c3c26461450 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 22 Sep 2026 08:35:42 +0200 Subject: [PATCH] feat(infra): fait tourner Airflow 3 dans la stack Compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image apache/airflow:3.3.2-python3.12. Le webserver devient l'api-server (healthcheck /api/v2/monitor/health) et le dag-processor est un service à part : depuis Airflow 3 le scheduler ne parse plus les DAGs. Les tâches passent par l'Execution API de l'api-server avec un jeton signé par AIRFLOW_JWT_SECRET, nouveau secret du .env partagé entre conteneurs. AIRFLOW_WEBSERVER_SECRET_KEY devient AIRFLOW_API_SECRET_KEY. airflow-init refuse toujours de démarrer si l'un des secrets manque. FabAuthManager explicite : le SimpleAuthManager par défaut ne sait pas créer le compte admin que airflow-init pose via _AIRFLOW_WWW_USER_*. Pas de triggerer, aucun opérateur déférable dans les DAGs. --- .env.example | 9 ++++--- .github/workflows/airflow.yml | 5 ++-- Makefile | 8 +++---- db/init/120-airflow-database.sql | 2 +- docker-compose.prod.yml | 2 +- docker-compose.yml | 40 +++++++++++++++++++++++--------- etl/airflow/Dockerfile | 11 ++++----- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index e909bd1..3f6e626 100644 --- a/.env.example +++ b/.env.example @@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me APP_MOCK_API_PASSWORD=change_me APP_MOCK_API_TIMEOUT_SECONDS=10 -# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le +# Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le # même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus. AIRFLOW_PORT=8080 # Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" AIRFLOW_FERNET_KEY=change_me -# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la +# Clé de session de l'api-server Airflow, distincte de la précédente. Générer la # vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" -AIRFLOW_WEBSERVER_SECRET_KEY=change_me +AIRFLOW_API_SECRET_KEY=change_me +# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret +# doit être partagé). Même générateur que ci-dessus. +AIRFLOW_JWT_SECRET=change_me AIRFLOW_ADMIN_USERNAME=admin # Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les # comptes `app_user` d'EnerVision. diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index eeab85e..5e72195 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -1,8 +1,9 @@ name: Airflow # Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 -# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de -# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). +# (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests +# doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image +# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). # # Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une # modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans diff --git a/Makefile b/Makefile index 9d4beb4..9e257ff 100644 --- a/Makefile +++ b/Makefile @@ -108,12 +108,12 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow -airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. - docker compose up -d airflow-init airflow-webserver airflow-scheduler +airflow-up: ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant. + docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" -airflow-down: ## Arrête le webserver et le scheduler Airflow - docker compose stop airflow-webserver airflow-scheduler +airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow + docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) docker compose logs -f airflow-scheduler diff --git a/db/init/120-airflow-database.sql b/db/init/120-airflow-database.sql index 05b7f72..fb0d9b2 100644 --- a/db/init/120-airflow-database.sql +++ b/db/init/120-airflow-database.sql @@ -1,4 +1,4 @@ --- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base +-- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base -- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire -- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un -- service dedie, pour ne pas ajouter un conteneur de plus (issue #115). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3d374cb..058c089 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,7 +17,7 @@ services: ports: !override - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" - airflow-webserver: + airflow-apiserver: ports: !override - "127.0.0.1:${AIRFLOW_PORT:-8080}:8080" diff --git a/docker-compose.yml b/docker-compose.yml index da19f43..d06b79b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,9 @@ name: enervision -# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du -# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte -# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce +# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de +# l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte +# que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce # dernier puisse au besoin l'inspecter sans en devenir dependant. x-airflow-common: &airflow-common build: @@ -19,9 +19,16 @@ x-airflow-common: &airflow-common # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de # filtrer les services : une variable requise manquante casserait aussi `make db-up`, # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par - # `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. + # `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent. AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} - AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} + AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-} + # Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret + # généré au démarrage ne serait pas partagé, il doit venir du .env. + AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-} + AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/ + # FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider + # FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`. + AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) : # memes identifiants que le backend en attendant. @@ -110,11 +117,11 @@ services: # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume - # `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son + # `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler, qui attendent son # succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, # jamais par `argv` (ni `ps`, ni `docker compose config`). # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous - # refuse en plus les deux cles de chiffrement vides. + # refuse en plus les cles et secrets vides. airflow-init: <<: *airflow-common restart: "no" @@ -134,13 +141,14 @@ services: - | set -euo pipefail : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" - : "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" + : "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}" + : "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}" : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" exec airflow version - airflow-webserver: + airflow-apiserver: <<: *airflow-common - command: webserver + command: api-server ports: - "${AIRFLOW_PORT:-8080}:8080" depends_on: @@ -149,7 +157,7 @@ services: airflow-init: condition: service_completed_successfully healthcheck: - test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] + test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"] interval: 30s timeout: 10s retries: 5 @@ -164,6 +172,16 @@ services: airflow-init: condition: service_completed_successfully + # Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même. + airflow-dag-processor: + <<: *airflow-common + command: dag-processor + depends_on: + db: + condition: service_healthy + airflow-init: + condition: service_completed_successfully + volumes: pgdata: airflow_logs: diff --git a/etl/airflow/Dockerfile b/etl/airflow/Dockerfile index b934588..4212839 100644 --- a/etl/airflow/Dockerfile +++ b/etl/airflow/Dockerfile @@ -1,9 +1,9 @@ # Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python -# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte -# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, +# 3.14, distincts du Python 3.12 de l'image de base qui fait tourner Airflow lui-meme, pour que +# les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, # `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un # consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008. -FROM apache/airflow:2.10.4-python3.12 +FROM apache/airflow:3.3.2-python3.12 # LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans # toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file` @@ -21,9 +21,8 @@ RUN apt-get update \ RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend USER airflow -# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de -# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot -# (apps/backend/Dockerfile). +# L'image de base embarque deja un `uv`, mais pas celui que le depot epingle par ailleurs +# (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout. COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv # Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run`