Aller au contenu principal

Déployer en journée sans couper le service

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 09/06/2026
schedule 14 min lecture
Un déploiement progressif bascule le trafic vers la nouvelle version d'une application sans interruption de service

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 . 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 :

  1. État initial — A (v1) et B (v1) servent le trafic derrière le Service.
  2. 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.
  3. 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.
  4. A est retiré — d'abord sorti du Service (plus aucune nouvelle requête), puis arrêté proprement. Toujours deux pods disponibles (maxUnavailable: 0).
  5. D (v2) est créé — même cycle : démarrage, readiness, entrée dans le Service.
  6. B est retiré — le dernier pod v1 sort du Service, puis s'arrête.
  7. État final — C et D (v2) servent tout le trafic. oc wait rend 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.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Pourquoi un rolling update plutôt qu'un blue/green ? expand_more
Le rolling update fournit l'essentiel du bénéfice — zéro interruption — pour une fraction du coût : pas de double infrastructure, pas de bascule de route à orchestrer. Le blue/green se justifie quand un rollback en secondes est une exigence réelle, ce qui est rare pour des déploiements fréquents et petits.
Combien de replicas faut-il au minimum ? expand_more
Deux. Avec un seul pod, le remplacer revient à l'arrêter : il n'y a rien à faire rouler. Deux replicas avec maxSurge à 1 et maxUnavailable à 0 garantissent qu'à tout instant la capacité nominale est servie pendant la bascule.
Comment gérer les migrations de base de données ? expand_more
En les rendant additives : pendant la bascule, l'ancienne version exécute son code sur le schéma déjà migré. Les suppressions et renommages se font en deux déploiements — le premier cesse d'utiliser la colonne, le second la supprime.
Cette approche fonctionne-t-elle hors OpenShift ? expand_more
Oui. La mécanique — replicas, stratégie RollingUpdate, probes, attente vérifiée du rollout — est du Kubernetes standard. Seuls l'outillage CLI (oc au lieu de kubectl) et les composants to-be-continuous utilisés changent selon la plateforme.