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

  1. NetworkPolicy (enforcement réseau)
  2. HPA / Autoscaling
  3. TLS / cert-manager
  4. Pod Security Admission (PSA)
  5. Probes / Health
  6. Connectivité / Smoke tests
  7. Secrets externes (ESO / IRSA)
  8. GitOps / ArgoCD
  9. Observabilité (métriques, logs)
  10. Alerting (Prometheus → Alertmanager → Slack)
  11. BoĂźte Ă  outils

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Ă©).
kubectl exec -n fastapi 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.

Test inter-pods (optionnel)

Vérifier qu'un pod hors allowlist ne joint pas un pod protégé.

# pod témoin dans un autre namespace, tente de joindre le service fastapi
kubectl run probe --image=curlimages/curl -n default --restart=Never --rm -it -- \
  curl -m 5 http://fastapi.fastapi.svc.cluster.local/
Selon les rÚgles ingress, attendu = réponse OK (ingress 8080 sans from) ou timeout.


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

Méthode recommandée : charge DEPUIS l'intérieur du cluster (pas de limite de bande passante internet). La charge externe depuis le homelab plafonne à ~46 req/s (latence ~1s/req), insuffisant pour saturer le CPU.

# Terminal 1 — observation
watch -n 2 kubectl get hpa,pods -n fastapi

# Terminal 2 — charge interne via fortio (namespace default, sans NetworkPolicy)
kubectl run loadgen --image=fortio/fortio --restart=Never --rm -it -n default -- \
  load -c 150 -qps 0 -t 120s http://fastapi.fastapi.svc.cluster.local/
- 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 : cible = ramener le CPU moyen à 70%. À 194% sur 1 replica → ceil(1 × 194/70) = 3 replicas. Comme la CPU limit (500m) vaut 200% de la request (250m), le CPU moyen reste > 70% → scale jusqu'à maxReplicas (5).

Étape 4 — 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 min (1). Normal, pas un bug.

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.

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).

Vérifier les labels du namespace

kubectl get ns fastapi --show-labels
Attendu : pod-security.kubernetes.io/enforce=restricted.

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

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", endpoint="http"}     # = 1 (cible scrapée)
http_requests_total                            # le compteur de requĂȘtes monte
Attendu : up = 1, http_requests_total présent. Le ServiceMonitor fastapi scrape /metrics en interne (ClusterIP, jamais public, #81).

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_*).

Logs : Loki + Alloy

Dans Grafana → Explore (datasource Loki) :

{namespace="fastapi"}
Attendu : les logs des pods fastapi remontent (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",endpoint="http"} == 0 2m critical
FastAPITargetMissing absent(up{namespace="fastapi",endpoint="http"}) 2m critical
FastAPIPodCrashLooping increase(kube_pod_container_status_restarts_total{namespace="fastapi"}[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. 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

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