Infra
6 min de lecture

Migration VM → Kubernetes : 43 services en prod, zéro downtime

Comment j'ai migré 43 services d'un SaaS B2C depuis des VMs gérées à la main (Ansible + Jenkins) vers Kubernetes, sans une seule coupure, grâce à un routage HAProxy réversible service par service.

Arthur Zinck
Arthur Zinck
Expert DevOps Kubernetes & Cloud

200 personnes. 43 services en production. Des VMs configurées à coups de playbooks Ansible l’une après l’autre. Et une contrainte non négociable : les clients ne devaient rien voir passer.

TL;DR : Le problème : 43 services sur des VMs gérées à la main, une maintenance devenue un travail à plein temps, des coûts qui montaient à chaque nouveau client. La contrainte : un SaaS grand public utilisé en continu. Zéro interruption tolérée. Le résultat : 100 % des services migrés sur Kubernetes, aucune coupure visible pour les utilisateurs, des déploiements en self-service, environ 30 % de facture d’infrastructure en moins et ~6 mois de dette technique évités. La durée : 8 semaines au total. 2 semaines pour monter le cluster et l’outillage, 6 semaines de bascule, réversible à chaque étape.

Le contexte

Un éditeur SaaS B2C d’environ 200 personnes. Toute la production (43 services, déjà conteneurisés) tournait en docker-compose distribué sur une flotte de VMs, le tout provisionné et orchestré à la main via Ansible. Les nouvelles releases applicatives étaient déployées par des jobs Jenkins côté dev. Plusieurs applications cohabitaient manuellement sur une même VM.

Et ce n’était pas que la prod. 12 environnements en tout : une dizaine de dev (un par équipe QA), une preprod et la prod, tous sur des VMs, les releases dev poussées par Jenkins. Chaque environnement : un petit groupe de serveurs à maintenir à la main.

Sur le papier, c’est « de l’infra as code ». Dans la réalité, c’était :

  • Côté ops, chaque patch, chaque scaling, chaque changement de config = de l’Ansible lancé à la main, souvent le soir. Les jobs Jenkins, eux, ne servaient qu’aux releases des devs.
  • Un config drift installé, et multiplié par douze : chaque environnement divergeant de son côté. Le fameux « ça marche sur l’env 3 mais pas sur l’env 7 ».
  • Des serveurs uniques, chacun avec sa petite spécificité que personne n’osait trop toucher. Et comme commander une VM neuve prenait des semaines (specs par mail au fournisseur, un devis à valider, puis la livraison), on préférait faire des do-release-upgrade Ubuntu en place. Résultat : des serveurs plusieurs versions d’OS en retard, très à la traîne côté sécurité.
  • Un outillage Ansible que seule une partie de l’équipe savait manier, piloté depuis un bastion ops : savoir et accès concentrés sur quelques personnes.

Le déclencheur

Personne ne migre « pour faire du Kubernetes ». On migre parce que quelque chose fait mal tous les jours. Ici, deux douleurs.

Le MCO (maintien en condition opérationnelle) était devenu un travail à plein temps. 43 services × 12 environnements à patcher, redémarrer et rescaler à la main : une astreinte permanente.

La DevX était en berne. Monter ou rafraîchir un environnement, c’était provisionner des VMs puis dérouler Ansible, un chantier de plusieurs semaines, à multiplier par une dizaine d’environnements. Le classique « ça marche chez moi » (ou « ça marche sur l’env 3 ») polluait chaque livraison. Déployer était lent, manuel, et stressant.

Et le pire : chaque nouveau client ajoutait de la charge à opérer à la main. Les coûts montaient client après client, au lieu de se mutualiser.

La containte critique

C’est un SaaS grand public. Des utilisateurs s’en servent en continu, à toute heure. Un incident se voit immédiatement : trafic perdu, réputation entamée, et des revenus qui s’évaporent le temps de la panne.

Donc pas de big-bang. Pas de « on coupe tout un week-end pour basculer ». La migration devait être invisible.

Mon approche

L’idée-clé : rendre la migration réversible à chaque étape, service par service. Tout repose sur une couche de routage placée devant l’ensemble du trafic. La cible : un cluster Kubernetes déployé en GitOps (Helm + ArgoCD), aux côtés des services de données déjà en place (bases Oracle, stockage objet). Voici comment le routage fonctionne, en détail.

Les stateless

Décision de cadrage claire dès le départ : seules les apps stateless étaient concernées. La donnée ne bougeait pas : elle vivait dans des bases Oracle et du stockage objet, qui restaient en place par choix. Migrer les bases n’a jamais été au programme : frontière nette, risque minimal.

Et c’est exactement ce qui rendait la répartition du trafic aussi sûre. Une requête pouvait tomber indifféremment sur une VM ou un pod, les deux tapant les mêmes bases et le même stockage. Aucune persistance de session à gérer (ni sticky sessions, ni cookie d’affinité) : le round-robin pondéré suffisait, et cette absence d’état local est précisément ce qui autorisait une bascule aussi progressive.

La couche de routage

Devant les backends, deux HAProxy en haute disponibilité. L’entrée du trafic, c’est une VIP (Virtual IP) portée par Keepalived, partagée entre les deux HAProxy : à un instant donné, un seul des deux tient la VIP et reçoit le trafic, l’autre est en veille. C’est un montage actif/passif : Keepalived surveille en continu le nœud actif et, s’il tombe (process ou machine), la VIP bascule sur le passif en une fraction de seconde, sans intervention, le trafic ne s’en aperçoit pas, car le serveur qui reprend la main annonce l’adresse MAC et le trafic suit. On ne remplace pas une VM fragile par une couche de routage fragile : cette couche est elle-même redondée, sinon on aurait juste déplacé le risque d’un cran.

Tout le trafic suit donc : client → VIP (Keepalived) → HAProxy → backend, en clair (HTTP) à partir de HAProxy.

Schéma de la couche de routage : le trafic client arrive sur une VIP portée par Keepalived, passe par la paire HAProxy actif/passif, puis est réparti au poids entre les VMs legacy et le cluster Kubernetes

Les certificats ?

Le TLS était terminé sur les HAProxy eux-mêmes, au niveau de la couche de routage. En sortie, les deux backends ne voyaient donc que du HTTP en clair.

C’est un vrai atout pour ce type de migration : aucun certificat à dupliquer, à synchroniser ou à basculer côté Kubernetes pendant la transition. Le certificat vivait à un seul endroit, intouché du début à la fin. Au niveau HTTP, VM et pod étaient donc strictement interchangeables.

La contrepartie, assumée : le trafic interne (HAProxy → backend) circule en clair, ce qui suppose un réseau privé de confiance, cloisonné au niveau firewall.

Deux backends par service, un poids

Pour chaque service, un backend HAProxy avec deux server : l’ancien (les VMs) et le nouveau (l’entrée du cluster Kubernetes). Le partage se fait au poids, en round-robin. Au départ, tout le poids est sur les VMs, zéro sur Kubernetes :

backend app_backend
    balance roundrobin
    option httpchk GET /health
    http-check send hdr Host app.interne   # le LB et Traefik routent par Host
    server legacy 10.0.0.10:8080   weight 100 check   # l'app sur sa VM
    server k8s    10.0.2.30:80     weight 0   check    # LB devant l'ingress Traefik

Le server k8s est déclaré, santé vérifiée en continu, mais reçoit 0 % du trafic. Il est prêt à entrer en jeu dès qu’on le décide. Comme plusieurs applications partageaient une même VM, c’est le couple IP:port qui ciblait précisément la bonne app côté legacy. Côté cluster, ce backend visait un load balancer devant le contrôleur d’ingress Traefik (pas un nœud unique, donc pas de SPOF) ; Traefik routait ensuite par Host vers le bon service et ses pods.

La bascule

On ajuste les poids dans la conf HAProxy et on reload.

# étape 1 : canary 5 % (≥ 1h)         → legacy 95 / k8s 5, puis reload
server legacy 10.0.0.10:8080   weight 95 check
server k8s    10.0.2.30:80     weight 5  check
# étape 2 : 50/50, sur feu vert du dev → weights 50 / 50, reload
# étape 3 : 99 %, laissé toute la nuit → weights 1 / 99, reload
# étape 4 : ancienne appli coupée      → legacy disabled, reload

Le rythme, en pratique : canary à 5 %, maintenu au moins une heure. À faible trafic, certaines parties de l’appli ne sont pas encore sollicitées et une erreur de config peut rester invisible : il faut laisser le vrai trafic les faire remonter. Pendant ce temps, deux paires d’yeux : moi sur les métriques infra (CPU, RAM, erreurs et latence par backend, les métriques par serveur de HAProxy distinguant legacy de k8s), le dev sur son APM applicatif. On ne passe au palier suivant que sur son feu vert explicite. Puis 50 %, avec des paliers intermédiaires sur les services sensibles, et le soir 99 % laissé toute la nuit. Pas d’alerte au réveil : on coupe l’ancienne appli.

Au moindre écart (5xx qui montent, latence qui dérape), on remet le poids k8s à 0 et on recharge : le trafic reflue vers les VMs. Pas de redéploiement, pas de rollback applicatif : l’édition d’un fichier et un reload, une dizaine de secondes. C’est tout le filet de sécurité.

Un service a saturé sa base de données dès 5 % : 5xx immédiats. Rollback en un reload, les DBA ont relevé les pools de connexions, on a retenté : passage à 50 % sans broncher. Le canary avait fait exactement son travail : révéler le problème sur 5 % du trafic, pas sur 100 %.

À noter : ces changements de poids, on les faisait à la main dans la conf HAProxy. En théorie ils auraient dû passer par le gestionnaire de conf, mais vu l’état du socle Ansible, j’ai préféré rester en maîtrise totale. Du manuel assumé, le temps de la bascule.

Les health checks

Le check HAProxy du server k8s porte un header Host. Sans lui, le LB et Traefik ne sauraient pas vers quelle app router la sonde. Avec, le check traverse tout le chemin (LB → Traefik → pod) et tape le vrai /health de l’application, pas seulement le point d’entrée. Si l’app répond mal, HAProxy éjecte le server k8s et tout repart vers les VMs, sans intervention manuelle.

Deuxième étage de la fusée, dans le cluster : les readiness probes de Kubernetes font que Traefik ne route que vers des pods réellement prêts. Un pod qui tousse est sorti du service avant même que le check HAProxy s’en aperçoive.

Reste le cas vicieux : une app dont le /health répond 200 mais qui sert des 5xx sur le vrai trafic. Ni le check ni les probes ne le voient. C’est là qu’interviennent le canary à 5 % et l’observation des métriques et des logs.

Quand ça coinçait

Vu que ce n’était que des apps stateless, un pod qui se comportait mal ne pouvait pas souffrir d’un problème de données. Sur 43 services, les 5xx du démarrage revenaient toujours du même petit lot de causes :

  • un secret mal recopié, ou une variable d’env oubliée dans un playbook Ansible jamais transcrite en Secret/ConfigMap ;
  • un pool de connexions BDD saturé ;
  • un endpoint ou token de partenaire externe mal renseigné ;
  • une readiness probe trop laxiste : le pod prend du trafic avant d’être vraiment prêt ;
  • une liveness probe trop agressive : elle tue un démarrage un peu lent ;
  • un HPA mal réglé ou une limite mémoire trop basse.

La reconfiguration, une fois le service stabilisé

Une fois l’ancienne appli coupée, on laisse tourner. Environ une semaine à 100 % sur Kubernetes sans accroc et sans plainte, et on fige : la nouvelle conf (backend legacy retiré) part en Git, l’app est retirée de ses VMs. Mais la VM elle-même n’était décommissionnée qu’une fois toutes ses applications colocataires migrées.

Service par service. 43 fois, en six semaines. Oui, quelques 5xx passagers, mais soyons précis sur ce que ça veut dire : quand un écart de config se révélait sous vrai trafic, il ne touchait que la fraction servie par le canary (5 %), le temps de remettre le poids k8s à 0. Une poignée de requêtes en erreur sur un service, quelques minutes, le trafic aussitôt reflué vers les VMs. Jamais une coupure de service. C’est toute la différence entre un incident visible par les utilisateurs et un canary qui fait exactement son travail.

Le gros du travail

La méthode de routage était simple. Ce qui prend le temps, c’est tout le reste.

  1. Le config drift Ansible. L’état réel des VMs ne correspondait plus au code. Et pour cause : les playbooks tournaient depuis un bastion ops, avec des modifications locales jamais committées en Git. La source de vérité et la réalité avaient divergé. Avant de porter quoi que ce soit sur Kubernetes, il a fallu reconstruire la configuration de chaque service au travers d’un chart Helm générique.
  2. Les bases de données partagées. Pendant la bascule, VMs et pods tapaient les mêmes bases Oracle (qui restaient en place, hors périmètre). Il a fallu ouvrir les accès réseau du cluster vers ces bases (règles firewall, réseau privé) et surveiller les pools de connexions pour ne pas saturer les limites côté base.

Résultats

  • 43 services. Zéro interruption de service, quelques 5xx passagers en canary.
  • Durée : 8 semaines de projet au total. 2 semaines pour mettre en place le cluster et tout l’outillage (GitOps, chart Helm générique, observabilité), puis ~6 semaines de bascule, à un rythme d’environ 7 services par semaine.
  • Déploiements : d’une release livrée par job Jenkins à un simple git push en GitOps, plusieurs fois par jour, en self-service.
  • Coûts : fini les VMs à moitié vides. Chaque service déclare ses requests/limits, et le scheduler empaquette les workloads sur des nœuds partagés (bin-packing) au lieu de les éparpiller sur des machines dimensionnées au pire cas et sous-utilisées le reste du temps. La densité monte, la capacité réservée-mais-inactive (le poste où partait l’essentiel de la facture VM) fond. Et le scheduler place les workloads bien mieux que des humains à la main. Au global : environ 30 % de facture d’infrastructure en moins.
  • MCO : patching, scaling et redémarrages automatisés. Le quotidien d’astreinte enfin allégé.
  • Fin du Tetris manuel : plus besoin de déplacer une app d’une VM à l’autre pour faire de la place, ni de réclamer puis inventorier une nouvelle VM pour tenir la charge : le scheduler place les pods, l’autoscaling ajoute des nœuds.
  • Socle Ansible à la corbeille : un empilement de playbooks spaghetti, truffé d’étapes encore manuelles. Le remettre d’aplomb aurait coûté un bon semestre. La migration a rendu ce chantier caduc, soit ~6 mois de travail évités, plusieurs dizaines de milliers d’euros au coût chargé d’un profil devops.
  • DevX : la fin du « ça marche sur l’env 3 mais pas sur l’env 7 ».
  • Observabilité : de sondes HTTP externes à un monitoring par application : métriques et logs centralisés (VictoriaMetrics + VictoriaLogs), dashboards Grafana à l’appui.
  • Résilience : redémarrage automatique, reschedule sur panne de nœud, un MTTR en chute.
  • ROI : d’un côté, un chantier unique de 8 semaines. De l’autre, une économie ponctuelle (~6 mois d’ingénierie non dépensés) qui couvrait à elle seule le coût de la mission, et une économie récurrente (~30 % d’infra en moins, tous les mois). Autrement dit : remboursée dès la livraison, rentable chaque mois depuis.

Mais le résultat le plus important : fini le stress permanent. L’équipe ops a littéralement retrouvé ses soirées, et repris confiance dans son infra.

Le plus gros frein n’était pas technique. Les mises en prod sont devenues bien plus simples, mais faire adopter Git à l’équipe ops (les branches, les PR) a demandé du temps : pour eux, ça restait un truc de devs.

Et l’IA dans tout ça ?

Ai-je utilisé l’IA pour cette migration ? Non. Aurais-je dû ? Probablement. Regardez la liste des causes de 5xx plus haut : un secret mal recopié, une variable d’env oubliée, un endpoint mal renseigné. Ce ne sont pas des erreurs de conception, ce sont des erreurs d’inattention, celles que commettent des développeurs et des devops sous la pression d’une deadline, et précisément celles qu’une relecture automatisée attrape le mieux. L’IA n’aurait pas décidé de la stratégie de bascule à ma place. Mais pour transcrire 43 configurations de playbooks Ansible en charts Helm sans en oublier une ligne, elle aurait été un vrai filet de sécurité supplémentaire.


Une migration en prod sans fenêtre de maintenance, ça se dérisque étape par étape : c’est justement ce que je fais. Sortie de VMs, passage à Kubernetes, ou reprise en main d’un socle qui a dérivé : parlons-en.

Points clés à retenir

  • 43 services migrés depuis des VMs gérées à la main (Ansible + Jenkins) vers Kubernetes en ~6 semaines, sans une seconde d'interruption.
  • Deux backends par service dans HAProxy (la VM et Kubernetes) : on déplace le trafic en changeant les poids et en rechargeant. Canary 5 %, puis 50/50, puis on coupe la VM. Rollback en un reload.
  • La bascule est la partie facile. Le temps passe ailleurs : démêler le config drift Ansible pour le reconstruire en chart Helm, et partager les bases Oracle (hors périmètre) pendant la transition. Le tier migré étant sans état, aucune session à gérer.

Cet article t'a plu ?

Je publie régulièrement mes retours d'expérience infra, Kubernetes et FinOps sur LinkedIn. Abonne-toi pour les suivre. Mes MP sont ouverts, tu peux m'écrire direct.

kubernetes migration zero-downtime haproxy ansible jenkins vms gitops devops

Partager cet article

Twitter LinkedIn