Skip to content

ADR 029 — Stratégie multi-environnements : dev/staging/prod sur cluster unique par namespace + overlays (2026-07-07)

Statut

Accepté (2026-07-07), pris en amont du code (action rétro Sprint 5 n°2 : « ADR stratégie multi-env AVANT tout code »). Cadre le socle du Sprint 6 (itérations It.0 → It.4 : fondations, isolation par namespace/overlays, data/DNS/secrets par env, promotion par MR, tests E2E + prod). Décisions couvertes : D1 (isolation namespace + overlays), D2 (promotion par MR overlay-driven), D4 (1 RDS multi-database), D5 (main reste archive). Reportés hors socle et notés en « Évolution possible » : Kargo (automatisation de la promotion), red team (tests offensifs, It.5), Kyverno (policy as code, It.6), vCluster (isolation control plane). La validation live (isolation effective, promotion de bout en bout, gate E2E) sera consignée en fin d'It.4, à la manière des ADR 025/026.

Contexte

Depuis le Sprint 3, l'application tourne sur un environnement unique : ce qui est déployé sur le cluster est la production de fait (api.devopsyouss.com). Toute modification (code, manifeste, montée de version) est donc éprouvée directement sur l'environnement qui sert les utilisateurs. Il n'existe aucun palier pour valider un changement avant qu'il n'atteigne la prod : pas de dev où casser sans conséquence, pas de staging iso-prod où jouer des tests de bout en bout. C'est le principal angle mort du projet à ce stade.

La promotion actuelle est mono-env : la CI construit l'image, la pousse sur ECR, écrit le tag dans k8s/base (write-back GitOps, ADR 013), ArgoCD réconcilie. Un seul tag, un seul destinataire. Le mécanisme est sain mais ne sait pas exprimer « cette version est bonne en dev, faisons-la monter en staging puis en prod ».

Contrainte structurante assumée : le projet dispose d'un seul cluster EKS, pour des raisons de coût (projet portfolio). Multiplier les clusters managés par environnement est hors budget. La question n'est donc pas « combien de clusters » mais « comment découper proprement 3 environnements dans un cluster, avec un chemin de promotion tracé, sans dégrader la sécurité ».

Deux dettes identifiées à la rétro Sprint 5 trouvent ici leur place : - les jobs infra start/stop peuvent entrer en collision avec le teardown programmé (2 récidives), à cadenasser (resource_group) avant les sessions live multi-env ; - la question du déploiement prod depuis main (#57, aujourd'hui branche d'archive) doit être tranchée par cette stratégie.

Décision 1 — Isolation par namespace + overlays Kustomize

On matérialise les trois environnements par trois namespaces par workload (fastapi-dev / fastapi-staging / fastapi-prod, idem frontend-*), chacun alimenté par un overlay Kustomize qui pointe sur le base commun et n'exprime que les différences d'environnement (namespace cible, host DNS, nombre de replicas, ressources, chemin des secrets). Le code déployé est identique d'un env à l'autre ; seule la configuration diffère.

Arborescence cible :

k8s/
  base/            # manifests fastapi communs (inchangé)
  frontend/        # manifests frontend communs (inchangé)
  overlays/
    fastapi/{dev,staging,prod}/     # patchs par env (ns, host, replicas, ressources)
    frontend/{dev,staging,prod}/

Instanciation côté ArgoCD : un ApplicationSet avec un générateur de liste [dev, staging, prod] génère les Applications par env, au lieu d'écrire et maintenir 3 Application quasi identiques à la main. Une seule source de vérité, ajouter un env = ajouter une entrée. (Alternative écartée : 3 Application dupliquées, plus verbeux et sujet au copier-coller divergent.)

Isolation apportée (chaque brique = une story d'It.1), du plus fort au plus faible : - NetworkPolicy par namespace : default-deny (déjà le pattern dans base) + autorisations explicites intra-env uniquement. Un pod de fastapi-dev ne doit atteindre ni la DB des autres envs ni leurs pods. C'est la barrière qui rend l'isolation inter-env réelle et non déclarative. - ResourceQuota par namespace : plafonne la somme des limits/requests d'un env → un env ne peut pas affamer les autres sur le nœud partagé. C'est précisément le ResourceQuota reporté par l'ADR 025 (« reporté Sprint 6 ») : il est livré ici. - LimitRange par namespace : valeurs par défaut à l'admission (plus de pod BestEffort). Créé au bootstrap Ansible, avant les pods, comme établi par l'ADR 025 (un LimitRange n'agit qu'à l'admission). - RBAC par namespace : des rôles scopés à un env, pour que la manipulation d'un env ne touche pas les autres (et discipliner le kubectl de session live). Livré (#136) sous une forme différente de celle prévue ici : voir « Où vit réellement le RBAC par env » ci-dessous. - Pod Security Admission restricted par namespace (déjà en place sur frontend et fastapi), reconduit sur chaque env. Livré dès #134 : les six namespaces d'env naissent avec les labels enforce/warn/audit: restricted posés par le bootstrap Ansible, donc sans fenêtre sans enforcement. Vérifié sur kind le 2026-07-26 (pod non conforme refusé à l'admission).

Où vit réellement le RBAC par env (#136)

L'énoncé initial (« des rôles scopés à un env ») supposait des Role/RoleBinding Kubernetes. La livraison a montré que ce n'est pas là que se joue le cloisonnement, pour une raison simple : depuis le passage en GitOps (2026-06-11), le seul acteur qui écrit dans les namespaces d'env est ArgoCD. Le pipeline applicatif ne contient plus aucun kubectl — il pousse une image et commit un tag. Scoper des Roles Kubernetes par env aurait durci un chemin d'accès que plus personne n'emprunte.

Le vrai risque est ailleurs : les six Applications tournaient en project: default, dont les destinations valent namespace: '*'. Rien n'empêchait l'Application fastapi-dev d'écrire dans fastapi-prod si son overlay pointait le mauvais namespace. Le champ destination d'un ApplicationSet est une intention ; seul l'AppProject est une contrainte.

D'où un AppProject par environnement (env-dev / env-staging / env-prod, k8s/platform/argocd-apps/env-appprojects.yaml), qui borne pour chaque env : les namespaces de destination (les deux de l'env), le dépôt source autorisé, et les ressources cluster-scoped (aucune — les overlays d'env ne produisent que du namespacé). Granularité par env et non par workload : le flux de promotion fait avancer back et front ensemble, ils partagent donc leur blast radius. Les Applications de plateforme (monitoring, Loki, Tempo…) restent en default : cluster-scoped par nature, elles ne participent pas au cloisonnement par env.

Vérifié sur kind le 2026-07-26, ArgoCD réel : une Application env-dev visant fastapi-prod est refusée (InvalidSpecError), une source hors dépôt du projet est refusée, une ressource cluster-scoped est refusée à la sync. Refus notable au passage — une ressource dont le metadata.namespace est codé en dur vers un autre namespace est refusée elle aussi (namespace fastapi is not permitted in project 'env-dev'), pas seulement le champ destination de l'Application : c'est le cas d'accident le plus réaliste.

Reconfirmé sur EKS le 2026-07-26 : 14/14 Applications Synced/Healthy, les 6 Applications d'env sur leur env-<env> et les 8 Applications de plateforme restées en default. Le refus de sortie d'env se lit dans .status.conditions :

application destination server 'https://kubernetes.default.svc' and namespace 'fastapi-prod'
do not match any of the allowed destinations in project 'env-dev'

À noter pour la migration : le passage des 6 Applications de project: default à env-<env> s'est fait sans incident, le sync-wave: -1 des AppProject suffisant à les créer avant les ApplicationSet qui les référencent. Aucune Application n'est passée par un état ComparisonError.

Le RBAC du user CI n'est pas traité ici. Il conserve une EKS Access Entry AmazonEKSEditPolicy scopée au namespace plateforme fastapi et deux Role/RoleBinding (ESO, HTTPRoute) qui datent d'avant le GitOps et ne servent plus à aucun job. Retrait à instruire séparément (blast radius Terraform + bootstrap, vérifiable seulement sur un cluster live). Retiré le 2026-09-11 par #223 (audit EK-10) : l'Access Entry et les deux Role/RoleBinding sont supprimés, la politique IAM ecr-push reste.

Honnêteté sur le niveau d'isolation. Cette approche est une isolation « soft » : les trois environnements partagent le même control plane et les mêmes nœuds (même noyau). Un cluster-admin voit tout, et un incident au niveau du control plane ou d'un nœud a un blast radius commun aux trois envs. On l'assume : le coût d'un cluster EKS managé par environnement est injustifié pour un projet portfolio, et l'isolation soft bien configurée (NetworkPolicy + Quota + RBAC + PSA) couvre le besoin réel — se donner un dev où casser et un staging où valider avant la prod. Le cran d'isolation supérieur (vCluster : control plane virtuel par env) est documenté comme évolution (voir « Évolution possible »), pas retenu ici pour ne pas empiler une couche à opérer avant d'avoir stabilisé le multi-env.

Décision 2 — Promotion par MR, modèle overlay-driven

Où vit la version déployée

Le tag d'image quitte base pour devenir un réglage par overlay : chaque env fixe son propre images[].newTag (transformer Kustomize). base ne porte plus de tag « vivant ». Conséquence : les trois environnements peuvent tourner sur trois versions différentes en même temps (dev en avance, prod stable), ce qui est tout l'intérêt d'un pipeline de promotion.

Le flux

  • dev = automatique. Le job CI update-image-tag (write-back GitOps existant, ADR 013) est reciblé : au lieu d'écrire dans base, il écrit le nouveau tag dans overlays/fastapi/dev à chaque merge sur develop. ArgoCD déploie dev tout seul. C'est le seul env en continuous deployment.
  • staging puis prod = promotion par MR. Promouvoir = ouvrir une MR qui copie le tag validé de l'overlay amont vers l'overlay aval (devstaging, puis stagingprod). Rien d'autre ne change dans la MR : même image, déjà construite et scannée, on ne fait que la faire avancer.
flowchart LR
    ci["merge sur develop<br/>build + scan + push ECR"] -->|write-back auto| dev["overlays/fastapi/dev<br/>newTag = sha"]
    dev -->|"MR de promotion"| stg["overlays/fastapi/staging<br/>newTag = sha"]
    stg -->|"MR de promotion<br/>(review obligatoire)"| prod["overlays/fastapi/prod<br/>newTag = sha"]
    dev -.sync.-> ad[(ArgoCD dev)]
    stg -.sync.-> as[(ArgoCD staging)]
    prod -.sync.-> ap[(ArgoCD prod)]

La gate de promotion

Dans le socle (It.0→It.4), la promotion vers prod est gardée par deux mécanismes : - protection de branche + review obligatoire sur la MR de promotion prod (approbation explicite, un humain valide la montée) ; - E2E staging vert comme condition (It.4) : on ne promeut vers prod que si les tests de bout en bout sur staging sont passés.

Cette gate est manuelle par choix pédagogique (on voit toute la mécanique). Son automatisation (verification qui bloque la promo si les tests échouent) est le rôle de Kargo, retenu comme itération suivante hors socle (voir « Évolution possible »).

Pourquoi PAS « une branche par environnement »

La tentation classique est de faire develop = dev, une branche staging, main = prod, et de promouvoir en mergeant d'une branche à l'autre. On l'écarte explicitement, c'est un anti-pattern GitOps : - les branches divergent dans le temps (un hotfix sur main qui n'est pas dans staging, de la config qui dérive) ; - la promotion devient un merge bruyant mêlant code ET config d'env, au lieu d'un simple changement de tag ; - on perd la lisibilité : impossible de voir d'un coup d'œil « quelle version tourne où » sans comparer des branches.

Le modèle overlay-driven garde une seule branche de config (develop) où les trois envs coexistent comme trois dossiers. La version du code est la même partout, seul le tag par overlay distingue les envs. « Quelle version tourne où » se lit dans trois fichiers, dans le même commit.

Sort de main (#57)

Cette décision tranche #57 : main reste une branche d'archive/release, sans déploiement associé. La prod, c'est overlays/fastapi/prod sur develop, pas un déploiement « depuis main ». On ne réintroduit pas de déploiement branché sur main (ce serait retomber dans le branch-per-env).

Décision 3 — Data / DNS / secrets par environnement

Base de données — 1 RDS, 3 databases

On garde une seule instance RDS et on y crée trois databases (app_dev, app_staging, app_prod), chacune avec son propre utilisateur PostgreSQL dont les droits (GRANT) ne portent que sur sa database. Compromis budget assumé face à trois instances RDS séparées (le coût d'une instance managée par env est injustifié ici).

Honnêteté : c'est le point le moins isolé de la stratégie. La RDS étant managée hors cluster et partagée, tous les envs joignent le même endpoint ; la NetworkPolicy in-cluster ne peut donc pas distinguer un env d'un autre au niveau réseau. La barrière réelle entre environnements est au niveau base : databases distinctes + utilisateurs distincts + GRANT scopés (l'utilisateur de dev ne peut pas lire/écrire app_prod). Les données de test de staging ne peuvent donc pas contaminer prod. Une isolation plus forte (instances séparées, ou security groups par env) est notée en évolution.

DNS — sous-domaines par env

Levé le 2026-07-30 par #138 — l'API de dev et staging est exposée

L'encadré ci-dessous décrit la situation entre le 2026-07-26 et le 2026-07-30. Il est conservé pour le récit, mais les trois conditions qu'il posait sont remplies : database et user par env (#137), secret ASM et rôle IRSA par env (#138), et un host couvert par le certificat existant grâce au schéma plat.

L'API de dev et staging est donc exposée sur api-dev.devopsyouss.com et api-staging.devopsyouss.com. Le frontend ne l'est pas : son URL d'API est figée à l'image, les trois envs servent une SPA qui pointe sur la prod. Voir #156.

Dev et staging ne sont PAS exposés tant que le socle n'est pas fini (#146)

Ce qui suit décrit la cible, atteinte une fois #137 et #138 livrés. En l'état, les overlays de dev et staging suppriment la HTTPRoute héritée du base plutôt que de lui donner un host : aucun attachement au Gateway, donc aucun enregistrement créé par ExternalDNS. Ces envs restent joignables par kubectl port-forward et par leur Service ClusterIP.

Pourquoi. Tant que #137 (bases séparées) et #138 (secrets par env) ne sont pas livrés, les trois envs lisent le même secret fastapi-eks/app et tapent donc la même base que la prod. Constaté en live le 2026-07-26 : curl http://dev.api.devopsyouss.com/healthz/ready renvoyait 200, en clair, sur un endpoint public adossé aux données de production. Le risque n'est d'ailleurs pas d'abord l'intrusion : le tag de dev est bumpé automatiquement à chaque merge sur develop, donc du code n'ayant passé ni staging ni E2E écrivait dans les données de prod.

La leçon, au-delà du correctif. L'exposition s'est faite toute seule au démarrage du cluster : dès la synchronisation des overlays, ExternalDNS crée les enregistrements et les HTTPRoutes s'attachent au Gateway. La consigne « ne pas remonter le multi-env en live avant la fin du socle » n'était écrite nulle part où elle s'appliquait — même classe de problème que le gate prod documentaire. Le garde-fou devait être technique, il l'est désormais.

À retirer en #138, quand chaque env aura ses secrets, sa database et son certificat propres.

Chaque env expose ses propres hosts via ExternalDNS (Cloudflare, déjà en place). Schéma plat, tranché en #138 (voir l'encadré ci-dessous) :

  • API : api-dev.devopsyouss.com, api-staging.devopsyouss.com, api.devopsyouss.com (prod)
  • Frontend : app-dev.devopsyouss.com, app-staging.devopsyouss.com, app.devopsyouss.com (prod)

Le host est un patch d'overlay (HTTPRoute). Le Gateway étant partagé et vivant dans le namespace plateforme fastapi, ce patch doit aussi poser parentRefs[0].namespace: fastapi : sans lui la route cherche le Gateway dans son propre namespace et reste silencieusement non attachée.

Correction (2026-07-26) — le wildcard ne couvre PAS ces hosts

Cet ADR affirmait initialement que le certificat wildcard *.devopsyouss.com (DNS-01, déjà émis) couvrait tous ces sous-domaines sans émission supplémentaire. C'est faux. Un wildcard ne vaut que pour un seul niveau de label (RFC 6125) : *.devopsyouss.com couvre api.devopsyouss.com, mais pas dev.api.devopsyouss.com.

Les 4 hosts de dev et staging sont donc en erreur de certificat, la prod et Grafana étant seules couvertes. Vérifié en live le 2026-07-26 :

$ curl -sSv https://dev.api.devopsyouss.com/healthz/ready
*  subject: CN=*.devopsyouss.com
*  subjectAltName does not match dev.api.devopsyouss.com
curl: (60) SSL: no alternative certificate subject name matches target host name

Symptôme trompeur : les listeners du Gateway n'ont aucun champ hostname, donc les HTTPRoutes de dev et staging s'attachent normalement et le TLS termine. Tout a l'air branché, seul le nom du certificat ne correspond pas.

Arbitrage renvoyé à #138 : soit un certificat par env, soit un schéma plat (api-dev.devopsyouss.com) qui repasse tout sur un seul niveau et laisse le wildcard existant tout couvrir. Le schéma plat est privilégié : le cluster est éphémère, et chaque émission DNS-01 supplémentaire est un point de défaillance de plus au démarrage du matin.

Tranché le 2026-07-30 (#138) : schéma plat. Aucun certificat supplémentaire n'est émis, le wildcard existant couvre les six hosts. La liste des hosts ci-dessus a été corrigée en conséquence.

Conséquence non anticipée, traitée en #146 et #147 : ces hosts sont malgré tout publiquement joignables en clair sur le port 80, faute de redirection HTTP vers HTTPS.

Secrets — chemins ASM séparés + IRSA scopé

Chaque env lit ses secrets depuis un chemin AWS Secrets Manager dédié via un ExternalSecret propre à l'overlay (credentials DB de la bonne database, etc.).

Livré en #138 — nommage réel et point non anticipé

Les chemins effectifs sont fastapi-eks/dev, fastapi-eks/staging et fastapi-eks/prod (un secret par env, payload JSON multi-clés), et non l'arborescence /fastapi/<env>/* esquissée ici.

Ce que cet ADR n'avait pas vu : scoper l'IRSA ne suffisait pas, il fallait d'abord changer la façon dont ESO s'authentifie. Le SecretStore fonctionnait en modèle « controller IRSA » — aucun bloc auth, donc ESO lisait avec le rôle du pod contrôleur, unique pour tout le cluster. IAM ne voyait jamais l'identité du namespace demandeur, et un ExternalSecret de dev nommant le chemin de prod aurait été servi. Le passage en auth.jwt.serviceAccountRef est ce qui fait évaluer par IAM le ServiceAccount de l'environnement, et donc ce qui rend le scope réel.

Chaque env reçoit aussi son propre SECRET_KEY : avec une clé partagée, un JWT émis par dev restait valide en prod, la signature vérifiant. Le cloisonnement des databases n'y aurait rien changé.

La prod bascule elle aussi sur app_prod et quitte le user master, qui a CREATE DATABASE et CREATE ROLE sur l'instance. Sans migration : le RDS vit dans le stack éphémère et renaît vide à chaque montée.

Détail : Identités et secrets par environnement.

L'isolation ici est réelle et forte : le ServiceAccount de chaque env assume, via IRSA, un rôle IAM dont la policy n'autorise que son préfixe de chemin ASM. Le pod de dev ne peut donc pas lire les secrets de prod, même s'il essayait. C'est la brique qui compense la faiblesse d'isolation de la RDS partagée : même endpoint DB, mais des credentials que seul l'env légitime peut obtenir.

Conséquences

  • On gagne un palier de validation : un changement passe par dev (auto) puis staging (iso-prod, tests E2E) avant d'atteindre prod. On arrête de tester en production.
  • Un merge sur develop ne touche plus jamais la prod directement : seul dev est auto-déployé. La prod n'avance que par une MR de promotion revue. Blast radius d'une régression réduit à dev.
  • « Quelle version tourne où » se lit dans Git : trois newTag dans trois overlays, dans le même commit. Traçabilité totale de la promotion (une MR = une montée).
  • Le ResourceQuota reporté par l'ADR 025 est livré ; chaque env est borné et ne peut pas affamer les autres sur le nœud partagé.
  • Charge opérationnelle en hausse : 3× les namespaces, quotas, NetworkPolicy, ExternalSecrets, Applications à opérer et surveiller. L'ApplicationSet et les overlays limitent la duplication, mais le nombre d'objets vivants triple.
  • Coût maîtrisé : 1 cluster, 1 RDS. L'augmentation reste marginale (compute des pods dev/staging, souvent dimensionnés bas).

Alternatives écartées (détail dans le cadrage import/cadrage-sprint6-multi-env.md) : - Cluster EKS par environnement : l'isolation de référence, écartée pour le coût (3 control planes managés) sur un projet portfolio. - vCluster : isolation control plane par env pour un coût moindre, mais une couche à opérer/debug de plus — reporté en évolution, pas avant que le multi-env namespace soit stable. - Branch-per-environment (develop/staging/main = envs) : anti-pattern GitOps (branches qui divergent, promotion = merge bruyant, lisibilité perdue). Remplacé par l'overlay-driven. - Kargo dès ce sprint : l'outil résout la promotion gatée, mais on veut d'abord manipuler la mécanique à la main — reporté en évolution. - ArgoCD Image Updater : bump auto du tag, contraire à une promotion contrôlée vers prod. Écarté.

Évolution possible

  • Kargo : automatiser la promotion dev → staging (après verification/E2E) et garder prod en promotion manuelle approuvée — le pattern d'entreprise « auto jusqu'à staging, gate humaine avant prod ».
  • vCluster : monter le cran d'isolation (control plane virtuel par env) si le besoin d'isolation forte se justifie.
  • Argo Rollouts : progressive delivery (canary/blue-green) + analyse automatique des métriques dans un env — testing in production avancé.
  • Kyverno : policy as code à l'admission (image signée, resource limits obligatoires, latest interdit) — sécurité préventive par env.
  • Red team / bilan sécurité : tests offensifs (Schemathesis, OWASP ZAP, validation NetworkPolicy inter-env, RBAC, CIS) exploitant justement la séparation par env — cadré section 4.3 du document de cadrage.
  • Isolation DB renforcée : instances RDS séparées ou security groups par env, si la RDS partagée devient un point dur.

Date : 2026-07-07 Sprint : 6 Issue : #133