En bref — un déploiement « classique » avec Docker arrête l'ancienne version avant que la nouvelle soit prête. Tant que l'application démarre en deux secondes, personne ne le voit. Quand elle en met quarante, les visiteurs voient une page d'erreur à chaque mise en ligne. La solution tient en une inversion : démarrer la nouvelle version à côté de l'ancienne, attendre qu'elle soit saine, et seulement ensuite arrêter l'ancienne. Mais cette inversion ne fonctionne que si l'application a été pensée pour tourner en deux exemplaires, même brièvement.

Quarante secondes de 502

Mes projets sont déployés de la même façon : un git push, une CI qui se connecte au serveur, reconstruit l'image et relance le conteneur. Simple, reproductible, et pendant longtemps suffisant.

Jusqu'au jour où l'un de ces projets, un jeu en ligne, a commencé à avoir de vrais joueurs. À chaque mise en ligne, le jeu disparaissait pendant une quarantaine de secondes : le reverse proxy renvoyait des erreurs 502, les parties en cours perdaient leur connexion. Le temps que les migrations passent et que le serveur Node démarre, l'ancienne version était déjà arrêtée depuis longtemps.

Mon premier réflexe a été de soupçonner le build. Mais l'image était déjà reconstruite pendant que l'ancienne version servait encore : le temps de build n'était pas en cause. Le problème, c'était ce qui se passait ensuite. Quand on demande à Docker Compose de mettre à jour un service, il fait exactement ce qu'on lui dit : il arrête l'ancien conteneur, puis démarre le nouveau. Entre les deux, il n'y a personne pour répondre.

Première parade : faire patienter le proxy

Pour les applications qui redémarrent vite, une parade légère suffit. Plutôt que d'abandonner immédiatement quand l'application ne répond pas, le reverse proxy peut réessayer la connexion pendant quelques secondes :

reverse_proxy app:3000 {
    lb_try_duration 8s
    lb_try_interval 250ms
}

La requête qui tombe pendant la bascule attend quelques secondes au lieu d'échouer, puis passe normalement. C'est efficace et presque gratuit, mais la limite est claire : ça absorbe une bascule de quelques secondes, pas une application qui met quarante secondes à démarrer. Et c'est très bien ainsi — un proxy qui attendrait une minute masquerait une vraie panne au lieu d'absorber un redémarrage.

La vraie solution : inverser l'ordre

Le bon ordre, c'est celui qu'on appliquerait naturellement à la main : lancer la nouvelle version, vérifier qu'elle fonctionne, basculer le trafic, puis arrêter l'ancienne. C'est ce que font les orchestrateurs comme Kubernetes avec leurs déploiements progressifs. Sur un serveur unique avec Docker Compose, un petit plugin fait la même chose : il démarre un second conteneur à côté du premier, attend que son healthcheck passe au vert, puis arrête l'ancien. Si la nouvelle version ne devient jamais saine, elle est supprimée, l'ancienne continue de servir, et le déploiement échoue sans que personne ne s'en aperçoive côté visiteurs.

J'ai mesuré le résultat avec une sonde qui interrogeait le site toutes les 200 millisecondes pendant un vrai déploiement : la nouvelle instance a mis environ vingt-trois secondes à devenir saine, l'ancienne a été arrêtée une seconde plus tard, et sur les 384 requêtes envoyées pendant l'opération, 384 ont reçu une réponse 200. Avant : une quarantaine de secondes d'erreurs.

Mais voilà le point important : l'outil n'est que la partie facile. Pendant quelques secondes, deux versions de l'application tournent en même temps. Et beaucoup d'applications ne le supportent pas.

Ce que l'application doit savoir faire

Garder son état hors du processus. Si les sessions, les parties en cours ou les files d'attente vivent en mémoire, deux instances ont deux vérités différentes. Tout ce qui doit survivre à un redémarrage — et être partagé entre deux instances — doit vivre dans la base ou dans le cache, et les transitions critiques doivent être protégées par un verrou.

Ne pas lancer de tâche unique en double. Un planificateur intégré, un worker qui dépile une file, un cron qui envoie un e-mail récapitulatif : si deux instances tournent, ces tâches s'exécutent deux fois. Pour une application dont le conteneur web héberge aussi les workers, ce modèle n'est tout simplement pas applicable tel quel — il faut d'abord séparer ces rôles.

Écrire des migrations rétro-compatibles. C'est le point le plus souvent oublié. La migration s'exécute au démarrage de la nouvelle version, pendant que l'ancienne sert encore le trafic. L'ancien code doit donc fonctionner contre le nouveau schéma. Renommer une colonne en une seule étape casse l'ancienne version pendant la bascule ; il faut le faire en deux temps : ajouter la nouvelle colonne, déployer le code qui écrit dans les deux, puis supprimer l'ancienne lors d'un déploiement ultérieur.

S'arrêter proprement. Quand Docker arrête un conteneur, il envoie un signal SIGTERM, attend dix secondes, puis tue le processus sans ménagement. Une application correcte doit intercepter ce signal : cesser d'accepter de nouvelles connexions, laisser se terminer les requêtes en cours, fermer ses sockets, puis sortir.

Le piège du sh -c

C'est là que j'ai trouvé le bug le plus instructif. Mon application gérait bien le SIGTERM. Pourtant, chaque arrêt prenait exactement dix secondes, et le message « signal reçu » n'apparaissait jamais dans les journaux. La cause était dans le Dockerfile :

# Le shell reste PID 1 et ne transmet pas le signal
CMD sh -c "npm run migrate && node server.js"

# Le shell se remplace par Node, qui reçoit le signal directement
CMD sh -c "npm run migrate && exec node server.js"

Dans la première forme, le shell est le processus principal du conteneur, et il ne relaie pas le signal à son enfant. Docker attendait donc dix secondes, puis tuait tout. Le mot-clé exec remplace le shell par le processus Node, qui reçoit alors le signal directement. Après correction, l'arrêt prend moins d'une seconde. Un détail d'une ligne, mais sans lui, chaque déploiement coupait brutalement les connexions en cours.

Les derniers détails qui comptent

Quelques ajustements complètent le dispositif. Le healthcheck doit être léger et vérifier fréquemment au démarrage, sinon l'outil attend trente secondes avant même le premier contrôle. Le conteneur ne peut plus porter de nom fixe, puisque deux exemplaires coexistent : le proxy vise désormais un alias réseau commun aux deux. Et tout ce qui visait le conteneur par son nom — scripts, sondes de surveillance, commandes de maintenance — doit être retrouvé et adapté, faute de quoi il cesse de fonctionner silencieusement.

Ce que j'en retiens

Le déploiement sans coupure n'est pas une fonctionnalité qu'on ajoute à la fin avec un outil. C'est une propriété de l'application : état externalisé, tâches uniques isolées, migrations en deux temps, arrêt propre. L'outil de bascule tient en quelques lignes ; ce sont ces quatre points qui décident si ça marche.

La bonne nouvelle, c'est que chacun d'eux rend aussi l'application plus saine pour d'autres raisons : plus facile à faire évoluer, à redémarrer après une panne ou à déplacer vers un autre serveur. On commence pour éviter quarante secondes d'erreur, et on finit avec une application nettement plus robuste.