Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
406 lines
13 KiB
Markdown
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
|
|
```
|