Compartilhar via


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

Dica

Esse conteúdo é um trecho do eBook, arquitetura de microsserviços do .NET para aplicativos .NET em contêineres, disponível em do .NET Docs ou como um PDF para download gratuito que pode ser lido offline.

miniatura da capa do eBook sobre arquitetura de microsserviços do .NET para aplicativos .NET em contêineres.

Você pode ter seus bancos de dados (SQL Server, PostgreSQL, MySQL etc.) em servidores autônomos regulares, em clusters locais ou em serviços de PaaS na nuvem, como o BD SQL do Azure. No entanto, para ambientes de desenvolvimento e teste, ter seus bancos de dados em execução como contêineres é conveniente, pois 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, pois o banco de dados é iniciado no contêiner e sempre é preenchido com os mesmos dados de exemplo, para que os testes possam 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 fundamental nos microsserviços é que cada microsserviço possui seus dados relacionados, portanto, ele deve ter seu próprio banco de dados. No entanto, os bancos de dados podem estar em qualquer lugar. Nesse caso, eles estão todos 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 suficiente para desenvolvimento e, talvez, teste, 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 up. Observe que o código YAML consolidou informações de configuração do arquivo de docker-compose.yml genérico e do arquivo docker-compose.override.yml. (Normalmente, você separaria as configurações de ambiente das informações base 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"

De maneira semelhante, em vez de usar docker-compose, 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, será 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. Depois que o SQL Server estiver em execução como um contêiner, você poderá atualizar o banco de dados conectando-se por meio de qualquer conexão SQL regular, como do SQL Server Management Studio, do Visual Studio ou do código C#.

O aplicativo eShopOnContainers inicializa cada banco de dados de microsserviço com dados de exemplo ao preenchê-los com dados no momento da inicialização, conforme explicado na seção a seguir.

Ter o SQL Server em execução como um contêiner não é apenas útil para uma demonstração em que talvez você não tenha acesso a uma instância do SQL Server. Como observado, também é ótimo para ambientes de desenvolvimento e teste, permitindo que você execute facilmente testes de integração começando com uma imagem limpa do SQL Server e dados conhecidos, adicionando novos dados de exemplo.

Recursos adicionais

Semeamento com dados de teste na inicialização da aplicação web

Para adicionar dados ao banco de dados quando o aplicativo for iniciado, você pode adicionar código como o Main seguinte ao 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 propagar 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 o servidor estar disponível. Essa lógica de repetição é tratada pelo MigrateDbContext() método de extensão, conforme mostrado no seguinte código:

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 CatalogContextSeed personalizada 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, é útil ter uma maneira de gerar dados consistentes com seus testes de integração. 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 InMemory do EF Core 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 ...
}

Há uma pegadinha importante, no entanto. O banco de dados na memória não dá suporte a muitas restrições específicas a um banco de dados específico. Por exemplo, você pode adicionar um índice exclusivo em uma coluna em seu modelo EF Core e gravar 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, não poderá lidar com í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 teste e protótipo. Porém, 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, será necessário usar um banco de dados real, como o SQL Server. Para essa finalidade, executar o SQL Server em um contêiner é uma ótima opção e mais precisa do que o provedor de banco de dados InMemory do EF Core.

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, pois você pode ter todas as suas dependências em execução em contêineres, não apenas para seus computadores 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 (Plataforma como serviço). No seu código, você só precisa alterar as strings de conexão.

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

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

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

docker run --name some-redis -d redis

A imagem do Redis inclui expose:6379 (a porta usada pelo Redis), portanto, a vinculação de contêiner padrão a disponibilizará automaticamente 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 múltiplos 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 redis e publicando a porta 6379 internamente. Essa configuração significa que ela só estará acessível de outros contêineres em execução no host do Docker.

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

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

Conforme mencionado antes, o nome do microsserviço basketdata é resolvido pelo DNS de rede interna do Docker.