# 15 — Exploitation et incidents Document de référence, à consulter au coup par coup. --- ## Points de vigilance connus Trois limites identifiées dans le code. Aucune n'empêche l'ouverture, mais toutes doivent être connues avant qu'un client ne les découvre. ### 1. Notifications temps réel — dégradées avec 2 replicas **Constat.** `notifications.gateway.ts` conserve la correspondance `userId → sockets` **en mémoire du processus** et n'utilise pas `@socket.io/redis-adapter`. **Conséquence.** Avec deux replicas backend, une notification émise par le replica A n'atteint pas un utilisateur connecté au replica B. En pratique, environ **une notification temps réel sur deux se perd**. Les sessions collantes sont configurées (`k8s/base/04-backend.yaml`), ce qui règle la négociation du handshake Socket.IO — mais **pas** la diffusion entre instances. **Atténuation actuelle.** Les notifications restent visibles au rechargement (elles sont lues via l'API REST) et les e-mails partent normalement. Seul le « temps réel » est affecté. **Solution de contournement immédiate**, si le temps réel est critique : ```bash kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1 kubectl -n xpeditis-prod patch hpa xpeditis-backend --type=merge \ -p '{"spec":{"minReplicas":1,"maxReplicas":1}}' ``` Prix à payer : plus de haute disponibilité, et une brève coupure à chaque déploiement. **Correctif de fond** (à faire dans le premier mois) : ```bash cd apps/backend && npm i @socket.io/redis-adapter ioredis ``` ```ts // src/infrastructure/websocket/redis-io.adapter.ts import { IoAdapter } from '@nestjs/platform-socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import { Redis } from 'ioredis'; import type { ServerOptions } from 'socket.io'; export class RedisIoAdapter extends IoAdapter { private adapterConstructor: ReturnType; async connectToRedis(): Promise { const opts = { host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT ?? 6379), password: process.env.REDIS_PASSWORD, }; const pubClient = new Redis(opts); const subClient = pubClient.duplicate(); this.adapterConstructor = createAdapter(pubClient, subClient); } createIOServer(port: number, options?: ServerOptions): unknown { const server = super.createIOServer(port, options); (server as { adapter: (a: unknown) => void }).adapter(this.adapterConstructor); return server; } } ``` ```ts // main.ts, avant app.listen() const redisIoAdapter = new RedisIoAdapter(app); await redisIoAdapter.connectToRedis(); app.useWebSocketAdapter(redisIoAdapter); ``` La carte `userSockets` en mémoire reste utile localement ; le diffuseur Redis prend en charge la propagation entre instances. ### 2. La sonde de disponibilité ne teste rien `health.controller.ts` renvoie `{status: 'ready', checks: {database: 'ok', redis: 'ok'}}` **en dur**, sans interroger quoi que ce soit. **Conséquence.** Un pod incapable de joindre PostgreSQL est déclaré prêt et Traefik lui envoie du trafic. Les utilisateurs prennent des 500 au lieu d'être redirigés vers un pod sain. **Correctif** (`@nestjs/terminus`) : ```bash cd apps/backend && npm i @nestjs/terminus ``` ```ts @Get('ready') @HealthCheck() check() { return this.health.check([ () => this.db.pingCheck('database', { timeout: 3000 }), () => this.redis.checkHealth('redis'), ]); } ``` En attendant, la sonde de vivacité (`/health/live`) et les alertes Prometheus couvrent l'essentiel : un pod réellement mort est bien détecté. ### 3. Retour arrière et migrations `rollback` revient à l'image précédente, **pas au schéma précédent**. Voir [§ Retour arrière avec migration](#retour-arriere-avec-migration). --- ## Le site est hors ligne ### Diagnostic, du plus extérieur au plus intérieur ```bash # 1. Est-ce Cloudflare ou nous ? curl -sI https://app.xpeditis.com/ | head -3 # 521/522 → Cloudflare n'atteint pas l'origine # 523 → problème DNS # 200 → ce n'est pas l'infrastructure : voir l'application # 2. Le serveur répond-il ? ping -c3 ssh deploy@ 'uptime; df -h /; free -m' # 3. k3s tourne-t-il ? ssh deploy@ 'sudo systemctl status k3s --no-pager | head -20' # 4. Les pods ? kubectl -n xpeditis-prod get pods kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20 # 5. La base ? ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose ps' ``` ### Causes fréquentes | Symptôme | Cause | Correctif | |---|---|---| | 521/522, serveur joignable | Les rangs IP Cloudflare ont changé | `bash scripts/refresh-cloudflare-ips.sh --write && terraform apply` | | Pods `Evicted`, `df` proche de 100 % | Disque plein | Voir § Disque plein | | Pods `Pending` | Ressources insuffisantes | `kubectl describe pod` ; réduire les replicas ou agrandir le serveur | | `CrashLoopBackOff` | Erreur applicative | `kubectl logs --previous` | | `ImagePullBackOff` | `regcred` périmé | Recréer le secret ([05 § 4](./05-cluster-k3s.md)) | | Erreurs de connexion base | db-01 en panne | `docker compose up -d` sur db-01 | | k3s ne démarre pas | `--protect-kernel-defaults` | `sysctl --system` puis `systemctl restart k3s` | ### Disque plein La panne la plus fréquente d'un serveur laissé seul. ```bash ssh deploy@ ' df -h sudo du -sh /var/lib/rancher/k3s/agent/containerd 2>/dev/null sudo du -sh /var/log/* 2>/dev/null | sort -h | tail -10 sudo du -sh /var/lib/xpeditis/* 2>/dev/null ' # Sur app-01 : purger les images inutilisées ssh deploy@ 'sudo k3s crictl rmi --prune' # Journaux systemd ssh deploy@ 'sudo journalctl --vacuum-size=500M' # Sur db-01 : WAL qui s'accumulent = archivage cassé ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ psql -c "SELECT * FROM pg_stat_archiver;"' # Si failed_count augmente : WAL-G ne peut plus écrire sur Object Storage. # Vérifier les identifiants dans .env.data et la joignabilité du service. ``` > Des WAL qui s'accumulent finissent par remplir le disque et **arrêter > PostgreSQL**. Un `failed_count` qui monte dans `pg_stat_archiver` est une > urgence, pas un avertissement. --- ## Retour arrière ### Cas simple, sans migration ```bash make -C infra/prod rollback make -C infra/prod smoke ``` Ou pour une version précise : ```bash kubectl -n xpeditis-prod rollout history deploy/xpeditis-backend kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend --to-revision=3 ``` ### Retour arrière avec migration **`rollout undo` ne défait pas les migrations.** Si la version retirée contenait une migration destructrice, l'ancienne version applicative peut ne plus fonctionner contre le schéma courant. Trois situations : | Migration | Peut-on revenir en arrière simplement ? | |---|---| | Ajout de table ou de colonne nullable | **Oui.** L'ancien code ignore ce qu'il ne connaît pas. | | Ajout de colonne NOT NULL sans défaut | **Non.** L'ancien code fera échouer les insertions. | | Suppression ou renommage de colonne | **Non.** L'ancien code interrogera une colonne absente. | | Changement de type | **Non.** | Pour les cas non réversibles : ```bash # 1. Arrêter les écritures kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0 # 2. Annuler la dernière migration kubectl -n xpeditis-prod run migration-revert --rm -it --restart=Never \ --image=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod- \ --overrides='{"spec":{"imagePullSecrets":[{"name":"regcred"}],"containers":[{ "name":"migration-revert", "image":"rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-", "command":["node","./node_modules/typeorm/cli.js","migration:revert","-d", "dist/infrastructure/persistence/typeorm/data-source.js"], "envFrom":[{"configMapRef":{"name":"xpeditis-backend-config"}}, {"secretRef":{"name":"xpeditis-backend-secrets"}}]}]}}' # 3. Revenir à l'image précédente kubectl -n xpeditis-prod set image deploy/xpeditis-backend backend=...:prod- kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 ``` Si `down()` n'est pas implémentée ou échoue, il ne reste que la restauration PITR ([12](./12-sauvegardes-restauration.md)). > **Écrivez des migrations réversibles.** Pour supprimer une colonne, procédez > en deux temps : d'abord une version qui cesse de l'utiliser, puis, une fois > celle-ci stabilisée en production, une seconde qui supprime la colonne. Le > retour arrière reste possible à chaque étape. --- ## Montée en charge ### Signaux | Signal | Seuil | Action | |---|---|---| | CPU backend soutenu | > 70 % | Le HPA monte à 4. Au-delà, agrandir le serveur. | | Mémoire nœud | > 85 % | Passer en CPX51 (16 vCPU / 32 Go). | | Connexions PostgreSQL | > 80 % | Augmenter `max_connections` ou ajouter PgBouncer. | | Latence P95 | > 1 s | Chercher la cause avant d'ajouter des ressources. | | Disque db-01 | > 70 % | Agrandir le volume (à chaud). | ### Agrandir un serveur ```bash kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1 # app-01 seulement ssh deploy@ 'sudo shutdown -h now' # Console Hetzner → Rescale → CPX51 → Power on # Ou : $EDITOR terraform.tfvars puis terraform apply ``` Quelques minutes de coupure. Le disque ne peut qu'être **agrandi**, jamais réduit : la manœuvre est irréversible. ### Agrandir le volume PostgreSQL Sans coupure : ```bash cd infra/prod/terraform $EDITOR terraform.tfvars # db_volume_size = 100 terraform apply ssh deploy@ ' sudo resize2fs /dev/disk/by-id/scsi-0HC_Volume_* df -h /var/lib/xpeditis/pgdata ' ``` ### Passer à deux nœuds (phase 2) Quand un seul nœud ne suffit plus, ou que la coupure lors d'une panne devient inacceptable : 1. Ajouter un `hcloud_server.app2` dans Terraform (mêmes firewalls, même réseau). 2. L'installer en agent k3s : ```bash curl -sfL https://get.k3s.io | K3S_URL=https://10.10.1.10:6443 \ K3S_TOKEN=$(ssh deploy@ 'sudo cat /var/lib/rancher/k3s/server/node-token') sh - ``` 3. Ajouter un Load Balancer Hetzner LB11 (6 €/mois) devant les deux nœuds. 4. Les `topologySpreadConstraints` déjà présents répartiront automatiquement les pods — ils sont en `ScheduleAnyway`, ils deviennent effectifs sans modification. 5. Ajouter un standby PostgreSQL en réplication sur un troisième serveur. Budget correspondant : ligne « 1 000 utilisateurs » du fichier de prévisions. --- ## Mises à jour ### Application Chaîne normale : `preprod` → validation → `main` → déploiement automatique. ### Système (automatique) `unattended-upgrades` applique les correctifs de sécurité chaque nuit et redémarre à 04h30 si le noyau l'exige. ```bash ssh deploy@ 'sudo journalctl -u unattended-upgrades -n 30 --no-pager' ``` > Le redémarrage automatique d'app-01 provoque **quelques minutes de coupure** : > k3s redémarre, puis les pods. C'est un choix assumé — un noyau non corrigé est > un risque plus grand que quelques minutes d'indisponibilité nocturne. Avec > deux nœuds (phase 2), décalez les fenêtres pour supprimer la coupure. ### k3s (trimestriel) ```bash # Sauvegarde de l'état du cluster ssh deploy@ 'sudo k3s etcd-snapshot save --name avant-maj' # Mise à jour ssh deploy@ 'curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.32.x+k3s1 sh -' kubectl get nodes make -C infra/prod smoke ``` Restez sur une version mineure de retard par rapport à la dernière : les correctifs de régression arrivent vite. ### PostgreSQL Les correctifs (15.x → 15.y) sont sans risque : ```bash ssh deploy@ 'cd /opt/xpeditis/data-node && \ sudo docker compose --env-file .env.data pull postgres && \ sudo docker compose --env-file .env.data up -d postgres' ``` Une montée de version **majeure** (15 → 16) impose un `pg_dump` / `pg_restore` et une fenêtre de maintenance planifiée. Ne l'improvisez pas. --- ## Commandes de tous les jours ```bash cd infra/prod make status # vue d'ensemble make logs # journaux backend en direct make events # derniers événements Kubernetes make smoke # l'extérieur voit-il un service sain ? make restart # redémarrer les pods (après changement de secret) make rollback # revenir à la version précédente # Base de données ssh deploy@ cd /opt/xpeditis/data-node sudo docker compose exec -u postgres postgres psql -d xpeditis_prod # Requêtes en cours sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c " SELECT pid, now()-query_start AS duree, state, left(query, 80) FROM pg_stat_activity WHERE state <> 'idle' ORDER BY duree DESC;" # Tuer une requête bloquante sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \ "SELECT pg_cancel_backend();" # doux # puis, si nécessaire : pg_terminate_backend() ``` --- ## Journal des incidents Tenez-le. Après trois mois, il vous dira où porter vos efforts bien mieux que n'importe quelle intuition. ```markdown ## AAAA-MM-JJ — Titre court **Détecté** : par qui / quoi, à quelle heure **Durée** : de HH:MM à HH:MM **Impact** : combien d'utilisateurs, quelles fonctions **Cause** : ce qui s'est réellement passé **Correctif** : ce qui a été fait **Prévention** : ce qui empêche la récidive — et si rien, pourquoi ```