blog.vfg.lol

Deux mois de déploiement dans le vide — et la garde qui manquait

Le blog poussait son code vers un répertoire que personne ne servait : 30 exécutions sur 30 en échec, /arbre/ absent, flux RSS gelés depuis juillet. Le diagnostic, le correctif, et le contrôle ajouté pour que ça ne se reproduise pas en silence.

TL;DR

En auditant la PR #78, une phrase a mis la puce à l'oreille : « qui régénère les flux RSS en production ? publish-article.mjs appelle generate-rss.mjs à chaque publication ». C'était vrai dans le dépôt. C'était faux sur le serveur.

L'enquête a révélé que le workflow de déploiement du blog échouait 30 fois sur 30 depuis juillet. Il poussait le code vers /var/www/blog.vfg.lol — un vestige de l'export statique v1, propriété de root, qu'aucune configuration Caddy ne sert — alors que le service tourne dans /opt/rapport-experientiel.

Trois symptômes invisibles de l'extérieur : /arbre/ en 404, aucun fil d'Ariane ni article relié, et des flux RSS figés au 12 juillet.

Le correctif tient en trois points. Le plus important n'est pas le chemin : c'est le contrôle anti-dérive ajouté pour que ce type de panne se signale tout seul.


1. Contexte : une phrase qui ne collait pas

Le point de départ n'était pas une alerte, ni un utilisateur mécontent. C'était une affirmation à vérifier dans la description d'une pull request.

En auditant la PR #78 (détrackage des flux RSS), je suis tombé sur ce raisonnement, présenté comme une preuve de robustesse : les fichiers XML versionnés étaient des fantômes, puisque de toute façon publish-article.mjs régénère les flux à chaque publication sur le VPS.

Vérification faite dans le dépôt : exact. Le hook existe, il est appelé. La conclusion semblait donc bonne.

Sauf qu'une question restait ouverte, et personne ne l'avait posée : est-ce que c'est bien ce publish-article.mjs-là qui tourne sur le VPS ?

Réponse : non. Et derrière cette réponse, tout un édifice de choses cassées sans que rien ne le dise.


2. Ce qui a été fait

#ActionRésultat
1Vérifier l'état réel du VPS en lecture seuleblog-read/blog-admin actifs, WorkingDirectory=/opt/rapport-experientiel
2Lire la config Caddy de blog.vfg.lolreverse_proxy 127.0.0.1:5195 — aucun file_server
3Comparer la cible du workflow à la réalitéBLOG_DEPLOY_PATH=/var/www/blog.vfg.lol (root:root) ≠ /opt
4Lire l'historique des exécutions code-deploy30 runs, 30 échecs, tous à l'étape rsync
5Vérifier la version du code déployérelations.cjs, templates/, toc.js absents — antérieur à la v3
6Tester la fraîcheur des fluxRSS figé : 0 article de septembre, dernier item le 12/07
7Tester le redémarrage sans sudo sur un service secondaireFonctionne : nouveau PID, service active, HTTP 200
8Corriger le workflow + créer la variable dédiéePR #78, commit 1708681
9Documenter l'inventaire manquantvps-infra/docs/vps-inventory/services-systemd.md
10Ajouter le contrôle anti-dérive au healthcheckvps-rescue-scripts/vps-health-check.sh

3. Détail technique

Le piège : deux répertoires, un seul vivant

Caddy  blog.vfg.lol → reverse_proxy 127.0.0.1:5195

blog-read.service   User=deploy  WorkingDirectory=/opt/rapport-experientiel  ✅ servi
code-deploy.yml     BLOG_DEPLOY_PATH=/var/www/blog.vfg.lol                   ❌ root:root

Le workflow envoyait donc son rsync vers un répertoire que le site ne lit pas, et dans lequel l'utilisateur deploy n'a même pas le droit d'écrire :

rsync: mkstemp "/var/www/blog.vfg.lol/..." failed: Permission denied (13)
rsync error: code 23

30 exécutions sur 30. De juillet à septembre. Toujours la même erreur, jamais remontée à personne.

Le plus révélateur : la variable BLOG_DEPLOY_PATH n'était pas absurde à l'origine. /var/www/blog.vfg.lol était la bonne cible du temps où le blog était un export statique (v1). Quand le blog est devenu dynamique — un serveur Node dans /opt, derrière un reverse_proxy — le workflow a gardé l'ancien chemin. Personne n'a repensé la variable parce que personne ne regardait les runs en échec.

Le vestige, prouvé par l'expérience

Plutôt que de faire confiance à la lecture de la config Caddy, j'ai cherché un fichier présent uniquement dans /var/www :

FichierPrésent dans /var/wwwPrésent dans /optServi en public
build-meta.jsonouinon404
index.htmlouinon—

Conclusion nette : /var/www/blog.vfg.lol n'est plus qu'un vestige de 16 Mo.

Le deuxième obstacle : pas de sudo

Le workflow faisait sudo systemctl restart blog-read blog-admin. Or sur ce VPS, l'utilisateur deploy n'a pas de sudo NOPASSWD :

$ sudo -n true
sudo: a password is required

Même avec le bon chemin, l'étape aurait échoué. La solution était déjà dans le dépôt, dans deploy/vps/rollout.sh : les units tournent en User=deploy avec Restart=always/RestartSec=5, donc un simple signal suffit à les redémarrer.

Testé en conditions réelles sur blog-admin, avant d'écrire quoi que ce soit :

PID avant : 962834
signal envoye a 962834
PID apres : 680247
REDEMARRAGE CONFIRME
is-active : active
blog-admin : 200

Les trois corrections

  1. Variable dédiée BLOG_CODE_DEPLOY_PATH (défaut /opt/rapport-experientiel). Choix délibéré de ne pas réutiliser BLOG_DEPLOY_PATH : celle-ci est partagée avec blog-deploy.yml, qui y pousse dist/ (export statique de secours). La rediriger vers /opt aurait mélangé un export statique au code vivant.

  2. Redémarrage sans sudo — une fonction reload_unit() qui tente sudo -n puis retombe sur un signal au PID. Elle dégrade proprement si blog-admin refuse, sans faire échouer tout le déploiement.

  3. Contrôle anti-dérive — le point important, détaillé juste après.

Le contrôle qui manquait

C'est la partie qui compte vraiment. Un curl /healthz renvoyait 200 pendant tout l'incident. Le service allait bien. Il servait simplement du vieux code.

Vérifier « le service répond » ne dit rien sur « le service sert ce que je viens de déployer ». Il fallait un marqueur de version observable de l'extérieur. Le blog en avait un sans le savoir : la route /arbre/, apparue avec la v3.

arbre_code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5195/arbre/)
if [ "$arbre_code" != "200" ]; then
  echo "ERREUR: /arbre/ renvoie $arbre_code après déploiement." >&2
  echo "Le service ne sert pas ${DEPLOY_PATH} : vérifier le WorkingDirectory" >&2
  exit 1
fi

Un 404 ici n'est pas « une route absente ». C'est la preuve que le service tourne sur une copie antérieure du code. Un marqueur de fonctionnalité utilisé comme témoin de version.

Le workflow échoue désormais franchement dans ce cas, au lieu de sortir vert.

Le même contrôle, côté exploitation

Ce contrôle ne sert pas qu'au déploiement. Je l'ai porté dans le healthcheck d'exploitation (vps-rescue-scripts/vps-health-check.sh), qui ne vérifiait ni Caddy ni les applications HTTP — seulement Docker, le disque, la RAM et quelques services systemd.

Sa nouvelle section 6 vérifie que blog-read/blog-admin tournent avec le bon WorkingDirectory, que /healthz répond, et que /arbre/ répond 200. Elle signale aussi le vestige /var/www s'il existe encore, et un flux RSS non régénéré depuis plus de 30 jours — ce qui aurait suffi, à lui seul, à détecter la panne en août.

Testé contre l'état réel, il échoue correctement :

6. 📝 Blog vfg.lol (services systemd)...
   ✓ blog-read actif
   ✓ blog-admin actif
   ✓ :5195/healthz répond 200
   ❌ :5195/arbre/ répond 404 — DÉRIVE : le service sert une
      copie antérieure du code (attendu 200 depuis blog v3).
   ⚠️  /var/www/blog.vfg.lol existe encore (root:root) — vestige statique v1
   ⚠️  flux RSS non régénéré depuis 65 jours

Un healthcheck qui échoue sur la vraie panne qu'il est censé attraper, c'est la seule façon de savoir qu'il fonctionne.


4. Risques & limites

  • Risque : la correction est sur une branche de PR, pas sur main. Le déploiement ne se déclenche que sur main → la production reste en retard jusqu'à la fusion. → Mitigation : la PR #78 est MERGEABLE/CLEAN, la fusion est un geste humain explicite. Le healthcheck échouera bruyamment en attendant, ce qui est le comportement voulu.

  • Risque : deploy dépend d'un kill plutôt que de systemctl. Si quelqu'un passait Restart=no sur les units, le déploiement resterait vert alors que le service ne reviendrait pas. → Mitigation : le contrôle /arbre/ échoue dans ce cas — le filet existe.

  • Risque : /arbre/ est un marqueur de version implicite. Si une future version renommait la route, le contrôle casserait pour une mauvaise raison. → Mitigation : à remplacer par un vrai endpoint de version (/healthz.version) lors de la migration.

  • Limite : je n'ai touché à rien sur le VPS. Diagnostic en lecture seule, correctifs dans les dépôts. Le redémarrage testé portait sur blog-admin (service secondaire), pas sur blog-read.

  • Limite : /opt contient 538 articles, le dépôt 524. La publication local-first écrit directement sur le VPS, donc le dépôt n'est pas la source des articles. Ce n'est pas un risque d'écrasement (devblog/posts/ est exclu du rsync), mais ça invalide l'intuition « le dépôt est la vérité ».

  • Limite : deux vestiges subsistent — /var/www/blog.vfg.lol et le conteneur vfg-fleet-blog-static-1 (nginx, actif depuis 2 mois, port 8092 non mappé, non référencé par Caddy). Non supprimés : décision d'infra, pas de nettoyage à chaud sur une prod.


5. Prochaine session

  • Fusionner la PR #78 → déclenche le premier déploiement vert (et le passage du v3/v4 en production)
  • Vérifier https://blog.vfg.lol/arbre/ → attendu 200
  • Confirmer que les flux RSS reprennent (article de septembre présent)
  • Trancher le sort de /var/www/blog.vfg.lol et de vfg-fleet-blog-static-1
  • Envisager un vrai endpoint /healthz.version pour remplacer le marqueur implicite
  • Optionnel : règle sudoers pour deploy si l'on veut cesser de dépendre du signal

6. Liens

  • PR #78 — RAPPORT-EXPERIENTIEL — correctif + détrackage des flux
  • Inventaire des services systemd (vps-infra, dépôt privé) — docs/vps-inventory/services-systemd.md, le chaînon manquant de l'inventaire
  • Handoff de session (vps-infra, dépôt privé) — docs/journal/handoffs/2026-09-20-2145_handoff-deploy-blog-chemin-divergent.md
  • Healthcheck VPS (vps-rescue-scripts, dépôt privé) — vps-health-check.sh, contrôle de dérive

Épilogue : ce que cette panne dit vraiment

Le détail le plus instructif n'est pas le chemin erroné. C'est que le workflow était en rouge depuis deux mois et que cela n'a rien déclenché.

Il y avait pourtant trois garde-fous dans ce dépôt : la suite de tests, la vérification des liens, la vérification des compteurs. Tous verts. Ils ne pouvaient pas voir le problème, parce qu'aucun ne regardait le monde réel — ils regardaient le dépôt.

Le seul contrôle qui a détecté la panne est celui qui pose une question différente : « ce que je viens de déployer est-il effectivement servi ? » Un healthz en 200 ne répond pas à cette question. Une route qui n'existe que dans la version à jour, si.

Une garde éteinte ne prévient jamais. Un déploiement rouge non plus, si personne ne le lit.


Session du 20 septembre 2026 — audit de la PR #78, diagnostic VPS en lecture seule (SSH deploy@<vps>), correctifs sur RAPPORT-EXPERIENTIEL, vps-infra et vps-rescue-scripts. Aucune mutation sur le VPS de production.

source markdown

Concepts de cet article

VPSCaddygardehandoffdérive

Partage le plus de concepts avec