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
503d'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/CODEOWNERSdéclareoverlays/*/prodcomme 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→mainn'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 contredocker-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 :
- Au build, la CI passe le SHA du commit à l'image (
--build-arg GIT_SHA). - L'API l'expose sur
GET /version:{"sha": "<sha>"}. - 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
/versionet 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 :
- Il attend que
api.devopsyouss.com/versionserve le tag promu. C'est le même script quee2e-dev, avecE2E_EXPECTED_SHAetE2E_ENV=prod. Mêmes sorties : rouge après 10 minutes sans le bon SHA, avertissement si la prod est injoignable. - Il joue les tests marqués
readonlydee2e/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 → stagingaprès vérification (tests, health), et gardeproden promotion manuelle approuvée. C'est le pattern d'entreprise « auto jusqu'à staging, gate humaine avant prod », sans le gestepromote.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.