xpeditis2.0/docs/mise-en-prod/15-exploitation-incidents.md
David b22f4e0b74 docs: procedure de mise en production pas a pas
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:50 +02:00

406 lines
13 KiB
Markdown

# 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<typeof createAdapter>;
async connectToRedis(): Promise<void> {
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 <app_public_ipv4>
ssh deploy@<app_public_ipv4> 'uptime; df -h /; free -m'
# 3. k3s tourne-t-il ?
ssh deploy@<app_public_ipv4> '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@<db_ip> '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@<ip> '
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@<app_ip> 'sudo k3s crictl rmi --prune'
# Journaux systemd
ssh deploy@<ip> 'sudo journalctl --vacuum-size=500M'
# Sur db-01 : WAL qui s'accumulent = archivage cassé
ssh deploy@<db_ip> '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-<sha_actuel> \
--overrides='{"spec":{"imagePullSecrets":[{"name":"regcred"}],"containers":[{
"name":"migration-revert",
"image":"rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_actuel>",
"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-<sha_precedent>
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@<ip> '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@<db_ip> '
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@<app1> '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@<ip> '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@<app_ip> 'sudo k3s etcd-snapshot save --name avant-maj'
# Mise à jour
ssh deploy@<app_ip> '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@<db_ip> '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@<db_ip>
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(<pid>);" # doux
# puis, si nécessaire : pg_terminate_backend(<pid>)
```
---
## 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
```