GitOps traite bien les parts d’un déploiement qui ont un avant/après propre : le template de pods d’un Deployment change, le contrôleur réconcilie, terminé. Une migration de schéma n’entre pas dans ce moule. Elle a une contrainte d’ordre que le reste du pipeline n’impose pas naturellement (la migration doit tourner avant le code qui l’attend), et elle n’est souvent pas réversible de la façon dont un rollback de code l’est. La fiche GitOps du site désigne d’ailleurs elle-même ce point comme un des vrais angles morts du modèle : GitOps décrit bien un état cible, mal un processus avec un avant et un après, et une migration de schéma est exactement ce genre de processus.

L’ordre : la migration tourne avant le rollout, pas pendant

Le problème mécanique se résume simplement : le code applicatif qui interroge une nouvelle colonne ne doit pas commencer à tourner avant que cette colonne existe. Deux façons courantes d’imposer cet ordre dans un pipeline.

Un job de CI dédié, conditionné pour tourner avant l’étape de déploiement :

# GitHub Actions, simplifié
jobs:
  migrate:
    steps:
      - run: migrate -database "$DATABASE_URL" -path db/migrations up
  deploy:
    needs: migrate   # le déploiement ne démarre pas tant que migrate n'a pas réussi
    steps:
      - run: kubectl set image deployment/app app=registry/app:${{ github.sha }}

Ou, côté Kubernetes, un Job exécuté comme hook pre-install/pre-upgrade de Helm, sur lequel le cycle de vie du chart lui-même bloque :

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "-1"
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: migrate/migrate
          args: ["-path", "/migrations", "-database", "$(DATABASE_URL)", "up"]
      restartPolicy: Never

Les deux formes donnent la même garantie : le rollout n’avance pas tant que le job de migration tourne encore ou a échoué. Ce qu’elles ne donnent pas, seules, c’est la sécurité pour la fenêtre où ancien et nouveau code tournent en même temps contre le nouveau schéma, ce qui est l’état normal pendant tout rolling deployment.

Expand/contract : le pattern qui survit à deux versions en simultané

Pendant un rollout, anciens et nouveaux pods servent du trafic contre la même base de données en même temps, parfois pendant plusieurs minutes. Une migration qui change le sens d’une colonne en une seule étape casse la version qui ne s’y attend pas. Expand/contract découpe le changement en étapes individuellement compatibles avec les deux versions.

Renommer une colonne, fait proprement, ressemble à trois migrations séparées sur trois déploiements séparés, pas une seule :

-- Migration 1 (expand) : ajouter la nouvelle colonne, garder l'ancienne, rétro-remplir
ALTER TABLE users ADD COLUMN email_address text;
UPDATE users SET email_address = email WHERE email_address IS NULL;

-- Déploiement 1 : écrire dans les DEUX colonnes, lire depuis l'ANCIENNE.
-- Les anciens pods (code pré-migration) ne sont pas affectés ; ils ne voient
-- jamais la nouvelle colonne.

-- Déploiement 2 : écrire dans les deux colonnes encore, lire depuis la NOUVELLE.
-- Ne part qu'une fois que le déploiement 1 tourne depuis assez longtemps pour
-- que chaque ligne écrite depuis ait les deux colonnes renseignées.

-- Migration 2 (contract) : supprimer l'ancienne colonne, une fois que plus rien ne la lit
ALTER TABLE users DROP COLUMN email;

Chaque étape prise seule est neutre pour la version de code qui ne la connaît pas encore. La discipline que ça demande, c’est de la patience : le contract n’arrive que quand on est sûr qu’aucune version en cours ne dépend plus de l’ancienne forme, ce qui suppose en pratique au moins un cycle de déploiement complet après l’expand, parfois davantage si le rollback de la version précédente reste une option ouverte.

Le mythe du rollback

Un rollback de code coûte peu : on revert le commit, on redéploie l’ancienne image, le binaire précédent tourne contre ce qu’il a toujours attendu. Un rollback de migration n’est pas la même opération sous un autre nom, et c’est en le traitant comme tel qu’une équipe se fait mal.

Une migration additive (ajouter une colonne, ajouter une table) revient en arrière proprement : on supprime ce qu’on a ajouté, rien d’autre n’a été touché. Une migration destructive, non : DROP COLUMN supprime les données qu’elle contenait, et aucune « migration down » ne recrée des données qui n’existent plus nulle part. ALTER COLUMN price TYPE integer tronque chaque valeur décimale de cette colonne ; la migration down peut restaurer le type, pas les chiffres qu’elle a jetés. La règle honnête, c’est qu’une migration n’est réversible qu’à hauteur de l’information qu’elle détruit, ce qui est précisément pourquoi expand/contract existe : les étapes purement additives restent réversibles aussi longtemps que nécessaire, et la seule étape vraiment destructrice (le contract) est reportée jusqu’à ce que l’option rollback ne soit plus nécessaire du tout.

Ce qu’il faut vérifier avant la prochaine migration

Avant de merger une migration, posez-vous deux questions que le pipeline lui-même ne peut pas poser à votre place. Cette migration reste-t-elle valide si la version précédente de l’application tourne encore contre elle pendant les prochaines minutes ? Et s’il faut l’annuler dans cinq minutes, la migration down restaure-t-elle un comportement, ou seulement une forme de schéma avec les données déjà perdues ? Une migration qui échoue à l’une des deux questions n’est pas prête à partir en une seule étape ; découpez-la. Cette discipline d’ordonnancement fait partie de ce que j’apporte dans une mission d’industrialisation CI/CD : un pipeline qui déploie le code en sécurité mais traite les changements de schéma comme un détail garde un incident en réserve.