Pour démystifier 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 :
- Fermer le service
- Arrêter l’application
- Faire un backup des données, pour pouvoir les restaurer en cas de problème
- Migrer les données
- Déployer la nouvelle version de l’application
- Démarrer la nouvelle version de l’application
- Vérifier que tout fonctionne bien
- 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 |
|
2 |
1 |
Modifier le schéma de BDD pour permettre de stocker les données nécessaires à |
|
3 |
2 |
Déployer une nouvelle version de l’application exposant |
Déployer une nouvelle version de l’application exposant |
4 |
2 |
Migrer les données au modèle |
Migrer les données au modèle |
5 |
3 |
Déployer une nouvelle version de l’application exposant |
|
6 |
3 |
Nettoyer le schéma de BDD des artefacts |
|
7 |
4 |
Déployer une nouvelle version de l’application gérant |
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
etv2
n’expose que les services/v1
, vous faites ainsi l’économie du cas où un appel de service/v2
accède à des donnéesv1
; - 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 ralentit 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 |
Répondre comme auparavant |
(Seulement si migration après ouverture de |
Données stockées au format |
Compatibilité |
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 :
- Version initiale : l’adresse est dans la colonne
address
de la tableperson
, le code ne sait fonctionner que de cette manière. - Ajout de la nouvelle table
address
dans la base de données, à cette étape le code ne connaît pas encore cette table. - Déploiement du code qui fournit l’api
/v1
et qui est compatible avec les deux manières de stocker l’adresse. - Exécution du script de migration.
- 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 colonneaddress
de la tableperson
n’est plus utilisée par le code. - Suppression de la colonne
address
de la tableperson
.
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 |
---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Résultat : la personne se retrouve avec deux adresses !
Exemple avec verrou :
Fil d’exécution 1 | Fil d’exécution 2 |
---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
deperson
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 dansaddress
. 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’unINSERT … SELECT …`
. - Il vide le champs
address
de ces entrées dans la tableperson
. - 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
- Version initiale fournissant l’API
/v1
et où l’adresse est stockée dans la colonneaddress
de la tableperson
. - 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. - Fournit l’API
/v1
, stocke l’adresse dans la tableaddress
et sait la lire aux deux endroits. Lors d’une lecture en/v1
sur une donnéev1
la donnée n’est pas migrée env2
pour garder le code plus simple. - Migration des adresses vers la table
address
. - Fournit les API
/v1
et/v2
, et ne sait la lire qu’au formatv2
, suppression de la colonneaddress
de la tableperson
du code, la colonne est alors toujours en base. - Suppression en base de la colonne
address
de la tableperson
. 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 :
- À l’étape 3 le DAO de personne avec les méthodes permettant de poser des verrous pour éviter les incohérences.
- À l’étape 4 le script de migration. Comme il s’agit d’un script et pas d’une requête unique, il est sous forme d’une classe Java appelée depuis Liquibase.
- À l’étape 6 il est possible de supprimer la colonne
address
car PostgreSQL se contente de la rendre invisible, et récupère l’espace plus tard.
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”