Il est 14 h un mardi. Le pipeline est vert, quelqu'un clique sur « déployer en production », et… rien ne se passe. Aucune page d'erreur, aucune session perdue, aucun utilisateur déconnecté. Quelques minutes plus tard, la nouvelle version sert tout le trafic. Pas de fenêtre de maintenance nocturne, pas de bandeau « retour à 15 h », pas de sueurs froides.
Ce non-événement n'a rien de magique : c'est un rolling update zero-downtime, mis en place sur une application Symfony déployée sur OpenShift pour un grand opérateur télécom — la même application dont je décrivais récemment comment l'image Docker de production est testée de bout en bout avant chaque déploiement. Cet article est le chapitre suivant : l'image est validée, reste à l'amener en production sans couper le service. Voici le montage complet — GitLab CI, to-be-continuous, OpenShift — et surtout les pièges qui ne se voient qu'une fois que deux versions de l'application tournent en même temps.
Le vrai coût d'un déploiement qui fait peur
Avant la technique, le fond du problème. Un déploiement qui exige une interruption de service est un déploiement qu'on planifie, qu'on négocie, qu'on repousse. Il devient rare. Et un déploiement rare est un déploiement gros : des dizaines de changements accumulés, livrés d'un bloc, avec une surface de risque proportionnelle. Quand quelque chose casse, le coupable se cache parmi cinquante commits au lieu de cinq.
Le cercle vicieux est bien connu : déployer fait peur, donc on déploie moins souvent, donc chaque déploiement est plus risqué, donc déployer fait encore plus peur. Pendant ce temps, les correctifs attendent, les fonctionnalités terminées dorment dans une branche, et l'écart entre le code écrit et le code en production se creuse — un mécanisme cousin de celui qui fait grossir la dette technique. Rendre le déploiement invisible pour les utilisateurs casse ce cercle : on peut déployer souvent, en journée, par petits incréments. C'est un investissement d'infrastructure dont le retour est avant tout organisationnel.
Le contexte
L'application est un back-office métier critique : Symfony / PHP 8.4 rendu côté serveur, un front React en cours d'introduction selon le strangler fig pattern, MySQL, Redis, des workers Symfony Messenger et une dizaine de CronJobs. Le tout tourne sur un cluster OpenShift interne, avec un pipeline GitLab CI dont j'ai détaillé les étapes dans l'article sur les tests e2e de l'image de production : l'image qui arrive en production a déjà démarré, migré sa base et servi de vraies pages dans un vrai navigateur. La question de cet article est différente — le déploiement lui-même est-il invisible pour les utilisateurs ? Ce sont deux risques distincts : ce qu'on déploie peut être parfait et le déploiement, lui, couper le service pendant trente secondes.
La chaîne CI : GitLab CI et to-be-continuous
Le pipeline s'appuie sur to-be-continuous, un projet open source de templates GitLab CI composables, largement utilisé dans les grands groupes français et étonnamment peu couvert en dehors de sa documentation officielle. Le principe : chaque préoccupation (build Docker, déploiement OpenShift, analyse de sécurité…) est un composant versionné qu'on inclut et qu'on configure par des inputs, au lieu de réécrire des centaines de lignes de YAML par projet :
include:
- component: $CI_SERVER_FQDN/to-be-continuous/docker/gitlab-ci-docker@8.0
inputs:
healthcheck-disabled: true
- component: $CI_SERVER_FQDN/to-be-continuous/openshift/gitlab-ci-openshift@6.0
inputs:
base-app-name: app
scripts-dir: os
staging-project: app-staging
staging-environment-url: https://app-staging.example.com
prod-project: app-production
prod-environment-url: https://app.example.com
# staging se déploie tout seul ; la production attend un clic
prod-deploy-strategy: manual
Le composant openshift apporte la mécanique complète : les jobs de
déploiement par environnement, la gestion des identifiants du cluster, et la
convention des hook scripts — un répertoire os/ dans le dépôt
où l'on place son propre script de déploiement, que le composant exécute avec tout
le contexte déjà résolu (image à déployer, URL de l'environnement, nom du projet
OpenShift). On garde la main sur le comment du déploiement, le composant
s'occupe du quand et du où. Le déploiement en staging est
automatique à chaque passage sur la branche principale ; la production est le même
job, déclenché manuellement.
Avant la mécanique : comment le trafic atteint les pods
Un détour rapide par le routage OpenShift, parce que tout le reste en découle. L'application est exposée par une Route — l'équivalent OpenShift de l'Ingress Kubernetes — qui porte le nom de domaine public et la terminaison TLS. Cette Route pointe vers un Service, une adresse interne stable du cluster. Et c'est le Service qui répartit chaque requête entre les pods prêts — uniquement eux.
Le point décisif : pendant un déploiement, ni la Route ni le Service ne changent. Seule évolue la liste des pods éligibles derrière le Service, au rythme où ils deviennent prêts ou disparaissent. Le trafic entre toujours par la même porte ; la bascule se joue entièrement dans la liste des destinations. C'est ce découplage qui rend le rolling update possible — et c'est la readiness probe qui décide, pod par pod, de qui figure dans cette liste.
Les quatre ingrédients du rolling update zero-downtime
Un rolling update qui ne coupe rien repose sur quatre conditions. Chacune est simple ; c'est leur conjonction qui fait le zero-downtime, et l'absence d'une seule suffit à produire des erreurs pendant la bascule.
1. Deux replicas minimum
Avec un seul pod, il n'y a rien à faire rouler : le remplacer, c'est l'arrêter. Deux replicas suffisent pour qu'à tout instant au moins un pod serve le trafic pendant que l'autre se renouvelle. C'est aussi, accessoirement, une protection contre la perte d'un nœud du cluster en temps normal.
2. Une stratégie qui ne descend jamais sous le nominal
Le comportement de la bascule se règle avec deux paramètres du Deployment, qui fixent les bornes du nombre de pods pendant le remplacement :
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # un pod supplémentaire pendant la bascule
maxUnavailable: 0 # jamais moins de 2 pods disponibles
Ces deux paramètres se lisent ensemble : maxUnavailable: 0 interdit
de retirer un ancien pod tant que son remplaçant n'est pas prêt, et
maxSurge: 1 autorise un pod excédentaire le temps de la bascule. Le
déroulé est donc : créer un pod en version N+1, attendre qu'il soit prêt, retirer
un pod N, recommencer. L'application ne descend jamais sous sa capacité nominale —
il monte temporairement au-dessus.
3. Des probes qui disent la vérité
Tout le mécanisme précédent repose sur un mot : prêt. C'est la readiness probe qui en décide, et c'est ici que la plupart des rolling updates mentent. Si la probe répond OK dès qu'Apache écoute, OpenShift bascule le trafic vers un pod dont l'application n'a peut-être pas fini de démarrer — cache froid, connexion base non établie — et les utilisateurs récoltent des erreurs 500 le temps que l'application démarre vraiment. Le rolling update est alors zero-downtime sur le papier seulement.
L'application expose une petite API de statut, sur laquelle s'appuient les trois probes du Deployment — avec une subtilité qui mérite d'être soulignée : la vérification profonde est réservée à la readiness.
startupProbe:
httpGet: { path: '/status', port: 8080 }
periodSeconds: 5
failureThreshold: 24 # jusqu'à 2 minutes pour démarrer
readinessProbe:
httpGet: { path: '/status?verify=1', port: 8080 } # vérification profonde
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet: { path: '/status', port: 8080 } # vérification superficielle
periodSeconds: 30
failureThreshold: 3
Côté Symfony, le contrôleur de statut tient en quelques lignes — le paramètre
verify déclenche la vérification des dépendances :
#[Route('/status', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
if ($request->query->getBoolean('verify')) {
// vérification profonde : les dépendances répondent
$this->connection->executeQuery('SELECT 1');
$this->redis->ping();
}
return new JsonResponse(['status' => 'ok']);
}
La distinction n'est pas cosmétique. Un échec de readiness retire le pod du Service — il ne reçoit plus de trafic, sans conséquence destructive. Un échec de liveness redémarre le container. Si la liveness vérifiait la base de données, une micro-coupure MySQL ferait redémarrer tous les pods applicatifs en cascade — transformant un incident mineur sur une dépendance en indisponibilité totale de l'application. La liveness ne doit répondre qu'à une question : « ce processus est-il vivant ? ». La readiness répond à une autre : « ce pod peut-il servir des requêtes maintenant ? ». Quant à la startup probe, elle accorde un budget de démarrage généreux (deux minutes ici) sans contaminer la période des deux autres.
4. Orchestrer et attendre — vraiment
Dernier ingrédient, côté script de déploiement : appliquer les manifestes ne suffit pas, il faut attendre la fin du rollout et la vérifier, sinon le pipeline passe au vert pendant que la production échoue silencieusement. Le script enchaîne trois temps — appliquer, attendre, vérifier :
# applique le Deployment avec la nouvelle image
oc process -p APP_REF="${APP_REF}" -p APP_IMAGE="${APP_IMAGE}" \
-f os/App.yml | oc apply -f -
oc rollout restart "deployment/${APP_REF}"
# bloque jusqu'à ce que le rollout soit terminé — ou échoue
oc wait --for=condition=Available --timeout=5m \
deployment --selector="app=${APP_REF}"
# dernière vérification, de l'extérieur cette fois :
# l'URL publique répond au travers de la Route et du Service
wget -t 20 -w 5 -T 5 --retry-on-http-error=503 "${environment_url}"
Le oc wait fait du job de déploiement un témoin fiable : s'il expire,
le pipeline est rouge et — grâce à maxUnavailable: 0 — l'ancienne
version sert toujours tout le trafic. Un déploiement raté est un déploiement qui
n'a pas eu lieu, pas une production cassée. La vérification HTTP finale
ajoute le point de vue qui manque aux probes : celui de l'extérieur du cluster,
route et TLS compris.
Le film complet d'un déploiement
Mis bout à bout, ces quatre ingrédients donnent le déroulé suivant, avec deux replicas — A et B en version 1, la version 2 qui arrive :
- État initial — A (v1) et B (v1) servent le trafic derrière le Service.
- Surge — C (v2) est créé (
maxSurge: 1) : il démarre, joue les migrations, monte en température. A et B servent toujours tout le trafic. - C devient prêt — sa readiness passe au vert, le Service l'ajoute à sa liste : le trafic se répartit sur A, B et C. v1 et v2 cohabitent.
- A est retiré — d'abord sorti du Service (plus aucune nouvelle requête), puis arrêté proprement. Toujours deux pods disponibles (
maxUnavailable: 0). - D (v2) est créé — même cycle : démarrage, readiness, entrée dans le Service.
- B est retiré — le dernier pod v1 sort du Service, puis s'arrête.
- État final — C et D (v2) servent tout le trafic.
oc waitrend la main, le pipeline passe au vert.
À aucun moment le nombre de pods prêts ne descend sous deux — et entre les étapes 3 et 6, deux versions différentes répondent aux requêtes. Ce dernier point n'est pas un détail d'implémentation : c'est la source de tous les pièges qui suivent.
Les pièges : deux versions en production en même temps
Tout ce qui précède est de la mécanique Kubernetes assez standard. Les vrais pièges sont applicatifs, et découlent tous d'une même réalité qu'on oublie facilement : pendant la bascule, les versions N et N+1 servent du trafic simultanément, sur la même base de données. Quelques minutes seulement — mais quelques minutes à chaque déploiement, donc potentiellement plusieurs fois par jour.
Les migrations doivent être compatibles N/N+1
Le pod N+1 joue ses migrations au démarrage, puis devient prêt — pendant que des
pods N exécutent encore l'ancien code sur le schéma déjà migré. Toute
migration destructive casse donc l'ancienne version en pleine bascule : supprimer
une colonne encore lue par N, renommer une table, ajouter une colonne
NOT NULL sans valeur par défaut, qu'aucun INSERT de N ne
renseigne. La règle d'or : les migrations d'un déploiement doivent être
additives. Les suppressions et renommages se font en deux temps — le
déploiement N+1 cesse d'utiliser la colonne, un déploiement ultérieur la supprime.
C'est la même discipline que pour une migration strangler fig, à l'échelle d'un
déploiement plutôt que d'un projet.
Les sessions doivent survivre aux pods
Avec deux replicas et des pods détruits à chaque déploiement, les sessions PHP stockées sur le système de fichiers du container deviennent une loterie : une requête sur deux atterrit sur un pod qui ne connaît pas la session, et chaque déploiement déconnecte tout le monde — précisément ce qu'on cherche à éviter. La solution est connue : externaliser les sessions dans Redis, déjà présent dans la pile. Côté Symfony, c'est une simple déclaration de handler :
services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler:
arguments:
- '@app.session.redis'
Le même raisonnement s'applique à tout état local : fichiers uploadés (stockage objet S3 plutôt que volume local), caches applicatifs partagés (Redis), verrous (composant Lock sur Redis). Un pod doit pouvoir disparaître à tout instant sans emporter quoi que ce soit d'irremplaçable — le déploiement n'est que le cas le plus fréquent de cette disparition.
Workers Messenger et CronJobs : les processus hors HTTP
Le trafic HTTP n'est pas le seul à traverser le déploiement. Les workers Symfony Messenger tournent dans leur propre Deployment, avec le même rolling update — et la même contrainte de compatibilité : un worker N peut consommer un message produit par le code N+1, et inversement. Les messages sont donc soumis à la même règle que le schéma de base : pas de changement incompatible de structure dans un seul déploiement.
Les CronJobs, eux, posent un problème différent : un cron qui se déclenche au milieu de la bascule s'exécuterait avec l'ancienne image sur un schéma déjà migré. Le script de déploiement les suspend donc le temps de l'opération :
# avant le déploiement : suspendre tous les CronJobs
for cron in $(oc get cronjobs -o name); do
oc patch "${cron}" -p '{"spec":{"suspend":true}}'
done
# … déploiement …
# après : les réactiver (avec la nouvelle image, mise à jour au passage)
Quelques lignes, mais elles éliminent toute une classe de bugs intermittents — ces erreurs de cron impossibles à reproduire parce qu'elles n'arrivent que lorsqu'un déploiement et une planification se croisent.
Et pourquoi pas du blue/green ?
La question revient systématiquement, et elle mérite une réponse honnête. Le blue/green — deux environnements complets en parallèle, une bascule de route de l'un vers l'autre — offre deux choses que le rolling update n'a pas : une bascule atomique (à aucun moment deux versions ne servent du trafic) et un rollback instantané (rebasculer la route). En échange, il impose soit le double d'infrastructure en permanence, soit une orchestration de création/destruction d'environnements nettement plus complexe, et il ne dispense pas de la compatibilité de schéma : les deux couleurs partagent la même base de données, sauf à la dupliquer aussi — et là, la complexité change d'ordre de grandeur.
Pour cette application, l'arbitrage est net. Les déploiements sont fréquents et petits, l'image est testée de bout en bout avant publication, et les migrations sont additives par discipline d'équipe : la probabilité d'avoir besoin d'un rollback instantané est faible, et un rollback par redéploiement de l'image précédente (quelques minutes) couvre le besoin réel. Le rolling update fournit l'essentiel du bénéfice — zéro interruption — pour une fraction du coût et de la complexité. Le blue/green se justifie ailleurs : bascules très risquées et peu fréquentes, obligation contractuelle de rollback en secondes, trafic tel qu'une dégradation partielle est inacceptable. Comme souvent, la bonne question n'est pas « quelle est la meilleure stratégie ? » mais « quel est le besoin réel, et quel est le montage le plus simple qui le couvre ? ».
Conclusion
Le zero-downtime n'a pas demandé d'outillage exotique : deux replicas, trois
probes honnêtes, deux paramètres de stratégie, un oc wait, des
sessions dans Redis et une discipline de migrations additives. La mécanique tient
en une centaine de lignes de YAML et de bash, portées par les conventions de
to-be-continuous. L'essentiel, comme souvent, est conceptuel : accepter que
pendant quelques minutes deux versions cohabitent, et en tirer les conséquences
partout — schéma, sessions, messages, crons.
Le bénéfice, lui, ne se mesure pas en YAML. Déployer est devenu un geste banal, fait en journée, plusieurs fois par semaine, par n'importe quel membre de l'équipe. Les correctifs partent quand ils sont prêts, les fonctionnalités ne s'accumulent plus en attendant une fenêtre de tir, et la mise en production a cessé d'être un sujet de réunion. C'est peut-être la meilleure définition d'une infrastructure réussie : celle dont plus personne ne parle.