Rolling update Kubernetes : migrer la base sans coupure

Pendant un rolling update, v1 et v2 tournent en même temps. La base doit donc être compatible avec les deux — et c’est l’ordre des opérations qui décide s’il y aura une panne.

9 min de lecturekubernetesconteneursdevops

Changer le tag de l’image et lancer kubectl apply suffit à déployer une nouvelle version. C’est justement le problème : c’est trop facile, et rien ne vous prévient que la base de données n’était pas prête.

La contrainte à comprendre tient en une observation. Un rolling update ne remplace pas les Pods d’un coup : il les remplace un par un, en gardant l’application disponible. Pendant plusieurs secondes ou minutes, la v1 et la v2 servent donc du trafic en même temps, sur la même base.

Ce que Kubernetes fait vraiment

text
v1   v1   v1

v2   v1   v1

v2   v2   v1

v2   v2   v2

C’est le comportement souhaité, et c’est ce qui évite la coupure. Mais il implique une conséquence dont découle tout le reste :

À un instant donné, une requête peut être servie par la v1 ou par la v2, et vous ne choisissez pas laquelle.

Une base migrée pour ne convenir qu’à la v2 casse donc les Pods v1 encore vivants. Et une base laissée telle quelle casse les Pods v2 qui arrivent. Il n’y a pas d’instant magique où basculer : il faut une période où les deux fonctionnent.

La règle d’ordre

On ne laisse pas Kubernetes découvrir tout seul que la v2 a besoin d’une colonne ou d’un service qui n’existent pas. On prépare l’environnement pour que les deux versions cohabitent, puis on déploie.

Ce motif a un nom, utile à connaître pour chercher plus loin : expand and contract, ou changement parallèle. On étend d’abord, on rétracte plus tard, et jamais dans le même déploiement.

Le cas concret : une colonne à ajouter

Au départ, la base convient à la v1 :

text
users
├── id
├── name
└── address

La v2 veut écrire dans new_address. La migration ajoute la colonne sans toucher à l’ancienne :

text
users
├── id
├── name
├── address        ← la v1 continue de la lire
└── new_address    ← la v2 l’utilise

La base devient compatible avec les deux versions. C’est la seule situation où le rolling update est sans danger :

text
              BASE
       compatible v1 + v2

        ┌─────┴─────┐
        │           │
     Pod v1      Pod v2

Les trois contraintes sur la migration

C’est ici que les migrations échouent en pratique, et aucune de ces règles n’est évidente.

Additive uniquement. Ajouter une colonne, une table, un index. Jamais supprimer, jamais renommer, jamais changer un type. Une suppression casse la v1 ; un renommage est une suppression déguisée.

Pas de contrainte que la v1 ne peut pas satisfaire. Une colonne NOT NULL sans valeur par défaut fait échouer tous les INSERT de la v1, qui ne connaît pas ce champ. Deux issues : rendre la colonne nullable, ou lui donner une valeur par défaut. Le passage en NOT NULL attendra le nettoyage.

Verrous et durée. Selon le moteur, ajouter une colonne avec valeur par défaut peut réécrire toute la table et la verrouiller. Sur une table de taille sérieuse, la migration devient elle-même l’incident. À vérifier sur le moteur concerné avant de la lancer en production, pas après.

Exécuter la migration : un Job

Un Job est prévu pour exécuter une tâche ponctuelle jusqu’à sa réussite, puis s’arrêter — exactement le cycle de vie d’une migration.

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migration-db-v2
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migration
          image: demo-k8s:2.0
          command: ['python', 'migrate.py']
          envFrom:
            - secretRef:
                name: demo-db
bash
kubectl apply -f migration-v2.yaml
kubectl wait --for=condition=complete job/migration-db-v2 --timeout=300s
kubectl logs job/migration-db-v2

kubectl wait plutôt qu’un kubectl get jobs à l’œil : dans un enchaînement automatisé, il faut un ordre qui bloque jusqu’à la réussite et échoue sur un délai dépassé.

Les identifiants de connexion viennent d’un Secret et non d’un ConfigMap : une migration se connecte à la base avec un mot de passe, qui n’a pas à figurer dans le manifeste.

Trois détails qui comptent :

  • restartPolicy: Never avec backoffLimit : le Job réessaie un nombre borné de fois, puis renonce. Sans plafond, une migration cassée boucle indéfiniment.
  • La migration doit être idempotente. Le Job peut réessayer. ADD COLUMN IF NOT EXISTS, ou une table de versions de schéma comme le font les outils de migration.
  • Le nom porte la version. migration-db-v2, pas migration. Un Job est immuable : réappliquer le même nom avec un contenu différent échoue, et le réappliquer à l’identique ne relance rien.
Pourquoi pas un initContainer plutôt qu’un Job ?

Parce qu’un initContainer s’exécute une fois par Pod. Avec trois réplicas, ce sont trois migrations lancées en parallèle sur la même base — et à chaque redémarrage de Pod, à chaque montée en charge, une de plus.

Un Job s’exécute une fois pour le cluster. C’est la bonne granularité. L’initContainer reste utile pour attendre que la base soit joignable, ce qui est une vérification et non une écriture.

Le rolling update

Une fois la base compatible, la mise à jour de l’application est le geste simple qu’on croyait faire depuis le début :

yaml
containers:
  - name: demo-web
    image: demo-k8s:2.0
bash
kubectl apply -f k8s/deployment.yaml
kubectl rollout status deployment/demo-web
kubectl get pods --watch

Deux réglages gouvernent la façon dont Kubernetes procède :

yaml
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  • maxUnavailable: 0 — jamais moins de Pods prêts que le nombre demandé. La capacité de service ne baisse pas pendant la mise à jour.
  • maxSurge: 1 — un Pod supplémentaire au maximum pendant la transition.

Cette paire donne le déploiement le plus prudent : Kubernetes crée un nouveau Pod, attend qu’il soit prêt, puis retire un ancien. Elle exige un Pod de capacité en plus, et elle est plus lente. Les valeurs par défaut, 25 % de chaque, vont plus vite en acceptant une baisse temporaire de capacité.

Le mot important est prêt. Sans readinessProbe, Kubernetes considère un Pod disponible dès que son conteneur démarre, et le Service lui envoie du trafic pendant son initialisation. Un rolling update sans sonde de disponibilité produit une coupure — silencieuse, brève, et bien réelle.

Et si la v2 a besoin d’un nouveau service ?

La logique est identique, et l’ordre encore plus contre-intuitif.

text
1. Déployer Redis

2. Le tester

3. La v1 fonctionne toujours

4. Rolling update vers la v2

5. Vérifier la v2

6. Retirer l’ancienne dépendance

Ce qu’il ne faut pas faire :

text
✗  Retirer l’ancienne dépendance

   Déployer la v2

Parce que pendant la transition, des Pods v1 tournent encore et utilisent cette dépendance. La retirer avant, c’est casser la version qui sert actuellement les utilisateurs pour préparer celle qui ne sert encore personne.

Le nettoyage, et pourquoi il attend

Quand tous les Pods sont en v2 et que la version est vérifiée, l’ancienne colonne peut partir. Pas avant, et pour une raison précise :

bash
kubectl rollout undo deployment/demo-web

Ce retour en arrière ramène la v1 en quelques secondes. C’est le filet de sécurité du Deployment — et il ne fonctionne que si la base sait encore parler à la v1. Une colonne supprimée dans le même déploiement rend le retour impossible au moment exact où on en a besoin.

Une bonne pratique est donc de laisser passer un délai : quelques jours en production, le temps que les vrais usages révèlent ce que les tests n’ont pas vu. Le nettoyage devient une migration à part entière, avec son propre Job et sa propre version.

Les données écrites pendant la fenêtre

Le point le plus subtil, et celui qui distingue une réponse d’ingénieur.

Pendant la coexistence, la v1 écrit dans address et la v2 dans new_address. À la fin de la mise à jour, les lignes créées par la v1 ont donc un new_address vide. La v2 doit les traiter — soit en lisant les deux champs, soit après un remplissage rétroactif.

Le schéma complet d’un renommage de colonne, qui est le cas où tout se joue :

  1. Ajouter new_address, nullable.
  2. Écrire dans les deux champs, et lire l’ancien. C’est la v2.
  3. Remplir les lignes anciennes par un Job.
  4. Lire le nouveau champ. C’est la v3.
  5. Arrêter d’écrire dans l’ancien. C’est la v4.
  6. Supprimer l’ancienne colonne.

Six étapes et quatre déploiements pour renommer une colonne. Cela paraît excessif jusqu’à la première fois où l’on essaie de le faire en une seule fois sur une base en service.

La base dans le cluster

Si la base tourne elle-même dans Kubernetes, elle ne se déploie pas comme l’application. Un Deployment traite ses Pods comme interchangeables ; une base a besoin d’une identité réseau stable et d’un stockage persistant qui la suit. C’est le rôle du StatefulSet : des noms ordonnés et prévisibles, un volume attaché par instance, un démarrage et un arrêt dans l’ordre.

Et la question à se poser avant : faut-il vraiment héberger la base dans le cluster ? Une base gérée par le fournisseur d’infrastructure retire de vos épaules les sauvegardes, les bascules et les mises à jour de version. Le StatefulSet est un outil correct, pas une obligation.

Ce qu’il faut retenir

Une phrase, si vous n’en gardez qu’une :

On rend d’abord l’infrastructure et la base compatibles avec la v1 et la v2, ensuite seulement on lance le rolling update, et on ne nettoie l’ancien que lorsque la v1 a complètement disparu.

Le raisonnement qui la produit vaut mieux que la règle : un rolling update crée nécessairement une période de coexistence, donc tout ce que partagent les deux versions doit convenir aux deux. Une fois cette phrase comprise, l’ordre des étapes se déduit tout seul.

Pour le reste du déploiement : le Deployment et ses sondes, le projet complet mis en pratique, et la répartition des rôles entre Docker et Kubernetes.

Ce sujet fait partie d’un cours complet

Développement et déploiement de solutions de données — les premiers modules sont en accès libre.

Voir le plan du cours

Continuer sur le même sujet