Condividi tramite


Usare un server di database in esecuzione come contenitore

Suggerimento

Questo contenuto è un estratto dell'eBook, Architettura di microservizi .NET per applicazioni .NET containerizzati, disponibile in documentazione .NET o come PDF scaricabile gratuitamente leggibile offline.

Architettura di Microservizi .NET per Applicazioni .NET Containerizzate miniatura della copertina dell'eBook.

È possibile avere i database (SQL Server, PostgreSQL, MySQL e così via) nei normali server autonomi, nei cluster locali o nei servizi PaaS nel cloud, ad esempio il database SQL di Azure. Tuttavia, per gli ambienti di sviluppo e test, l'esecuzione dei database come contenitori risulta utile perché non si dispone di alcuna dipendenza esterna e l'esecuzione del docker-compose up comando avvia l'intera applicazione. La disponibilità di tali database come contenitori è ideale anche per i test di integrazione, perché il database viene avviato nel contenitore e viene sempre popolato con gli stessi dati di esempio, in modo che i test possano essere più prevedibili.

In eShopOnContainers è presente un contenitore denominato sqldata, come definito nel file docker-compose.yml , che esegue un'istanza di SQL Server per Linux con i database SQL per tutti i microservizi che ne hanno bisogno.

Un punto chiave nei microservizi è che ogni microservizio possiede i propri dati correlati, pertanto deve avere un proprio database. Tuttavia, i database possono essere ovunque. In questo caso, si trovano tutti nello stesso contenitore per mantenere i requisiti di memoria Docker il più basso possibile. Tenere presente che si tratta di una soluzione sufficientemente efficace per lo sviluppo e, ad esempio, i test, ma non per la produzione.

Il contenitore SQL Server nell'applicazione di esempio viene configurato con il codice YAML seguente nel file docker-compose.yml, che viene eseguito quando si esegue docker-compose up. Si noti che il codice YAML include informazioni di configurazione consolidate dal file di docker-compose.yml generico e dal file docker-compose.override.yml. In genere si separano le impostazioni dell'ambiente dalle informazioni di base o statiche correlate all'immagine di SQL Server.

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

In modo analogo, invece di usare docker-compose, il comando seguente docker run può eseguire il contenitore:

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

Tuttavia, se si distribuisce un'applicazione multi-contenitore come eShopOnContainers, è più comodo usare il docker-compose up comando in modo che distribuisca tutti i contenitori necessari per l'applicazione.

Quando si avvia questo contenitore di SQL Server per la prima volta, il contenitore inizializza SQL Server con la password specificata. Quando SQL Server è in esecuzione come contenitore, è possibile aggiornare il database connettendosi tramite qualsiasi normale connessione SQL, ad esempio dal codice SQL Server Management Studio, Visual Studio o C#.

L'applicazione eShopOnContainers inizializza ogni database di microservizio con dati di esempio eseguendo il seeding con i dati all'avvio, come illustrato nella sezione seguente.

La presenza di SQL Server in esecuzione come contenitore non è utile solo per una demo in cui potrebbe non essere possibile accedere a un'istanza di SQL Server. Come indicato, è utile anche per gli ambienti di sviluppo e test, in modo da poter eseguire facilmente test di integrazione a partire da un'immagine pulita di SQL Server e dai dati noti eseguendo il seeding di nuovi dati di esempio.

Risorse aggiuntive

Seeding con dati di test all'avvio dell'applicazione Web

Per aggiungere dati al database all'avvio dell'applicazione, è possibile aggiungere codice simile al seguente al Main metodo nella Program classe del progetto 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();
    }
}

Quando si applicano migrazioni e seeding di un database durante l'avvio del contenitore, è importante tenere presente un'avvertenza importante. Poiché il server di database potrebbe non essere disponibile per qualsiasi motivo, è necessario gestire i tentativi di riconnessione mentre si attende che il server torni disponibile. Questa logica di ripetizione dei tentativi viene gestita dal MigrateDbContext() metodo di estensione, come illustrato nel codice seguente:

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

Il codice seguente nella classe Personalizzata CatalogContextSeed popola i dati.

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 si eseguono test di integrazione, è utile avere un modo per generare dati coerenti con i test di integrazione. La possibilità di creare tutto da zero, inclusa un'istanza di SQL Server in esecuzione in un contenitore, è ideale per gli ambienti di test.

Database EF Core InMemory rispetto a SQL Server in esecuzione come contenitore

Un'altra scelta ottimale quando si eseguono test consiste nell'usare il provider di database InMemory di Entity Framework. È possibile specificare tale configurazione nel metodo ConfigureServices della classe Startup nel progetto 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 ...
}

C'è però un'importante condizione. Il database in memoria non supporta molti vincoli specifici di un database specifico. Ad esempio, è possibile aggiungere un indice univoco in una colonna nel modello di EF Core e scrivere un test sul database in memoria per verificare che non sia possibile aggiungere un valore duplicato. Tuttavia, quando si usa il database in memoria, non è possibile gestire indici univoci in una colonna. Pertanto, il database in memoria non si comporta esattamente come un database SQL Server reale, ma non emula vincoli specifici del database.

Anche in questo caso, un database in memoria è ancora utile per il test e la creazione di prototipi. Tuttavia, se si vogliono creare test di integrazione accurati che tengano conto del comportamento di un'implementazione specifica del database, è necessario usare un database reale come SQL Server. A tale scopo, l'esecuzione di SQL Server in un contenitore è una scelta ottimale e più accurata rispetto al provider di database EF Core InMemory.

Uso di un servizio cache Redis in esecuzione in un contenitore

È possibile eseguire Redis in un contenitore, in particolare per scenari di sviluppo e test e per scenari di verifica. Questo scenario è utile perché è possibile avere tutte le dipendenze in esecuzione nei contenitori, non solo per i computer di sviluppo locali, ma per gli ambienti di test nelle pipeline CI/CD.

Tuttavia, quando si esegue Redis nell'ambiente di produzione, è preferibile cercare una soluzione a disponibilità elevata come Redis Microsoft Azure, che viene eseguita come PaaS (piattaforma distribuita come servizio). Nel codice è sufficiente modificare le stringhe di connessione.

Redis fornisce un'immagine Docker con Redis. L'immagine è disponibile nell'hub Docker all'URL seguente:

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

È possibile eseguire direttamente un contenitore Docker Redis eseguendo il comando dell'interfaccia della riga di comando Docker seguente nel prompt dei comandi:

docker run --name some-redis -d redis

L'immagine Redis comprende expose:6379 (la porta usata da Redis), quindi il collegamento standard dei container la renderà automaticamente disponibile ai container collegati.

In eShopOnContainers il basket-api microservizio usa una cache Redis in esecuzione come contenitore. Tale basketdata contenitore viene definito come parte del file di docker-compose.yml multi-contenitore, come illustrato nell'esempio seguente:

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

Questo codice nella docker-compose.yml definisce un contenitore denominato basketdata in base all'immagine Redis e pubblica internamente la porta 6379. Questa configurazione significa che sarà accessibile solo da altri contenitori in esecuzione all'interno dell'host Docker.

Infine, nel file docker-compose.override.yml il basket-api microservizio per l'esempio eShopOnContainers definisce la stringa di connessione da usare per il contenitore Redis:

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

Come accennato in precedenza, il nome del microservizio basketdata viene risolto dal DNS di rete interno di Docker.