· 7 min de lecture

Déploiements sans interruption, simplement.

Déploiements sans interruption, simplement.

Je déploie beaucoup en production. Vraiment beaucoup. Une de mes applications peut être mise à jour vingt ou trente fois dans la même journée. Chacun de ces déploiements avait un coût que j’ai découvert en analysant les requêtes d’erreurs : pendant environ une seconde, le site de mon application répondait 502 Bad Gateway.

La cause était assez banale. L’application est un serveur Node.js en SSR (TanStack Start). À chaque release, le processus redémarrait, et pendant ce redémarrage plus rien n’écoutait sur le port. nginx n’avait personne vers qui relayer, il renvoyait donc un 502.

Une seconde d’indisponibilité, ça paraît négligeable, jusqu’à ce que vous le fassiez trente fois par jour, et jusqu’à ce que l’une de ces secondes tombe sur un vrai utilisateur en plein paiement.

La réponse évidente, et pourquoi je l’ai écartée

Le remède classique pour ne jamais laisser le backend s’éteindre, c’est de confier le travail à un outil prévu pour ça. Il en existe toute une gamme.

On trouve des orchestrateurs complets comme Kubernetes, ou des outils de déploiement auto-hébergés comme Dokku ou Docker Swarm. Tous font la même chose : ils démarrent la nouvelle version, attendent un health check, basculent le trafic, puis retirent l’ancienne.

N’importe lequel aurait fait l’affaire. Mais chacun est un outil de plus dans ma stack : quelque chose à installer, apprendre, tenir à jour et déboguer quand il déraille.

J’ai donc décidé de construire la plus petite chose possible qui garantisse la seule propriété que je voulais vraiment : le backend n’est jamais totalement à l’arrêt pendant un déploiement.

La forme de la solution

Toute l’idée tient en une phrase : faire tourner deux copies de l’application, mettre nginx devant, et les recharger une à la fois.

Navigateur


nginx  (TLS, proxy_next_upstream)


upstream { 127.0.0.1:3000 ; 127.0.0.1:3001 }
   │              │
   ▼              ▼
PM2 fork :3000   PM2 fork :3001

Si l’instance A redémarre, l’instance B répond toujours, et nginx contourne celle qui est à l’arrêt. On recharge A, on attend qu’elle soit vraiment prête, puis on recharge B.

Deux instances, ports figés en dur

PM2 lance l’application comme deux simples processus fork, chacun épinglé à son propre port :

const sharedConfig = {
  cwd: '/var/www/project',
  script: '/var/www/project/current/.output/server/index.mjs',
  exec_mode: 'fork',
  kill_timeout: 5000,
};

module.exports = {
  apps: [
    { ...sharedConfig, name: 'project', env: { PORT: 3000 } },
    { ...sharedConfig, name: 'project-2', env: { PORT: 3001 } },
  ],
};

nginx contourne celle qui est à l’arrêt

L’upstream liste les deux instances, et on demande au proxy de réessayer sur l’autre quand l’une refuse une connexion :

upstream project_app {
  server 127.0.0.1:3000 max_fails=1 fail_timeout=2s;
  server 127.0.0.1:3001 max_fails=1 fail_timeout=2s;
}

# dans location / et @app
proxy_pass http://project_app;
proxy_next_upstream error timeout non_idempotent;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;

Savoir quand l’instance est prête

Le point délicat de devoir recharger une instance à la fois, c’est de savoir quand une instance est réellement de retour.

Chaque instance expose donc un endpoint /ready qui exécute une requête anodine sur la base de données et ne renvoie 200 que lorsqu’elle peut vraiment servir. Le script de rechargement interroge ça :

for pair in $pairs; do
  id="${pair%%:*}"; port="${pair##*:}"

  pm2 reload "$id"

  ready=0
  for _ in $(seq 1 20); do
    if curl -sf -o /dev/null "http://127.0.0.1:${port}/ready"; then
      ready=1; break
    fi
    sleep 1
  done

  if [ "$ready" -ne 1 ]; then
    echo "ERROR: instance $id failed readiness; aborting deploy"
    exit 1   # l'autre instance continue de servir via nginx
  fi

  sleep 3    # on laisse nginx purger sa blacklist fail_timeout
done

Deux détails rendent ça fiable. Le script recharge par pm_id, une à la fois, donc l’autre instance n’est jamais touchée.

Et sur un échec de readiness, il annule. La mauvaise release part généralement en crash-loop et ne se lie jamais à son port, donc nginx garde toutes les requêtes sur l’instance saine pendant que mon run de CI vire au rouge et m’alerte. La production reste active sur la dernière bonne release.

Bascule atomique

La dernière partie, c’est de s’assurer que le processus vivant ne lit jamais depuis un répertoire en cours de reconstruction. Chaque déploiement se construit dans un dossier neuf releases/<timestamp>-v<version>/, puis bascule un lien symbolique en un seul renommage atomique :

ln -sfn "$NEW_RELEASE" "$APP_ROOT/.current.tmp"
mv -Tf "$APP_ROOT/.current.tmp" "$APP_ROOT/current"

bash "$APP_ROOT/scripts/reload-app.sh"

On bascule le lien, on lance le déploiement continu, terminé. En prime, conserver les assets de contenu des anciennes releases dans un pool dédié garantit que les navigateurs encore sur l’ancienne version du site ne tombent jamais sur un 404 pour un chunk qu’un build récent a supprimé (mais c’est l’histoire d’un autre article 😉).

Est-ce que ça a marché ?

Oui, et je l’ai vérifié plutôt deux fois qu’une. J’ai lancé un curl contre l’application toutes les 0,2 seconde pendant un déploiement complet. Chaque réponse était un 200. Zéro 502.

Tout le système, c’est quatre scripts shell versionnés, un bloc de config nginx, et un fichier PM2 de deux lignes. Je peux tout relire d’une traite.

Conclusion

Il y a quelques années, le choix rationnel aurait été de se tourner vers un outil open-source. Écrire la version sur-mesure moi-même m’aurait coûté des jours que je n’avais pas, sur un domaine que je ne connaissais qu’à moitié.

À l’ère agentique, construire son propre petit outil est quasi gratuit.

Et ça compte, au-delà du confort. Chaque dépendance que vous tirez, c’est du code que vous n’avez pas écrit, maintenu par des gens que vous ne connaissez pas, à un rythme de release que vous ne contrôlez pas. Et ces derniers temps, on nous rappelle presque chaque semaine comment ça finit : un paquet compromis, une injection dans la chaîne d’approvisionnement, etc.

L’outil généraliste doit servir tout le monde, il porte donc mille fonctionnalités qui ne sont pas les vôtres. Une petite chose que vous avez écrite vous-même fait exactement le même travail et a une surface que vous pouvez auditer en un rien de temps.

Posséder ses outils, c’est posséder son temps.