Versionning d’API, Zero Downtime Deployment et migration SQL : théorie et cas pratique

Pour démythifier le Zero Downtime Deployment

Dans les patterns associés aux géants du web, le Zero Downtime Deployment (ZDD) partage une caractéristique avec l’auto-scaling : on en parle d’autant plus qu’ils sont peu mis en œuvre.

Le ZDD est victime d’un cercle vicieux : il a l’air très complexe car il est peu pratiqué, et comme il est peu pratiqué, on pense qu’il est très complexe.

Pour sortir de cette impasse, cet article présente un cas pratique, code à l’appui.

L’objectif n’est pas que tout le monde fasse du ZDD, car on verra qu’il ajoute du travail et de la complexité, mais que vous vous en ayez une vision claire. Cela vous permettra de pouvoir décider d’en faire ou pas en connaissance de cause.

Notre cas d’exemple

Notre exemple s’approche de celui décrit dans le premier article.

Soit une application exposée via une API REST.

Au départ cette application gère des personnes, chaque personne ayant zéro ou une adresse. Cette version est exposée sur le préfixe /v1 à l’aide d’un unique type de ressource /person.

La version suivante de l’application permettra d’associer plusieurs adresses à une personne, et sera exposée sur le préfixe /v2 en utilisant deux ressources, /person et /address.

Les deux versions

Cette application met en avant sa haute disponibilité, il est donc primordial que toutes les opérations soient effectuées sans interruption de service.

L’API est publique, nous ne maîtrisons donc pas l’utilisation qui en est faite. Il est donc impossible de faire une bascule de /v1 à /v2 en une seule fois. Les deux versions devront donc fonctionner ensemble le temps de permettre la migration de tous les utilisateurs. Cette période pouvant être très longue suivant les cas.

Les clients consomment les API via des intermédiaires, il est donc possible que pendant cette période ils utilisent à la fois les versions /v1 et /v2.

Dans la suite de l’article, /v1 et /v2 correspondent aux versions des services, et v1 et v2 correspondent aux deux manières dont les données seront stockées en base.

La stratégie

Comment gérer la migration ?

Il est possible de se passer complètement de script de migration au sens traditionnel : vous exposez les services en /v2, et, lorsqu’ils sont appelés pour une donnée encore au format /v1, vous migrez la donnée à la volée. Cela permet de migrer les données petit à petit au fur et à mesure que les utilisateurs des services appelleront les APIs /v2. C’est l’approche qui est souvent prise avec les bases de données NoSQL.

Malheureusement, en procédant ainsi, il est possible que la migration ne se termine jamais, ou alors seulement dans très longtemps (si vous purgez les données trop anciennes). Pendant ce temps, vous devez maintenir le code supplémentaire permettant de prendre en charge ce cas.

L’autre approche est d’utiliser un script. Cela permet de faire en sorte que la migration se fasse rapidement. C’est le même type de script que vous utilisez pour vos migrations habituelles, sauf qu’il doit prendre en compte le fait qu’il s’exécute en même temps que le code. Ainsi toutes les opérations qui créent des verrous pendant plus de quelques millisecondes ne doivent pas être utilisées, et il faut s’assurer de ne pas créer d’interblocage.

La migration doit se faire de manière transactionnelle, pour éviter d’introduire des incohérences dans les données. En cas de problème, le script doit également pouvoir être interrompu et relancé sans que cela ne perturbe l’exécution du programme.

Dans l’article c’est cette approche que nous allons utiliser.

Quand migrer ?

Sans ZDD, le process de migration est classiquement le suivant :

  1. Fermer le service
  2. Arrêter l’application
  3. Faire un backup des données, pour pouvoir les restaurer en cas de problème
  4. Migrer les données
  5. Déployer la nouvelle version de l’application
  6. Démarrer la nouvelle version de l’application
  7. Vérifier que tout fonctionne bien
  8. Ouvrir le service

En ZDD, plus question de fermer le service : la migration de données a lieu « à chaud ».

Il y a deux manières possibles de s’y prendre, suivant que la migration a lieu avant ou après l’ouverture des services /v2 :

Les deux manières de migrer

Numéro de l’étape Version du code Migration après l’ouverture de /v2 Migration avant l’ouverture de /v2

1

1

Application servant /v1 avec un modèle de de donnée v1

2

1

Modifier le schéma de BDD pour permettre de stocker les données nécessaires à /v2

3

2

Déployer une nouvelle version de l’application exposant /v1 et /v2 sur les modèles de donnée v1 ou v2

Déployer une nouvelle version de l’application exposant /v1 sur les modèles de donnée v1 ou v2

4

2

Migrer les données au modèle v2 sans interruption de service, en tenant compte du code /v1 et /v2

Migrer les données au modèle v2 sans interruption de service, en tenant compte du code qui sert /v1

5

3

Déployer une nouvelle version de l’application exposant /v1 et /v2 sur le modèle de donnée v2

6

3

Nettoyer le schéma de BDD des artefacts v1

7

4

Déployer une nouvelle version de l’application gérant /v2 sur le modèle de donnée v2

La première approche permet d’ouvrir les services /v2 plus rapidement car il n’y a pas besoin d’attendre la migration des données.

La seconde approche est plus simple :

  • la version exposant de l’application fonctionnant avec les modèles de données v1 et v2 n’expose que les services /v1, vous faites ainsi l’économie du cas où un appel de service /v2 accède à des données v1 ;
  • pendant la migration de données, les services /v2 ne sont pas encore exposés, cela veut dire moins de patterns d’accès aux données à prendre en compte pour désigner une migration évitant les incohérences de données et les interblocages.

Sauf si votre process de migration est extrêmement long, la seconde approche est à privilégier, et c’est celle qui sera utilisée dans la suite de l’article.

/v1 et /v2 sont dans un bateau …

Les migrations d’APIs ouvertes posent deux problèmes métier et un problème technique.

Comment migrer les données ?

Le premier problème, valable aussi pour les API fermées, est de savoir comment migrer les données de /v1 à /v2. Je ne parle pas d’un point de vue technique mais bien d’un point de vue métier : la sémantique change entre les deux versions, il faut donc déterminer comment transformer les données de /v1 en /v2 d’une manière qui soit logique et qui ne surprenne pas les utilisateur·rice·s de l’API.

Dans notre cas la solution est immédiate : /v1 a au plus une seule adresse, et /v2 peut en avoir plusieurs, l’adresse de /v1 devient donc une des adresses de /v2.

Comment gérer la rétro-compatibilité ?

L’autre problème est de savoir comment interpréter en /v1 des données /v2. En effet si l’API est ouverte, vos utilisateur·rice·s peuvent appeler vos services /v1 alors que les données sont déjà au modèle /v2.

Il est souvent plus compliqué que le premier car au fur et à mesure des évolutions, les API ont tendance à devenir plus riches. Accéder à des données plus riches de la /v2 au travers du prisme plus étroit de l’API /v1 peut être un vrai casse-tête.

Si c’est le seul moyen que cette transition se passe bien, il est parfois nécessaire d’adapter le design de l’API /v2.

C’est un équilibre à trouver entre la facilité de transition, des restrictions possibles à ajouter pour les appelants de l’API, et le temps à investir.

Comment répondre vite et bien ?

Le problème technique est de parvenir à rendre les différents services, y compris la compatibilité, tout en s’assurant de toujours avoir des données cohérentes et sans (trop) pénaliser les performances. Si, entre les deux versions, les données ne sont plus structurées de la même manière, la gestion de la compatibilité peut demander de croiser les données de plusieurs tables.

Ainsi dans notre exemple, en v1 les adresses sont stockées dans la table person alors qu’en v2 elles sont dans une table address séparée. Pendant la période de compatibilité, il faut que les appels à v1 qui mettent à jour le nom de la personne et son adresse modifient les deux tables de manière transactionnelle pour éviter qu’une lecture v1 qui se produirait au même moment ne renvoie des données incohérentes. De plus, il faut parvenir à le faire sans avoir à poser trop de verrous en base de données, car cela raletit les accès.

La meilleure stratégie est de privilégier une approche que vous maîtrisez bien et qui donne des résultats acceptables plutôt qu’une solution plus efficace ou plus rapide mais plus complexe.

Dans tous les cas, des tests sont absolument essentiels.

Pour servir les deux versions de l’API, vous pouvez utiliser une application unique ou choisir de séparer votre code en deux applications, une par version de services. Cette question n’étant pas structurante pour la question du ZDD, nous choisissons de ne pas la traiter ici. Dans notre exemple, nous avons choisi de n’avoir qu’une seule application.

… et ZDD les rejoint à bord

Sans ZDD la situation est claire : on arrête l’application, les données sont migrées, et on redémarre l’application dans la nouvelle version. Il y a donc un avant et un après.

Avec ZDD la migration s’effectue à chaud pendant que les services sont disponibles, s’ajoute une situation intermédiaire.

Pendant cette période, les données peuvent donc être encore stockées au format /v1 ou migrées au format /v2.

Il faut alors parvenir à déterminer dans quel état sont les données : pour savoir quel code doit être appelé il faut savoir si la donnée a été migrée ou pas. De plus, le morceau de code en charge de cela va être exécuté très souvent, il doit donc être très efficace.

En cas de difficulté, la solution qui devrait fonctionner dans tous les cas est d’ajouter dans les tables impliquées un numéro indiquant la « version de schéma » de la donnée correspondante, et qui sera incrémenté lors de la migration de la donnée. Dans ce cas l’opération de vérification est très simple et rapide. L’opération d’ajout de colonne est alors à faire en avance de phase, ce qui augmente le travail nécessaire à la migration.

Si vous choisissez de faire la migration de données après l’ouverture de /v2, s’ajoute le cas où on appelle une api /v2 alors que la donnée est encore stockée au format v1. Il faut alors migrer la donnée à chaud, de manière transactionnelle en limitant les ralentissements induits.

Pour résumer, il y a quatre situations :

Appel /v1 Appel /v2

Données stockées au format v1

Répondre comme auparavant

(Seulement si migration après ouverture de /v2) Migrer les données à chaud

Données stockées au format v2

Compatibilité v1

Répondre avec la nouvelle sémantique

Bien ouvrir /v2, et bien décomissionner /v1

Lorsque vous ouvrez /v2 pour la première fois, faites-attention à la manière dont la bascule vers la nouvelle version est faite.

Avant de rendre les nouveaux endpoints accessibles, assurez-vous que tous les serveurs utilisent la dernière version de l’application. Dans le cas contraire, si vous appelez un /v1 alors que la donnée correspondante a été migrée en v2 le code ne saura pas la lire correctement et risque de planter ou de renvoyer une information fausse.

Un autre problème se pose suivant la manière dont vous avez implémenté les modifications de donnée lorsque vous appelez une API /v1.

Le premier cas consiste à sauvegarder la donnée au format v2, mais cela veut dire qu’à nouveau, les versions précédentes de l’application ne pourront pas la lire. La solution la plus simple est alors d’utiliser le feature flipping pour faire basculer le code.

Dans le cas contraire, votre code doit détecter sous quel format la donnée est stockée, et la resauvegarder sous ce même format : une donnée v1 reste en v1, et une donnée v2 reste en v2.
On évite le feature flipping, mais en échange le code est plus complexe.

Pour décomissionner /v1 il suffit de rendre les endpoints inaccessibles, la suppression du code peut se faire plus tard.

À propos des verrous et des modifications de schémas

Comme on vient de le voir, le ZDD s’appuie beaucoup sur l’utilisation de la base de données, et notamment ses fonctionnalités d’accès concurrent. Si vos comportements métiers sont simples, que vous utilisez un ORM, et que vous avez des tests de performances automatisés, il s’agit d’un domaine auquel vous n’avez pas souvent à vous intéresser. Si vous vous y prenez mal, il est facile de bloquer la base, renvoyer des erreurs (en cas d’interblocage), ou des résultats incohérents.

Notre conseil est de bien vous documenter en amont voire de faire des POC pour éviter d’avoir à refaire un design parce que votre base de données ne fonctionne pas comme vous l’imaginiez. Ne faites pas confiance à des souvenirs ou à des rumeurs : lisez en détail la documentation correspondant à la version de l’outil que vous utilisez, et surtout testez !

Si vous n’avez jamais creusé ces sujets ou que vous êtes rouillé·e, la première migration vous demandera sûrement pas mal de travail, et vous donnera quelques sueurs froides lorsque vous l’exécuterez. Mais dites-vous que toutes les opérations suivantes manipuleront les mêmes concepts, et se passeront donc beaucoup mieux.

Il n’y a pas que le REST dans la vie

REST possède deux caractéristiques qui en font un candidat idéal pour le ZDD :

  • exposer plusieurs versions de services est une pratique standard ;
  • les appels sont supposés être sans état.

Si vos services sont exposés d’une autre manière, il faudra donc vous intéresser à ces sujets. Les sessions, comme tous les types de cache, peuvent demander une attention particulière si les données qu’elles contiennent font l’objet d’un changement de structure entre versions.

Retour à notre exemple

Nous prenons l’hypothèse où le modèle de données suit directement les ressources à exposer. L’adresse est initialement un champ de la table person, et est migrée dans une table address distincte.

L’évolution du schéma

Nous n’utilisons pas de colonne spécifique pour stocker la « version de schéma » des objet. À la place nous allons vérifier en base la manière dont les données sont stockées : si la table person contient une adresse, c’est qu’elle est en version v1, sinon il faut vérifier l’existence d’une adresse dans la table dédiée. Cela évite d’alourdir le schéma SQL, mais augmente le nombre de requêtes exécutées.

Les étapes à suivre pour la migration :

  1. Version initiale : l’adresse est dans la colonne address de la table person, le code ne sait fonctionner que de cette manière.
  2. Ajout de la nouvelle table address dans la base de données, à cette étape le code ne connaît pas encore cette table.
  3. Déploiement du code qui fournit l’api /v1 et qui est compatible avec les deux manières de stocker l’adresse.
  4. Exécution du script de migration.
  5. Déploiement du code qui fournit les api /v1 et /v2 et qui est compatible avec la nouvelle manière de stocker l’adresse, la colonne address de la table person n’est plus utilisée par le code.
  6. Suppression de la colonne address de la table person.

Le ZDD a pour conséquence d’ajouter des versions de code et des migrations de schémas intermédiaires. Dans un environnement où les déploiements ne sont pas automatisés, cela signifie une augmentation de la charge de travail et donc du risque d’erreur. Mieux vaut donc s’outiller et disposer d’un pipeline de livraison fiable avant de se lancer.

Analyse détaillée

La compatibilité des services

Dans notre exemple le problème de compatibilité est le suivant : une fois une personne migrée, elle peut avoir plusieurs adresses. Que faire quand on récupère cette même personne en passant par l’API /v1 ?

Ici il n’y a pas de réponse évidente : il n’y a pas de notion d’adresse préférée, ou de dernière adresse utilisée qui fournirait une manière de discriminer les différentes possibilités. Comme la réponse influe sur le comportement de l’API, c’est une décision à prendre par les personnes du métier.

La solution choisie ici est de renvoyer une adresse parmi celle dans la liste. Elle n’est pas parfaite, mais elle peut être acceptable suivant l’usage qui en est fait : il revient aux personnes du métier d’en décider.

La transactionnalité

Pour résoudre la question de transactionnalité, nous avons choisi la solution la plus simple : poser un verrou sur les entrées correspondantes de la table person.

Si toutes les opérations suivent le même principe, ce verrou joue le rôle d’une mutex en s’assurant que les appels s’exécutent bien l’un après l’autre : lorsqu’une opération pose un risque, elle commence par demander l’accès à ce verrou, et pour cela elle doit attendre son tour.

Exemple avec un appel à PUT /v1/people/127 alors que la personne correspondante est stockée au format v2 mais n’a pas encore d’adresse.

Exemple sans verrou :

Fil d’exécution 1 Fil d’exécution 2

PUT /v1/people/127/addresses

PUT /v1/people/127/addresses

BEGIN

BEGIN

SELECT * from person where id = 127 pour récupérer la personne, vérifie qu’il n’y a pas d’adresse et que les autres champs ne sont pas à modifier

SELECT * from person where id = 127 pour récupérer la personne, vérifie qu’il n’y a pas d’adresse et que les autres champs ne sont pas à modifier

SELECT * from address where id_person = 127 pour récupérer une adresse à mettre à jour, n’en trouve pas et déduit donc qu’il faut en insérer une

SELECT * from address where id_person = 127 pour récupérer une adresse à mettre à jour, n’en trouve pas et déduit donc qu’il faut en insérer une

INSERT INTO address … pour insérer l’adresse

INSERT INTO address … pour insérer l’adresse

commit

commit

Résultat : la personne se retrouve avec deux adresses !

Exemple avec verrou :

Fil d’exécution 1 Fil d’exécution 2

PUT /v1/people/127/addresses

PUT /v1/people/127/addresses

BEGIN

BEGIN

SELECT address from person where id = 127 FOR UPDATE pour récupérer la personne, vérifie qu’il n’y a pas d’adresse et que les autres champs ne sont pas à modifier et verrouille la ligne

SELECT * from address where id_person = 127 pour récupérer une adresse à mettre à jour, n’en trouve pas et déduit donc qu’il faut en insérer une

INSERT INTO address … pour insérer l’adresse

commit qui relache le verrou sur person

SELECT address from person where id = 127 FOR UPDATE pour récupérer la personne, vérifie qu’il n’y a pas d’adresse et que les autres champs ne sont pas à modifier et verrouille la ligne, attendait que le verrou sur person soit disponible

SELECT id, address FROM address WHERE id_person = 127 récupère l’adresse

SELECT * from address where id_person = 127 pour récupérer une adresse à mettre à jour, trouve l’adresse insérée par l’autre fil d’exécution

UPDATE address set address = … where id = 4758 met à jour l’adresse

commit qui relâche le verrou sur person

Résultat : une seule adresse.

Le script de migration SQL

Le script de migration déplace les données par blocs de person à address.

Dans notre exemple, une fois le code basculé à la nouvelle version, toutes les données sont écrites au format v2, qu’il s’agisse des créations ou des modifications.

La migration est donc irréversible, nous savons qu’il suffit de migrer toutes les données une fois pour que le travail soit fait.

  • Il commence par récupérer l’ id de person le plus élevé. Comme le script est lancé après le déploiement de la nouvelle version, toutes les personnes créées après ce moment le sont avec une adresse stockée dans address. Cela signifie que le script peut s’arrêter à cette valeur.
  • Le script itère par groupes de person de 0 à l’ id qu’il vient de récupérer. Le pas de l’itération est à déterminer expérimentalement : un pas plus grand permet de faire moins de requêtes donc de diminuer le temps total de la migration, au détriment du temps unitaire de chaque itération, et donc du temps où les verrous existent en base.
    • Il démarre une transaction.
    • Il sélectionne les id des personnes qui ont une adresse, et les verrouille.
    • Il insère dans address les données correspondantes à l’aide d’un INSERT … SELECT …`.
    • Il vide le champs address de ces entrées dans la table person.
    • Il valide la transaction, relâchant ainsi les données.

En cas d’arrêt du script, les données déjà migrées ne sont pas perdues, et relancer le script ne pose pas de problèmes, les données migrées n’étant pas retraitées.

Les étapes à suivre

  1. Version initiale fournissant l’API /v1 et où l’adresse est stockée dans la colonne address de la table person.
  2. Ajout en base de la table address, non encore utilisée par le code. La création d’une table n’a en principe aucun impact sur la base mais il faut le vérifier.
  3. Fournit l’API /v1, stocke l’adresse dans la table address et sait la lire aux deux endroits. Lors d’une lecture en /v1 sur une donnée v1 la donnée n’est pas migrée en v2 pour garder le code plus simple.
  4. Migration des adresses vers la table address.
  5. Fournit les API /v1 et /v2, et ne sait la lire qu’au format v2, suppression de la colonne address de la table person du code, la colonne est alors toujours en base.
  6. Suppression en base de la colonne address de la table person. Dans certaines bases de données, supprimer une colonne déclenche la réécriture de toute la table et ne peut donc se faire en ZDD. On se contente donc d’une suppression logique, par exemple en ajoutant un underscore devant son nom, et en la « recyclant » lorsqu’on a besoin d’une nouvelle colonne.

L’implémentation

L’implémentation se trouve sur GitHub. Le code est en open source sous licence MIT, vous pouvez donc vous en servir.

Chaque étape de la migration est dans un module à part, cela permet de facilement examiner ce qui se passe sans avoir à manipuler git.

Le code est en Java et utilise la bibliothèque Dropwizard. La base de donnée est PostgreSQL, l’accès se fait via Hibernate, et les migrations SQL utilisent Liquibase.

Quelques éléments saillants :

Pour conclure

Faire du ZDD n’est pas magique : cela demande du travail et de la rigueur. Si vous pouvez faire sans, tant mieux pour vous, mais si vous en avez besoin, vous devriez maintenant avoir une idée un peu plus précise de ce que ça représente. Rappelez-vous que l’exemple développé ici est un cas simple : servez-vous en pour avoir une idée de la démarche à suivre, et pas comme un guide pour mesurer l’effort à fournir.

La première migration sera sûrement un peu un défi, mais les suivantes seront de plus en plus faciles. Dans tous les cas, n’oubliez pas de tester, tester, et encore tester !

Un commentaire sur “Versionning d’API, Zero Downtime Deployment et migration SQL : théorie et cas pratique”

  • Dans le schéma présentant les étapes de migration, 2eme colonne, les versions stockées ne sont qu'au format V1 et non au format V1 & V2
    1. Laisser un commentaire

      Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *


      Ce formulaire est protégé par Google Recaptcha