Skip to content

Comprendre la promotion multi-environnements

Ce guide explique comment une version d'image avance de dev vers staging puis prod, sur un cluster unique découpé en trois environnements.

ADR lié : Stratégie multi-environnements (Décision 2). Voir aussi Comprendre ArgoCD et GitOps avec ArgoCD.


En une phrase

Le tag de l'image vit dans l'overlay de chaque env (pas dans base) : dev est bumpé automatiquement à chaque merge sur develop, staging et prod avancent uniquement par une MR qui recopie le tag validé de l'env amont. Aucun rebuild : on fait avancer la même image, déjà construite et scannée.

Où vit la version déployée

Chaque environnement a son propre newTag dans son kustomization.yaml :

k8s/overlays/
  fastapi/{dev,staging,prod}/kustomization.yaml   → images[].newTag
  frontend/{dev,staging,prod}/kustomization.yaml  → images[].newTag

« Quelle version tourne où » se lit donc dans trois fichiers, dans le même commit. Les trois envs peuvent tourner sur trois versions différentes en même temps (dev en avance, prod stable) : c'est tout l'intérêt d'un pipeline de promotion.

Le flux global

flowchart LR
    merge["merge sur develop<br/>(build + scan + push ECR)"]
    merge -->|"write-back CI<br/>(automatique)"| dev["overlays/*/dev<br/>newTag = sha"]
    dev -->|"MR de promotion<br/>(promote.sh dev staging)"| stg["overlays/*/staging<br/>newTag = sha"]
    stg -->|"MR de promotion + review<br/>(promote.sh staging prod)"| prod["overlays/*/prod<br/>newTag = sha"]
    dev -.->|sync| ad[(ArgoCD dev)]
    stg -.->|sync| as[(ArgoCD staging)]
    prod -.->|sync| ap[(ArgoCD prod)]

Le déroulé détaillé, avec les gates

Le schéma ci-dessous suit une promotion réelle (2026-10-15, deployed-b15a0c5c) : qui lance quoi, où tournent les tests, et ce qui arrête la promotion. Les vues guidées du viewer isolent le palier staging, le gate de la prod et le contrôle après merge.

Ouvrir le schéma en plein écran

1. dev = automatique

À chaque merge sur develop, le job CI update-image-tag (write-back GitOps) réécrit le newTag de overlays/fastapi/dev et overlays/frontend/dev, commit en [skip ci] et push sur develop. ArgoCD détecte le diff et déploie dev seul. staging et prod ne bougent pas.

sequenceDiagram
    participant Dev as Développeur
    participant CI as CI (develop)
    participant Git as Git (overlays/dev)
    participant Argo as ArgoCD
    Dev->>CI: merge sur develop
    CI->>CI: build + scan + push ECR (deployed-sha)
    CI->>Git: kustomize edit set image (dev) + commit [skip ci]
    Git-->>Argo: diff détecté sur overlays/*/dev
    Argo->>Argo: sync → déploie dev

Le parcours E2E sur dev (e2e-dev, #140)

Juste après le write-back, le job e2e-dev rejoue la suite e2e/ contre api-dev.devopsyouss.com. Il repère une image cassée avant la promotion en staging.

Le write-back n'écrit que dans Git : ArgoCD déploie ensuite, sans délai garanti. Le job commence donc par attendre que dev serve l'image de ce pipeline (scripts/e2e-wait-dev.sh, qui lit GET /version, jusqu'à 10 minutes). Quatre issues :

Ce que voit le script Sortie Résultat du job
dev sert ce commit 0 la suite E2E tourne, vert ou rouge
dev sert un commit plus récent 10 succès sans tester : le pipeline suivant teste sa propre image
erreur réseau (DNS, connexion refusée) 3 ⚠ avertissement : cluster éteint, cas courant
dev répond mais ne sert jamais ce commit 1 ❌ rouge : sync ArgoCD, pod en crash, migration

Deux choix à connaître :

  • Cluster éteint = avertissement, pas rouge. Un rouge à chaque merge, cluster éteint, deviendrait du bruit qu'on apprend à ignorer. Le gate bloquant reste e2e-staging, sur la promotion vers prod.
  • Une erreur HTTP n'est pas « cluster éteint ». Un 503 d'Envoy prouve que le cluster tourne et que les pods de dev vont mal : c'est la panne à attraper. Seules les erreurs réseau donnent l'avertissement. Ce piège a été trouvé en testant le script, qui classait d'abord toute erreur comme « injoignable ».

2. staging et prod = MR de promotion

Promouvoir = recopier le tag de l'env amont vers l'aval, sur une branche, puis MR vers develop. Le script scripts/promote.sh fait la recopie et prépare la branche (il ne pousse pas, ne merge pas) :

# dev → staging
scripts/promote.sh dev staging
git push -u origin promote/staging-<sha>
# → ouvrir une MR vers develop, relire, merger

# staging → prod (après validation de staging)
scripts/promote.sh staging prod
git push -u origin promote/prod-<sha>
# → MR vers develop, review obligatoire avant merge

Une fois la MR mergée, ArgoCD voit le nouveau newTag dans l'overlay cible et déploie l'env correspondant. Le saut direct dev → prod est interdit par le script : staging est un palier obligatoire.

Pourquoi le script n'utilise plus kustomize edit set image (#151)

Il l'utilisait jusqu'au 2026-07-30, et produisait 43 lignes de diff sur 2 fichiers pour un bump de tag qui devrait en toucher 2.

kustomize edit set image ne modifie pas la ligne visée : il relit le fichier dans sa représentation interne puis le réécrit entièrement dans son style canonique. Indentation des listes renormalisée, clés des patches réordonnées, et surtout commentaires remontés au-dessus de leur section, donc détachés de ce qu'ils documentent.

Le rendu de kustomize build étant identique, il n'y avait aucun impact fonctionnel. Le dégât était ailleurs : un diff de 43 lignes rend la relecture de la MR de promotion inopérante, alors que c'est la seule barrière de relecture avant la prod, le gate Code Owner étant une fonctionnalité Premium (voir #140).

Le script édite désormais la ligne newTag: en place. Mesuré sur une promotion réelle dev staging :

Avant Après
Lignes de diff 43 2
Fichiers touchés 2 2
Différence dans kustomize build tag d'image seul tag d'image seul
kustomize requis dans le PATH oui non

newName n'est pas touché : il porte le registre ECR, identique d'un env à l'autre. Seul le tag avance.

La leçon générale : un outil qui réécrit un fichier au lieu de le modifier détruit tout ce que le format portait et que l'outil ne modélise pas — ici les commentaires, qui sont précisément ce sur quoi s'appuie la relecture humaine.

Le script exige develop, propre et à jour

Il lit le newTag dans le working tree courant et branche depuis le HEAD courant. Lancé depuis une branche obsolète, il promouvrait un tag périmé ; lancé avec des modifications en cours, il les emporterait sur la branche de promotion.

Dans les deux cas la MR produite a l'air parfaitement normale : le mode de défaillance est silencieux, et c'est ce qui le rend dangereux. Le script refuse donc de démarrer si la branche n'est pas develop, si le working tree n'est pas propre, ou si develop n'est pas aligné sur origin/develop.

Pourquoi dev gardait toujours une longueur d'avance

C'était structurel, et ça surprenait au premier abord : promouvoir vers staging faisait avancer dev.

La MR de promotion est mergée sur develop. Ce merge est un push sur develop, donc il déclenchait le pipeline complet — build, scan, promote-image, puis le write-back qui bumpe les overlays de dev. Staging recevait le tag N, et dev passait à N+1 dans la foulée.

flowchart TD
    m["MR de promotion mergée<br/>staging : N-1 → N"] --> p["pipeline sur develop<br/>(aucun filtrage par chemin)"]
    p --> b["build + scan + promote-image<br/>nouveau tag N+1"]
    b --> w["write-back<br/>overlays/*/dev : N → N+1"]
    w --> r["dev repart avec un cran d'avance"]

Staging ne rattrapait donc jamais dev. Ce n'était pas un défaut du script de promotion, mais la conséquence de deux choix : le write-back automatique sur dev, et l'absence de filtrage par chemin dans le pipeline.

Ce qui a changé : le filtrage par chemin (#157)

Depuis !314, la chaîne image ne se déclenche que si backend/, frontend/ ou .gitlab-ci.yml a changé. Une MR de promotion ne touche que k8s/overlays/** : elle ne construit plus rien, ne pousse plus dans ECR, et ne fait plus avancer dev.

Le cercle est rompu : staging peut rattraper dev.

Ce filtrage tient en trois lignes parce que #161 a d'abord déplacé le backend sous backend/. Écrite avant, la même règle aurait dû énumérer app/**, alembic/**, requirements*, Dockerfile, tests/** — une liste à ne pas oublier, exactement le défaut qu'on venait de corriger ailleurs.

Les neuf jobs de la chaîne partagent le même déclencheur, volontairement

On pourrait vouloir plus fin : ne reconstruire que le backend quand seul le backend change. C'est un piège.

Les jobs sont chaînés par needs:, et update-image-tag comme tag-inuse-images dépendent des deux promote. Or un needs vers un job absent du pipeline n'est pas un job sauté, c'est une erreur de création : le pipeline n'existerait pas.

Le contournement par needs: optional: true serait pire : le write-back bumperait le tag d'une image jamais promue, et ArgoCD tirerait une référence inexistante — soit INC-062 reproduit volontairement.

Un pipeline sans aucun job est failed, pas skipped

Une fois tout filtré, une MR ne touchant que docs/ n'a plus un seul job à lancer. Un pipeline vide n'est pas neutre : GitLab le crée, le marque failed et n'émet aucune erreur YAML. C'est le mécanisme exact du schedule de 21h (pipeline 2762923575).

Le projet exigeant un pipeline vert pour merger (only_allow_merge_if_pipeline_succeeds), et allow_merge_on_skipped_pipeline ne couvrant que skipped, toute MR de documentation serait devenue immergeable.

D'où le job pipeline-scope : quelques secondes, aucune condition, il garantit qu'un pipeline n'est jamais vide et affiche le périmètre détecté — la seule trace lisible quand la chaîne image ne tourne pas.

Et l'image change vraiment, pas seulement son tag

On pourrait croire que N et N+1 désignent le même contenu, le code applicatif n'ayant pas bougé. Ce n'est pas le cas, et la raison est plus profonde qu'il n'y paraissait.

Le .dockerignore laissait passer k8s/ dans le contexte de build, donc les manifestes étaient copiés dans l'image backend et modifier un overlay suffisait à changer le digest. C'est corrigé depuis !288 : le contexte ne contient plus que l'applicatif.

Le digest change quand même. Mesuré le 2026-08-11 : deux builds sans le moindre changement de contexte produisent deux digests différents. Sur 10 couches, les 4 de l'image de base (épinglée par digest) sont stables et les 6 couches construites dérivent spontanément — groupadd, apt-get --only-upgrade, la reconstruction du venv, la suppression des caches pip.

Conséquences, inchangées : dev redéploie réellement pour un changement qui ne le concerne pas, chaque MR de promotion consomme un build, un scan et un push ECR, et la rétention du registre se remplit d'autant plus vite (voir INC-062).

Le seul levier qui supprime l'avance est donc le filtrage du pipeline par chemin, pour qu'un changement purement manifeste ou documentaire ne déclenche aucun build. Nettoyer le contexte de build, lui, ne rend pas deux images identiques : il réduit la surface de l'image et découple son contenu des manifestes, ce qui est utile mais ne suffit pas ici. Livré en #157, voir la section suivante.

Le gate de promotion prod

La promotion vers prod est le moment sensible. Trois garde-fous :

  • La MR de promotion elle-même : sur develop (branche protégée, merge réservé Maintainer), tu relis le diff — un simple changement de tag — et tu merges. Pour ce projet, c'est le point de contrôle humain.
  • .gitlab/CODEOWNERS déclare overlays/*/prod comme possédé, ce qui auto-suggère le reviewer. L'enforcement d'une approbation obligatoire par code owner est une fonctionnalité GitLab Premium+ : sur un plan Free il ne bloque pas, il documente et suggère.
  • E2E staging vert : c'est le gate « dur » et gratuit, livré en #140. Détail ci-dessous.

Le job e2e-staging (#140)

Une MR vers develop qui touche k8s/overlays/*/prod/ déclenche le job e2e-staging. Il joue un parcours utilisateur contre api-staging.devopsyouss.com : inscription, login, création d'un post, vote, recherche, suppression. Le merge exigeant un pipeline vert, un rouge bloque la promotion.

flowchart LR
    mr["MR staging → prod<br/>(touche overlays/*/prod)"] --> job[e2e-staging]
    job -->|HTTP| stg[(api-staging)]
    job -->|Chromium| app[(app-staging)]
    app -->|fetch cross-origin| stg
    job -->|vert| ok[merge possible]
    job -->|rouge| ko[merge bloqué]

Le job joue aussi un parcours navigateur (e2e/test_ui_journey.py) : un vrai Chromium, piloté par Playwright, ouvre app-staging.devopsyouss.com et fait ce que ferait un utilisateur. Inscription, pseudo déjà pris, publication, vote, détail, modification, suppression, et vérification que les boutons Modifier/Supprimer n'apparaissent que sur ses propres posts. Il échoue aussi si l'email d'un autre utilisateur arrive jusqu'au navigateur (#229).

Il attrape ce que la suite API ne peut pas voir : la config runtime du frontend (config.js par overlay, #156), CORS, et les règles d'affichage. Vérifié en local : avec ALLOWED_ORIGINS volontairement faux, la suite API reste verte (7 passed) et le parcours navigateur échoue, car le navigateur bloque la réponse.

Playwright n'a pas de navigateurs pour Alpine : le parcours navigateur ne tourne donc que dans e2e-staging (image mcr.microsoft.com/playwright/python). Dans e2e-dev (image Alpine), il est sauté automatiquement. Le tag de l'image et playwright== dans e2e/requirements-ui.txt vont de pair.

Ce que ce test prouve et que pytest ne prouve pas : la suite backend/tests/ tourne contre le code, avec une base jetable. La suite e2e/ tourne contre l'image déployée, avec ses vrais secrets, sa vraie base et son vrai réseau. Elle ne connaît l'API que par HTTP, comme un client.

Pour la lancer à la main :

pip install -r e2e/requirements.txt
E2E_BASE_URL=http://localhost:8080 pytest e2e/     # stack docker-compose-dev
E2E_BASE_URL=https://api-staging.devopsyouss.com pytest e2e/

# Avec le parcours navigateur, dans l'image du job (navigateurs fournis) :
docker run --rm --network host -v "$PWD":/repo -w /repo \
  -e E2E_BASE_URL=http://localhost:8080 -e E2E_APP_URL=http://localhost:3000 \
  mcr.microsoft.com/playwright/python:v1.55.0-noble \
  sh -c 'pip install -q -r e2e/requirements.txt -r e2e/requirements-ui.txt && pytest e2e/'

Pour voir le parcours navigateur, trois variables (absentes en CI) : E2E_HEADED=1 ouvre une fenêtre Chromium, E2E_SLOWMO=400 ralentit chaque action, E2E_ARTIFACTS=<dossier> enregistre trace.zip (à ouvrir sur https://trace.playwright.dev, lu localement) et la vidéo du parcours.

Trois conséquences à connaître :

  • Cluster éteint = aucune promotion prod. Staging ne répond pas, le job est rouge. C'est voulu : une image n'arrive plus en prod sans avoir tourné en staging.
  • La MR de release develop → main n'est pas concernée. Elle rejoue un sprint, ce n'est pas une promotion.
  • Modifier e2e/ ne déclenche pas le job. Sans cluster, il serait rouge à chaque MR. La suite se valide localement contre docker-compose-dev.yaml.

Tester la bonne image (#228)

Sans précaution, le job teste ce que staging sert, pas forcément l'image qu'on promeut. Si ArgoCD n'a pas encore synchronisé staging, le job teste l'ancienne image et passe à tort : un faux vert.

D'où une vérification préalable :

  1. Au build, la CI passe le SHA du commit à l'image (--build-arg GIT_SHA).
  2. L'API l'expose sur GET /version : {"sha": "<sha>"}.
  3. Le job lit le tag promu dans k8s/overlays/fastapi/prod/kustomization.yaml (deployed-<sha>) et le premier test compare les deux. Différents = rouge.

En local, sans E2E_EXPECTED_SHA, ce test est sauté (skipped).

Deux limites

  • Seul le back est vérifié. Le frontend n'expose pas de SHA ; son tag promu n'est pas comparé à ce que sert app-staging.
  • Une image construite avant #228 n'a pas de /version et ne peut pas passer le gate. Sans effet en pratique : toute promotion future porte une image neuve.

Après le merge : le smoke test de la prod (e2e-prod, #234)

e2e-staging teste staging avant le merge. Une fois la MR de promotion mergée, ArgoCD déploie la prod, et rien ne vérifiait qu'elle servait bien l'image promue.

Le job e2e-prod comble ce trou. Il tourne sur develop quand un push touche k8s/overlays/*/prod, donc au merge d'une promotion. Il fait deux choses :

  1. Il attend que api.devopsyouss.com/version serve le tag promu. C'est le même script que e2e-dev, avec E2E_EXPECTED_SHA et E2E_ENV=prod. Mêmes sorties : rouge après 10 minutes sans le bon SHA, avertissement si la prod est injoignable.
  2. Il joue les tests marqués readonly de e2e/test_api_journey.py : /version, /healthz/ready, le flux public, la pagination et le refus sans jeton.

Aucun test qui écrit en prod

L'inscription et la création de post restent hors du smoke test : la base de prod se remplirait de comptes de test, et l'API n'expose pas de DELETE pour les utilisateurs. Un nouveau test ne reçoit @pytest.mark.readonly que s'il n'écrit rien.

Pas de recoupement avec #225 : smoke-image lance l'image candidate dans la CI, avant tout déploiement. e2e-prod interroge l'image déployée, dans le cluster.

Rollback

Revenir en arrière = promouvoir un ancien tag. On recopie manuellement le tag connu-bon dans l'overlay cible (ou kustomize edit set image à la main), MR, merge. Comme chaque promotion est une MR, l'historique Git donne la liste des tags par env : retrouver le dernier tag sain est trivial.

Et après ? Vers une promotion outillée (Kargo & alternatives)

Le flux actuel est manuel par choix pédagogique : on manipule toute la mécanique à la main pour la comprendre. Il évoluera vers de l'outillage dédié (noté en « Évolution possible » de l'ADR 029) :

  • Kargo — l'évolution cible. Modélise la promotion en Stages et Freight : il détecte une nouvelle image, la promeut automatiquement dev → staging après vérification (tests, health), et garde prod en promotion manuelle approuvée. C'est le pattern d'entreprise « auto jusqu'à staging, gate humaine avant prod », sans le geste promote.sh à la main.
  • Argo Rollouts — progressive delivery dans un env : canary / blue-green + analyse automatique des métriques avant de basculer 100 % du trafic.
  • ArgoCD Image Updater — écarté : il bumpe le tag automatiquement partout, contraire à une promotion contrôlée vers prod.
flowchart LR
    subgraph Aujourdhui["Aujourd'hui — manuel (promote.sh)"]
        d1[dev] -->|MR à la main| s1[staging] -->|MR à la main| p1[prod]
    end
    subgraph Cible["Cible — Kargo"]
        d2[dev] -->|"auto après<br/>vérification"| s2[staging] -->|"gate humaine<br/>approuvée"| p2[prod]
    end

Le socle multi-env (namespaces, overlays, promotion par MR) est le prérequis : Kargo s'installe par-dessus cette structure, il ne la remplace pas.