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-addonslistantvpc-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=$?"
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
{"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
3m 60Mi). Si error: Metrics API not available, metrics-server KO.
Ătape 2 â le HPA lit les mĂ©triques¶
kubectl get hpa -n fastapi
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/
3. TLS / cert-manager¶
VĂ©rifier les ClusterIssuers¶
kubectl get clusterissuers
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
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) 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
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
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
:80ne doit plus servir qu'à rediriger. Les certificats passent par DNS-01, donc aucun challenge HTTP-01 à préserver. Si unClusterIssuerHTTP-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¶
- Ăditer
certificate.yaml:issuerRef.name: letsencrypt-staging->letsencrypt-prod kubectl apply -f certificate.yamlkubectl delete secret wildcard-devopsyouss-tls -n fastapi(force la ré-émission)- 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
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
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
/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
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
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
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
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)
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
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, paskube-prometheus-stack-prometheus(seul Grafana porte le prĂ©fixe long). curl -gest obligatoire dĂšs que la requĂȘte contient des crochets : sans lui,curlinterprĂš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-.+"}
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
[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
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
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 :
InfoInhibitorest désormais routé versnull(#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
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
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 gateHPAScaleToZero). Il faut donc « réveiller » le workload à la main (scale --replicas=2), ensuite le HPA reprend le relais. Couplé au fait que/spec/replicasest enignoreDifferences(#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
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
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"
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
Lire la consommation du quota¶
kubectl -n fastapi-dev describe resourcequota default-quota
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
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
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-metricsreste présent dansfastapi-devetfastapi-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
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"}'
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
destinationde l'Application et se lit dans.status.conditions. Il en existe un second, à la sync, quand une ressource de l'overlay a unmetadata.namespacecodé 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
.112et sansci_config_path.awsdoit ĂȘtre jouĂ© aveciamadmin(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> |