如何使用Terraform快速部署Docker?


【编者的话】 这篇文章解释了如何更细粒度地控制Terraform,将基于Ruby on Rails,Node.js和Scala构建的服务迁移到业务流程平台(例如AWS ECS)以及当服务建立起来时,如何有效地控制Terraform的执行以满足部署需求、CI/CD需求,并且能够有效地应用于有状态服务场景。
1.png

前言

很容易理解如何使用Terraform设置容器编排平台,例如AWS Elastic Container Service(ECS)。基本上,你只需要阅读文档,理解所涉及的部分,并像乐高一样把它们整合在一起即可。
2.png

Terraform支持设置所有与ECS相关的关键资源。你只需将各个部分组织在一起。

但不清楚的是,一旦你的服务建立起来,如何部署更新后的Docker镜像,特别是管理自己模式的有状态服务。此外,如何将其作为持续交付(CD)管道的一部分?例如,在下面的管道中,我们如何在部署步骤中使用Terraform以自动方式部署构建步骤中构建的最新Docker镜像,并且零停机时间,即使新镜像需要模式更新?这部分并不是那么清楚。
3.png

在代码提交(Source)上,构建一个新的Docker镜像(Build),并将其部署到ECS(Deploy)上,同时处理任何潜在的架构更新。

当我们计划将基于Ruby on Rails,Node.js和Scala构建的服务迁移到业务流程平台(例如AWS ECS)时,我们留下了一系列问题,无法找到明确的答案:
  1. 我们如何在持续交付(CD)管道中使用Terraform?
  2. 如果我们使用Terraform来配置我们的服务,我们将如何部署包含数据库架构更新的后续更新?
  3. 我们如何才能在我们的CD中限制Terraform所需的权限从而进行代码更新,而不是像数据库配置更新那样?
  4. 我们如何在保持蓝绿部署的同时实现这一目标?


我们开始想也许Terraform无法做到这些,也许Terraform不是这项工作的合适工具。但这很难接受,Terraform让我们如此接近我们的目标!它可以在ECS上启动和运行我们的整个服务,所有这些都在一个单一的Terraform应用中,但对于增量代码和模式更新,我们不得不使用不同的工具?这不太现实,对吧?

所以我们进一步挖掘,并且用一点聪明才智,我们想出了如何有效地控制Terraform的执行以满足我们的需求,并且能够解决我们的问题。在这篇文章中,我想和大家分享我们是如何做到的,以及我们在这一过程中学到了什么。

本文假设你对Terraform有一个基本的了解。虽然示例是使用AWS ECS(因为我们在ACL上使用ECS),但核心知识与ECS无关,并且通常可以与Terraform一起使用。

AWS弹性容器服务(ECS)

你可能知道Terraform是什么,但AWS ECS 是什么呢?它是一个容器编排平台,你可以在其中声明要运行的Docker容器,ECS会为你找出运行它们的最佳方法。它与Terraform相似,因为你声明了你想要的东西,ECS会负责实现它。虽然ECS还有很多内容,但就本文而言,我们需要了解的是ECS允许我们定义我们想要运行的容器(即ECS任务)以及如何运行它们(即ECS服务)。如果你正在使用Kubernetes,你可以通过对比Kubernetes PodServices来理解这篇文章。

命令式与声明式

在大多数情况下,可以很容易地使用Terraform和ECS来启动和运行。有大量的文档和示例可供复制。然而,当你想要更细粒度地控制基础设施和容器是如何形成的,这就变得具有挑战性了。在某些情况下,你不仅仅想声明你的需求,而是希望指定如何满足你的需求。这对于有状态服务尤为重要。例如:
  1. 在配置数据库时,你可能希望运行“种子”脚本来设置初始表定义、存储过程等,以便数据库从一开始就为应用程序的逻辑做好准备。
  2. 部署代码更新时,你可能需要事先执行“架构更新”。
  3. 使用持续部署(CD)管道应用更新时,你可能希望将CD的影响范围限制为仅更新容器。
  4. 最后,你希望以特定顺序完成所有这些操作,这样如果发生任何故障,部署将停止,以便你可以在继续之前进行调试。


如何使用Terraform实现这一点并非易事,需要一些技巧。特别是,它需要使用Terraform的null_resource资源,使用depends_on显式地定义依赖关系,以及用目标确定plan/ apply执行的范围。

null_resource - 在Terraform中执行任意逻辑

尽管Terraform提供了大量的程序,但在某些情况下你需要执行自己的逻辑。这是null_resource派上用场的地方。直接从文档中获取:


null_resource的行为与任何其他资源完全相同,因此你可以像配置其他资源一样配置提供程序连接详细信息和其他元参数。
例如,如果要配置种子数据库,可以在创建数据库后创建一个null_resource执行种子逻辑:
variable "password" { default = "password123" }

resource "aws_db_instance" "example" {
allocated_storage    = 10
storage_type         = "gp2"
engine               = "postgres"
instance_class       = "db.t2.micro"
name                 = "example"
username             = "user"
password             = "${var.password}"
}

resource "null_resource" "seed" {
provisioner "local-exec" {
command = "PGPASSWORD=${var.password} psql --host=${aws_db_instance.example.address} --port=${aws_db_instance.example.port} --username=${aws_db_instance.example.username} --dbname=${aws_db_instance.example.name} < seed.sql"
}


在这个简单的示例中,在创建数据库之后,null_resourcelocal-exec供应程序将在运行Terraform的本地环境中执行。它将执行psql以使用seed.sql脚本为数据库设定种子。在实践中,你可以在必要时使用其他配置程序和更复杂的脚本来实现相同的功能,但关键点仍然是相同的:使用null_resource,你可以执行任意逻辑作为常规Terraform执行的一部分。

实际上,使用null_resource,你可以在Terraform执行的任何时刻注入任意逻辑来控制发生的事情。这为你提供了极大的自由度和自定义Terraform执行的能力,而无需自定义提供程序。但Terraform如何知道在数据库创建后执行该逻辑的呢?要理解这一点,你需要了解Terraform的工作原理。

控制资源图

Terraform执行的一个重要特征是如何创建“资源图”来执行其工作。引言来自:

Terraform构建所有资源的图形,并并行化任何非依赖资源的创建和修改。因此,Terraform尽可能高效地构建基础架构,运营商可以深入了解基础架构中的依赖关系。

这个图表允许我们简单地声明我们想要配置的内容并让Terraform处理它。默认情况下,Terraform会尽可能地并行化,除非有依赖性阻止它这样做。因此,为了控制Terraform的执行顺序,我们需要考虑底层的依赖关系图。通过适当的依赖关系设置,我们可以并行或按顺序执行任务。要显式地定义顺序依赖关系,可以使用所有Terraform资源可用的depends_on属性。例如,默认情况下,将并行创建以下三个资源:
resource "null_resource" "first" {
provisioner "local-exec" {
command = "echo 'first'"
}
}

resource "null_resource" "second" {
provisioner "local-exec" {
command = "echo 'second'"
}
}

resource "null_resource" "third" {
provisioner "local-exec" {
command = "echo 'third'"
}


4.png

底层依赖图显示并行执行(简化视图)

但是,如果我们正确配置depends_on属性,我们可以按顺序创建它们:
resource "null_resource" "first" {
provisioner "local-exec" {
    command = "echo 'first'"
}
}

resource "null_resource" "second" {
depends_on = ["null_resource.first"]
provisioner "local-exec" {
    command = "echo 'second'"
}
}

resource "null_resource" "third" {
depends_on = ["null_resource.second"]
provisioner "local-exec" {
    command = "echo 'third'"
}


5.png

底层依赖图显示顺序执行(简化视图)

现在你可能已经开始看到我们如何使用Terraform将更新部署到有状态服务的解决方案了:使用null_resources,并使用适当的depends_on依赖关系对关键步骤进行顺序执行。尤其是:
  1. 定义要部署的新容器(即更新aws_ecs_task_definition资源)
  2. 如果需要,更新数据库架构(例如,使用模式更新脚本运行null_resource,在第1步的aws_ecs_task_definition上使用depends_on
  3. 部署新的容器(例如,从第1步更新aws_ecs_service以使用新容器,并在第2步中依赖null_resource


这可以确保在部署新代码之前我们的数据库架构是最新的。简直棒极了!

我们离目标越来越近了,但缺少了一些东西。对于相当复杂的服务,你不希望在代码部署期间对所有内容执行terraform apply。例如,你的Terraform逻辑可能设置DNS条目、设置Redis、设置负载均衡器等,这些都是你在代码部署期间不希望触及的。一次更改太多,它会将不经常更改的资源(例如DNS)与频繁更改的资源(例如应用程序逻辑)混合在一起,并且它需要你的CD拥有大量权限。为了缩小范围,我们需要使用另一个特性:Terraform的-target执行能力。

有针对性的改变

随着你的基础架构变得越来越复杂,Terraform资源的数量不断增加,让Terraform以自动化的方式简单地apply所有内容变得很危险。你想减少apply的范围。幸运的是,Terraform通过-target执行期间为你提供特定资源的选项来解决此问题。


-target选项可用于将Terraform的注意力仅集中在一部分资源上。
当我们想要针对单一资源时,我们很早就使用了这个。但是,当我们需要同时部署资源组时(例如ECS任务、ECS服务、null_resource等),这是不可行的。幸运的是,我们意识到我们可以通过组合null_resource,聚合将多个目标聚合为一个depends_on。例如:
resource "null_resource" "deployment" {
triggers {
revision = "${var.git_revision}"
}
depends_on = [
"aws_ecs_service.application",
"aws_ecs_task_definition.application", 
"null_resource.migrate"
]


因此,当我们可以执行terraform apply -target=null_resource.deployment时,由于与此资源相关的资源图,它也会更新所有关联的部署资源。在本例中,它确保只更新我们的aws_ecs_task_definitionaws_ecs_service以及通过null_resource.migrate运行任何架构更新。使用目标资源,我们可以放心地将CD发送到Terraform apply,而不会有任何意外的变化。

有了这三个关键部分,我现在可以与你分享ACL如何使用Terraform设置其部署的。

把它们放在一起 - 使用Terraform进行部署

在ACL中,我们使用AWS CodePipeline进行部署。虽然我将使用CodePipeline解释我们的解决方案,但可以在任何持续交付工具(例如Jenkins)上使用此解决方案。以下是我们在管道中使用Terraform部署的示例服务:
6.png

  1. 构建:我们创建服务的Docker镜像并推送到AWS ECR。
  2. 准备:我们使用Terraform与目标null_resource.prepare为我们的服务创建新的ECS任务,但尚未更新相关的ECS服务以使用它们。
  3. 批准:我们准备好所有东西,但等待明确的审批步骤进行部署。这使开发团队能够控制,我们的合规团队可以清楚地了解授权部署的人员。
  4. 部署:我们使用Terraform与目标null_resource.deploy来运行我们的架构更新脚本(如果有的话),并更新我们的ECS服务以使用新的ECS任务。


正如管道演示的那样,我们使用各种null_resources来控制Terraform的部署执行。我们的管道执行环境是我们纯粹为部署目的而构建的Docker镜像。它安装了Terraform,有了所需的其他语言和工具,我们null_resourceslocal-exec提供程序能够成功地执行。例如,我们在Ruby中编写了一个脚本来调度一次性ECS任务,我们用它来运行我们的架构更新脚本。以下是null_resource.migrate使用它并作为部署的一部分运行的资源。
resource "null_resource" "migrate" {
triggers {
revision = "${var.git_revision}"
database = "${aws_db_instance.application.address}"
}
depends_on = ["aws_ecs_task_definition.application"]
provisioner "local-exec" {
command = <<EOS
ruby ./ecs_task_runner.rb 
-f ${aws_ecs_task_definition.application.family} 
-v ${aws_ecs_task_definition.application.revision} 
-e .ecs/db_migrate.sh
EOS
}


这种混合自定义逻辑并在正确的时间执行它们的能力,使我们能够对如何完成部署有很大的控制权。这也是为什么我们不觉得有必要寻找单独的部署工具,因为我们可以根据我们的需求定制Terraform。

额外的知识

在此过程中,我们了解了一些额外的细节,我想我将与大家分享,以防你们走这条路:
  1. 非脚本语言(如Scala)可能需要单独的ECS任务才能运行迁移脚本。我们使用Play框架,运行应用程序的ECS任务没有执行模式迁移的代码,因此我们必须创建一个单独的ECS任务来运行迁移脚本。
  2. 我们在local-exec提供程序内部运行ECS任务的脚本最初是使用AWS CLI编写的;遗憾的是,我们了解到AWS CLI将在10分钟后超时。我们不得不在Ruby中重写它们,以支持更高的限制。
  3. 我们有意识地决定将持续集成管道(CI)与持续交付管道(CD)区分开来。CI是在专用于CI的独立第三方平台上完成的,团队可以快速轻松地运行其测试套件,而CD则在AWS CodePipeline上完成的,我们的基础架构团队使用它以安全、合规的方式将代码部署到生产环境中。


结束语

好了,无需单独的工具,你就可以使用Terraform以蓝绿色方式将你的服务部署到具有高级控制权的ECS上了。这并不意味着Terraform 应该是所有场景的部署工具。相反,它意味着Terraform可以成为部署工具,直到真正需要一个单独的工具。

事实上,通过保持简单性并在AWS CodePipeline中使用Terraform,我们可以依赖AWS的安全和合规服务,而不是依赖安全性较低的第三方CI/CD服务。这大大缩短了我们的交付时间,并开放了我们的基础设施团队的能力,以专注于其他挑战。AWS为我们管理的越多越好!

我希望这篇文章解释了如何更细粒度地控制Terraform。如果你有任何问题或建议,请在评论中分享。感谢你的阅读和评论!

原文链接:Docker Deployments using Terraform

译者:Mr.lzc,软件工程师、DevOpsDays深圳核心组织者,目前供职于华为,从事云存储工作,以Cloud Native方式构建云文件系统服务,专注于Kubenetes、微服务领域。

0 个评论

要回复文章请先登录注册