Skip to content

Runbook de validation EKS — fastapi-eks-project

Tests de validation pour le cluster EKS, organisĂ©s par catĂ©gorie. Principe directeur : valider par la preuve (test), pas par l'intention (le manifest). Chaque contrĂŽle de sĂ©curitĂ© ou de rĂ©silience se valide par un test positif (ce qui doit passer) ET un test nĂ©gatif (ce qui doit ĂȘtre bloquĂ©).


Table des catégories

Le cluster étant éphémÚre, la colonne Quand compte autant que le titre : tout ne se rejoue pas à chaque montage. Commencer par la séquence de reprise.

# Catégorie Quand
0 Reprise aprĂšs montage du cluster chaque montage
1 NetworkPolicy (enforcement réseau) à la livraison, sur incident réseau
2 HPA / Autoscaling Ă  la livraison, sur incident de charge
3 TLS / cert-manager chaque montage (certificat réémis)
4 Pod Security Admission (PSA) Ă  la livraison
5 Probes / Health Ă  la livraison
6 Connectivité / Smoke tests chaque montage
6b → Frontends par environnement (#156) chaque montage
7 Secrets externes (ESO / IRSA) chaque montage (ESO rematérialise)
8 GitOps / ArgoCD chaque montage
9 Observabilité (métriques, logs) à la livraison, sur incident
10 Alerting (Prometheus → Alertmanager → Slack) à la livraison
11 Gouvernance des ressources (LimitRange / ResourceQuota) à la livraison, sur éviction
12 BoĂźte Ă  outils au besoin
13 Garde-fou de teardown (hors cluster) aprĂšs l'apply du module, puis une fois pour l'exercer

0. Reprise aprĂšs montage du cluster

À jouer dans cet ordre aprĂšs chaque infra-start, avant toute autre validation. Les trois piĂšges ci-dessous ont chacun coĂ»tĂ© du temps en sĂ©ance : ils viennent du fait que le cluster est recréé, pas redĂ©marrĂ©.

# 1. Kubeconfig — l'endpoint EKS CHANGE à chaque montage
aws eks update-kubeconfig --name fastapi-eks-cluster --region eu-west-3
kubectl get nodes

« no such host » sur l'endpoint EKS n'est pas une panne réseau

terraform apply recrĂ©e le cluster, donc un nouvel identifiant d'endpoint (<ID>.gr7.eu-west-3.eks.amazonaws.com). Un kubeconfig hĂ©ritĂ© de la session prĂ©cĂ©dente pointe sur un cluster qui n'existe plus : la rĂ©solution Ă©choue avant mĂȘme toute question d'identitĂ© ou de droits. RegĂ©nĂ©rer le kubeconfig est le premier geste, pas un recours.

Identité : profil youss-admin (user/youss_admin). devops-portfolio résout sur user/iamadmin, absent des access entries EKS, et l'API server répond alors un trompeur « must be logged in ».

# 2. GitOps — tout doit finir Synced / Healthy
kubectl -n argocd get apps

Ne pas diagnostiquer une Application dans les premiĂšres minutes

À froid, monitoring, kube-prometheus-stack et loki passent par Missing, Degraded et Progressing avant de converger seuls. Laisser la rĂ©conciliation finir avant d'ouvrir une investigation : un Ă©tat transitoire ressemble beaucoup Ă  une panne.

# 3. Exposition — les enregistrements DNS sont republiĂ©s Ă  chaque montage
kubectl get httproute -A
# Les routes doivent ĂȘtre Accepted + ResolvedRefs :
kubectl get httproute frontend -n frontend-dev \
  -o jsonpath='{.status.parents[*].conditions[*].type}{"\n"}{.status.parents[*].conditions[*].status}{"\n"}'

Un host qui ne résout pas n'est pas forcément un host qui n'existe pas

ExternalDNS supprime les enregistrements au teardown et les recrĂ©e aprĂšs attachement des routes. Un host interrogĂ© pendant cette fenĂȘtre renvoie NXDOMAIN, que le rĂ©solveur met en cache nĂ©gatif (TTL de l'ordre de 5 min, rĂ©armĂ© Ă  chaque nouvelle interrogation trop prĂ©coce). RĂ©sultat classique et trompeur : app-staging rĂ©pond alors que app-dev semble mort, tous deux servis par le mĂȘme NLB.

Trancher sans attendre, en interrogeant l'autoritatif puis en contournant le résolveur :

nslookup app-dev.devopsyouss.com 1.1.1.1        # l'enregistrement existe-t-il ?
NLB=$(getent hosts app.devopsyouss.com | awk '{print $1}')
curl -sI --resolve "app-dev.devopsyouss.com:443:$NLB" \
  https://app-dev.devopsyouss.com/ | head -1

Rappel de lecture : un code 000 ne vient jamais du serveur, c'est curl qui n'a pas pu se connecter (DNS ou TCP). Un service en panne répond 502/503/504. CÎté poste, ipconfig /flushdns (Windows, qui sert de résolveur à WSL) puis chrome://net-internals/#dns pour le cache propre au navigateur.

À valider au prochain montage : le provider AWS est passĂ© en v6

Le provider Terraform AWS est passé de ~> 5.0 à ~> 6.0 le 2026-09-07 (!375), sur les trois stacks. C'est un changement majeur, et il n'a jamais été appliqué en vrai : terraform apply ne vit que dans infra-start, donc merger n'a rien monté.

Ce qui a été vérifié avant le merge, hors infra :

Terraform v1.15.9
+ provider registry.terraform.io/hashicorp/aws v6.63.0

backend / persistent / ephemeral  ->  Success! The configuration is valid.

⚠ terraform validate lit le schĂ©ma du provider, il n'appelle pas AWS. Il Ă©limine les arguments supprimĂ©s et les ressources renommĂ©es, pas les changements de comportement Ă  l'exĂ©cution : valeurs par dĂ©faut modifiĂ©es, appels d'API diffĂ©rents.

Le premier infra-start est donc le vrai juge. S'il échoue, lire l'erreur avant de suspendre autre chose : un apply qui casse au premier montage aprÚs un major de provider vient presque toujours de là, pas de la derniÚre MR fonctionnelle.

À valider au prochain montage : le master RDS est absent du state (#219, ADR 038)

Depuis l'ADR 038, aws_db_instance reçoit son mot de passe par password_wo, en écriture seule. Un lab hors infra a prouvé que le plan ne porte pas la valeur. Seul un apply réel écrit un state : c'est lui qu'il faut vérifier, sur deux questions distinctes.

1. La valeur est absente du state. On cherche la valeur elle-mĂȘme, pas seulement un attribut vide. Depuis la VM WSL (glab n'est pas dans le devcontainer) :

set -a; . ~/.aws/session-aws.env; set +a
V=$(glab variable get TF_VAR_db_password); echo "longueur: ${#V}"   # > 0, sinon ARRÊT
aws s3 cp s3://yk-devops-terraform-state/fastapi-eks/ephemeral/terraform.tfstate - \
  | grep -c -F "$V"                                                  # attendu : 0
aws s3 cp s3://yk-devops-terraform-state/fastapi-eks/ephemeral/terraform.tfstate - \
  | jq '.resources[] | select(.type == "aws_db_instance") | .instances[].attributes
        | {password, password_wo, password_wo_version}'
# attendu : password et password_wo Ă  null, password_wo_version Ă  1

⚠ La vĂ©rification de longueur n'est pas dĂ©corative. Une valeur vide fait matcher grep -F "" sur toutes les lignes : le compte serait Ă©norme, et la conclusion fausse.

2. RDS a bien reçu la valeur. L'instance renaßt vide : les users app_<env> n'existent que si le Job db-bootstrap s'est connecté en master. Des pods API Ready dans fastapi-dev (leur initContainer alembic se connecte en app_dev) le prouvent.

kubectl -n fastapi-dev get pods

Ensuite seulement, jouer les sections marquées « chaque montage » dans la table ci-dessus, puis les validations propres à ce qui a été livré depuis la derniÚre session.


1. NetworkPolicy

PrĂ©requis pour que ce soit enforced — depuis la migration Cilium (#80, ADR 019) : l'enforcement est dĂ©sormais natif Ă  Cilium, qui applique les NetworkPolicy K8s standard sans aws-node ni addon managĂ©. Le piĂšge INC-046 (NetworkPolicy inertes en vpc-cni self-managed) ne s'applique plus : il n'y a plus de vpc-cni du tout.

Vérifier que Cilium enforce :

kubectl get pods -n kube-system -l k8s-app=cilium   # agent Cilium Running (et 0 aws-node)
cilium status | grep -i policy                      # si la CLI cilium est dispo

Avant #80 (vpc-cni managé), le prérequis était aws eks list-addons listant vpc-cni + enableNetworkPolicy=true. ObsolÚte sous Cilium.

Test négatif (le deny fonctionne)

Depuis un pod soumis Ă  une politique egress restrictive, tenter une sortie NON autorisĂ©e. La policy fastapi autorise seulement 5432 (RDS), 443 (AWS), 53 (DNS). Le port 80 doit ĂȘtre bloquĂ©.

# L'image fastapi n'embarque pas curl : tester via python (DNS 53 autorisé résout le nom,
# le connect TCP port 80 doit ĂȘtre droppĂ©).
# ⚠ `fastapi` est le namespace PLATEFORME (Gateway partagĂ©) : il n'y a plus de Deployment
# fastapi dedans depuis le multi-env. Cibler un namespace d'env, ici `fastapi-dev`.
kubectl exec -n fastapi-dev deploy/fastapi -- python -c "import socket; socket.create_connection(('example.com',80),5)"; echo "EXIT=$?"
Attendu (sous Cilium, validĂ© le 2026-06-18) : OSError: [Errno 101] Network is unreachable + exit non nul. Cilium droppe le paquet d'egress → Network unreachable (plus net que le Connection timed out du vpc-cni). Si le connect rĂ©ussit (EXIT=0, aucune erreur) : policies non enforced → ne pas valider, vĂ©rifier l'agent Cilium.

Test positif (les allow rules passent)

Valider que le trafic autorisé fonctionne toujours. /healthz/ready fait un SELECT 1 sur RDS, donc il exerce DNS (port 53, résolution du hostname RDS) + port 5432 (connexion TCP).

curl -s https://api.devopsyouss.com/healthz/ready
Attendu : {"status":"ok"} (HTTP 200) Si 503 : une rĂšgle allow (DNS ou 5432) bloque Ă  tort.

Isolation inter-environnements (#135)

Trois niveaux, trois questions différentes. Le troisiÚme porte la vraie isolation, et c'est le seul dont le résultat surprend.

L'isolation n'est pas assurĂ©e par la mĂȘme couche des deux cĂŽtĂ©s : rĂ©seau entre les pods, PostgreSQL entre les bases. Ce n'est pas un contournement, c'est la consĂ©quence directe de l'ADR 029 — une instance RDS, trois databases, trois rĂŽles.

Niveau 1 — un pod de dev ne joint pas un pod de staging

STAGING_IP=$(kubectl get pod -n fastapi-staging -l app=fastapi \
  -o jsonpath='{.items[0].status.podIP}')

kubectl exec -n fastapi-dev deploy/fastapi -- python -c "
import socket
s = socket.socket(); s.settimeout(5)
try:
    s.connect(('$STAGING_IP', 8080)); print('CONNECTE — la politique ne bloque pas')
except Exception as e:
    print('BLOQUE :', type(e).__name__, e)
"

Attendu : BLOQUE : TimeoutError. BloquĂ© deux fois plutĂŽt qu'une — le port 8080 n'est pas dans l'egress autorisĂ© de dev, et l'ingress de staging n'accepte que envoy-gateway-system et monitoring.

Niveau 2 — un pod de dev atteint le serveur RDS, et c'est normal

kubectl exec -n fastapi-dev deploy/fastapi -- python -c "
import socket, os
h = os.environ['DB_HOSTNAME']; print('hĂŽte:', h)
s = socket.socket(); s.settimeout(5)
try:
    s.connect((h, 5432)); print('CONNECTE au RDS sur 5432')
except Exception as e:
    print('BLOQUE :', type(e).__name__, e)
"

Attendu : CONNECTE. Les trois databases vivent sur un seul RDS, donc la mĂȘme IP et le mĂȘme port pour les trois environnements. Une NetworkPolicy travaille sur des IP et des ports : elle ne peut pas distinguer app_dev de app_staging. Aucun rĂ©glage rĂ©seau ne changera ce rĂ©sultat.

Niveau 3 — les identifiants de dev n'ouvrent pas les autres bases

kubectl exec -n fastapi-dev deploy/fastapi -- python -c "
import os, psycopg
for db in ('app_dev', 'app_staging', 'app_prod'):
    try:
        psycopg.connect(
            host=os.environ['DB_HOSTNAME'], port=5432, dbname=db,
            user=os.environ['DB_USERNAME'], password=os.environ['DB_PASSWORD'],
            connect_timeout=5).close()
        print(f'{db:12} OUVERTE')
    except Exception as e:
        print(f'{db:12} REFUSEE — {type(e).__name__}: {str(e).strip()[:90]}')
"

Attendu : app_dev OUVERTE, app_staging et app_prod REFUSEE avec FATAL: permission denied.

Un seul paramĂštre change entre les trois tours de boucle, dbname. L'hĂŽte, l'utilisateur et le mot de passe sont ceux du pod de dev.

Lire la nature du refus, pas seulement le fait qu'il refuse

Réponse Qui a refusé
TimeoutError: timed out Personne n'a répondu. Les paquets sont droppés par la NetworkPolicy
FATAL: permission denied Le serveur a répondu, et il a dit non. C'est PostgreSQL

Un silence et un refus explicite ne viennent pas de la mĂȘme couche. Le message d'erreur de PostgreSQL nomme d'ailleurs l'IP du serveur : la mĂȘme pour les trois databases, ce qui dĂ©montre Ă  lui seul qu'il n'y a qu'une instance.

Mesuré le 2026-08-16

Les trois niveaux se comportent comme décrit. app_staging et app_prod refusées depuis un pod de fastapi-dev, sur le serveur 10.0.3.149. Isolation inter-env démontrée.

Détail de la décision : Bases par environnement et l'ADR 029, Décision 1.


2. HPA / Autoscaling

Prérequis : metrics-server installé (via bootstrap Ansible) + HPA défini (min:1 max:5, cpu:70%).

Étape 1 — metrics-server remonte des mĂ©triques

kubectl top nodes
kubectl top pods -n fastapi
Attendu : des valeurs chiffrées (ex 3m 60Mi). Si error: Metrics API not available, metrics-server KO.

Étape 2 — le HPA lit les mĂ©triques

kubectl get hpa -n fastapi
Attendu : cpu: 5%/70% (un pourcentage réel). PiÚge classique EKS : cpu: <unknown>/70% = metrics-server ne remonte pas, HPA aveugle.

Étape 3 — dĂ©clencher le scale-up par la charge

La charge doit passer par Envoy, JAMAIS par le ClusterIP (#167)

La version précédente de cette étape visait http://fastapi.fastapi-prod.svc.cluster.local/, donc le ClusterIP du Service. Cette mesure est fausse, et elle l'a été pendant des mois.

Le ClusterIP répartit par connexion (niveau 4). Fortio ouvre ses 150 connexions au démarrage et les garde : chacune est épinglée à un pod au moment du connect. Les pods ajoutés ensuite par le HPA ne reçoivent aucune nouvelle connexion.

MesurĂ© le 2026-08-12 sur fastapi-prod : deux pods d'origine collĂ©s Ă  leur plafond CPU, deux pods neufs Ă  3-4m, soit strictement leur consommation au repos. Tous les indicateurs Ă©taient pourtant au vert — 4 pods Running, 4 ready, compteur de replicas correct.

Envoy rĂ©partit par requĂȘte (niveau 7), et c'est le chemin qu'empruntent les vrais clients. C'est le seul qui mesure quelque chose.

D'abord repérer le Service interne d'Envoy :

ENVOY_IP=$(kubectl -n envoy-gateway-system get svc \
  -l gateway.envoyproxy.io/owning-gateway-name=fastapi-gateway \
  -o jsonpath='{.items[0].spec.clusterIP}')
echo "ENVOY_IP=$ENVOY_IP"   # vide = sélecteur à corriger, ne pas continuer
# Terminal 1 — l'autoscaling
watch -n 2 'kubectl get hpa,pods -n fastapi-prod -o wide'

# Terminal 2 — les pods restent-ils dans le service ? (spirale readiness, #169)
watch -n 2 'kubectl get endpointslices -n fastapi-prod \
  -l kubernetes.io/service-name=fastapi \
  -o jsonpath="{range .items[*].endpoints[*]}{.targetRef.name}{\" ready=\"}{.conditions.ready}{\"\n\"}{end}"'

# Terminal 3 — la charge, par le chemin Envoy
kubectl run loadgen --image=fortio/fortio --restart=Never --rm -it -n monitoring \
  --overrides="{\"spec\":{\"hostAliases\":[{\"ip\":\"$ENVOY_IP\",\"hostnames\":[\"api.devopsyouss.com\"]}]}}" -- \
  load -c 150 -qps 0 -t 300s https://api.devopsyouss.com/

Le hostAliases fait résoudre api.devopsyouss.com sur l'IP interne d'Envoy dans le pod de charge. On obtient ainsi un vrai SNI et un certificat valide, sans passer par le NLB ni par le DNS public.

Viser HTTPS, le port 80 ne sert Ă  rien

k8s/platform/httproute-https-redirect.yaml répond 301 sur tout le listener :80 (#147). Une charge en HTTP ne mesurerait que la vitesse d'une redirection.

Depuis #135, la charge ne peut plus partir de default

135 (28/07) a borné l'ingress de fastapi à deux sources et deux seulement, le

data plane Envoy et Prometheus. Ce qui compte n'est pas que le namespace source soit libre, mais que la destination l'accepte.

Lancer donc la charge depuis monitoring. En passant par Envoy, le trafic atteint les pods depuis envoy-gateway-system, donc par la premiÚre des deux sources autorisées.

  • viser / (JSON statique, CPU-bound) et NON /healthz/ready (I/O-bound sur la DB, ne fait pas monter le CPU)
  • -qps 0 = dĂ©bit max, -c 150 = 150 connexions concurrentes

Calcul HPA : la cible est de ramener le CPU moyen Ă  70 % de la request (100m). Le conteneur n'ayant plus de limits.cpu (#169), il consomme largement au-delĂ  : mesurĂ© Ă  407 % et 439 % en 2026-08-16. Le HPA file donc droit Ă  maxReplicas, 4 en prod (#149, couplĂ© au ResourceQuota du mĂȘme overlay).

Étape 4 — vĂ©rifier que les pods ajoutĂ©s travaillent vraiment

C'est l'étape qui manquait, et c'est elle qui juge l'autoscaling. Deux façons, la seconde est la preuve directe.

Pendant la charge, comparer Ă  la consommation au repos (~3m, ~87Mi par pod) :

watch -n 5 'kubectl top pods -n fastapi-prod'

AprĂšs coup, demander Ă  Envoy quel pod a traitĂ© quoi. Le champ upstream_host de son log d'accĂšs donne l'IP du pod, requĂȘte par requĂȘte :

{namespace="envoy-gateway-system", container="envoy"} |= `api.devopsyouss.com`

Croiser les IP avec kubectl get pods -n fastapi-prod -o wide. Mesure du 2026-08-16 : 8 requĂȘtes sur 10 sont parties vers les deux pods ajoutĂ©s par le HPA.

Ces logs ne sont PAS lisibles par kubectl logs

À 667 requĂȘtes par seconde, Envoy produit ~160 Mo de JSON en cinq minutes, et kubelet fait tourner ses fichiers tous les 10 Mo. La fenĂȘtre visible par kubectl logs est d'une Ă  deux minutes, et un --tail=500 ne montre qu'une fraction de seconde.

Passer par Loki (Grafana → Explore), qui reçoit les lignes d'Alloy avant la rotation. RĂšgle gĂ©nĂ©rale : kubectl logs est la fenĂȘtre vivante, Loki est la mĂ©moire.

Étape 5 — observer le scale-down

À l'arrĂȘt de la charge, le scale-down N'EST PAS immĂ©diat : fenĂȘtre de stabilisation par dĂ©faut de 5 min (anti-flapping). Les replicas restent hauts puis redescendent vers minReplicas (2 en prod). Normal, pas un bug.

Valeurs de référence (mesure du 2026-08-16, prod)

À comparer au prochain tir. Un Ă©cart franc est un signal, pas une variation.

Grandeur Valeur
DĂ©bit 667,8 req/s, 200 580 requĂȘtes en 300 s
Latence 224 ms en moyenne, 269 ms au p50, 543 ms au p99
Codes 200 : 200 545 (100,0 %), 503 : 35 (0,0 %)
Sockets 185 pour 150 prĂ©vues — l'Ă©cart vaut exactement le nombre de 503
HPA 2 → 4 replicas en ~25 s, maxReplicas atteint
Readiness aucun context deadline exceeded (c'était le défaut de #169)

Les 35 × 503 rĂ©siduels portent le flag Envoy UC et sont suivis par #172 : ils ne viennent pas de l'autoscaling. Voir Le 503 sous charge.

Charge externe (alternative, avec réserve)

# apache2-utils (ab) ou hey ; limité par la bande passante homelab -> AWS
ab -t 120 -n 1000000 -c 200 https://api.devopsyouss.com/
Bon pour un smoke test HTTPS, insuffisant pour saturer le CPU (cf 46 req/s observé).


3. TLS / cert-manager

Vérifier les ClusterIssuers

kubectl get clusterissuers
Attendu : letsencrypt-staging et letsencrypt-prod, READY: True.

Vérifier le certificat (wildcard depuis #76)

Depuis #76, un seul certificat wildcard *.devopsyouss.com (cert wildcard-devopsyouss, secret wildcard-devopsyouss-tls, ns fastapi, issuer letsencrypt-prod) couvre api, grafana et tout futur sous-domaine. Le listener HTTPS du Gateway le référence.

kubectl get certificate -n fastapi
kubectl describe certificate wildcard-devopsyouss -n fastapi
Attendu : READY: True, et dans les events The certificate has been successfully issued. Le wildcard impose le challenge DNS-01 (un HTTP-01 ne peut pas valider un *), déjà en place via Cloudflare. La colonne de renouvellement confirme que cert-manager gÚre le cycle de vie (~30j avant expiration).

Vérifier la chaßne TLS cÎté client

# Voir l'émetteur et la validité
curl -vI https://api.devopsyouss.com 2>&1 | grep -Ei "issuer|subject|expire"
# Ou inspection complĂšte
echo | openssl s_client -connect api.devopsyouss.com:443 -servername api.devopsyouss.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates
Staging : issuer (STAGING) Let's Encrypt, NON trusté navigateur (normal). Prod : issuer Let's Encrypt, cadenas vert.

Redirection HTTP -> HTTPS (#147)

Principe : ce qui ferme le clair, c'est le sectionName: https sur les 4 routes applicatives, qui les retire du listener :80. La route de redirection ne fait qu'offrir une sortie propre à qui arrive encore en HTTP. Les deux se testent séparément.

Test négatif (plus rien ne se sert en clair) :

for h in api.devopsyouss.com app.devopsyouss.com grafana.devopsyouss.com argocd.devopsyouss.com; do
  printf '%-30s %s\n' "$h" "$(curl -sS -o /dev/null -w '%{http_code} -> %{redirect_url}' http://$h/)"
done
Attendu : 301 -> https://<host>/ sur les quatre. Un 200 signifie que la route sert encore en clair : vérifier que son parentRefs porte bien sectionName: https.

Test positif (le HTTPS fonctionne toujours) :

curl -sS https://api.devopsyouss.com/healthz/ready     # {"status":"ok"}
curl -sSL -o /dev/null -w '%{http_code}\n' http://api.devopsyouss.com/healthz/ready   # 200 aprĂšs suivi du 301

Vérifier l'attachement cÎté Gateway :

kubectl get httproute -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,SECTION:.spec.parentRefs[*].sectionName
Attendu : https partout, sauf https-redirect (ns fastapi) qui doit ĂȘtre sur http. Une colonne vide = la route s'attache aux deux listeners, c'est le dĂ©faut d'origine de #147.

Le :80 ne doit plus servir qu'à rediriger. Les certificats passent par DNS-01, donc aucun challenge HTTP-01 à préserver. Si un ClusterIssuer HTTP-01 est ajouté un jour, la route attrape-tout l'aveuglera : exclure /.well-known/acme-challenge/ avant de le mettre en service.

Bascule staging -> prod

  1. Éditer certificate.yaml : issuerRef.name: letsencrypt-staging -> letsencrypt-prod
  2. kubectl apply -f certificate.yaml
  3. kubectl delete secret wildcard-devopsyouss-tls -n fastapi (force la ré-émission)
  4. Surveiller kubectl describe certificate wildcard-devopsyouss -n fastapi

4. Pod Security Admission (PSA)

PrĂ©requis : namespace fastapi labellisĂ© pod-security.kubernetes.io/enforce: restricted (posĂ© par le bootstrap Ansible, INC-045). Depuis le multi-env (#134/#136), les 6 namespaces d'env naissent restricted eux aussi : les labels sont posĂ©s Ă  la crĂ©ation du namespace, pas par une task dĂ©diĂ©e ensuite, donc il n'existe aucune fenĂȘtre sans enforcement.

Vérifier les labels du namespace

kubectl get ns fastapi --show-labels

# Les 6 namespaces d'env (enforce + warn + audit)
kubectl get ns -l app=fastapi-eks \
  -o custom-columns=NS:.metadata.name,ENFORCE:.metadata.labels.pod-security\\.kubernetes\\.io/enforce
Attendu : pod-security.kubernetes.io/enforce=restricted partout, y compris sur le namespace plateforme fastapi (aucun pod n'y tourne, mais il reste labellisé).

Test négatif (pod non conforme rejeté)

kubectl run nginx-test --image=nginx -n fastapi
Attendu : rejet à l'admission (violates PodSecurity "restricted": runAsNonRoot, seccompProfile, etc.). Si le pod est créé : PSA non enforced.

Test positif (pod conforme admis)

Le pod fastapi (runAsNonRoot, seccomp RuntimeDefault, capabilities drop ALL, readOnlyRootFilesystem) doit ĂȘtre admis et passer Running 1/1.

kubectl get pods -n fastapi


5. Probes / Health

L'app expose deux endpoints dédiés (issue #47) : - /healthz/live : le process répond (liveness) - /healthz/ready : SELECT 1 sur la DB, renvoie 503 si la DB est injoignable (readiness)

# Direct sur un pod (port 8080)
kubectl exec -n fastapi deployment/fastapi -- wget -qO- http://localhost:8080/healthz/live
kubectl exec -n fastapi deployment/fastapi -- wget -qO- http://localhost:8080/healthz/ready
# Via l'URL publique
curl -s https://api.devopsyouss.com/healthz/ready
Note : /healthz seul renvoie 404, ce n'est pas une route. Les routes sont /healthz/live et /healthz/ready.


6. Connectivité / Smoke tests

# Nodes prĂȘts
kubectl get nodes

# Tout le namespace applicatif
kubectl get all -n fastapi

# DNS interne (depuis un pod autorisé)
kubectl exec -n fastapi deployment/fastapi -- nslookup kubernetes.default

# Endpoint public de bout en bout
curl -s https://api.devopsyouss.com/        # {"message":"Hello all the World"}
curl -s https://api.devopsyouss.com/docs     # Swagger UI

# Hostname du NLB (le CNAME Cloudflare est créé tout seul par ExternalDNS, #66/#70)
kubectl get svc -n envoy-gateway-system
# Attendu : un Service LoadBalancer avec un hostname *.elb.amazonaws.com, servi par un
# NLB (depuis #70 : annotation aws-load-balancer-type=nlb via l'EnvoyProxy nlb-config),
# PAS un Classic ELB. CÎté console AWS : type = network, et aucun CLB orphelin.

# Logs applicatifs
kubectl logs -n fastapi -l app=fastapi -f

Frontends par environnement (#156)

Ce qu'on prouve : chaque front sert l'URL de SON api, et non celle de prod héritée de l'image. La preuve tient en un curl : l'URL n'est plus dans le bundle mais dans un fichier servi, donc elle se lit directement sans ouvrir de navigateur.

# Les 3 hosts répondent, certificat wildcard valide
for h in app-dev app-staging app; do
  echo -n "$h -> "; curl -s -o /dev/null -w "%{http_code}\n" "https://$h.devopsyouss.com/"
done
# Attendu : 200 partout.

# LE test : chaque env annonce son propre backend
for h in app-dev app-staging app; do
  echo -n "$h -> "; curl -s "https://$h.devopsyouss.com/config/config.js"
done
# Attendu, dans l'ordre : api-dev, api-staging, api.
# Un `api.devopsyouss.com` renvoyé par app-dev signifie que le ConfigMap n'est pas celui
# de l'overlay, ou que le pod tourne sur une image antérieure à #156.

Test négatif : l'URL de prod ne doit apparaßtre nulle part dans le bundle

# RécupÚre le bundle servi et cherche une URL d'API compilée dedans
JS=$(curl -s https://app-dev.devopsyouss.com/ | grep -oE '/assets/index-[^"]+\.js')
curl -s "https://app-dev.devopsyouss.com$JS" | grep -oE 'https://api[a-z-]*\.devopsyouss\.com' || echo "aucune URL figĂ©e — attendu"

Une occurrence ici signifie qu'une image d'avant #156 est déployée : le bundle ignorerait window.__ENV et taperait la prod, ConfigMap monté ou non. C'est le scénario que #156 supprime, il se re-crée si un overlay est exposé sans avoir été promu sur une image récente.

# Origine du fichier : le ConfigMap de l'env, monté en lecture seule
kubectl get cm -n frontend-dev | grep frontend-config      # nom suffixé par un hash
kubectl exec -n frontend-dev deployment/frontend -- cat /usr/share/nginx/html/config/config.js

# Le hash du ConfigMap doit correspondre à celui référencé par le pod
kubectl get deployment frontend -n frontend-dev \
  -o jsonpath='{.spec.template.spec.volumes[?(@.name=="runtime-config")].configMap.name}{"\n"}'

# CORS : l'API de l'env accepte l'origine de SON front (pas celle d'un autre)
curl -s -o /dev/null -w "%{http_code}\n" -X OPTIONS \
  -H "Origin: https://app-dev.devopsyouss.com" \
  -H "Access-Control-Request-Method: GET" \
  https://api-dev.devopsyouss.com/posts/public

Confirmation navigateur (facultative) : ouvrir https://app-dev.devopsyouss.com, onglet Réseau, et vérifier que le fetch /posts/public part vers api-dev. Les curl ci-dessus prouvent déjà la configuration servie ; le navigateur ne prouve en plus que le comportement réel du bundle.


7. Secrets externes (ESO / IRSA)

Principe : aucun secret en clair dans Git. External Secrets Operator (ESO) lit AWS Secrets Manager via une identité IRSA (least-privilege, ARNs scopés) et matérialise des Secrets Kubernetes. ADR 009.

Vérifier la chaßne ESO

kubectl get secretstore -A
kubectl get externalsecret -A
Attendu : - SecretStore : STATUS = Valid (ESO parle à AWS via IRSA). - ExternalSecret : STATUS = SecretSynced, READY = True (secret récupéré et écrit).

Trois secrets gérés à ce jour :

ExternalSecret Namespace Secret K8s produit Usage
app (DB) fastapi creds appli mot de passe RDS
grafana monitoring grafana-admin login admin Grafana (#76)
alertmanager-slack monitoring alertmanager-slack webhook Slack (#77)

Test : le secret existe cÎté cluster, jamais dans Git

kubectl -n monitoring get secret grafana-admin -o jsonpath='{.data.admin-user}' | base64 -d
grep -rn "webhook\|password" k8s/   # ne doit JAMAIS sortir une vraie valeur
Attendu : la valeur existe dans le cluster (rĂ©cupĂ©rĂ©e d'AWS), mais le repo ne contient que des rĂ©fĂ©rences (nom/clĂ© du secret AWS), jamais le secret lui-mĂȘme.

PiÚge vécu (#77, validation live 2026-06-14)

Le stack Terraform persistent crĂ©e le secret AWS ; l'ephemeral (IRSA) le rĂ©fĂ©rence. Appliquer l'ephemeral avant le persistent fait Ă©chouer le data "aws_secretsmanager_secret" (couldn't find resource). Ordre : persistent (crĂ©e) → ephemeral (rĂ©fĂ©rence).


8. GitOps / ArgoCD

Principe : déploiement pull-based (#72, ADR 013). La CI ne fait plus kubectl apply : elle écrit le tag d'image dans k8s/base/kustomization.yaml (champ images:) et pousse sur develop ; ArgoCD réconcilie. Git = seule source de vérité. Guide : Comprendre ArgoCD.

Vérifier l'état des Applications

kubectl -n argocd get applications
Attendu : toutes en SYNC STATUS = Synced, HEALTH STATUS = Healthy. Le root-app apps (app-of-apps) déploie fastapi, kube-prometheus-stack, monitoring, loki, alloy.

Test selfHeal (drift d'un champ géré)

Modifier un champ présent dans Git (ex. l'image) :

kubectl -n fastapi set image deploy/fastapi fastapi=nginx:alpine   # drift volontaire
Attendu : ArgoCD repĂšre OutOfSync et remet la valeur de Git (selfHeal) ; la ressource redevient Synced.

Limite à connaßtre : ArgoCD est additif (validé en test 2026-06-14)

ArgoCD ne supprime que les champs qu'il a lui-mĂȘme posĂ©s (suivis via last-applied / managedFields). Un champ ajoutĂ© hors-bande (kubectl patch d'un champ absent du Git, ex. un command: injectĂ©) n'est pas vu comme un drift : l'Application reste Synced et le selfHeal ne le retire pas. C'est voulu (coexistence avec HPA, webhooks mutants, opĂ©rateurs). Pour retirer un tel champ : le faire Ă  la main, ou forcer (Replace=true / ServerSideApply strict). RĂšgle mentale : ArgoCD dĂ©fend ce que tu as dĂ©clarĂ©, il ne police pas ce que tu n'as pas dĂ©clarĂ©.

Cascade app-of-apps (validé en test 2026-06-14)

Le root-app apps gÚre l'Application fastapi avec son propre selfHeal. Pour neutraliser temporairement le selfHeal de fastapi, museler d'abord le parent apps, sinon il répare l'enfant aussitÎt :

kubectl -n argocd patch application apps    --type merge -p '{"spec":{"syncPolicy":{"automated":{"selfHeal":false}}}}'
kubectl -n argocd patch application fastapi --type merge -p '{"spec":{"syncPolicy":{"automated":{"selfHeal":false}}}}'
# ... manipulation ...
# RESTAURER ensuite selfHeal:true sur les DEUX (ne pas laisser une dérive non corrigée)
Rappel HPA + GitOps (INC-054) : spec.replicas est retiré du Git et mis en ignoreDifferences dans l'Application, sinon selfHeal se bat avec le HPA.


9. Observabilité (métriques, logs)

Stack : kube-prometheus-stack + Loki + Alloy, déployés par ArgoCD (#74/#75, ADR 014/015). Vitrine : https://grafana.devopsyouss.com (#76, login admin via secret grafana-admin). Guide : Comprendre l'observabilité.

AccÚs Grafana en local (si non exposé)

kubectl -n monitoring port-forward svc/kube-prometheus-stack-grafana 3000:80
# mot de passe admin = secret grafana-admin (clé admin-password), PAS "prom-operator"

Métriques : Prometheus scrape /metrics

Dans Grafana → Explore (datasource Prometheus) :

up{namespace=~"fastapi-(dev|staging|prod)", endpoint="http"}   # = 1 par pod, sur les 3 envs
http_requests_total                            # le compteur de requĂȘtes monte
Attendu : une sĂ©rie up = 1 par pod et par environnement (INC-063), http_requests_total prĂ©sent. Le ServiceMonitor fastapi porte namespaceSelector: {any: true} et suit les Services labellisĂ©s app: fastapi oĂč qu'ils soient ; il scrape /metrics en interne (ClusterIP, jamais public, #81).

Interroger Prometheus sans Grafana

Utile pour scripter une vĂ©rification, ou quand localhost ne mĂšne nulle part (poste sous devcontainer : le port-forward et le curl ne partagent pas le mĂȘme localhost). On sort un pod jetable dans le cluster, donc aucun tunnel :

kubectl -n monitoring run promq --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
  curl -gs 'http://kps-prometheus:9090/api/v1/query?query=count+by+(namespace)+(http_requests_total)'

Deux piÚges qui coûtent du temps :

  • Le Service Prometheus s'appelle kps-prometheus, pas kube-prometheus-stack-prometheus (seul Grafana porte le prĂ©fixe long).
  • curl -g est obligatoire dĂšs que la requĂȘte contient des crochets : sans lui, curl interprĂšte [5m] comme une plage de globbing d'URL et le pod sort en erreur. Une requĂȘte sans crochets passe, ce qui rend le symptĂŽme dĂ©routant.

Dashboard

Dashboard FastAPI RED (Rate / Errors / Duration) provisionné as-code (sidecar Grafana), calé sur les vraies métriques (http_requests_total{status="5xx"}, histogrammes http_request_duration_*).

Depuis #153, il porte une variable Environnement (namespace), par dĂ©faut fastapi-prod, et toutes ses requĂȘtes sont filtrĂ©es dessus.

VĂ©rifiĂ© dans l'interface le 2026-07-31 : le sĂ©lecteur apparaĂźt en haut du dashboard et bascule bien entre les namespaces d'environnement. La validation du 30/07 n'avait portĂ© que sur les requĂȘtes PromQL, ce qui prouvait le filtrage mais pas qu'il soit manipulable.

Vérifier la séparation par environnement

Générer un trafic asymétrique, puis lire les débits par namespace. /healthz/* et /metrics sont exclus de l'instrumentator : les marteler ne produit aucune métrique.

# dev : des 2xx
for i in $(seq 1 400); do curl -s -o /dev/null https://api-dev.devopsyouss.com/posts/public; done
# staging : des 4xx (endpoint authentifié, sans token)
for i in $(seq 1 400); do curl -s -o /dev/null https://api-staging.devopsyouss.com/posts/; done
# prod : rien

# attendre 2-3 min (fenetre rate[5m]), puis :
kubectl -n monitoring run promq --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
  curl -gs 'http://kps-prometheus:9090/api/v1/query?query=sum+by+(namespace,status)+(rate(http_requests_total[5m]))'

Attendu : trois profils distincts au mĂȘme instant. MesurĂ© le 2026-07-30 :

Namespace 2xx 4xx
fastapi-dev 1.481 req/s 0
fastapi-staging 0 1.481 req/s
fastapi-prod 0 0

Et la comparaison qui montre ce que #153 corrigeait, sur ce mĂȘme trafic :

sum(rate(http_requests_total[5m]))                             -> 2.963 req/s   (ancienne requĂȘte)
sum(rate(http_requests_total{namespace="fastapi-prod"}[5m]))   -> 0             (nouvelle)

L'ancien dashboard affichait donc prĂšs de 3 req/s sur une production qui n'en recevait aucune, et une courbe 4xx bien visible alors qu'aucune erreur ne venait de la prod.

Logs : Loki + Alloy

Dans Grafana → Explore (datasource Loki) :

{namespace=~"fastapi-.+"}
Attendu : les logs des pods fastapi remontent, tous environnements confondus (chaüne Alloy DaemonSet → push → Loki). Labels low-cardinality : namespace / pod / container.


10. Alerting (Prometheus → Alertmanager → Slack)

Principe : 5 PrometheusRule FastAPI (#77 + #93, ADR 016) → Alertmanager (routage par sĂ©vĂ©ritĂ©) → Slack. Webhook gĂ©rĂ© par ESO (jamais en clair). RĂšgles : k8s/platform/monitoring/prometheusrules-fastapi.yaml. Guide : Comprendre l'alerting.

Alerte Expression (résumé) for sévérité
FastAPITargetDown up{namespace="fastapi-prod",endpoint="http"} == 0 2m critical
FastAPITargetMissing absent(up{namespace="fastapi-prod",endpoint="http"}) 2m critical
FastAPIPodCrashLooping increase(kube_pod_container_status_restarts_total{namespace="fastapi-prod"}[10m]) > 3 5m critical
FastAPIHighErrorRate part de 5xx > 5% sur 5m 5m warning
FastAPIHighLatency p99 latence > 1s sur 5m 5m warning

Test synthétique (routage Slack, sans rien casser)

kubectl -n monitoring port-forward svc/alertmanager-operated 9093:9093 &
amtool alert add FastAPITest severity=warning --alertmanager.url=http://localhost:9093
Attendu : message [FIRING] dans Slack puis [RESOLVED] Ă  l'expiration. Le Watchdog (deadman's switch, toujours firing par design) et InfoInhibitor doivent ĂȘtre routĂ©s vers null (absents de Slack).

Test live « target down + CrashLoop » (réalisé le 2026-06-14)

Provoquer un CrashLoop (pods présents mais qui plantent) déclenche deux alertes :

# 1. couper le selfHeal (cf. section 8, cascade app-of-apps)
# 2. casser la commande + relancer des pods
kubectl -n fastapi patch deploy fastapi --type json \
  -p '[{"op":"add","path":"/spec/template/spec/containers/0/command","value":["sh","-c","exit 1"]}]'
kubectl -n fastapi scale deploy fastapi --replicas=2
Dans Grafana → Alerting → Alert rules, observer le cycle de vie Normal → Pending → Firing (le for: est le dĂ©lai anti-faux-positif). Suivre la condition en direct dans Explore : increase(kube_pod_container_status_restarts_total{namespace="fastapi"}[10m]). Puis Slack reçoit [FIRING] FastAPITargetDown + [FIRING] FastAPIPodCrashLooping.

Rétablir :

kubectl -n fastapi patch deploy fastapi --type json \
  -p '[{"op":"remove","path":"/spec/template/spec/containers/0/command"}]'
# RESTAURER selfHeal:true sur apps PUIS fastapi
→ pods sains, alertes Normal, [RESOLVED] dans Slack.

⚠ PiĂšge clĂ© : « sĂ©rie absente » vs « sĂ©rie Ă  0 »

FastAPITargetDown repose sur up == 0. Selon la panne, le comportement diffĂšre :

Situation État de la sĂ©rie up up == 0 se dĂ©clenche ?
Scale Ă  0 (--replicas=0) aucun pod → aucune cible → sĂ©rie absente NON (rien Ă  Ă©valuer)
Pod prĂ©sent mais plantĂ© (CrashLoop, scrape KO) cible existe → sĂ©rie = 0 OUI

up == 0 ne couvre donc pas la disparition totale (scale-à-0, deployment supprimé). Corrigé (#93) par la rÚgle FastAPITargetMissing = absent(up{namespace="fastapi", endpoint="http"}), validée live le 2026-06-16 (test ci-dessous).

Note : InfoInhibitor est désormais routé vers null (#94), validé le 2026-06-16 (absent de Slack pendant le test ci-dessous).

Test live « scale-Ă -0 → FastAPITargetMissing » (rĂ©alisĂ© le 2026-06-16)

Prouver que la disparition de la cible (série up absente) déclenche bien FastAPITargetMissing. Deux piÚges à connaßtre, sinon le scale-à-0 ne « tient » pas.

# 1. Museler le selfHeal, le PARENT d'abord (sinon le root-app répare fastapi)
kubectl -n argocd patch application apps    --type merge -p '{"spec":{"syncPolicy":{"automated":null}}}'
kubectl -n argocd patch application fastapi --type merge -p '{"spec":{"syncPolicy":{"automated":null}}}'
# 2. Supprimer le HPA (minReplicas=2 maintiendrait 2 pods), puis scale Ă  0
kubectl -n fastapi delete hpa fastapi
kubectl -n fastapi scale deploy fastapi --replicas=0
Dans Grafana → Explore : up{namespace="fastapi"} passe en No data (la sĂ©rie disparaĂźt, elle ne tombe pas Ă  0). AprĂšs for: 2m, Slack reçoit [FIRING] FastAPITargetMissing (critical). InfoInhibitor n'apparaĂźt pas (routĂ© null, #94).

Rétablir :

kubectl -n argocd patch application fastapi --type merge -p '{"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true}}}}'
kubectl -n argocd patch application apps    --type merge -p '{"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true}}}}'
# Le HPA est recréé par ArgoCD, MAIS il ne réveille pas un workload à 0 -> scale à la main
kubectl -n fastapi scale deploy fastapi --replicas=2
→ 2 pods Ready, sĂ©rie up revient Ă  1, Slack [RESOLVED].

⚠ PiĂšge HPA-Ă -0 (vĂ©cu) : un HPA refuse de scaler un Deployment Ă  replicas=0 (ScalingDisabled: scaling is disabled since the replica count of the target is zero). Il ne scale qu'Ă  partir de ≄ 1 (sauf feature gate HPAScaleToZero). Il faut donc « rĂ©veiller » le workload Ă  la main (scale --replicas=2), ensuite le HPA reprend le relais. CouplĂ© au fait que /spec/replicas est en ignoreDifferences (#82), ArgoCD ne remonte pas non plus les replicas tout seul : le scale manuel est obligatoire pour sortir du 0.


11. Gouvernance des ressources (LimitRange / ResourceQuota)

Principe : deux objets complémentaires, et l'un sans l'autre est un piÚge (#112 puis #136, ADR 025). Le LimitRange fournit des valeurs par défaut aux pods qui n'en déclarent pas ; le ResourceQuota plafonne la somme par namespace. Un quota posé sans LimitRange rejette tout pod sans requests au lieu de le laisser naßtre Burstable.

OĂč vit quoi : le LimitRange est posĂ© par le bootstrap Ansible (il doit prĂ©cĂ©der ArgoCD, leçon INC-061) ; les ResourceQuota vivent dans les overlays d'env (valeur propre Ă  chaque env).

Vérifier que les deux sont en place sur les 6 namespaces d'env

for ns in fastapi-dev fastapi-staging fastapi-prod frontend-dev frontend-staging frontend-prod; do
  echo "== $ns"
  kubectl -n $ns get limitrange default-limits -o jsonpath='{.spec.limits[0].default}{"\n"}'
  kubectl -n $ns get resourcequota default-quota -o jsonpath='{.status.hard}{"\n"}'
done
Attendu : un default-limits et un default-quota dans chacun. Si un default-quota manque, l'Application ArgoCD de cet env n'est pas synchronisée.

Avant de toucher aux bornes d'un HPA — comparer Used à Hard

À faire systĂ©matiquement avant de modifier maxReplicas dans un overlay (#149). Le quota est calculĂ© sur maxReplicas + 2 de surge (ADR 025) : changer l'un sans l'autre casse le prochain rollout, pas le dĂ©ploiement en cours, donc le lien de cause Ă  effet est invisible au moment de l'erreur.

kubectl describe resourcequota default-quota -n fastapi-prod
Lire les 4 dimensions, pas seulement pods : requests.cpu, requests.memory, limits.memory et pods peuvent saturer indépendamment. Sur fastapi-prod elles tombent exactement sur le plafond quand le HPA est à 5, soit zéro marge.

Si un rollout paraĂźt lent alors que le HPA est au maximum, chercher la trace ici :

kubectl get events -n fastapi-prod | grep -i "exceeded quota"
Vécu le 2026-08-11 : 10 FailedCreate ... exceeded quota pendant un rollout, qui a malgré tout abouti par réessais. kubectl rollout status affichait successfully rolled out sans rien signaler. Ne pas conclure d'un rollout réussi que le quota était confortable.

Test positif — aucun pod BestEffort

kubectl get pods -A -o custom-columns=NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass \
  | grep BestEffort
Attendu : aucune ligne dans les namespaces possédés. Un pod BestEffort signifie qu'il est né avant le LimitRange de son namespace (le LimitRange n'agit qu'à l'admission, jamais rétroactivement) : le recréer suffit à le faire renaßtre Burstable.

Lire la consommation du quota

kubectl -n fastapi-dev describe resourcequota default-quota
Attendu : Used strictement sous Hard sur les 4 dimensions (requests.cpu, requests.memory, limits.memory, pods). Une dimension à 100 % bloque le prochain déploiement sans rien casser tout de suite : c'est le mode de défaillance à surveiller.

Test négatif (le plafond refuse à l'admission)

Utiliser frontend-dev et non fastapi-dev : le frontend n'a pas de HPA, ses réplicas sont fixes, donc rien ne se bat avec toi pendant le test. Sur un workload piloté par un HPA, celui-ci reprend la main et le résultat devient illisible. Museler ArgoCD d'abord (section 8).

kubectl -n frontend-dev scale deploy/frontend --replicas=8   # quota pods = 3
sleep 5
kubectl -n frontend-dev get deploy frontend -o jsonpath='{.status.conditions[?(@.type=="ReplicaFailure")].message}{"\n"}'
kubectl -n frontend-dev get pods

Attendu (observé sur EKS le 2026-07-26) :

Error creating: pods "frontend-7ffc964455-jrjvc" is forbidden: exceeded quota: default-quota,
requested: limits.memory=64Mi,pods=1,requests.cpu=10m,requests.memory=32Mi,
used: limits.memory=192Mi,pods=3,requests.cpu=30m,requests.memory=96Mi,
limited: limits.memory=192Mi,pods=3,requests.cpu=30m,requests.memory=96Mi

Deux choses Ă  lire dans ce message. Le refus vient du replicaset-controller en FailedCreate, donc Ă  l'admission : les 3 pods sous le plafond restent Running pendant tout le test. Un quota saturĂ© ne casse rien de ce qui tourne, il bloque la prochaine crĂ©ation. Et les quatre dimensions sont citĂ©es ensemble, toutes saturĂ©es au mĂȘme point — le quota se comporte donc comme un simple plafond de pods, ce qui le rend prĂ©visible.

Remettre ensuite le nombre de réplicas nominal, puis restaurer selfHeal.

Le piĂšge limits.cpu (reproduit en test le 2026-07-26)

Un ResourceQuota qui borne une ressource exige que chaque pod du namespace la dĂ©clare. Comme le LimitRange n'injecte volontairement aucune limite CPU (ADR 025 DĂ©cision 2 : le CPU est compressible, une limite throttle en silence), ajouter limits.cpu au quota gĂšle le namespace entier : must specify limits.cpu Ă  l'admission, Deployment Ă  0 UP-TO-DATE, alors que les pods dĂ©jĂ  en place continuent de tourner comme si de rien n'Ă©tait. C'est la panne la plus vicieuse de la sĂ©rie — elle ne se rĂ©vĂšle qu'au dĂ©ploiement suivant. Les quotas du repo bornent requests.cpu, requests.memory, limits.memory et pods, jamais limits.cpu.

Dev et staging ne sont pas exposés (#146)

Tant que #137 (bases sĂ©parĂ©es) et #138 (secrets par env) ne sont pas livrĂ©s, les trois envs tapent la mĂȘme base que la prod. Les overlays dev et staging suppriment donc la HTTPRoute hĂ©ritĂ©e du base : sans route, rien ne s'attache au Gateway et ExternalDNS ne publie aucun enregistrement.

Test positif (les routes n'existent pas) :

kubectl get httproute -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,HOSTS:.spec.hostnames
Attendu : des routes uniquement dans fastapi-prod, frontend-prod, monitoring, argocd et fastapi (la redirection). Aucune dans fastapi-dev, fastapi-staging, frontend-dev, frontend-staging.

Test négatif (rien ne répond publiquement) :

for h in dev.api staging.api dev.app staging.app; do
  printf '%-12s %s\n' "$h" "$(curl -sS -m 5 -o /dev/null -w '%{http_code}' http://$h.devopsyouss.com/ 2>&1 || echo 'pas de résolution')"
done
Attendu : aucune rĂ©solution DNS, ou aucune rĂ©ponse applicative. Un 200 signifie que le garde-fou a sautĂ© — vĂ©rifier qu'aucun overlay dev/staging n'a rĂ©introduit de HTTPRoute.

AccÚs légitime à un env non exposé :

kubectl -n fastapi-dev port-forward svc/fastapi 8080:80
curl -sS http://localhost:8080/healthz/ready

Le HTTPRouteFilter deny-metrics reste présent dans fastapi-dev et fastapi-staging, sans route pour le référencer. Il est inerte (un filtre ne fait rien tant qu'aucune route ne le pointe) et resservira quand #138 rétablira les routes. Ce n'est pas un résidu à nettoyer.

Cloisonnement ArgoCD par env (AppProject, #136)

Les 6 Applications d'env tournent chacune dans l'AppProject de leur environnement (env-dev / env-staging / env-prod), qui borne les namespaces de destination, le dĂ©pĂŽt source et les ressources cluster-scoped. Sans lui, project: default autorise namespace: '*' — l'Application fastapi-dev pouvait Ă©crire dans fastapi-prod.

kubectl -n argocd get applications -o custom-columns=NAME:.metadata.name,PROJECT:.spec.project
kubectl -n argocd get appprojects
Attendu : les 6 Applications d'env sur env-<env>, les Applications de plateforme (monitoring, loki, tempo, alloy, root-app) restant en default — elles sont cluster-scoped par nature et ne participent pas au cloisonnement par env.

Test négatif (une Application ne peut pas sortir de son env) :

# Pointer temporairement l'Application dev vers le namespace prod
kubectl -n argocd patch application fastapi-dev --type merge \
  -p '{"spec":{"destination":{"namespace":"fastapi-prod"}}}'
kubectl -n argocd get application fastapi-dev -o jsonpath='{.status.conditions}{"\n"}'
Attendu (observé sur EKS le 2026-07-26) :
application destination server 'https://kubernetes.default.svc' and namespace 'fastapi-prod'
do not match any of the allowed destinations in project 'env-dev'

Restaurer ensuite fastapi-dev (museler d'abord le parent apps, cf. section 8 sur la cascade app-of-apps, sinon il répare avant que le test soit lisible).

Ne pas confondre deux refus de libellés différents. Celui ci-dessus porte sur le champ destination de l'Application et se lit dans .status.conditions. Il en existe un second, à la sync, quand une ressource de l'overlay a un metadata.namespace codé en dur vers un autre namespace (namespace fastapi is not permitted in project 'env-dev'). Le second est le cas d'accident le plus réaliste : un overlay copié-collé d'un env à l'autre.


12. BoĂźte Ă  outils

Outil Usage Note
kubectl top CPU/mémoire pods et nodes nécessite metrics-server
fortio charge interne (HPA) image fortio/fortio, Ă  lancer en pod dans le cluster
ab (apache2-utils) charge externe / smoke HTTPS limité par la bande passante homelab
hey charge externe (alternative Ă  ab) binaire Go unique
openssl s_client inspection chaĂźne TLS issuer, dates, SAN
curl -vI headers HTTP + infos TLS rapides
nslookup / dig résolution DNS tester la rÚgle egress 53
aws eks list-addons vérifier les addons managés clé pour l'enforcement NetworkPolicy
amtool tester le routage Alertmanager → Slack amtool alert add (firing/resolved synthĂ©tique)
promtool check rules valider les PrometheusRule en local sans cluster, avant merge
Grafana Explore requĂȘtes PromQL (mĂ©triques) / LogQL (logs) Ă  la main l'outil pour diagnostiquer en direct
kubectl -n argocd get applications état Synced/Healthy des Applications la photo GitOps en une commande

13. Garde-fou de teardown (hors cluster)

ADR 031, issue #174. Ce contrĂŽle ne se joue pas sur le cluster : il vĂ©rifie le mĂ©canisme qui signale un cluster oubliĂ©. Il tourne sur AWS, sans GitLab, sans le runner .112 et sans ci_config_path. aws doit ĂȘtre jouĂ© avec iamadmin (source ~/.aws/session-aws.env), pas avec le profil du cluster.

13.1 Aprùs l'apply — confirmer l'abonnement, sinon rien ne part

L'abonnement SNS naĂźt en PendingConfirmation : AWS envoie un courriel qu'il faut ouvrir une fois. Tant qu'il ne l'est pas, le sujet publie dans le vide.

TOPIC=$(cd terraform/persistent && terraform output -raw teardown_guardrail_sns_topic_arn)

# Doit montrer un ARN d'abonnement, PAS la chaĂźne "PendingConfirmation"
aws sns list-subscriptions-by-topic --topic-arn "$TOPIC" \
  --query 'Subscriptions[].[Protocol,SubscriptionArn]' --output table

13.2 Le planning est-il armé ?

aws scheduler get-schedule --name fastapi-eks-teardown-guardrail \
  --query '{state:State,cron:ScheduleExpression,tz:ScheduleExpressionTimezone}'

Attendu : ENABLED, cron(0 23 * * ? *), Europe/Paris. Le fuseau compte : une rÚgle EventBridge classique ne connaßt que l'UTC et dériverait d'une heure à chaque changement d'heure.

13.3 Exercer le mĂ©canisme — la seule Ă©tape qui prouve quelque chose

Un contrÎle configuré n'est pas un contrÎle vérifié (leçon d'INC-066). Tant que cette étape n'a pas été jouée sur un cluster réellement vivant, le schedule GitLab de 21 h reste en place (décision 4 de l'ADR 031).

FN=$(cd terraform/persistent && terraform output -raw teardown_guardrail_lambda_name)

# Cluster monté -> attendu : {"clusters": 1, "notified": true} + un courriel
aws lambda invoke --function-name "$FN" /dev/stdout

# Cluster détruit -> attendu : {"clusters": 0, "notified": false} + AUCUN courriel
aws lambda invoke --function-name "$FN" /dev/stdout

Les deux sens comptent. Le cas notified: true prouve que l'alerte part ; le cas notified: false prouve qu'elle ne part pas pour rien. Un garde-fou qui alerte chaque nuit devient du décor en une semaine, exactement comme le scan de registre de 8 h (INC-072).

13.4 Ce que ce garde-fou ne dit PAS

Il répond à une seule question : « existe-t-il un cluster EKS vivant à 23 h ? ». Ne sont pas vus le NLB (il vit hors Terraform, ADR 017), RDS, le NAT Gateway, les ENIs orphelins (INC-011).

Son silence n'est pas un certificat de propreté du compte. Le contrÎle manuel en huit points reste la vérification de fin de session.

13.5 Le contrĂŽle en huit points

bash scripts/check-teardown.sh     # sous iamadmin, aprĂšs infra-stop

Il compte les huit postes qui survivent à un destroy partiel ou qui vivent hors Terraform, et imprime toujours le compte, jamais un simple « ok » : un contrÎle qui ne dit que « ok » ne permet pas de distinguer « vérifié et vide » de « pas vérifié ».

# Poste Pourquoi il est dans la liste
1 Clusters EKS le poste de dépense dominant
2 Instances EC2 les nƓuds
3 Instances RDS facturée à l'heure, hors cluster
4 NAT Gateways facturé à l'heure
5 EIP non associées une EIP détachée se facture, une EIP attachée non
6 Load balancers le NLB vit hors Terraform (ADR 017), un destroy ne le voit pas
7 ENIs orphelins INC-011, résidus d'un destroy mal ordonné
8 Volumes EBS détachés un volume détaché continue de facturer

Mesure du 2026-09-04, les deux sens dans la mĂȘme session : 5 rouges avec l'infra debout (dont le point 6), 8 verts aprĂšs infra-stop. Un script de vĂ©rification qu'on n'a jamais vu Ă©chouer ne prouve rien.


Rappel des deux tests qui ferment la boucle sécurité

ContrÎle Test négatif (deny) Test positif (allow)
NetworkPolicy curl http://example.com dans le pod -> timeout /healthz/ready -> 200 (DNS+5432 OK)
PSA kubectl run nginx dans fastapi -> rejeté pod fastapi -> Running 1/1
HPA (n/a) charge fortio -> scale 1 vers 5, puis scale-down
TLS cert staging non trusté navigateur cert prod -> cadenas vert
ResourceQuota scale --replicas=10 -> exceeded quota réplicas nominaux + surge admis
LimitRange (n/a) pod sans requests -> Burstable, jamais BestEffort
AppProject Application env-dev visant fastapi-prod -> InvalidSpecError les 6 Applications d'env Synced sur env-<env>