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/VIP réversible service par service.

Arthur Zinck
Arthur Zinck
Expert DevOps Kubernetes & Cloud

200 personnes. 43 services en production. Aucune équipe Ops dédiée pour absorber la charge — juste des VMs configurées à la main, un playbook Ansible après l’autre. Et une contrainte non négociable : les clients ne devaient rien voir passer.

Voici comment on a fait.

Le contexte

Un éditeur SaaS B2C d’environ 200 personnes. Toute la production — 43 services — tournait sur des machines virtuelles provisionnées et configurées à la main via Ansible, avec Jenkins pour orchestrer les déploiements. Et, comme souvent, plusieurs applications cohabitaient sur une même VM.

Et ce n’était pas que la prod. Il y avait autant d’environnements que d’équipes QA — soit une dizaine d’environnements de développement et de recette, eux aussi sur des VMs, eux aussi déployés par Jenkins. Chaque environnement : un petit troupeau de serveurs à maintenir à la main.

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

  • Chaque patch de sécurité, chaque montée de version, chaque scale = un job Jenkins déroulant des playbooks Ansible, souvent relancé à la main, souvent le soir.
  • Un config drift installé, et multiplié par dix : une dizaine d’environnements qui divergeaient chacun de leur côté. Le fameux « ça marche sur l’env 3 mais pas sur l’env 7 ».
  • Des serveurs « pets » : chacun avec sa petite spécificité que personne n’osait toucher.

Le déclencheur : le MCO et la DevX qui étouffent

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. Maintenir 43 services à la main, c’est une astreinte permanente : ça patche, ça redémarre, ça rescale, ça casse.

La DevX était en berne. Monter ou rafraîchir un environnement, c’était provisionner des VMs puis dérouler Ansible — un chantier de plusieurs jours, à 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 contrainte qui a tout dicté : zéro downtime

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.

Les fausses solutions écartées

  • Le cutover big-bang (basculer les 43 services d’un coup) : ingérable, irréversible, suicidaire.
  • Le lift-and-shift brut (copier les VMs telles quelles dans des conteneurs) : on transporte le problème au lieu de le résoudre.
  • Le tunnel (tout containeriser parfaitement avant de basculer quoi que ce soit) : des mois sans valeur livrée, et un risque énorme le jour J.

Mon approche : un double HAProxy + VIP comme couche de routage

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. Voici comment elle fonctionne, en détail.

La couche de routage — et pourquoi elle n’est pas un SPOF

Devant tout le trafic entrant, deux HAProxy en actif/passif, et une VIP (adresse IP virtuelle) portée par keepalived en VRRP. Le HAProxy actif détient la VIP ; si le processus ou la machine tombe, keepalived fait basculer la VIP sur le second nœud en moins d’une seconde. On ne remplace pas une VM fragile par une couche de routage fragile : celle-ci est elle-même hautement disponible, sinon on aurait juste déplacé le risque d’un cran.

Tout le trafic suit donc : client → load balancer (terminaison TLS) → VIP → HAProxy → backend, en clair (HTTP) à partir du load balancer.

Les certificats ? Terminés en amont

Le TLS était terminé plus haut dans la chaîne, sur le load balancer d’entrée — au-dessus de la couche de routage. HAProxy et 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 (load balancer → HAProxy → backend) circule en clair, ce qui suppose un réseau privé de confiance, cloisonné par VPC et security groups.

Deux backends par service, un curseur de 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
    cookie SRV insert indirect nocache
    server legacy 10.0.0.10:8080   weight 256 check cookie legacy
    server k8s    10.0.1.20:30080  weight 0   check cookie k8s

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 à la seconde où 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.

La bascule à chaud, sans reload et sans couper une seule connexion

C’est le cœur du truc : on ne touche jamais au fichier de conf pour déplacer le trafic. Reload = risque de couper des connexions établies. À la place, on pilote les poids à chaud via la Runtime API de HAProxy (le socket d’admin) :

# 5 %  → poids 13/256
echo "set server app_backend/k8s weight 13"  | socat stdio /run/haproxy/admin.sock
# 25 % → 64, puis 50 % → 128, puis 100 % → 256
echo "set server app_backend/k8s weight 256" | socat stdio /run/haproxy/admin.sock
# et si besoin, on ferme le robinet côté VMs
echo "set server app_backend/legacy weight 0" | socat stdio /run/haproxy/admin.sock

Chaque commande prend effet instantanément, sur les nouvelles connexions, sans reload, sans drop. On monte palier par palier — 5 % → 25 % → 50 % → 100 % — en observant à chaque étape le taux d’erreur et la latence par backend (les métriques par serveur de HAProxy distinguent legacy de k8s).

Au moindre écart — 5xx qui montent, latence qui dérape — une seule commande ramène le poids à 0 et le trafic reflue vers les VMs en une fraction de seconde. Pas de redéploiement, pas de rollback applicatif : juste un curseur qu’on referme.

Les health checks font le filet de sécurité

option httpchk vérifie en continu que les deux backends répondent. Si le backend Kubernetes se met à échouer pendant qu’il prend du trafic, HAProxy l’éjecte automatiquement et tout repart vers les VMs — sans que j’aie besoin d’intervenir. Le filet est passif et permanent.

La persistance pour ne pas casser les sessions

En round-robin brut, un même utilisateur alternerait entre VM et pod à chaque requête — mortel pour un service à état. D’où la ligne cookie SRV insert : dès qu’un utilisateur est routé vers k8s, un cookie de persistance l’y maintient. On déplace ainsi des utilisateurs entiers, pas des requêtes isolées — la bascule progressive devient franche du point de vue de chaque session.

La reconfiguration propre, une fois stabilisé

Quand un service tourne à 100 % sur Kubernetes depuis plusieurs jours sans accroc, on fige : la nouvelle conf (backend legacy retiré) part en Git, appliquée avec un reload hitless de HAProxy (transfert des sockets via -x, aucune connexion perdue). L’app est alors 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. La colocation imposait cet ordre : on libère une machine seulement quand plus personne n’y habite.

Service par service. 43 fois. Jamais une coupure.

Les pièges — le vrai 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. Avant de containeriser quoi que ce soit, il a fallu reconstruire la vraie configuration de chaque service.
  2. La base de données partagée. Pendant la bascule, VM et pods tapaient la même base. Il a fallu gérer les pools de connexions et ouvrir les routes réseau du nouveau cluster vers la DB sans exploser les limites de connexions.
  3. L’état en mémoire. Certains services gardaient des sessions en local. Il a fallu les externaliser (Redis) avant d’oser répartir leur trafic.
  4. Le trafic est-ouest. Des services s’appelaient entre eux par IP ou hostname en dur. Ce trafic interne devait, lui aussi, passer par cette couche de routage.
  5. Les allowlists. Le nouveau cluster devait sortir vers les API tierces et partenaires avec les mêmes IP autorisées que les anciennes VMs. Un détail qui casse tout si on l’oublie.

Résultats

  • 43 services migrés. 0 downtime. Le seul chiffre que le client retiendra.
  • Déploiements : d’un job Jenkins déroulant des playbooks Ansible (lent, contrôlé par un Ops) à un simple git push, plusieurs fois par jour, en self-service.
  • Environnements : d’une dizaine de troupeaux de VMs maintenus à la main à des environnements Kubernetes reproductibles, provisionnés à la demande.
  • MCO : patching, scaling et redémarrages automatisés. Le quotidien d’astreinte enfin allégé.
  • DevX : la fin du « ça marche sur l’env 3 mais pas sur l’env 7 ».
  • Résilience : redémarrage automatique, reschedule sur panne de nœud → un MTTR en chute.

Ma différenciation

Je n’ai pas vendu Kubernetes. J’ai supprimé une douleur quotidienne, service par service, sans jamais mettre un seul client en risque.

La bascule réversible faisait que le risque était nul à chaque étape — on pouvait revenir en arrière en deux secondes. Et j’ai laissé derrière moi une plateforme que l’équipe opère elle-même, pas une boîte noire dont elle dépend.

Vous êtes sur des VMs et le MCO vous coûte vos soirées ? Parlons-en.

Points clés à retenir

  • 43 services migrés depuis des VMs gérées à la main (Ansible) vers Kubernetes, sans une seconde d'interruption.
  • La clé : un double HAProxy (VIP + keepalived), bascule du trafic à chaud via la Runtime API (poids 5 → 25 → 50 → 100 %) sans reload, et retour arrière instantané en une commande, service par service.
  • Le vrai travail n'est pas la bascule mais les à-côtés : config drift Ansible, base de données partagée, état en mémoire, trafic est-ouest, allowlists egress.

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 keepalived ansible jenkins vms gitops devops

Partager cet article

Twitter LinkedIn