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

13 KiB

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 :

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) :

cd apps/backend && npm i @socket.io/redis-adapter ioredis
// 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;
  }
}
// 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) :

cd apps/backend && npm i @nestjs/terminus
@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.


Le site est hors ligne

Diagnostic, du plus extérieur au plus intérieur

# 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)
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.

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

make -C infra/prod rollback
make -C infra/prod smoke

Ou pour une version précise :

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 :

# 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).

É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

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 :

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 :
    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.

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)

# 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 :

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

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.

## 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