CI/CD、Terraform 和 AWS ECS:使用 Lambda 应用数据库迁移?

CI/CD, Terraform and AWS ECS: Applying database migrations using Lambda?

我有一个包含多个服务的应用程序,每个服务都有自己的 postgres 数据库。我想将它部署到 AWS。 Kube 对我来说太复杂了,所以我决定将 AWS ECS 用于服务 + AWS RDS 用于数据库。并使用 Terraform 部署所有内容。

我设置了一个 CI/CD 管道,在合并到暂存分支后,构建、测试应用程序并将其部署到相应的环境。部署基本上包括构建 docker 图像并将其推送到 AWS ECR,然后调用 terraform plan/apply.

Terraform creates/updates VPC、子网、带任务的 ECS 服务、RDS 实例等

这有效。

但我不确定如何应用数据库迁移。

我有一个单独的控制台应用程序,其唯一目的是应用迁移然后退出。所以我可以在应用 Terraform 之前或之后在 CI/CD 管道中 运行 它。但是,before 不起作用,因为如果它是第一次部署,那么数据库还不存在,并且 after 不起作用,因为我想先应用迁移,然后然后启动服务,而不是相反。

所以我需要一些方法在 terraform 部署过程中 运行 这个迁移器控制台应用程序——在 rds 之后但在 ecs 之前。

我读了一篇 Andrew Lock 的文章,他通过在 Kubernetes 中使用作业和初始化容器解决了这个问题。但我没有使用 Kube,所以这不是我的选择。

我在 AWS ECS 文档中看到您可以 运行 独立任务(一次性任务),这基本上是我所需要的,并且您可以使用 AWS CLI 运行 它们,但是同时我 可以 使用管道中的 cli,我不能在 terraform 执行它的操作时使用它。我不能只对 terraform 说“运行 在创建此资源之后但在那之前的一些随机命令”。

然后我想到了使用 AWS Lambda。 Terraform 中有一种名为 aws_lambda_invocation 的数据源类型,它的作用与名称中的完全相同。所以现在我正在考虑在管道的构建阶段构建迁移器的 docker 图像,将其推送到 AWS ECR,然后在 terraform 中从图像创建 aws_lambda_function 资源和 aws_lambda_invocation 调用函数的数据源。让 ECS 依赖于调用,它应该可以工作,对吗?

这有一个问题:在计划和申请时都会查询数据源,但我只希望在申请时迁移器 lambda 为 运行。我认为可以通过在调用数据源中使用 count 属性和一些自定义变量来解决。

我认为这种方法可能行得通,但一定有更好、更简单的方法吗?有什么建议吗?

注意:我不能从服务本身应用迁移,因为每个服务都有多个实例,所以有可能有两个服务试图同时将迁移应用到同一个数据库,这结局会很惨

如果您想知道,我使用 .NET 5 和 GitLab,但我认为这与问题无关。

好吧,如果您想知道,我在问题 post 中描述的 lambda 解决方案是有效的。这不是很方便,但它确实有效。在 terraform 中,您首先需要创建一个连接到数据库所在的 vpc 的函数,将所有必要的条目添加到入口的 db sg 和出口的 lambda sg,然后像这样调用它(这里我将连接字符串传递为一个参数):

data "aws_lambda_invocation" "migrator" {
  count         = var.apply_migrations == "yes" ? 1 : 0
  function_name = aws_lambda_function.migrator.function_name
  input         = <<JSON
"Host=${aws_db_instance.service_a.address};Port=${aws_db_instance.service_a.port};Database=${aws_db_instance.service_a.db_name};Username=${aws_db_instance.service_a.username};Password=${aws_db_instance.service_a.password};"
JSON
}

使apply_migration默认为“否”。那么你只需要在申请时指定它 – terraform apply -var apply_migrations=yes.

然后让 aws_ecs_service(或您用来部署应用程序的任何东西)依赖于调用。

此解决方案的最大问题是 运行宁 terraform destroy 需要很长时间。这是因为为了将 lambda 连接到 vpc,AWS 会自动为其创建一个网络接口(因此它不受 terraform 管理)。当 destroy 销毁 lambda 时,界面在销毁后会保持“使用中”状态一段时间(它会有所不同——需要 10 分钟或更长时间——你甚至不能手动删除它)。导致terraform无法删除接口使用的子网,导致terraform挂了很久

但这并不重要,因为我找到了一个更好的解决方案,它需要更多的设置,但工作完美。

事实证明,terraform 可以 运行 任意命令。有一个 docker 提供程序可用,你基本上可以启动任何你想做的容器。

terraform {
  # ...

  required_providers {
    # ...

    docker = {
      source  = "kreuzwerker/docker"
      version = "2.16.0"
    }
  }
}

# this setup works for gitlab ci/cd with docker-in-docker
provider "docker" {
  host = "tcp://docker:2376"

  ca_material   = file("/certs/client/ca.pem")
  cert_material = file("/certs/client/cert.pem")
  key_material  = file("/certs/client/key.pem")

  registry_auth {
    address  = var.image_registry_uri
    # username and password are passed via DOCKER_REGISTRY_USER and DOCKER_REGISTRY_PASS env vars
  }
}

data "docker_registry_image" "migrator" {
  name = var.migrator_image_uri
}

resource "docker_image" "migrator" {
  name          = data.docker_registry_image.migrator.name
  pull_triggers = [data.docker_registry_image.migrator.sha256_digest]
}

resource "docker_container" "migrator" {
  name     = "migrator"
  image    = docker_image.migrator.repo_digest
  attach   = true # terraform will wait for container to finish before proceeding
  must_run = false # it's a one-time job container, not a daemon
  env = [
    "BASTION_PRIVATE_KEY=${var.bastion_private_key}",
    "BASTION_HOST=${aws_instance.bastion.public_ip}",
    "BASTION_USER=ec2-user",
    "DATABASE_HOST=${aws_db_instance.service_a.address}",
    "DATABASE_PORT=${aws_db_instance.service_a.port}",
    "DATABASE_NAME=${aws_db_instance.service_a.db_name}",
    "DATABASE_USER=${aws_db_instance.service_a.username}",
    "DATABASE_PASSWORD=${aws_db_instance.service_a.password}"
  ]
}

如您所见,您需要一个堡垒实例设置,但无论如何您可能都需要它。然后在迁移程序中,您需要使用 ssh 隧道连接到数据库。应该不是问题,ssh 包适用于每种语言。这是 .NET Core 示例:

using var stream = new MemoryStream();
using var writer = new StreamWriter(stream);
writer.Write(Environment.GetEnvironmentVariable("BASTION_PRIVATE_KEY"));
writer.Flush();
stream.Position = 0;

using var keyFile = new PrivateKeyFile(stream);

using var client = new SshClient(
    Environment.GetEnvironmentVariable("BASTION_HOST"),
    Environment.GetEnvironmentVariable("BASTION_USER"),
    keyFile
);

client.Connect();

var localhost = "127.0.0.1";
uint localPort = 5432;

var dbHost = Environment.GetEnvironmentVariable("DATABASE_HOST");
var dbPort = uint.Parse(Environment.GetEnvironmentVariable("DATABASE_PORT"));
var dbName = Environment.GetEnvironmentVariable("DATABASE_NAME");
var dbUser = Environment.GetEnvironmentVariable("DATABASE_USER");
var dbPassword = Environment.GetEnvironmentVariable("DATABASE_PASSWORD");

using var tunnel = new ForwardedPortLocal(localhost, localPort, dbHost, dbPort);
client.AddForwardedPort(tunnel);

tunnel.Start();

var dbConnectionString = $"Host={localhost};Port={localPort};Database={dbName};Username={dbUser};Password={dbPassword};";

var host = ServiceA.Api.Program
    .CreateHostBuilder(args: new[] { "ConnectionStrings:ServiceA=" + dbConnectionString })
    .Build();

using (var scope = host.Services.CreateScope()) {
    var dbContext = scope
        .ServiceProvider
        .GetRequiredService<ServiceADbContext>();

    dbContext.Database.Migrate();
}

tunnel.Stop();
client.Disconnect();

在 gitlab ci/cd 中,terraform 作业使用:

image:
  name: hashicorp/terraform:1.1.6
  entrypoint:
    - "/usr/bin/env"
    - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

services:
  - docker:19.03.12-dind

variables:
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_REGISTRY_USER: "AWS"
  # set DOCKER_REGISTRY_PASS after authenticating to the registry