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

小窍门

此内容摘自电子书《适用于容器化 .NET 应用程序的 .NET 微服务体系结构》,可以在 .NET Docs 上获取,也可以下载免费的 PDF 以供离线阅读。

适用于容器化 .NET 应用程序的 .NET 微服务体系结构电子书封面缩略图。

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

在 eShopOnContainers 中,有一个名为sqldata的容器(如docker-compose.yml文件中定义),该容器运行适用于所有需要 SQL 数据库的微服务的 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 应用程序启动时设定测试数据

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

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 映像。 此 URL 可从 Docker 中心获取该映像:

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 文件中, basket-api eShopOnContainers 示例的微服务定义了用于该 Redis 容器的连接字符串:

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

如前所述,微服务 basketdata 的名称由 Docker 的内部网络 DNS 解析。