Chapitre 16.1 — Runbooks de dépannage
⏱️ TL;DR — Un runbook, c’est une procédure écrite à froid pour un incident précis, qu’on suit à chaud sans réfléchir. Chacun donne le symptôme (comment ça se manifeste), les hypothèses (les causes probables, de la plus fréquente à la plus rare), les commandes de diagnostic dans l’ordre, et la résolution. On couvre les incidents qui arrivent vraiment : site down, 502/504/413, disque plein, OOM, certificat expiré, verrouillé dehors en SSH, port déjà pris, permission denied. La règle d’or : on ne devine pas, on remonte la chaîne et on teste chaque maillon.
🎯 Objectifs
- Diagnostiquer méthodiquement un incident au lieu de bricoler au hasard.
- Retrouver en dix secondes la procédure de l’incident qui te tombe dessus.
- Nommer le maillon fautif (DNS, TLS, pare-feu, Nginx, app, base) avant de toucher quoi que ce soit.
- Garder les bons réflexes sous stress : mesurer, isoler, corriger, vérifier.
La méthode, avant les recettes
Un incident se traite en quatre temps, toujours les mêmes :
- Constater — quel est le symptôme exact ? (code HTTP, message d’erreur, « rien ne répond ».) Reproduis-le.
- Isoler — remonte la chaîne de l’extérieur vers l’intérieur : DNS → TCP/TLS → pare-feu → Nginx → app → base (la carte de la Partie 1.5). Chaque maillon a son test.
- Corriger — la plus petite action qui répare, pas un chantier.
- Vérifier — reproduis le test initial : il doit passer. Puis note ce qui s’est passé.
💡 Réflexe — Le premier geste utile n’est presque jamais une action, c’est une mesure.
curl -I,systemctl status,journalctl,df -h: chacune élimine ou confirme une hypothèse. Deux minutes de diagnostic t’épargnent une heure de fausses pistes.
⚠️ Piège — Modifier plusieurs choses à la fois en panique (« je redémarre tout, je change trois configs »). Si ça remarche, tu ne sauras pas pourquoi ; si ça empire, tu ne sauras pas quoi annuler. Un changement, un test. Toujours.
Runbook — « Le site ne répond pas / est down »
Symptôme. Le site est injoignable : timeout, « serveur introuvable », page blanche, ou connexion qui n’aboutit pas. On ne sait pas encore où ça casse — c’est justement ce qu’on va trouver.
Hypothèses, du plus extérieur au plus intérieur : DNS cassé → connexion/réseau bloquée → pare-feu → certificat TLS → Nginx down → application tombée → base injoignable.
Diagnostic — remonte la chaîne, dans cet ordre :
# 1. DNS : le nom résout-il vers la BONNE IP ?
dig +short formacampus.fr # doit renvoyer l'IP de ton VPS
# rien / mauvaise IP => probleme DNS (enregistrement A, propagation, TTL)
# 2. Connexion : le serveur répond-il sur le port web ?
curl -I https://formacampus.fr # un code HTTP (meme 502) => on est arrive jusqu'a Nginx
nc -zv formacampus.fr 443 # "succeeded" => le port 443 est ouvert et ecoute
# timeout / "connection refused" => reseau, pare-feu, ou Nginx a terre
# 3. Sur le serveur (en SSH) : les services tournent-ils ?
systemctl status nginx # doit etre "active (running)"
systemctl status formacampus-web # ton app
sudo ss -tulpn | grep -E ':(80|443|3000)' # qui ecoute sur quoi
# 4. Le pare-feu laisse-t-il passer ?
sudo ufw status # 80/443 doivent etre ALLOW
# 5. Que disent les logs de Nginx ?
sudo tail -n 50 /var/log/nginx/error.logRésolution, selon le maillon isolé :
- DNS : l’enregistrement A ne pointe pas (ou plus) vers l’IP du VPS → corrige-le chez ton registrar, attends la propagation (le TTL). Vois la Partie 6.
- Pare-feu :
sudo ufw allow 80,443/tcpsi les ports web sont fermés. - Nginx à terre :
sudo nginx -t(teste la config), puissudo systemctl restart nginx. Une erreur de config empêche Nginx de démarrer — lenginx -tte dit quelle ligne. - App tombée : voir le runbook 502 ci-dessous.
- VPS lui-même KO (ne répond même pas au ping, SSH impossible) : va sur la console du fournisseur (panneau web) vérifier qu’il est allumé, et regarde une éventuelle panne datacenter.
🔒 Sécurité — Un site « down » peut aussi être un site sous attaque (flood, scan). Avant de tout ouvrir en grand pour « débloquer », regarde
sudo tail -f /var/log/nginx/access.logetsudo fail2ban-client status sshd: un pic anormal d’une même IP change le diagnostic. Ne désactive jamais le pare-feu « pour tester ».
Runbook — Erreur 502 Bad Gateway
Symptôme. Le navigateur affiche 502 Bad Gateway, souvent avec le style de page d’erreur de Nginx. Le DNS résout, le TLS est bon (on obtient bien une réponse de Nginx).
Hypothèses, par fréquence : l’application est tombée (crash, pas démarrée, pas enable) → l’app écoute sur un mauvais port ou pas sur 127.0.0.1 → le proxy_pass de Nginx pointe vers le mauvais host:port → l’app est trop lente à répondre (là c’est plutôt un 504, voir plus bas).
Diagnostic :
# 1. L'app tourne-t-elle vraiment ?
systemctl status formacampus-web
# "active (running)" => elle tourne ; "failed"/"activating" => elle est le probleme
# 2. Répond-elle en local, court-circuitant Nginx ?
curl -I http://127.0.0.1:3000 # 200 => l'app va bien, le souci est entre Nginx et elle
# "connection refused" => l'app n'ecoute pas la (crash ou mauvais port)
# 3. Sur quel port/adresse écoute-t-elle, au juste ?
sudo ss -tulpn | grep 3000 # doit montrer 127.0.0.1:3000
# 4. Que dit l'app dans ses logs ?
journalctl -u formacampus-web -n 50 --no-pager
# 5. Que dit Nginx du côté proxy ?
sudo tail -n 30 /var/log/nginx/error.log
# "connect() failed (111: Connection refused) while connecting to upstream" = classique du 502Résolution :
- App
failed: lisjournalctl -u formacampus-webpour la cause (erreur de build, variable d’env manquante,command not found…), corrige, puissudo systemctl restart formacampus-web. - App qui n’écoute pas sur le bon port : aligne le port de l’app (
.env.production,PORT=3000) avec leproxy_pass http://127.0.0.1:3000;du server block. - Après avoir corrigé le unit :
sudo systemctl daemon-reloadpuisrestart— sinon systemd relance l’ancienne config. - Vérifie :
curl -I https://formacampus.frdoit renvoyer 200.
⚠️ Piège — Passer une heure dans la config Nginx alors que le 502 dit « je n’ai pas joint l’app derrière ». Le 502 est presque toujours un problème d’application, pas de proxy. Regarde
systemctl statusde l’app avant de toucher Nginx.
🧭 Sur FormaCampus — Deux services locaux : le front
formacampus-web(port 3000) et l’API Symfony. Un 502 sur une page = regardeformacampus-web; un 502 sur/api/*= regarde l’API (PHP-FPM / le service back) et son socket. Isole quel upstream a lâché avant de corriger.
Runbook — Erreur 504 Gateway Timeout
Symptôme. 504 Gateway Timeout après un temps d’attente (souvent ~30–60 s). Différence clé avec le 502 : l’app répond, mais trop lentement — Nginx se lasse d’attendre et coupe.
Hypothèses : requête applicative lente (grosse requête SQL non indexée, appel externe qui rame, traitement lourd) → base saturée ou verrouillée → app surchargée (CPU à 100 %, pool de workers plein) → timeout Nginx trop court pour un endpoint légitimement long.
Diagnostic :
# 1. Confirme la lenteur côté app, sans Nginx
curl -o /dev/null -s -w "%{time_total}s\n" http://127.0.0.1:3000/la-page-lente
# plusieurs secondes => l'app est bien le goulot
# 2. La machine souffre-t-elle ?
htop # CPU/RAM par process (q pour quitter)
uptime # load average : > nombre de coeurs = surcharge
# 3. La base rame-t-elle ? (requêtes actives Postgres)
sudo -u postgres psql -c "SELECT pid, state, now()-query_start AS duree, query FROM pg_stat_activity WHERE state='active' ORDER BY duree DESC;"
# 4. Depuis quand ? Corrèle avec un déploiement récent
journalctl -u formacampus-web --since "30 min ago"Résolution :
- Cause applicative (le vrai fond) : optimise la requête lente (index manquant, N+1), mets un cache, ou rends le traitement asynchrone. Un timeout qu’on rallonge sans corriger la lenteur ne fait que déplacer le problème.
- Endpoint légitimement long (export, rapport) : augmente le timeout pour ce
locationseulement, côté Nginx :
location /export/ {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 120s; # laisse plus de temps a l'upstream, ICI seulement
}Puis sudo nginx -t && sudo systemctl reload nginx.
- Base saturée : identifie et, si besoin, termine la requête bloquante (
SELECT pg_terminate_backend(<pid>);), puis corrige la cause. Voir la Partie 11.
💡 Réflexe — 502 = « l’app ne répond pas » ; 504 = « l’app répond trop tard ». Le premier se répare en relançant ; le second en accélérant (ou, exceptionnellement, en accordant plus de temps à un endpoint précis).
Runbook — Erreur 413 Request Entity Too Large
Symptôme. 413 Request Entity Too Large au moment d’un upload (image, PDF, import). Les petits fichiers passent, les gros non.
Hypothèse : Nginx plafonne la taille du corps des requêtes via client_max_body_size (défaut ~1 Mo). Le fichier dépasse ce plafond, Nginx refuse avant même d’atteindre l’app.
Diagnostic :
# La directive est-elle définie, et à combien ?
sudo grep -R client_max_body_size /etc/nginx/
# absente => plafond par defaut (~1 Mo)
# Confirme dans les logs au moment de l'upload
sudo tail -f /var/log/nginx/error.log
# "client intended to send too large body" = signature du 413Résolution. Relève le plafond au bon endroit (dans http, un server, ou un location précis) :
# dans le server block (ou http {} pour tout le serveur)
server {
# ...
client_max_body_size 25M; # autorise des corps jusqu'a 25 Mo
}sudo nginx -t && sudo systemctl reload nginx # teste puis recharge sans couper⚠️ Piège — Ne régler que Nginx. Si l’upload traverse une autre couche (PHP :
upload_max_filesizeetpost_max_size; ou une limite applicative), il faut aligner les trois, sinon le blocage se déplace d’un cran plus loin. Et ne mets pas un plafond démesuré (« 2G ») : c’est une porte ouverte à la saturation disque/mémoire. Choisis la taille maximale légitime de tes uploads, pas plus.
🔒 Sécurité —
client_max_body_sizeest aussi une protection : un plafond raisonnable empêche un attaquant de te noyer sous des corps de requête géants. Ne le désactive jamais (0= illimité).
Runbook — Disque plein / « No space left on device »
Symptôme. Erreurs No space left on device, l’app plante en écrivant, la base refuse d’écrire, les logs se figent, un déploiement échoue. Le disque est plein (ou les inodes le sont).
Hypothèses : logs qui gonflent sans rotation → images/conteneurs Docker accumulés → anciennes releases de déploiement jamais purgées → caches/artefacts de build → dumps de sauvegarde locaux oubliés → (plus rare) inodes épuisés par une myriade de petits fichiers.
Diagnostic :
# 1. Confirme et regarde QUEL montage est plein
df -h # colonne "Use%" a 100% ; repere le point de montage (souvent /)
# 2. Les inodes aussi peuvent être pleins (df -h dit "de la place" mais ça écrit pas)
df -i # IUse% a 100% => trop de fichiers, pas trop d'octets
# 3. Où est passée la place ? (les plus gros dossiers)
sudo du -xhd1 / | sort -rh | head # top niveau, sans changer de systeme de fichiers
sudo ncdu -x / # explorateur interactif (plus pratique)
# 4. Suspects classiques
sudo du -sh /var/log/* # logs
docker system df # ce que Docker consomme (si Docker)
sudo du -sh /var/www/formacampus/releases/* # anciennes releasesRésolution — libère, du plus sûr au plus radical :
# Logs : vider proprement les journaux systemd au-delà de X
sudo journalctl --vacuum-time=7d # ne garde que 7 jours
# ou par taille : sudo journalctl --vacuum-size=200M
# Docker : purger images/conteneurs/volumes ORPHELINS (rien d'utilise n'est touche)
docker system prune -a # images non utilisees + conteneurs arretes
docker volume prune # volumes orphelins (ATTENTION aux donnees)
# Anciennes releases : garder les 3-5 dernieres, purger le reste
# (ne JAMAIS supprimer celle pointee par le symlink "current")
# Un gros fichier "fantome" ? Un fichier supprime mais garde ouvert par un process
sudo lsof +L1 # fichiers deletes encore ouverts => redemarrer le process les liberePrévenir la récidive : mets en place logrotate (rotation + compression + purge des vieux logs), plafonne les logs Docker (max-size dans la config du démon), et purge les releases automatiquement dans ton deploy.sh. Un disque plein est presque toujours un manque de rotation.
⚠️ Piège —
df -haffiche « de la place libre » mais rien ne s’écrit ? Regardedf -i: ce sont les inodes qui sont épuisés (souvent un dossier de cache avec des millions de micro-fichiers). Autre piège : supprimer un gros log encore ouvert par un process ne libère rien tant que le process tourne — il faut le redémarrer (ou tronquer le fichier avec: > /var/log/gros.log).
💡 Réflexe — Garde une alerte disque (Partie 14) qui te prévient à 80 %. Un disque plein découvert en panne est bien pire qu’un disque à 80 % découvert par une alerte.
Runbook — OOM : un processus tué par manque de mémoire
Symptôme. L’app (ou Postgres) disparaît sans crash applicatif clair : systemctl status montre un code de sortie brutal, ou le service a redémarré tout seul. Souvent pendant un build ou un pic de charge.
Hypothèse : la RAM est saturée, le OOM killer du noyau a tué le processus le plus gourmand pour sauver la machine. Pas de swap (ou trop peu) aggrave tout.
Diagnostic :
# 1. Le noyau a-t-il invoqué l'OOM killer ? (la preuve)
sudo journalctl -k | grep -i -E "out of memory|oom|killed process"
sudo dmesg | grep -i "killed process" # "Out of memory: Killed process 1234 (node)"
# 2. État mémoire actuel
free -h # RAM + swap, "available" est la vraie marge
htop # tri par MEM : qui consomme
# 3. Y a-t-il du swap, au moins ?
swapon --show # rien affiche => AUCUN swap (mauvais signe)Résolution :
- Ajoute du swap si absent (filet de sécurité, surtout pour absorber les pics de build) :
sudo fallocate -l 2G /swapfile # cree un fichier de 2 Go
sudo chmod 600 /swapfile # root SEULEMENT (obligatoire : sinon faille)
sudo mkswap /swapfile # formate en zone de swap
sudo swapon /swapfile # active immediatement
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab # rend permanent (au reboot)- Ne builde pas sur un petit VPS : le
next buildest glouton. Fais le build dans la CI (GitHub Actions) et n’envoie que l’artefact — voir Partie 13. C’est la solution durable. - Fond du problème : si l’app dépasse durablement la RAM en fonctionnement normal, monte la taille du VPS (Partie 1.4) ou chasse la fuite mémoire.
⚠️ Piège — Croire à un « bug aléatoire » de l’app. Un process qui meurt sans exception applicative, surtout au build ou en pic, c’est presque toujours l’OOM killer.
journalctl -kte donne la preuve en une ligne. Le swap n’est pas une solution de perf (c’est lent), c’est un filet contre le crash net.
Runbook — Certificat HTTPS expiré
Symptôme. Le navigateur affiche un avertissement de sécurité (« votre connexion n’est pas privée », NET::ERR_CERT_DATE_INVALID). Les certificats Let’s Encrypt vivent 90 jours ; s’il n’a pas été renouvelé, il expire.
Hypothèse : le renouvellement automatique (timer systemd de Certbot) a échoué en silence. Causes fréquentes : le port 80 n’est plus ouvert (le challenge HTTP-01 passe par lui), le DNS a changé, ou un hook post-renew casse le reload de Nginx.
Diagnostic :
# 1. Date d'expiration réelle du certificat servi
echo | openssl s_client -connect formacampus.fr:443 -servername formacampus.fr 2>/dev/null | openssl x509 -noout -dates
# regarde "notAfter" : la date d'expiration
# 2. Le timer de renouvellement tourne-t-il ?
systemctl list-timers | grep certbot # doit lister le timer, prochaine echeance
sudo certbot certificates # etat de chaque cert gere par Certbot
# 3. Le port 80 est-il ouvert ? (challenge HTTP-01)
sudo ufw status | grep 80
sudo ss -tulpn | grep ':80'Résolution :
# Simule d'abord un renouvellement (sans consommer les quotas Let's Encrypt)
sudo certbot renew --dry-run
# lis les erreurs : port 80 ferme ? DNS qui ne pointe plus ? webroot introuvable ?
# Puis renouvelle pour de vrai
sudo certbot renew
sudo systemctl reload nginx # sert le nouveau cert- Port 80 fermé :
sudo ufw allow 80/tcp(Certbot en a besoin pour le challenge, même si tout ton trafic est en HTTPS). - DNS qui ne pointe plus : corrige l’enregistrement A (le challenge doit atteindre ce serveur).
- Vérifie que le renouvellement automatique repart :
systemctl list-timers | grep certbot. Le but est de ne jamais refaire ça à la main. Voir Partie 8.3.
🔒 Sécurité — Mets une alerte d’expiration de certificat (ex. à J-14) dans ton monitoring (Partie 14). Un cert expiré, c’est un site inaccessible pour tes utilisateurs (avertissement rouge = ils fuient) et un signal d’amateurisme. L’automatisation doit être surveillée, pas supposée.
Runbook — « Je me suis verrouillé dehors en SSH »
Symptôme. Ta clé est refusée, ou tu as durci sshd_config (changé le Port, PasswordAuthentication no, AllowUsers) et plus aucune connexion SSH ne passe. Tu ne peux plus entrer par la porte normale.
Hypothèses : mauvaise clé ou clé absente de ~/.ssh/authorized_keys → mauvais port après un changement → AllowUsers qui exclut ton utilisateur → permissions trop ouvertes sur ~/.ssh (sshd refuse) → UFW a fermé le port SSH → service sshd tombé après un reload sur une config invalide.
Diagnostic (depuis ton poste, sans paniquer) :
# Le mode verbeux dit POURQUOI ça bloque
ssh -v deploy@203.0.113.10 # (ou -vvv) : lis les lignes "debug1"
ssh -p 2222 deploy@203.0.113.10 # si tu as change le Port, precise-le
nc -zv 203.0.113.10 22 # le port SSH repond-il seulement ?Résolution — la porte de secours : la console du fournisseur. Tous les hébergeurs (Hetzner, OVH, DigitalOcean…) offrent une console web (VNC / « rescue » / KVM) qui te connecte hors SSH, comme un écran physique. Tu t’y logges (mot de passe root/utilisateur) et tu répares :
# Via la console web du fournisseur, une fois connecté :
sudo sshd -t # teste la config : dit la ligne fautive
sudo ufw status # SSH (22 ou ton port) est-il ALLOW ?
sudo ufw allow 2222/tcp # rouvre TON port SSH si besoin
sudo systemctl restart ssh # relance le service apres correction
# verifie authorized_keys et ses permissions :
ls -la ~/.ssh # .ssh en 700, authorized_keys en 600Prévenir : avant de couper l’ancien accès, teste toujours le nouveau dans un second terminal (sans fermer la session qui marche). Garde une session root ouverte pendant tout durcissement SSH. Vois la Partie 4.4 et la Partie 5.
⚠️ Piège — Faire un
reload/restartdesshdaprès avoir éditésshd_configsans l’avoir testé avecsudo sshd -t, et sans session de secours ouverte. Si la config est invalide ou t’exclut, la connexion en cours peut tenir mais aucune nouvelle ne passera — et au prochain reboot, plus rien. Toujours :sshd -t, session de secours, puis appliquer.
Runbook — « Address already in use » / port déjà utilisé
Symptôme. Au démarrage de l’app (ou de Nginx) : Error: listen EADDRINUSE, bind() to 0.0.0.0:3000 failed (98: Address already in use). Le port est déjà pris par un autre processus.
Hypothèses : une ancienne instance de l’app tourne encore (double systemctl start, ou un lancement manuel qui traîne) → un autre service occupe le port → un process zombie n’a pas rendu le port.
Diagnostic :
# QUI écoute sur le port convoité ?
sudo ss -tulpn | grep :3000 # montre PID et nom du process (ex: users:(("node",pid=1234)))
sudo lsof -i :3000 # meme info, autre outilRésolution :
# Cas propre : c'est une instance gérée par systemd => stoppe-la proprement
sudo systemctl stop formacampus-web
sudo systemctl status formacampus-web # doit etre inactive
sudo systemctl start formacampus-web
# Cas "process orphelin" lancé à la main (PAS via systemd) : tue-le par son PID
sudo kill 1234 # SIGTERM : demande poli d'arret (a privilegier)
sudo kill -9 1234 # SIGKILL : force brute, en DERNIER recours- Cause fréquente : tu as lancé l’app à la main (
npm start) pour tester, puis systemd essaie de la lancer aussi → conflit. Choisis un seul mode de gestion : c’est systemd qui gère, pas des lancements manuels qui traînent. - Vérifie ensuite :
sudo ss -tulpn | grep :3000ne doit montrer qu’une instance.
💡 Réflexe —
SIGTERM(kill <pid>) avantSIGKILL(kill -9).SIGTERMlaisse le process fermer proprement (finir les requêtes en cours, flusher).-9le tue net, sans nettoyage — utile s’il est bloqué, mais à réserver. Voir les signaux en Partie 3.4.
Runbook — « Permission denied »
Symptôme. Permission denied : à l’exécution d’un script, à l’écriture dans un dossier (uploads, cache, logs), à la lecture d’un .env, ou l’app qui log EACCES.
Hypothèses : le propriétaire/groupe du fichier n’est pas l’utilisateur qui l’utilise → les permissions (rwx) sont trop restrictives → le script n’est pas exécutable (+x manquant) → un dossier parent bloque la traversée (x manquant sur le dossier) → (plus subtil) SELinux/AppArmor ou un durcissement systemd (ReadOnlyPaths, ProtectHome).
Diagnostic :
# 1. Qui possède quoi, et avec quels droits ?
ls -la /var/www/formacampus # colonne proprietaire:groupe + rwx
id # sous quel user/groupes TU es
# rappel : l'app tourne sous "deploy" (voir son unit : User=deploy)
# 2. Le script est-il exécutable ?
ls -l deploy.sh # cherche le "x" : -rwxr-xr-x
# 3. Un dossier parent bloque-t-il la traversée ?
namei -l /var/www/formacampus/.env.production # affiche les droits a CHAQUE niveau du cheminRésolution :
# Rendre un script exécutable
chmod +x deploy.sh
# Redonner la propriété à l'utilisateur applicatif (ex. deploy) — recursif sur le projet
sudo chown -R deploy:deploy /var/www/formacampus
# Droits sains : dossiers traversables (755), fichiers lisibles (644),
# secret bien fermé (600, lisible par son seul proprietaire)
chmod 600 /var/www/formacampus/.env.production- Le bon réflexe : le fichier doit appartenir à l’utilisateur qui l’utilise. L’app tourne sous
deploy→ ses fichiers de données/uploads/cache doivent être possédés pardeployet écrivables par lui. - Ne « répare » pas avec
chmod 777: voir le piège.
⚠️ Piège —
chmod 777« pour que ça marche ». Ça marche, oui — et ça ouvre le fichier/dossier en écriture à tout le monde, y compris à un process compromis. C’est le mauvais réflexe par excellence. La bonne réponse est presque toujours unchown(bon propriétaire) + des droits minimaux (755/644,600pour les secrets), jamais777.
🔒 Sécurité — Un
.envavec des secrets doit être en600(-rw-------), possédé par l’utilisateur applicatif. En644ou777, n’importe quel utilisateur du système peut lire tes mots de passe de base. Le durcissement des droits est de la sécurité, pas de la coquetterie.
Ces runbooks te font agir ; les cheatsheets te font retrouver la commande. Direction 16.2 — Cheatsheets.