Compartir a través de


Uso de un servidor de bases de datos que se ejecuta como contenedor

Sugerencia

Este contenido es un extracto del libro electrónico, ".NET Microservices Architecture for Containerized .NET Applications" (Arquitectura de microservicios de .NET para aplicaciones de .NET contenedorizadas), disponible en Documentación de .NET o como un PDF descargable y gratuito que se puede leer sin conexión.

Miniatura de la portada del libro electrónico 'Arquitectura de microservicios de .NET para aplicaciones .NET contenedorizadas'.

Puede tener las bases de datos (SQL Server, PostgreSQL, MySQL, etc.) en servidores independientes normales, en clústeres locales o en servicios PaaS en la nube, como Azure SQL DB. Sin embargo, para entornos de desarrollo y pruebas, tener las bases de datos que se ejecutan como contenedores es conveniente, ya que no tiene ninguna dependencia externa y simplemente ejecutar el docker-compose up comando inicia toda la aplicación. Tener esas bases de datos como contenedores también es excelente para las pruebas de integración, ya que la base de datos se inicia en el contenedor y siempre se rellena con los mismos datos de ejemplo, por lo que las pruebas pueden ser más predecibles.

En eShopOnContainers, hay un contenedor denominado sqldata, tal como se define en el archivo docker-compose.yml , que ejecuta una instancia de SQL Server para Linux con las bases de datos SQL para todos los microservicios que necesitan uno.

Un punto clave en los microservicios es que cada microservicio posee sus datos relacionados, por lo que debe tener su propia base de datos. Sin embargo, las bases de datos pueden estar en cualquier lugar. En este caso, todos están en el mismo contenedor para mantener los requisitos de memoria de Docker lo más bajo posible. Tenga en cuenta que se trata de una solución suficiente para el desarrollo y, quizás, probar pero no para producción.

El contenedor de SQL Server de la aplicación de ejemplo se configura con el siguiente código YAML en el archivo docker-compose.yml, que se ejecuta al ejecutar docker-compose up. Tenga en cuenta que el código YAML tiene información de configuración consolidada del archivo genérico docker-compose.yml y del archivo docker-compose.override.yml. (Normalmente, separaría la configuración del entorno de la información base o estática relacionada con la imagen de SQL Server).

  sqldata:
    image: mcr.microsoft.com/mssql/server:2017-latest
    environment:
      - SA_PASSWORD=Pass@word
      - ACCEPT_EULA=Y
    ports:
      - "5434:1433"

De forma similar, en lugar de usar docker-compose, el siguiente docker run comando puede ejecutar ese contenedor:

docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=Pass@word' -p 5433:1433 -d mcr.microsoft.com/mssql/server:2017-latest

Sin embargo, si va a implementar una aplicación de varios contenedores como eShopOnContainers, es más conveniente usar el docker-compose up comando para que implemente todos los contenedores necesarios para la aplicación.

Al iniciar este contenedor de SQL Server por primera vez, el contenedor inicializa SQL Server con la contraseña que proporcione. Una vez que SQL Server se ejecuta como un contenedor, puede actualizar la base de datos mediante la conexión a través de cualquier conexión SQL normal, como desde código de SQL Server Management Studio, Visual Studio o C#.

La aplicación eShopOnContainers inicializa cada base de datos de microservicios con datos de ejemplo al inicializarlos con datos en el inicio, como se explica en la sección siguiente.

Tener SQL Server ejecutándose como contenedor no solo es útil para una demostración en la que es posible que no tenga acceso a una instancia de SQL Server. Como se indicó, también es ideal para entornos de desarrollo y pruebas para que pueda ejecutar fácilmente pruebas de integración a partir de una imagen limpia de SQL Server y datos conocidos mediante la propagación de nuevos datos de ejemplo.

Recursos adicionales

Propagación con datos de prueba al iniciar la aplicación web

Para agregar datos a la base de datos cuando se inicia la aplicación, puede agregar código como el siguiente al Main método de la Program clase del proyecto de API web:

public static int Main(string[] args)
{
    var configuration = GetConfiguration();

    Log.Logger = CreateSerilogLogger(configuration);

    try
    {
        Log.Information("Configuring web host ({ApplicationContext})...", AppName);
        var host = CreateHostBuilder(configuration, args);

        Log.Information("Applying migrations ({ApplicationContext})...", AppName);
        host.MigrateDbContext<CatalogContext>((context, services) =>
        {
            var env = services.GetService<IWebHostEnvironment>();
            var settings = services.GetService<IOptions<CatalogSettings>>();
            var logger = services.GetService<ILogger<CatalogContextSeed>>();

            new CatalogContextSeed()
                .SeedAsync(context, env, settings, logger)
                .Wait();
        })
        .MigrateDbContext<IntegrationEventLogContext>((_, __) => { });

        Log.Information("Starting web host ({ApplicationContext})...", AppName);
        host.Run();

        return 0;
    }
    catch (Exception ex)
    {
        Log.Fatal(ex, "Program terminated unexpectedly ({ApplicationContext})!", AppName);
        return 1;
    }
    finally
    {
        Log.CloseAndFlush();
    }
}

Hay una advertencia importante al aplicar migraciones y inicializar una base de datos durante el inicio del contenedor. Dado que es posible que el servidor de bases de datos no esté disponible por cualquier motivo, debe manejar los reintentos mientras espera a que el servidor esté disponible. La lógica de reintento está gestionada por el método de extensión MigrateDbContext(), como se muestra en el código siguiente:

public static IWebHost MigrateDbContext<TContext>(
    this IWebHost host,
    Action<TContext,
    IServiceProvider> seeder)
      where TContext : DbContext
{
    var underK8s = host.IsInKubernetes();

    using (var scope = host.Services.CreateScope())
    {
        var services = scope.ServiceProvider;

        var logger = services.GetRequiredService<ILogger<TContext>>();

        var context = services.GetService<TContext>();

        try
        {
            logger.LogInformation("Migrating database associated with context {DbContextName}", typeof(TContext).Name);

            if (underK8s)
            {
                InvokeSeeder(seeder, context, services);
            }
            else
            {
                var retry = Policy.Handle<SqlException>()
                    .WaitAndRetry(new TimeSpan[]
                    {
                    TimeSpan.FromSeconds(3),
                    TimeSpan.FromSeconds(5),
                    TimeSpan.FromSeconds(8),
                    });

                //if the sql server container is not created on run docker compose this
                //migration can't fail for network related exception. The retry options for DbContext only
                //apply to transient exceptions
                // Note that this is NOT applied when running some orchestrators (let the orchestrator to recreate the failing service)
                retry.Execute(() => InvokeSeeder(seeder, context, services));
            }

            logger.LogInformation("Migrated database associated with context {DbContextName}", typeof(TContext).Name);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "An error occurred while migrating the database used on context {DbContextName}", typeof(TContext).Name);
            if (underK8s)
            {
                throw;          // Rethrow under k8s because we rely on k8s to re-run the pod
            }
        }
    }

    return host;
}

El código siguiente de la clase CatalogContextSeed personalizada rellena los datos.

public class CatalogContextSeed
{
    public static async Task SeedAsync(IApplicationBuilder applicationBuilder)
    {
        var context = (CatalogContext)applicationBuilder
            .ApplicationServices.GetService(typeof(CatalogContext));
        using (context)
        {
            context.Database.Migrate();
            if (!context.CatalogBrands.Any())
            {
                context.CatalogBrands.AddRange(
                    GetPreconfiguredCatalogBrands());
                await context.SaveChangesAsync();
            }
            if (!context.CatalogTypes.Any())
            {
                context.CatalogTypes.AddRange(
                    GetPreconfiguredCatalogTypes());
                await context.SaveChangesAsync();
            }
        }
    }

    static IEnumerable<CatalogBrand> GetPreconfiguredCatalogBrands()
    {
        return new List<CatalogBrand>()
       {
           new CatalogBrand() { Brand = "Azure"},
           new CatalogBrand() { Brand = ".NET" },
           new CatalogBrand() { Brand = "Visual Studio" },
           new CatalogBrand() { Brand = "SQL Server" }
       };
    }

    static IEnumerable<CatalogType> GetPreconfiguredCatalogTypes()
    {
        return new List<CatalogType>()
        {
            new CatalogType() { Type = "Mug"},
            new CatalogType() { Type = "T-Shirt" },
            new CatalogType() { Type = "Backpack" },
            new CatalogType() { Type = "USB Memory Stick" }
        };
    }
}

Al ejecutar pruebas de integración, resulta útil tener una manera de generar datos coherentes con las pruebas de integración. Poder crear todo desde cero, incluida una instancia de SQL Server que se ejecuta en un contenedor, es excelente para entornos de prueba.

Base de datos InMemory de EF Core frente a SQL Server que se ejecuta como un contenedor

Otra buena opción al ejecutar pruebas es usar el proveedor de bases de datos InMemory de Entity Framework. Puede especificar esa configuración en el método ConfigureServices de la clase Startup en el proyecto de API web:

public class Startup
{
    // Other Startup code ...
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IConfiguration>(Configuration);
        // DbContext using an InMemory database provider
        services.AddDbContext<CatalogContext>(opt => opt.UseInMemoryDatabase());
        //(Alternative: DbContext using a SQL Server provider
        //services.AddDbContext<CatalogContext>(c =>
        //{
            // c.UseSqlServer(Configuration["ConnectionString"]);
            //
        //});
    }

    // Other Startup code ...
}

Sin embargo, hay un truco importante. La base de datos en memoria no admite muchas restricciones específicas de una base de datos determinada. Por ejemplo, puede agregar un índice único en una columna del modelo de EF Core y escribir una prueba en la base de datos en memoria para comprobar que no permite agregar un valor duplicado. Pero cuando se usa la base de datos en memoria, no se pueden controlar índices únicos en una columna. Por lo tanto, la base de datos en memoria no se comporta exactamente igual que una base de datos real de SQL Server; no emula las restricciones específicas de la base de datos.

Incluso así, una base de datos en memoria sigue siendo útil para probar y crear prototipos. Pero si desea crear pruebas de integración precisas que tengan en cuenta el comportamiento de una implementación de base de datos específica, debe usar una base de datos real como SQL Server. Para ello, ejecutar SQL Server en un contenedor es una opción excelente y más precisa que el proveedor de bases de datos InMemory de EF Core.

Uso de un servicio de caché de Redis que se ejecuta en un contenedor

Puede ejecutar Redis en un contenedor, especialmente para desarrollo y pruebas y para escenarios de prueba de concepto. Este escenario es conveniente, ya que puede tener todas sus dependencias ejecutándose en contenedores, no solo para sus máquinas de desarrollo local, sino también para sus entornos de prueba en los tubos de CI/CD.

Sin embargo, al ejecutar Redis en producción, es mejor buscar una solución de alta disponibilidad como Redis Microsoft Azure, que se ejecuta como paaS (plataforma como servicio). En tu código, solo tienes que cambiar las cadenas de conexión.

Redis proporciona una imagen de Docker con Redis. Esa imagen está disponible en Docker Hub en esta dirección URL:

https://hub.docker.com/_/redis/

Puede ejecutar directamente un contenerizador de Docker Redis ejecutando el siguiente comando de la CLI de Docker en el sistema de comandos:

docker run --name some-redis -d redis

La imagen de Redis incluye expose:6379 (el puerto usado por Redis), por lo que la vinculación de contenedores estándar hará que esté disponible automáticamente para los contenedores vinculados.

En eShopOnContainers, el basket-api microservicio usa una caché de Redis que se ejecuta como un contenedor. Ese basketdata contenedor se define como parte del archivo docker-compose.yml de varios contenedores, como se muestra en el ejemplo siguiente:

#docker-compose.yml file
#...
  basketdata:
    image: redis
    expose:
      - "6379"

Este código del docker-compose.yml define un contenedor denominado basketdata en función de la imagen de redis y publica internamente el puerto 6379. Esta configuración significa que solo será accesible desde otros contenedores que se ejecutan en el host de Docker.

Por último, en el archivo docker-compose.override.yml , el basket-api microservicio del ejemplo eShopOnContainers define la cadena de conexión que se va a usar para ese contenedor de Redis:

  basket-api:
    environment:
      # Other data ...
      - ConnectionString=basketdata
      - EventBusConnection=rabbitmq

Como se mencionó antes, el nombre del microservicio basketdata se resuelve mediante el DNS de red interna de Docker.