使用作为容器运行的数据库服务器

提示

此内容摘自电子书《适用于容器化 .NET 应用程序的 .NET 微服务体系结构》,可在 .NET 文档上获取,也可作为免费可下载的 PDF 脱机阅读。

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

你可以将自己的数据库(SQL Server、PostgreSQL、MySQL 等)放在常规独立服务器、本地群集或云中的 PaaS 服务(如 Azure SQL DB)中。 但对于开发和测试环境来说,将数据库作为容器运行很方便,因为没有任何外部依赖项,只需运行 docker-compose up 命令,即可启动整个应用程序。 将这些数据库作为容器也有利于集成测试,因为数据库在容器中启动,并且始终填充相同的样本数据,因此预测测试也就更容易了。

在 eShopOnContainers 中,有一个名为 sqldata 的容器(如 docker-compose.yml 文件中所定义),该容器运行 SQL Server for Linux 实例,包含需要该实例的所有微服务的 SQL Server 数据库。

微服务中的一个关键点是每个微服务都拥有其相关数据,因此它应该具有自己的数据库。 但是,数据库可以位于任何位置。 在这种情况下,它们都位于同一容器中,以尽可能降低 Docker 内存需求。 请记住,这对于开发和测试是一个很好的解决方案,但对于生产而言却不是。

示例应用程序中的 SQL Server 容器在 docker-compose.yml 文件中配置有下列 YAML 代码,这些代码在运行 docker-compose up 时执行。 请注意,YAML 代码整合了来自通用 docker-compose.yml 文件和 docker-compose.override.yml 文件的配置信息。 (你通常会分离环境设置与 SQL Server 映像的相关基本信息或静态信息。)

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

按照类似的方法,不使用 docker-compose,而使用下列 docker run 命令便可运行该容器:

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

但是,如果要部署多容器应用程序(如 eShopOnContainers),使用 docker-compose up 命令为应用程序部署所有必需的容器会更方便。

首次启动此 SQL Server 容器时,容器使用提供的密码初始化 SQL Server。 SQL Server 作为容器运行后,可以通过连接任何常规 SQL 连接(例如 SQL Server Management Studio、Visual Studio 或 C# 代码)来更新数据库。

eShopOnContainers 应用程序提供在启动时在数据库中设定数据,使用样本数据来初始化每个微服务数据库,如下文所述。

将 SQL Server 作为容器运行不仅可用于无法访问 SQL Server 实例的演示。 如前所述,对开发和测试环境也非常有用,因为可以通过设定新的样本数据轻松地从清晰的 SQL Server 映像以及已知数据开始运行集成测试。

其他资源

在 Web 应用程序启动时设定测试数据

要在应用程序启动时向数据库添加数据,可以将以下代码添加到 Web API 项目 Program 类中的 Main 方法中:

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

在容器启动期间应用迁移并为数据库设定种子时,会出现一个重要警告。 由于数据库服务器可能因某种原因不可用,因此必须等待服务器可用时处理重试。 此重试逻辑由 MigrateDbContext() 扩展方法处理,如以下代码所示:

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

自定义 CatalogContextSeed 类中的以下代码可填充数据。

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

运行集成测试时,可以生成与集成测试一致的数据很有用。 能够从头开始创建所有内容(包括在容器上运行的 SQL Server 实例)对测试环境非常有用。

EF Core InMemory 数据库与作为容器运行的 SQL Server

运行测试时,另一个不错的选择是使用 Entity Framework InMemory 数据库提供程序。 可以在 Web API 项目 Startup 类的 ConfigureServices 方法中指定该配置:

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

不过,存在一个重要的问题。 内存数据库不支持指定于特定数据库的许多约束。 例如,在 EF Core 模型的列中添加唯一索引,以及针对内存数据库编写测试,检查其是否允许添加重复值。 但是,使用内存数据库时,无法处理列上的唯一索引。 因此,内存数据库与真正 SQL Server 数据库的工作方式不完全相同 - 前者不会模拟特定于数据库的约束。

即便如此,内存数据库仍然可用于测试和原型设计。 但如果想要创建考虑特定数据库实现行为的准确集成测试,则需要使用像 SQL Server 这样真正的数据库。 为此,在容器中运行 SQL Server 是一个不错的选择,它比 EF Core InMemory 数据库提供程序更准确。

使用在容器中运行的 Redis 缓存服务

可以在容器上运行 Redis,特别是用于开发和测试以及概念验证方案。 这种方案很方便,因为可以在容器上运行所有依赖项 - 不仅适用于本地开发机器,还适用于 CI/CD 管道中的环境测试。

但是,如果生产环境中运行 Redis,则最好是寻找像 Redis Microsoft Azure 这样的高可用性解决方案,该解决方案作为 PaaS(平台即服务)运行。 你只需在代码中更改连接字符串。

Redis 提供使用 Redis 的 Docker 映像。 该映像可在 Docker 中心获得,请访问此 URL:

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

可在命令提示符中执行以下 Docker CLI 命令来直接运行 Docker Redis 容器:

docker run --name some-redis -d redis

Redis 映像包含公开:6379(Redis 使用的端口),因此链接容器可自动使用标准容器链接。

在 eShopOnContainers 中,basket-api 微服务使用作为容器运行的 Redis 缓存。 该 basketdata 容器被定义为 docker-compose.yml 文件的一部分,如以下示例所示:

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

docker-compose.yml 中的此代码基于 redis 映像定义了一个名为 basketdata 的容器,并在内部发布端口 6379。 此配置意味着只能从 Docker 主机中运行的其他容器访问它。

最后,在 docker-compose.override.yml 文件中,eShopOnContainers 示例的 basket-api 微服务定义了用于该 Redis 容器的连接字符串:

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

如前文所述,微服务的名称 basketdata 通过 Docker 的内部网络 DNS 进行解析。