05. Exploitation
Monter, sauvegarder, démonter — et dire franchement ce que cette infrastructure ne couvre pas.
./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| Étape | Ce qu'elle fait | Barrière |
|---|---|---|
| 1/6 | Cluster Kind + Envoy Gateway v1.9.1 | rollout status du contrôleur |
| 2/6 | Metrics Server + patch --kubelet-insecure-tls | rollout status |
| 3/6 | Opérateur CloudNativePG 1.30 | wait condition=Available |
| 3b/6 | Opérateur RabbitMQ Cluster v2.15.0 | wait condition=Available |
| 4/6 | Build des deux images + kind load | — |
| 5/6 | Namespace, secrets, base, dépendances | wait cluster/task-manager-db Ready |
| 6/6 | Migrations, puis config, workloads, HPA, PDB, Gateway + HTTPRoute, politiques réseau | wait 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"}]'
fiLe 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.
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 confirmationLe 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é | Valeur | Conséquence |
|---|---|---|
| Type | Dump logique (pg_dump) | Portable, lisible, indépendant de la version binaire |
| Contenu | Schéma (dont alembic_version) + données | La restauration remet Alembic au bon cran |
| Restauration | --clean --if-exists | Recrée proprement les objets avant de recharger |
| Granularité | L'instant du dump | Pas de PITR : ce qui suit le dump est perdu |
| Destination | db-backups/, ignoré par git | Reste 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.
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.gzverify 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.
| Étape | Ce qu'elle prouve |
|---|---|
backup | On sait produire un fichier |
restore (base jetable) | Le fichier est lisible et se charge sans erreur |
| smoke test | Ce qui a été chargé a un schéma exploitable |
| verdict | Un résultat binaire, pas une impression |
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 question | Ce 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 oUn 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 s | Le pod primaire est supprimé |
+2,1 s | Première requête HTTP en échec |
+2,1 s | CloudNativePG promeut un réplica |
+15,3 s | L'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.
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êtreAvec 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])))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#
| Objectif | Mécanisme | Ce qu'il coûte |
|---|---|---|
| RPO ≈ 0 | Réplication synchrone (bloc commenté dans database.yaml) | Les écritures bloquent si trop peu de réplicas répondent |
| RPO borné dans le temps | Archivage WAL + PITR vers un stockage objet | Stockage, et une restauration à exercer |
| RTO plus court | Sondes plus agressives, pooler plus réactif, retries applicatifs | Plus de bascules intempestives sur faux positif |
| SLO surveillé | Alertmanager + alertes de burn-rate | Un composant de plus, et quelqu'un pour répondre |
| Pannes indépendantes | Plusieurs machines, plusieurs zones | Ce 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-managerLe 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 projetLe 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é#
| Outil | Ce qu'il donne | Accès |
|---|---|---|
| Prometheus | Métriques de l'API (/metrics, via prometheus-fastapi-instrumentator) | http://prometheus.localhost |
| Metrics Server | kubectl top pods et l'entrée du HPA | en ligne de commande |
| Mailpit | Les e-mails que le worker a réellement envoyés | http://mailpit.localhost |
| RabbitMQ | Files, consommateurs, et la DLQ email_notifications.dlq | http://rabbitmq.localhost |
| RedisInsight | Le 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#
| Absent | Pourquoi | Par quoi commencer |
|---|---|---|
| TLS / HTTPS | Cluster local, http://localhost | cert-manager + un Issuer |
| Secrets chiffrés | secrets.yaml contient des change-me | Sealed Secrets, SOPS, ou un gestionnaire externe |
| PITR | Dumps logiques suffisants pour une démo | backup CNPG vers un stockage objet |
| CI/CD | Le déploiement est manuel et local | Publier les images, puis GitOps |
| Alerting | Prometheus sans Alertmanager | Alertmanager + des règles |
| Autoscaling du worker | Le CPU est le mauvais signal (ch. 03) | KEDA sur la profondeur de file |
| Redondance matérielle | Kind = des conteneurs sur une machine | Un vrai cluster multi-machines |
| RPO / RTO cibles | Aucun engagement de service à tenir ici (ch. 01) | Les chiffrer, puis choisir réplication et sauvegardes en conséquence |
| Queues répliquées | Queues 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#
- Un script de déploiement doit être idempotent et attendre des conditions, jamais dormir.
- L'ordre opérateurs → base → migrations → workloads est une chaîne de dépendances strictes.
- Une sauvegarde demande à l'opérateur qui est primaire plutôt que de deviner un nom de pod.
- Un dump manuel n'est pas une politique de sauvegarde — et le dire fait partie du travail.
- Sans registry, une nouvelle image exige un
rollout restartexplicite, sur tous les workloads qui la partagent. - Les consoles rendent visibles les mécanismes du cours : cache, files, dead-letter, e-mails.