Partilhar via


Usar um servidor de banco de dados em execução como um contêiner

Gorjeta

Este conteúdo é um trecho do eBook, .NET Microservices Architecture for Containerized .NET Applications, disponível no .NET Docs ou como um PDF para download gratuito que pode ser lido offline.

.NET Microservices Architecture for Containerized .NET Applications eBook cover thumbnail.

Você pode ter seus bancos de dados (SQL Server, PostgreSQL, MySQL, etc.) em servidores autônomos regulares, em clusters locais ou em serviços PaaS na nuvem, como o Banco de Dados SQL do Azure. No entanto, para ambientes de desenvolvimento e teste, ter seus bancos de dados em execução como contêineres é conveniente, porque você não tem nenhuma dependência externa e simplesmente executar o docker-compose up comando inicia todo o aplicativo. Ter esses bancos de dados como contêineres também é ótimo para testes de integração, porque o banco de dados é iniciado no contêiner e é sempre preenchido com os mesmos dados de exemplo, portanto, os testes podem ser mais previsíveis.

No eShopOnContainers, há um contêiner chamado sqldata, conforme definido no arquivo docker-compose.yml , que executa uma instância do SQL Server para Linux com os bancos de dados SQL para todos os microsserviços que precisam de um.

Um ponto-chave em microsserviços é que cada microsserviço possui seus dados relacionados, portanto, deve ter seu próprio banco de dados. No entanto, as bases de dados podem estar em qualquer lugar. Nesse caso, todos eles estão no mesmo contêiner para manter os requisitos de memória do Docker o mais baixo possível. Tenha em mente que esta é uma solução boa o suficiente para desenvolvimento e, talvez, testes, mas não para produção.

O contêiner do SQL Server no aplicativo de exemplo é configurado com o seguinte código YAML no arquivo docker-compose.yml, que é executado quando você executa docker-compose upo . Observe que o código YAML consolidou as informações de configuração do arquivo docker-compose.yml genérico e do arquivo docker-compose.override.yml. (Normalmente, você separaria as configurações de ambiente das informações básicas ou estáticas relacionadas à imagem do SQL Server.)

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

Da mesma forma, em vez de usar docker-composeo , o seguinte docker run comando pode executar esse contêiner:

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

No entanto, se você estiver implantando um aplicativo de vários contêineres como eShopOnContainers, é mais conveniente usar o docker-compose up comando para que ele implante todos os contêineres necessários para o aplicativo.

Quando você inicia esse contêiner do SQL Server pela primeira vez, o contêiner inicializa o SQL Server com a senha fornecida. Quando o SQL Server estiver sendo executado como um contêiner, você poderá atualizar o banco de dados conectando-se por meio de qualquer conexão SQL regular, como a partir do SQL Server Management Studio, Visual Studio ou código C#.

O aplicativo eShopOnContainers inicializa cada banco de dados de microsserviço com dados de exemplo, propagando-os com dados na inicialização, conforme explicado na seção a seguir.

Ter o SQL Server em execução como um contêiner não é útil apenas para uma demonstração em que você pode não ter acesso a uma instância do SQL Server. Como observado, ele também é ótimo para ambientes de desenvolvimento e teste para que você possa executar facilmente testes de integração a partir de uma imagem limpa do SQL Server e dados conhecidos, propagando novos dados de exemplo.

Recursos adicionais

Semeadura com dados de teste na inicialização do aplicativo Web

Para adicionar dados ao banco de dados quando o aplicativo é iniciado, você pode adicionar código como o seguinte ao Main método na Program classe do projeto 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();
    }
}

Há uma ressalva importante ao aplicar migrações e semear um banco de dados durante a inicialização do contêiner. Como o servidor de banco de dados pode não estar disponível por qualquer motivo, você deve lidar com novas tentativas enquanto aguarda que o servidor esteja disponível. Essa lógica de repetição é manipulada MigrateDbContext() pelo método extension, conforme mostrado no código a seguir:

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;
}

O código a seguir na classe personalizada CatalogContextSeed preenche os dados.

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" }
        };
    }
}

Quando você executa testes de integração, ter uma maneira de gerar dados consistentes com seus testes de integração é útil. Ser capaz de criar tudo do zero, incluindo uma instância do SQL Server em execução em um contêiner, é ótimo para ambientes de teste.

Banco de dados EF Core InMemory versus SQL Server em execução como um contêiner

Outra boa opção ao executar testes é usar o provedor de banco de dados InMemory do Entity Framework. Você pode especificar essa configuração no método ConfigureServices da classe Startup em seu projeto 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 ...
}

No entanto, há um problema importante. O banco de dados na memória não oferece suporte a muitas restrições específicas de um banco de dados específico. Por exemplo, você pode adicionar um índice exclusivo em uma coluna em seu modelo EF Core e escrever um teste em seu banco de dados na memória para verificar se ele não permite adicionar um valor duplicado. Mas quando você estiver usando o banco de dados na memória, você não pode manipular índices exclusivos em uma coluna. Portanto, o banco de dados na memória não se comporta exatamente da mesma forma que um banco de dados real do SQL Server — ele não emula restrições específicas do banco de dados.

Mesmo assim, um banco de dados na memória ainda é útil para testes e prototipagem. Mas se você quiser criar testes de integração precisos que levem em conta o comportamento de uma implementação de banco de dados específica, você precisa usar um banco de dados real como o SQL Server. Para esse fim, executar o SQL Server em um contêiner é uma ótima opção e mais precisa do que o provedor de banco de dados EF Core InMemory.

Usando um serviço de cache Redis em execução em um contêiner

Você pode executar o Redis em um contêiner, especialmente para desenvolvimento e teste e para cenários de prova de conceito. Esse cenário é conveniente, porque você pode ter todas as suas dependências em execução em contêineres — não apenas para suas máquinas de desenvolvimento locais, mas para seus ambientes de teste em seus pipelines de CI/CD.

No entanto, quando você executa o Redis em produção, é melhor procurar uma solução de alta disponibilidade como o Redis Microsoft Azure, que é executado como um PaaS (Platform as a Service). No seu código, você só precisa alterar suas cadeias de conexão.

O Redis fornece uma imagem do Docker com o Redis. Essa imagem está disponível no Docker Hub neste URL:

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

Você pode executar diretamente um contêiner Redis do Docker executando o seguinte comando da CLI do Docker no prompt de comando:

docker run --name some-redis -d redis

A imagem Redis inclui expose:6379 (a porta usada pelo Redis), portanto, a vinculação de contêiner padrão a tornará automaticamente disponível para os contêineres vinculados.

No eShopOnContainers, o basket-api microsserviço usa um cache Redis em execução como um contêiner. Esse basketdata contêiner é definido como parte do arquivo docker-compose.yml de vários contêineres, conforme mostrado no exemplo a seguir:

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

Esse código no docker-compose.yml define um contêiner nomeado basketdata com base na imagem do redis e publicando a porta 6379 internamente. Essa configuração significa que ele só será acessível a partir de outros contêineres em execução no host do Docker.

Finalmente, no arquivo docker-compose.override.yml, o basket-api microsserviço para o exemplo eShopOnContainers define a cadeia de conexão a ser usada para esse contêiner Redis:

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

Como mencionado anteriormente, o nome do microsserviço basketdata é resolvido pelo DNS da rede interna do Docker.