Skip to content
Les déploiements sans interruption ne sont pas une fonctionnalité de la plateforme. C'est une discipline de migration de schéma.
← ← Retour aux Réflexions Cloud

Les déploiements sans interruption ne sont pas une fonctionnalité de la plateforme. C'est une discipline de migration de schéma.

Toute plateforme de conteneurs vous dira qu'elle fait des déploiements sans interruption. App Runner, Fargate, Cloud Run, Kubernetes : toutes démarrent de nouvelles instances, attendent qu'elles soient en bonne santé, basculent le trafic et arrêtent les anciennes. L'application n'est jamais indisponible. Cette partie est vraie, et c'est aussi la partie facile.

La partie difficile, c'est la base de données, parce que la base de données ne bascule pas progressivement. Au moment où le nouveau code commence à servir des requêtes, l'ancien code en sert encore aussi, et les deux parlent au même schéma. Si le nouveau code a besoin d'une colonne que l'ancien ignore, ou si l'ancien code a besoin d'une colonne que le nouveau vient de supprimer, quelqu'un reçoit une erreur, et le déploiement progressif de la plateforme a livré exactement zéro interruption à un système qui est néanmoins cassé.

La discipline n'est donc pas dans la plateforme. Elle est dans la façon dont vous changez le schéma. Voici l'ensemble de règles que nous suivons, le pattern qui les applique, et la Lambda qui les exécute.

La seule règle dont tout découle

Chaque migration doit être compatible avec le code qui tourne actuellement, et chaque changement de code doit être compatible avec le schéma actuellement déployé. Parce qu'un déploiement progressif signifie que la version précédente et la suivante tournent en même temps, pendant des minutes, sur un seul schéma, le schéma doit fonctionner pour les deux. C'est tout. Toute autre règle est celle-ci appliquée à un cas précis.

La conséquence à laquelle les gens résistent : un changement qui, dans une base de développement, est une étape, « renommer la colonne phone en phone_number », est en production trois ou quatre déploiements, et le schéma reste dans un état intermédiaire pendant des jours. Ce n'est pas le signe que vous faites mal. C'est ce que coûte la règle, et c'est bien moins cher que l'alternative.

Étendre, migrer, contracter

Le pattern est ancien et c'est toujours toute la réponse. Chaque changement incompatible est découpé en une étape d'extension qui ne fait qu'ajouter, une migration de données ou de code qui utilise les deux formes, et une étape de contraction qui ne fait que retirer, avec un déploiement entre chacune.

Renommer phone → phone_number, avec ancien et nouveau code chevauchés à chaque bascule 1 · étendreADD COLUMN phone_number NULLl'ancien code l'ignore · sûr sous N-1 2 · double écritureécrire les deux · lire nouveau ?? ancienle job de remplissage copie les lignes par lots 3 · basculerlire et écrire phone_number seulementancienne colonne présente, inutilisée 4 · contracterDROP COLUMN phoneplus rien ne la lit code en cours pendant chaque bascule : v1 + v2 · les deux OK avec une colonne nullable en plus v2 + v3 · les deux écrivent, les deux lisent avec repli v3 + v4 · ancienne colonne inutilisée par les deux v4 + v5 · la suppression est invisible pour les deux Quatre déploiements au lieu d'un. Chacun peut être annulé en déplaçant le tag d'image, parce que le schéma fonctionne pour la version d'avant. Le schéma intermédiaire vit des jours. C'est le prix, et c'est le seul prix.

Quelques cas, explicités, parce que le pattern est facile à approuver de la tête et facile à rater dans le détail :

  • Ajouter une colonne : nullable ou avec une valeur par défaut, en une étape. Jamais NOT NULL sans défaut, parce que l'ancien code ne la renseigne pas.
  • Ajouter un index : CREATE INDEX CONCURRENTLY en Postgres, hors transaction. Un CREATE INDEX ordinaire verrouille la table en écriture tant qu'il dure, ce qui, sur une grande table, est précisément l'interruption que le déploiement progressif devait éviter.
  • Renommer quoi que ce soit : les quatre étapes ci-dessus. Il n'y a pas de raccourci.
  • Changer un type : ajouter une nouvelle colonne du nouveau type, double écriture, remplissage, bascule, suppression. Les mêmes quatre étapes.
  • Supprimer une colonne : seulement après qu'une version qui ne la référence plus a été livrée, et après avoir confirmé en production que rien ne le fait. Les ORM qui font SELECT * vous surprendront ici.
  • Ajouter une contrainte : l'ajouter d'abord NOT VALID, ce qui ne l'impose qu'aux nouvelles lignes, puis VALIDATE CONSTRAINT dans une étape ultérieure une fois que le remplissage a rendu les lignes existantes conformes.

Les migrations ne sont pas des remplissages

Une migration change la structure. Elle s'exécute en secondes. Un remplissage change les données. Il peut tourner des heures. Mettre le second dans la première, c'est comme ça qu'on obtient un déploiement qui tient un verrou sur la table des commandes pendant quarante minutes pendant que chaque requête attend.

Nos migrations sont du DDL pur, plus au maximum un changement de données garanti de toucher un nombre borné de lignes (une table de référence, une ligne de configuration). Tout ce qui touche « toutes les lignes d'une grande table » est un remplissage, et un remplissage est un job : une Lambda ou un worker qui traite un lot, enregistre son point de reprise, et est réinvoqué, avec une concurrence de un. Il est idempotent, il peut être arrêté et repris, et il tourne entre le déploiement 2 et le déploiement 3 aussi longtemps que nécessaire, pendant que le code à double écriture garde les nouvelles lignes correctes.

-- migration 0042, s'exécute en secondes
ALTER TABLE customers ADD COLUMN phone_number text;

-- remplissage, tourne comme job par lots de 5 000 jusqu'à rapporter zéro ligne
UPDATE customers
   SET phone_number = phone
 WHERE id IN (SELECT id FROM customers WHERE phone_number IS NULL AND phone IS NOT NULL ORDER BY id LIMIT 5000);

Où tourne la migration : une Lambda dans le déploiement

La migration doit s'exécuter sur la base de production, depuis quelque chose qui peut l'atteindre (elle est dans un sous-réseau privé), avec des identifiants qui peuvent modifier le schéma (le rôle de l'application ne le peut pas, exprès), avant que le nouveau code ne démarre mais après que la nouvelle image existe. C'est un ensemble d'exigences très précis et il correspond à exactement une chose dans notre pile : une Lambda dans le VPC, avec son propre rôle de base de données, invoquée par le script de déploiement entre « images construites » et « bascule du service ».

construire les imagesle nouveau code existe, ne tourne pas Lambda schema-migrate · dans le VPCpg_advisory_lock · appliquer en ordreenregistrer dans schema_migrations · unlockrôle DB propre avec DDL · délai de 5 minutes ok basculeancien et nouveau code se chevauchent, le schéma convient aux deux vérifiersanté · SHA de release en cas d'échec : arrêt ici · aucun nouveau code n'a servi de requête · corriger en avant

La Lambda fait environ quatre-vingts lignes. Elle ouvre une connexion avec le rôle de migration, prend pg_advisory_lock(42) pour que deux déploiements ne puissent pas se faire concurrence, lit la table schema_migrations, applique chaque fichier en attente dans l'ordre dans sa propre transaction (sauf ceux CONCURRENTLY, qui ne peuvent pas être dans une transaction et sont marqués comme tels), enregistre chacun et libère le verrou. Elle est invoquée de façon synchrone par le script de déploiement, qui ne passe pas à la bascule à moins que l'invocation ne renvoie un succès. Un délai de cinq minutes est l'application de « les migrations ne sont pas des remplissages » : si ça prend plus longtemps, c'était un remplissage, et le déploiement échoue avant de blesser qui que ce soit.

La même Lambda, avec le même code, tourne sur la base de chaque pull request à la création du stack et sur staging à chaque merge sur main. Quand une migration atteint la production, elle a déjà tourné une douzaine de fois.

Le retour arrière, et pourquoi nous n'écrivons pas de migrations « down »

Un retour arrière de code est un changement de tag : pointer le service vers l'image précédente, quatre minutes, terminé. Un retour arrière de schéma n'est pas un changement de tag, et prétendre que c'en est un en écrivant un down() pour chaque migration produit un faux sentiment de sécurité, parce que DROP COLUMN dans une migration down détruit les données arrivées depuis que la migration up a tourné.

Donc nous ne les écrivons pas. La sécurité vient de la discipline à la place : parce que chaque migration est compatible avec la version précédente, revenir en arrière sur le code est toujours sûr, et revenir en arrière sur le schéma n'est jamais nécessaire. Si une migration elle-même est fausse, le correctif est une nouvelle migration qui va de l'avant. En dix-huit mois, nous avons voulu une migration down exactement zéro fois, et nous avons annulé du code six fois, à chaque fois sans incident.

La checklist que nous avons mise dans le modèle de pull request

  • Cette migration fonctionne-t-elle si le code actuellement déployé continue de tourner dessus ? Sinon, découpez-la.
  • Le code de cette PR fonctionne-t-il sur le schéma actuellement déployé ? Sinon, la migration part d'abord, dans sa propre PR.
  • Un NOT NULL sans défaut, un renommage, un changement de type, une suppression ? Alors c'est une séquence étendre/contracter : quelle étape est-ce ?
  • Un index sur une table de plus d'un million de lignes ? CONCURRENTLY, hors transaction.
  • Une instruction qui touche plus de lignes que vous ne pouvez compter ? C'est un remplissage ; déplacez-le dans un job.
  • Cela tournera-t-il en moins d'une minute en production ? En cas de doute, chronométrez-le sur un snapshot de staging.

Six questions. Elles ajoutent environ dix minutes à une PR de schéma et elles ont supprimé la catégorie d'incident où la plateforme a fait son travail parfaitement et où les utilisateurs ont quand même vu des erreurs.

Si vos déploiements sont sans interruption jusqu'à ce qu'ils touchent la base de données, nous vous aiderons à établir la discipline. C'est surtout la checklist ci-dessus et une Lambda.