FastAPI Hexagonal Code source

05. Exploitation

Monter, sauvegarder, démonter — et dire franchement ce que cette infrastructure ne couvre pas.

TL;DR — ./k8s/deploy.sh monte tout, par étapes avec des barrières explicites. db-backup.sh fait des dumps logiques dans db-backups/, et verify prouve qu'ils se restaurent. clean.sh détruit le cluster. RPO, RTO et SLO sont distingués des observations et des exemples chiffrés. Pas de PITR, pas de CI, pas de TLS : ce sont des absences assumées, pas des oublis.

Le script de déploiement#

Une commande depuis la racine du dépôt :

./k8s/deploy.sh
ÉtapeCe qu'elle faitBarrière
1/6Cluster Kind + Envoy Gateway v1.9.1rollout status du contrôleur
2/6Metrics Server + patch --kubelet-insecure-tlsrollout status
3/6Opérateur CloudNativePG 1.30wait condition=Available
3b/6Opérateur RabbitMQ Cluster v2.15.0wait condition=Available
4/6Build des deux images + kind load—
5/6Namespace, secrets, base, dépendanceswait cluster/task-manager-db Ready
6/6Migrations, puis config, workloads, HPA, PDB, Gateway + HTTPRoute, politiques réseauwait job/db-migrate complete, puis wait gateway Programmed

Ce qui fait la qualité de ce script tient en deux propriétés.

Le script réutilise le cluster et réapplique les manifests, mais sa relance n’est pas un mécanisme complet de mise à jour. Un Job terminé ne se réexécute pas avec apply, une nouvelle image sous latest ne redémarre pas les pods et un apply peut rétablir le nombre de replicas déclaré malgré le HPA. Il faut organiser séparément les migrations et les rollouts d’une nouvelle version.

if ! kind get clusters | grep -qx "$CLUSTER_NAME"; then
  kind create cluster --config k8s/kind-config.yaml
else
  echo "    cluster '$CLUSTER_NAME' déjà présent, on réutilise."
fi

# le patch n'est appliqué que s'il n'est pas déjà là
if ! kubectl get deployment metrics-server -n kube-system \
  -o jsonpath='{.spec.template.spec.containers[0].args[*]}' \
  | grep -qw -- '--kubelet-insecure-tls'; then
  kubectl patch -n kube-system deployment metrics-server --type=json \
    -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
fi

Le script attend plusieurs conditions utiles (opérateurs, base, Job, Gateway). Il n’attend toutefois pas tous les workloads : l’attente RabbitMQ est commentée, et aucun rollout status final ne valide API, worker et frontend. Le message « déploiement terminé » signifie que les commandes ont réussi, pas que l’application est entièrement disponible. Vérifier ensuite les rollouts, les pods, les routes et une requête métier.

L'ordre n'est pas cosmétique Opérateurs → CRD disponibles → cluster de base prêt → migrations terminées → workloads. Chaque flèche est une dépendance stricte : appliquer database.yaml avant l'opérateur CNPG donne no matches for kind "Cluster", et démarrer l'API avant le Job de migration donne des pods qui échouent sur un schéma absent.

Sauvegarde et restauration#

./k8s/db-backup.sh backup                 # dump → db-backups/task_manager-<horodatage>.sql.gz
./k8s/db-backup.sh list                   # liste les dumps
./k8s/db-backup.sh restore <fichier>      # demande confirmation
./k8s/db-backup.sh restore <fichier> -y   # sans confirmation

Le script trouve le primaire tout seul, en interrogeant l'état du cluster CNPG plutôt qu'en devinant un nom de pod :

primary() {
  kubectl get cluster "$CLUSTER" -n "$NS" -o jsonpath='{.status.currentPrimary}'
}

kubectl exec -n "$NS" "$(primary)" -c postgres -- \
  pg_dump -U postgres -d task_manager --clean --if-exists | gzip > "$file"

Le script demande le primaire courant à CNPG pour choisir une cible cohérente avec sa procédure. pg_dump peut aussi fonctionner sur un standby : il faut alors accepter son retard de réplication et les éventuels conflits avec le rejeu WAL. Lire depuis un réplica n’est pas en soi une erreur.

PropriétéValeurConséquence
TypeDump logique (pg_dump)Portable, lisible, indépendant de la version binaire
ContenuSchéma (dont alembic_version) + donnéesLa restauration remet Alembic au bon cran
Restauration--clean --if-existsRecrée proprement les objets avant de recharger
GranularitéL'instant du dumpPas de PITR : ce qui suit le dump est perdu
Destinationdb-backups/, ignoré par gitReste sur le disque local

ON_ERROR_STOP=1 interrompt psql au premier échec mais ne rend pas la restauration atomique. Sans transaction englobante, les instructions précédentes peuvent déjà être validées. La restauration actuelle écrase des objets de la base cible ; arrêter les écritures, sauvegarder et vérifier le dump sur une cible isolée avant cette opération.

Ce que ce dispositif n'est pas Un dump logique déclenché à la main n'est pas une politique de sauvegarde. Il n'y a ni planification, ni rétention, ni copie hors machine, ni restauration testée automatiquement. CloudNativePG sait faire du PITR continu vers un stockage objet — c'est le premier chantier à ouvrir si cette infrastructure devait porter autre chose qu'une démonstration.

Restaurer, sinon ce n'est pas un backup#

L'encadré ci-dessus dit ce qui manque. Il manquait aussi le geste : une sauvegarde qu'on n'a jamais restaurée n'est pas une sauvegarde validée, c'est un fichier dont on espère quelque chose. La plupart des dispositifs de sauvegarde échouent le jour où on en a besoin, pas le jour où on les écrit.

./k8s/db-backup.sh verify db-backups/task_manager-<horodatage>.sql.gz

verify restaure dans task_manager_verify, sur le même cluster, puis la supprime. Ce nom est fixe et la commande commence par le nettoyer : il doit être réservé, ne contenir aucune donnée à conserver et ne pas être utilisé par deux vérifications simultanées. Un dump SQL doit être de confiance. Cette vérification consomme aussi les ressources du cluster réel.

==> Vérification de 'db-backups/task_manager-20260830-155158.sql.gz' dans la base jetable 'task_manager_verify'
    restauration…
    contrôles…

    tables (schéma public) : 3
    cran alembic_version   : 001_initial
    lignes dans tasks      : 0
    lignes dans users      : 0

✅ Sauvegarde VALIDÉE : elle se restaure et le schéma est exploitable.

Les contrôles donnent une première indication de restaurabilité, pas une preuve complète. Le script exige au moins une table et une version Alembic non vide ; les comptes de tasks et users sont affichés mais ne conditionnent pas son verdict. Il ne compare pas les données à la source et peut donc accepter un contenu incomplet. Ajouter des assertions sur les tables attendues, des volumes de référence et des parcours applicatifs pour une validation plus forte.

ÉtapeCe qu'elle prouve
backupOn sait produire un fichier
restore (base jetable)Le fichier est lisible et se charge sans erreur
smoke testCe qui a été chargé a un schéma exploitable
verdictUn résultat binaire, pas une impression
Un détail de bash qui a mordu à l'écriture

Le nettoyage de la base jetable passe par un trap … EXIT. Or ce trap s'exécute après la sortie de la fonction, où une variable local n'existe plus : sous set -u, le nettoyage échouait au pire moment, en laissant la base derrière lui. Le nom du pod primaire est donc une variable globale, avec un commentaire qui dit pourquoi.

Ce qui manque encore pour que ce soit une vraie politique : une planification, une rétention, une copie hors machine, et cette vérification lancée automatiquement et périodiquement. Une vérification manuelle qu'on oublie de lancer vaut à peu près ce que vaut un backup jamais restauré.

RPO, RTO, SLO : chiffrer ce qu'on promet#

Ce dépôt a des mécanismes de résilience — trois instances, failover automatique, sauvegardes — et aucun objectif chiffré. Personne ne peut donc dire s'ils suffisent. Trois notions, souvent confondues, servent à poser la question dans le bon ordre.

La questionCe qui le détermine
RPO
sinistre
Combien de données j'accepte de perdre ?Le mécanisme de réplication et de sauvegarde
RTO
sinistre
Combien de temps j'accepte d'être coupé ?Le mécanisme de bascule ou de restauration
SLO
marche normale
Quelle qualité je m'engage à tenir ?L'instrumentation

L'ordre compte : on choisit d'abord un RPO et un RTO à partir de ce que coûte la panne, puis on choisit les mécanismes qui les tiennent. Ce dépôt fait l'inverse, comme la plupart des projets — d'où ce chapitre.

Le RPO de ce labo, mesuré#

Le RPO est un objectif, pas la valeur d’un compteur. En asynchrone, la perte possible dépend des WAL durablement reçus par le réplica qui sera promu. pg_stat_replication distingue émission, écriture, flush et rejeu : un retard de rejeu n’est pas une mesure directe de perte durable. Les chiffres ci-dessous sont des relevés historiques, à reproduire avec la requête SQL et le contexte de charge.

# au repos
task-manager-db-2  retard_envoi_rejeu=0 o
task-manager-db-3  retard_envoi_rejeu=0 o

# pendant une rafale d'écritures soutenue
task-manager-db-2  retard_envoi_rejeu=34734224 o   (~33 Mio de WAL non rejoué)
task-manager-db-3  retard_envoi_rejeu=34078864 o

Un retard de rejeu nul décrit cet instant uniquement. Des WAL non encore rejoués peuvent déjà être flushés sur un réplica et rejoués à la promotion : les 33 Mio affichés ne correspondent donc pas à 33 Mio de données perdues. Mesurer une perte exige de comparer les écritures acquittées avant la panne avec celles retrouvées après reprise ; les octets WAL ne sont pas directement un nombre de lignes métier.

Le RTO de ce labo, mesuré#

Le RTO est la durée maximale de reprise visée. L’essai ci-dessous rapporte un temps de reprise observé lors de la suppression du pod primaire et de sondages de /api/v1/tasks. Ce résultat historique ne constitue pas un objectif ni une garantie, et une suppression de pod ne simule pas une perte définitive de machine.

InstantÉvénement
+0 sLe pod primaire est supprimé
+2,1 sPremière requête HTTP en échec
+2,1 sCloudNativePG promeut un réplica
+15,3 sL'API répond de nouveau 200

Interruption observée : environ 13 secondes d'indisponibilité réelle. Deux enseignements. D'abord la promotion est quasi immédiate, mais le service ne revient pas avec elle : il faut encore que le Service -rw bascule, que le pooler rétablisse ses connexions et que les connexions périmées soient détectées. Ensuite, ce chiffre-là n'est pas transposable : il vaut pour ce cluster, sur cette machine, sans charge. Le mesurer régulièrement est ce qui en fait un objectif tenu plutôt qu'une espérance.

Ce que le RTO ne dit pas

Treize secondes de coupure, ce n'est pas treize secondes sans conséquence : les transactions en vol ont échoué, et l'application doit les tolérer et les rejouer. pool_pre_ping réduit le bruit, il ne rend pas la bascule transparente (ch. 03).

Constaté après la mesure ci-dessus : plusieurs minutes après le retour du service, une requête isolée est encore repartie en 500 — connection was closed in the middle of operation, une connexion périmée que le pool n'avait pas encore éjectée. Les six requêtes suivantes ont toutes répondu 200. Le RTO mesure le retour du service, pas la fin des erreurs : la queue de bascule dure plus longtemps que la bascule.

Le SLO, et le budget d'erreur#

RPO et RTO fixent des objectifs de reprise après incident. Le SLO décrit la qualité du service sur une fenêtre, incidents compris. Il se pose sur un SLI, un indicateur mesuré en continu :

SLI              part des requêtes qui ne renvoient pas 5xx
SLO              99,9 % sur 30 jours glissants
budget d'erreur  0,1 % des requêtes éligibles sur cette fenêtre

Avec un SLI fondé sur les requêtes, le budget se compte en requêtes échouées : une panne de 13 secondes consomme les échecs survenus pendant cette durée. Les 43,2 minutes sur 30 jours correspondent à un autre SLI, fondé sur le temps disponible. Ces deux budgets ne sont interchangeables que sous des hypothèses de trafic explicites.

Prometheus ne connaît que le SLI : la cible est une décision, pas une métrique. Les indicateurs sont calculés en continu par des recording rules, et non au moment où quelqu'un ouvre un graphique :

# k8s/infra.yaml — ConfigMap prometheus-rules
- record: sli:api_availability:ratio5m
  expr: |
    1 - (
      sli:api_requests_failed:rate5m
      / clamp_min(sli:api_requests:rate5m, 1e-9)
    )
- record: sli:api_latency_p95:5m
  expr: |
    histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
Lire correctement les métriques. Le label status est groupé (2xx, 5xx) par défaut. status="5xx" est explicite ; status=~"5.." correspond aussi à 5xx, car . accepte tout caractère. Le piège serait par exemple 5[0-9][0-9]. Voir les sélecteurs PromQL.

sum() sans série renvoie un vecteur vide. or vector(0) fournit un zéro, mais masque aussi une absence de collecte. La formule actuelle peut afficher 100 % sans trafic ou sans métriques : surveiller séparément up, l’absence de séries et une sonde externe. Une fenêtre de cinq minutes ne calcule pas à elle seule le SLO sur trente jours.

Les valeurs relevées après correction, sur un trafic de test :

sli:api_availability:ratio5m     1        (100 % de requêtes non-5xx)
sli:api_requests_failed:rate5m   0
sli:api_latency_p95:5m           0.095    (95 ms)

Ce qu'il faudrait pour des objectifs plus durs#

ObjectifMécanismeCe qu'il coûte
RPO ≈ 0Réplication synchrone (bloc commenté dans database.yaml)Les écritures bloquent si trop peu de réplicas répondent
RPO borné dans le tempsArchivage WAL + PITR vers un stockage objetStockage, et une restauration à exercer
RTO plus courtSondes plus agressives, pooler plus réactif, retries applicatifsPlus de bascules intempestives sur faux positif
SLO surveilléAlertmanager + alertes de burn-rateUn composant de plus, et quelqu'un pour répondre
Pannes indépendantesPlusieurs machines, plusieurs zonesCe que Kind ne peut pas simuler (ch. 06)

Mettre à jour une image#

docker build -t task-manager-api:latest ./backend
kind load docker-image task-manager-api:latest --name task-manager
kubectl rollout restart deployment/api deployment/mail-worker -n task-manager

Le rollout restart est indispensable : le tag n'a pas changé, donc rien ne dit à Kubernetes que le contenu est différent. C'est le revers du fonctionnement sans registry — pratique pour itérer, mais sans le déclenchement automatique qu'apporterait un tag immuable.

La commande demande les deux rollouts, mais ils progressent indépendamment : anciennes et nouvelles versions peuvent cohabiter. Les messages doivent rester compatibles pendant cette fenêtre et avec le contenu déjà en file. Charger une image ou utiliser un registry ne déclenche pas à lui seul un rollout ; modifier la référence d’image du Pod template le fait.

Nettoyage#

./k8s/clean.sh              # supprime le cluster Kind
./k8s/clean.sh --images     # + supprime les deux images du projet

Le cluster entier disparaît, volumes compris. Les dumps de db-backups/ survivent : ils sont sur le disque de l'hôte, pas dans le cluster — ce qui est exactement l'intérêt d'une sauvegarde.

Observabilité#

OutilCe qu'il donneAccès
PrometheusMétriques de l'API (/metrics, via prometheus-fastapi-instrumentator)http://prometheus.localhost
Metrics Serverkubectl top pods et l'entrée du HPAen ligne de commande
MailpitLes e-mails que le worker a réellement envoyéshttp://mailpit.localhost
RabbitMQFiles, consommateurs, et la DLQ email_notifications.dlqhttp://rabbitmq.localhost
RedisInsightLe contenu du cache, clés user:{id}http://redisinsight.localhost

Ces quatre consoles rendent visible ce que le cours décrit : on voit la clé user:{id} apparaître au premier GET puis disparaître au PATCH (cours ch. 11), et on voit un message atterrir en dead-letter quand un envoi partiel échoue.

Il manque en revanche l'essentiel d'une vraie exploitation : pas d'agrégation de logs, pas de traçage distribué, pas d'alerte. Prometheus collecte, personne ne regarde.

Ce qui n'est volontairement pas couvert#

AbsentPourquoiPar quoi commencer
TLS / HTTPSCluster local, http://localhostcert-manager + un Issuer
Secrets chiffréssecrets.yaml contient des change-meSealed Secrets, SOPS, ou un gestionnaire externe
PITRDumps logiques suffisants pour une démobackup CNPG vers un stockage objet
CI/CDLe déploiement est manuel et localPublier les images, puis GitOps
AlertingPrometheus sans AlertmanagerAlertmanager + des règles
Autoscaling du workerLe CPU est le mauvais signal (ch. 03)KEDA sur la profondeur de file
Redondance matérielleKind = des conteneurs sur une machineUn vrai cluster multi-machines
RPO / RTO ciblesAucun engagement de service à tenir ici (ch. 01)Les chiffrer, puis choisir réplication et sauvegardes en conséquence
Queues répliquéesQueues classiques sur un cluster de 3 nœuds (ch. 03)Quorum queues + publisher confirms

Cette liste est le pendant infrastructure du « quand ne pas utiliser cette architecture » du cours. Un dépôt de référence qui prétendrait tout couvrir serait moins utile, pas plus : ce qui manque est aussi instructif que ce qui est là, à condition d'être nommé.

À retenir#