Aujourd’hui, je souhaite faire un feedback sur la mise en place des bundles avec Entity Framework.

Les migrations

Avant de parler des bundles, il est important de revenir sur le concept de migration. Il s’agit d’un moyen, offert par Entity Framework, pour gérer les évolutions et/ou modifications de votre schéma de base de données dans le cadre d’une approche code-first. Cela permet donc de conserver une cohérence entre le schéma de la base et le modèle de données de l’application. C’est une alternative à l’écriture de scripts SQL (par exemple, via un projet SQL Server Database).

En terme pratique, cela se matéralise sous la forme de code C# décrivant les opérations nécessaires pour appliquer ou annuler ces modifications. Voici un exemple :

using Microsoft.EntityFrameworkCore.Migrations;

namespace EFCoreMigrationsExample.Migrations 
{ 
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "A",
            columns: table => new
            {
                Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
                Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
                CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_A", x => x.Id);
            });
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(name: "A");
    }
}

Comme n’importe quel fichier source, les fichiers de migrations peuvent être versionnés.

Au cours du développement, dans un environnement local ou de recette, on va généralement appliquer les migrations au démarrage de l’application via la fonction suivante :

dbContext.Database.Migrate();

Cela permet de s’assurer que la base de données est à jour avant que notre application démarre. En s’appuyant sur la table __MigrationHistory et sur la liste des migrations dans le code, EF va appliquer l’ensemble des migrations manquantes.

Mais on va voir juste après pourquoi ce n’est pas une bonne idée de continuer à faire ça sur un environnement de production…

Un peu de contexte

Mon problème est en réalité assez simple. Sur l’environnement de production, mon application est déployée avec plusieurs instances (derrière un load balancer). Au moment du démarrage, chaque instance va donc faire l’appel à dbContext.Database.Migrate().

Et c’est là que les soucis commencent ! En effet, chaque instance va essayer d’appliquer les mêmes migrations, sur la même base, et cela va donc causer des problèmes d’accès concurrents.

Au delà de ça, cette méthode pose d’autres soucis :

  • en cas d’erreur lors de l’application des migrations, l’application sera indisponible
  • cela ralenti le temps de démarrage de l’application, en particulier dans le cas de grosse migration
  • viole le principe du moindre privilège

Quelques précisions quand à ce dernier point. Dans le cadre de son exécution standard, une application n’a généralement pas besoin de tous les droits sur la base de données. Le plus souvent, les droits de lecture et d’écriture suffisent et le droit de modifier le schéma (supprimer une table ou une colonne par exemple) n’est pas nécessaire (et encore moins d’être owner !). Par contre, l’application des migrations peut nécessiter ce genre de droit. On va donc se retrouver à donner plus de droits que nécessaire pour un cas spécifique et on offre à un potentiel attaquant des possibilités de malveillances supplémentaires.

Mais alors, que faire ? Et bien c’est à moment que les bundles interviennent !

C’est quoi un bundle ?

Un bundle de migration est un fichier produit par EF Core permettant l’application des migrations sur une base de données. Cette fonctionnalité est apparue à partir de la version 6 d’EF.

Il offre 2 avantages majeurs :

  • il est totalement autonome et peut-être exécuté de manière indépendante du code source de l’application (idéal dans le cas d’un pipeline de CD)
  • ses dépendances sont minimales, il a uniquement besoin que le runtime .NET soit installé sur la machine qui l’exécute (et on peut même s’en passer totalement avec l’option --self-contained)

Cela permet donc de découpler totalement le pipeline de déploiement de la partie base de données du reste de l’applicatif.

Comment ça fonctionne ?

Création

La première étape est de créer le bundle.

Pour cela, on a besoin :

  • du code source de l’application
  • de l’outillage EF

Ensuite, on peut exécuter la commande suivante pour générer le bundle : dotnet ef migrations bundle --self-contained -r [linux-x64|win-x64]

Le paramètre --self-contained permet d’indiquer que l’on souhaite embarquer le runtime .NET dans le bundle. Cela permet d’exécuter le bundle sur une machine qui ne l’aurait pas déjà installé.

Le paramètre -r permet de spécifier le système cible du bundle. C’est notamment important si le système cible est différent de celui sur lequel sera construit le bundle. La liste des valeurs est disponible ici.

Utilisation

Une fois notre bundle construit, on peut passer à la seconde étape : l’exécution.

Commençons déjà par consulter l’aide du bundle :

.\efbundle.exe --help
Entity Framework Core Migrations Bundle 8.0.0

Usage: efbundle [arguments] [options] [[--] <arg>...]]

Arguments:
  <MIGRATION>  The target migration. If '0', all migrations will be reverted. Defaults to the last migration.

Options:
  --connection <CONNECTION>  The connection string to the database. Defaults to the one specified in AddDbContext or OnConfiguring.
  --version                  Show version information
  -h|--help                  Show help information
  -v|--verbose               Show verbose output.
  --no-color                 Don't colorize output.
  --prefix-output            Prefix output with level.

Cette aide nous indique plusieurs choses :

  • il est possible de préciser une chaine de connection, et donc de pouvoir exécuter le bundle facilement sur plusieurs instances de base de données (par défaut, si ce paramètre n’est pas spécifié, EF ira chercher la chaine de connexion dans le fichier appsettings.json)
  • il est possible de préciser le nom de la migration cible, et donc d’avoir la possibilité de ne pas appliquer toutes les migrations ou de revenir en arrière

Dans notre exemple, on exécuterai la commande suivante : ./efbundle.exe --connection 'Server=.;Database=MySampleDatabase;Trusted_Connection=True;Encrypt=False'

En résultat, la console affichera les scripts SQL qui sont exécutés ainsi que les migrations qui seront appliquées.

Conclusion

Pour être complet, il est important de préciser que certains scénarios de migration ne sont pas supportés par les bundles. Je pense en particulier au cas où l’on doit manipuler les données de la base. Par exemple, on a des données chiffrées via un algorithme custom en C# et l’on souhaite changer cet algorithme. Il faut donc lire les données actuelles, les déchiffrer, les chiffrer avec le nouvel algorithme puis les écrire de nouveau en base. Pour ce type de scénario, il faudra passer par une application console classique.

En conclusion, le bundle est une solution très séduisante pour gérer les évolutions de modèle en production. Ils offrent un moyen simplet et autonome pour appliquer les migrations, tout en permettant une intégration dans un système de pipeline d’intégration et de déploiement.

Pour aller plus loin sur la gestion des migrations en production, je vous invite à lire ces 2 excellents articles :