Cet article explore les problèmes et les solutions liés à la compatibilité des éléments d’une application distribuée : API web, schéma de base de données, librairie, etc.

L’enjeu de la compatibilité

De nos jours, il est rare qu’une application ne soit pas distribuée, ou au moins “morcelée” : on dépend presque toujours d’une base de données ou d’un service externe ; et même lorsque ce n’est pas le cas, il est fréquent de découper son code en librairies qui peuvent avoir leur propre cycle de vie.

L’illusion de l’instantanéité

Un des bénéfices d’un système distribué est qu’on peut déployer un élément indépendamment du reste. Mais ce n’est pas uniquement un bénéfice, c’est aussi une contrainte : à moins d’être prêt à éteindre complètement sa plateforme pour chaque déploiement, on doit être capable de déployer chaque élément en isolation.

En effet, même dans un contexte où tous les éléments d’une application sont déployés “en même temps”, en réalité rien n’est jamais instantané. Dans le contexte d’un site web par exemple, lorsqu’on déploie une nouvelle version du code, les pages web déjà chargées par les navigateurs vont tenter de se connecter aux nouveaux serveurs. Si ces nouveaux serveurs ne sont pas compatibles avec les anciens clients, le comportement de l’application pendant un déploiement sera dégradé.

Lorsqu’on veut faire des déploiements fréquents et transparents, il faut donc prendre en compte les contraintes de compatibilité.

L’illusion de la linéarité temporelle

Notre expérience du réel ne nous habitue pas à raisonner dans un monde où le retour dans le temps est possible. 😄

Pourtant, la possibilité de revenir à une version du code antérieure suite à un déploiement problématique est très précieuse. C’est l’ultime filet de sécurité du développeur.

Ce qu’on a souvent tendance à oublier, c’est que cette possibilité de retour en arrière implique que l’ancienne version du code soit compatible avec ce qui est arrivé pendant que la nouvelle version était active !

L’exemple typique est celui des données persistantes : pendant que le nouveau code était déployé, il a pu générer des données dans un nouveau format. Il est très facile d’imaginer que l’ancien code ne soit pas compatible avec ce nouveau format…

Le retour en arrière vers une ancienne version du code devient du coup impossible, et on s’aperçoit trop tard qu’on a saboté son propre filet de sécurité.

Les solutions

Les problèmes de compatibilité qu’on vient d’évoquer ont au moins 2 solutions : on peut versionner les éléments de l’application, ou on peut faire en sorte qu’ils gèrent la compatibilité ascendante.

Versionner son application

Lorsqu’on versionne son application, on crée en pratique une seconde application. Cette seconde application ne remplaçant pas la première, elle se doit d’être testée et maintenue.

C’est une solution qui peut rapidement se transformer en problème, car cela représente un coût conséquent (et pas toujours facile à expliquer).

Il est donc important, lorsqu’on décide de versionner, d’avoir soit :

  • un plan réaliste et rapide de dépréciation de l’ancienne version
  • des ressources suffisantes pour maintenir les différentes versions en parallèle.

Gérer la compatibilité ascendante

La compatibilité ascendante, c’est la promesse que la nouvelle version d’un élément est compatible avec les anciennes versions des autres éléments (et même avec ses propres anciennes versions si on écrit des données persistantes).

Cela peut sembler difficile, mais c’est en réalité plus simple qu’il n’y parait. En ce qui concerne les données, il n’y a que 3 règles à respecter :

  • On ne doit jamais modifier un élément actuellement utilisé (nom, type, signification, etc).
  • On ne doit jamais retirer un élément actuellement utilisé.
  • On peut librement ajouter de nouveaux éléments, à condition de leur donner une valeur par défaut.

Un “élément” dans ce contexte peut être par exemple :

  • Un attribut d’un objet JSON retourné par une API web
  • Une colonne ou une table dans une base de données
  • Un espace de stockage sur disque
  • Un argument d’une fonction
  • etc.

Exemple

Prenons l’exemple typique d’une API web, qui prend en paramètre et/ou retourne un objet, contenant un attribut date. Cette date est actuellement un timestamp UNIX, donc un entier. Un nouveau besoin nous amène à vouloir, désormais, passer cette date au format RFC3339 (une chaine de caractères), plus standard et gérant les fuseaux horaires.

Si on modifie simplement le contenu du champ, on n’est pas rétro-compatible : les anciens clients ne sauront pas lire le nouveau format, les anciennes informations présentes dans la base de données seront invalides, etc. Il faudrait être capable de faire simultanément : une migration des données, un déploiement des clients et un déploiement des serveurs ; ce qui, comme on l’a vu, est en réalité impossible.

Et même si on gère le problème de l’ancien format via du code (on introspecte l’élément pour décider si on le traite comme un timestamp UNIX ou comme une string RFC3339), il reste le problème du rollback : si on écrit ce format quelque part, on peut être certain que l’ancien code ne saura pas le gérer.

La solution rétro-compatible consiste à créer un nouveau champ (par exemple date_iso), dont la valeur par défaut est nulle, tout en conservant l’ancien champ.

La migration en 3 étapes

Si l’objectif était de modifier un élément existant, on ne peut pas s’arrêter là.

Ce type de besoin nécessite généralement une migration en 3 étapes. Ces 3 étapes se traduisent par 3 déploiements, car l’objectif est de pouvoir revenir à la version précédente à chacune des étapes.

Les étapes en question sont les suivantes :

  1. Duplication
  2. Bascule
  3. Nettoyage

1. Duplication

La première étape consiste à dupliquer la donnée ou la fonctionnalité existante. L’objectif est que l’ancien comportement reste parfaitement inchangé, tout en introduisant un nouveau comportement en parallèle. C’est une forme de versioning, mais avec l’objectif que la période pendant laquelle les deux versions existent soit la plus courte possible.

Dans notre exemple, on ajoute le nouveau champ date_iso, et on continue à gérer l’ancien champ date exactement de la même manière. L’information est dupliquée. On commence à “écrire” le nouvel élément, mais on continue à “lire” l’ancien.

Cette modification fait l’objet d’un déploiement, ce qui permet de vérifier que le système est stable malgré cet ajout. Si ce n’est pas le cas, on peut revenir sans risque à l’ancienne version du code.

2. Bascule

Une fois qu’on est sûrs que la présence du nouvel élément ne traumatise pas le système, on va commencer à l’utiliser : on continue à générer les deux attributs, mais on arrête de consommer l’ancien et on commence à consommer le nouveau à la place. On “écrit” toujours les deux éléments, mais cette fois on “lit” le nouveau.

On effectue un déploiement dans ce nouvel état. On peut sereinement revenir à la version de l’étape 1 en cas de problème, car on a continué à gérer les deux éléments pendant la bascule.

On peut également faire cette étape en plusieurs fois si nécessaire : une partie du code peut commencer à exploiter le nouveau champ pendant qu’une autre fonctionne toujours avec l’ancien.

3. Nettoyage

Une fois qu’on est satisfait de la stabilité du système et qu’on est convaincus que l’ancien élément n’est plus utilisé, on peut déployer une version du code qui ne gère plus cet ancien élément.

Si cette version du code échoue (on n’avait pas identifié une partie du code qui dépendait de l’ancien format par exemple), il est facile de revenir en arrière.

Migrations imbriquées

Si la modification souhaitée a un impact sur des données persistantes et qu’on veut toujours garantir la possibilité de revenir en arrière à chaque étape, on devra découper les étapes de duplication et de nettoyage :

  1. Duplication des données.
  2. Duplication dans le code.
  3. Bascule dans le code.
  4. Nettoyage du code.
  5. Nettoyage des données.

Si en plus on a un schéma, il faudra ajouter une étape de duplication du schéma avant la duplication des données ainsi qu’une étape de nettoyage du schéma après le nettoyage des données.

Et si le code est lui-même découpé en une partie serveur et une partie client, peut-être faut-il aussi ajouter des étapes à ce niveau ?

En réalité ce qu’on vient de définir comme des étapes supplémentaires peuvent être vues comme des migrations imbriquées :

  • Duplication dans le schéma
    • Duplication dans les données
      • Duplication dans le code du serveur
        • Duplication dans le code du client
        • Bascule dans le code client
        • Nettoyage du code du client
      • Nettoyage du code du serveur
    • Nettoyage des données
  • Nettoyage du schéma

Le principe qu’on voit émerger, c’est que l’étape 2 (la bascule) d’une migration donnée est parfois elle-même la migration en 3 étapes d’un sous-système.

Pour rester pragmatique, il faut simplement garder en tête que l’objectif est de s’arrêter avant d’avoir rendu le retour en arrière trop douloureux. À chacun d’évaluer, en fonction du contexte et des contraintes techniques, où il est approprié de découper une migration en 3 étapes, et où il est raisonnable de ne pas le faire.

Conclusion

On a vu pourquoi il était crucial de ne pas ignorer les problèmes de compatibilité, et on a présenté un modèle de solution qui permet d’assurer la compatibilité ascendante dans une multitude de scénarios.

Les règles de compatibilité ascendante et le principe de la migration en 3 étapes s’appliquent aussi bien à des API web, qu’à des migrations de schéma SQL ou à des mises à jour de librairies.

Le principal risque de la migration en 3 étapes, c’est que, sous la pression de contraintes externes, on s’arrête au milieu de la seconde : il est donc important, avant même de commencer la première étape, de planifier la dernière et de s’assurer qu’on aura les ressources pour la mener à bout.


Les images utilisées pour illustrer cet article sont des captures d’écran du jeu vidéo Train Valley 2. Elles sont utilisées avec l’autorisation de ses auteurs.