从我们手动配置和管理基础架构资源,到完全声明式的IaC。
by Theodore Kirkiris, Workable 高级站点可靠性工程师(SRE)Kirkiris 和 Konstantinos Rousopoulos, Workable 高级站点可靠性工程师(SRE)Rousopoulos
摘要在这篇文章中,我们将讨论我们在Workable的基础设施即代码(IaC)之旅。对于那些有IaC经验的读者,特别是已经采用Terraform作为实现这一目标的首选工具的人来说,你们已经了解了在组织和架构代码以使其无重复、可维护(考虑到一个错误或拼写错误可能会导致重要生产资源的误配置或丢失)同时使其可扩展、灵活的过程中所面临的挑战和挫败感,以满足快速成长公司的不断变化需求。虽然本文提供了我们多年来IaC的发展历程,但可以直接跳到最后一个部分,如果你们在寻找如何结构化IaC的建议。在那个部分,我们描述了我们当前的架构,我们认为它符合DRY原则,灵活,并且为我们的团队在有效地管理日益增长的基础设施方面带来了效率和信心的提升。
虽然Workable工作中一部分工程演化的简要介绍被提及以提供背景,但这绝不是一个完整的旅程。如果你想了解更多,可以看看我们工程VP在Voxxed Days上的精彩演讲(视频链接如下):https://www.youtube.com/watch?v=9QZu_bAgAqs。
当Workable在2012年成立时,一个由几名工程师组成的小团队开发了第一代系统,那是一个单块应用,需要很少的基础设施资源。我们使用PostgreSQL作为主要的持久层,Solr进行文本检索,以及Redis作为分布式缓存。我们的代码部署在Heroku,这是一个基于容器的平台即服务(PaaS),提供了集成的数据服务和强大的生态系统,用于部署和运行现代应用程序。这让开发人员可以专注于应用逻辑,而不必担心生产环境中的基础设施复杂性。
产品多年来一直在增长,到了2016年,工程团队已经引入了几个微服务以提供更多的功能来增强用户体验。然而,这些微服务并不完全适合单体架构的栈和领域模型,而且还有更多的微服务即将被引入。代码仍然部署在Heroku上,不过产品已经显著增长,我们已经开始使用外部供应商提供的服务(如云存储、数据库等),并且基础设施的维护和监控仍然由工程团队负责。这时,成立了SRE团队。
一开始,我们在评估情况并确定管理基础设施的方式时,所有事情都是手动设置和准备的。2017年,随着我们公司规模的扩大,我们到了一个需要更有效方式的地步。我们需要一个流程,帮助我们从越来越复杂且容易出错的重复、手动任务中转移出来,简化并标准化我们的基础设施配置,并让我们能够以最小的努力进行扩展。
这是我们与Terraform之间的故事:一段关于定义基础设施即代码的旅程,随着我们公司的发展和壮大,不断演进和转变。
1G IaC — 使用 Terraform基础设施作为代码(IaC)工具让您能够通过配置文件来管理基础设施。这些工具通过定义资源配置来帮助您安全且一致地构建、修改和维护基础设施,这些配置可以被版本控制、重复利用和共享。Terraform 是这类工具之一,由 HashiCorp 这家公司开发。
Terraform 可以让您使用 HashiCorp 开发的声明性语言(HCL:HashiCorp 配置语言)在易于阅读的配置文件中描述资源和基础设施。声明性语言描述了您希望基础设施达到的最终状态,而不是像过程式编程语言那样需要详细步骤的指令,例如在 IaC 领域中的 Ansible。
Terraform 还允许你通过维护一个状态文件来管理基础设施的整个生命周期,状态文件作为识别并应用更改的唯一来源,以确保基础设施的最终状态与定义的配置一致。此外,它还能确定资源间的依赖关系,并按正确的顺序创建或销毁。
刚开始的时候,我们的基础设施需求并不复杂,仅限于一个提供商。因此,我们的Terraform代码目录结构符合当时小型到中等复杂度基础设施的标准。
基础设施
└── aws
├── 生产环境
│ ├── eu-west-1
│ │ ├── ec2
│ │ ├── lambda
│ │ └── ...
│ ├── 全球区
│ │ └── iam
│ └── us-east-1
│ ├── ec2
│ ├── rds
│ └── ...
└── 准生产
└── ...
管理少量资源还可以应付,清晰明了,便于追踪资源分配情况,并能完全隔离生产环境和测试环境。然而,采用微服务架构并扩展至多个开发、测试和生产环境时,事情就变得复杂起来:
- 我们没有使用模块,因此我们的代码既不遵循DRY原则也不标准化。
- 随着同一个AWS资源的实例在同一个文件中越来越多,配置文件变得越来越繁琐,这导致:
– 更新或审查这些配置需要更多的时间和精力投入
– Terraform完成规划和执行所需的时间也越来越长
我们需要管理大量的微服务,每个微服务都有其特定的作用。
- 需要不同的资源
- 部署在几个不同的环境中,配置略有差异
并且可以 在代码里加一些业务逻辑,根据条件创建和配置基础设施。例如
- 一个微服务在生产环境中需要专属资源,但在开发或测试环境中可以采用共享资源,比如云存储服务
- 开发和测试环境的基础设施无需高可用性
不过,由于目录结构并没有反映我们的微服务架构,我们无法将一个微服务所需的所有资源打包在一起。这使得一次性提供或废弃这些资源变得困难,可能会留下一些孤儿资源。
除了上述内容,2018年,我们决定放弃Heroku。虽然Heroku曾经是一个优秀的选择,但它变得成本过高,并且它缺乏我们所需要的灵活性和详细的监控功能。这一决定显著增加了我们使用Terraform管理的基础设施的规模和复杂性。此时我们意识到,我们原先简单的架构不再具备可扩展性,需要重新考虑我们的IaC架构。
Terraform 是一个管理基础设施的优秀工具,但它在 2018 年时也有一些局限性。实现业务逻辑比较麻烦,共享配置文件也不够直接,改变目录结构以支持“每个环境一个应用”也无法原生处理。即使我们使用 Terraform 模块,仍然需要手动为每个模块和环境复制大量代码,使得配置远非符合 DRY 原则,也无法解决将资源打包在一起的需求。
你现在可能在想 CI/CD。我明白你的意思,但我们还没到那一步。
我们的目标是从基于资源的结构转变为基于微服务的结构,但Terraform缺少我们重新设计IaC所需的关键特性。这时,Terragrunt登场了。
2G — Terragrunt 救场Terragrunt 是由 Gruntwork 开发的 Terraform 的包装工具,提供了额外的工具来避免重复(DRY),管理和处理多个 Terraform 模块,并管理 远程状态。
Terragrunt 让我们可以,
- 保持 Terraform 代码 DRY
- 去掉重复的后端代码
- 从父目录继承配置信息
- 一次可以应用多个模块
这将使我们能够创建所需的微服务蓝图,并相应地重新组织我们的IaC。
在此阶段,我们重新设计了代码的目录结构,使其与我们基础设施的层级结构相匹配:这种设计在设计上不依赖于特定的云供应商或服务提供商,并且通过以下方式清晰地分隔了范围:
- 组织,例如预发布、生产环境等
- 环境设置,例如测试环境、开发环境、生产环境等
- 微服务,
自从我们决定重构并使我们的IaC更加一致以来,我们还需要为我们的资源建立一个新的、一致的命名方案,适用于Terraform资源和实际云中的资源。考虑到每个环境都是最高级别的抽象层次,并且资源不会跨环境共享,我们提出了一种命名约定,适用于Terraform资源名称、变量名称以及资源标签。
模块:有了清晰的目录结构和命名约定,我们开始把基础设施拆分成模块,来创建抽象并用架构术语描述我们的基础设施,而不是直接描述物理对象。按照 Terraform 的最佳实践,我们创建了可重用的模块来打包每个微服务的不同资源(通常来自不同的供应商),并融入业务逻辑以实现条件性的资源配置。
最后,我们的目录结构是这样的:
modules/
├── README.md
└── 组织
└── 基础设施
└── 环境
├── gke
│ ├── firewall.tf
│ ├── iam.tf
│ ├── main.tf
│ ├── nodepools.tf
│ ├── providers.tf
│ ├── remote_state.tf
│ └── variables.tf
├── 微服务1
│ ├── README.md
│ ├── iam.tf
│ ├── mongo.tf
│ ├── providers.tf
│ ├── s3.tf
│ └── variables.tf
└── 微服务2
├── README.md
├── cloudfront.tf
├── iam.tf
├── iam_policy.json
├── postgres.tf
├── providers.tf
├── remote_state.tf
├── s3.tf
├── s3_policy.json
└── variables.tf
通过切换到 Terraform 模块,我们实现了所需的抽象化。然而,为了实例化每个模块并设置输入变量的值、定义输出变量、配置提供者以及管理远程状态,仍然带来了大量的维护负担。
实时通过 Terragrunt,我们增加了一层抽象,并推广了我们代码在不同环境中的版本化、不可变的工件。该工具可以拉取远程的 Terraform 配置,这些配置存在于典型的 Terraform 代码里,并且需要输入值来适应不同环境的差异。
在单独的代码仓库中,遵循类似的目录结构,,我们为所有环境,定义了实际运行的代码。现在,它仅包含3个文件。
- [REQUIRED] 一个 Terragrunt .hcl 文件来指定代码的来源
- [REQUIRED] 一个 Terraform .auto.tfvars 文件,其中只应包含配置资源所需的键值对信息
- [OPTIONAL] 一个 Terraform 机密 .auto.tfvars 文件仅用于存储机密信息。为了安全地保管这些机密,我们使用 git-crypt 进行透明加密和解密,以确保在 Git 仓库中的文件内容的安全。
就这样,模块具有环境无关性,但配置代码在不同环境和组织中的每个微服务中都会有所不同。最终,我们的动态配置看起来像这样。
实时/
├── 生产
│ ├── org_config.auto.tfvars
│ ├── production1
│ │ ├── env_config.auto.tfvars
│ │ ├── gke
│ │ │ ├── terragrunt.hcl
│ │ │ ├── variables.auto.tfvars
│ │ │ └── secrets.auto.tfvars
│ │ ├── 微服务1
│ │ │ ├── terragrunt.hcl
│ │ │ └── variables.auto.tfvars
│ │ └── 微服务2
│ │ ├── terragrunt.hcl
│ │ └── variables.auto.tfvars
│ └── production2
│ ├── env_config.auto.tfvars
│ ├── gke
│ │ ├── terragrunt.hcl
│ │ ├── variables.auto.tfvars
│ │ └── secrets.auto.tfvars
│ ├── 微服务1
│ │ ├── terragrunt.hcl
│ │ └── variables.auto.tfvars
│ └── 微服务2
│ ├── terragrunt.hcl
│ └── variables.auto.tfvars
└── 预发布
├── dev
│ ├── env_config.auto.tfvars
│ ├── gke
│ │ ├── terragrunt.hcl
│ │ ├── variables.auto.tfvars
│ │ └── secrets.auto.tfvars
│ ├── 微服务1
│ │ ├── terragrunt.hcl
│ │ └── variables.auto.tfvars
│ └── 微服务2
│ ├── terragrunt.hcl
│ └── variables.auto.tfvars
├── org_config.auto.tfvars
└── qa
├── env_config.auto.tfvars
├── gke
│ ├── terragrunt.hcl
│ ├── variables.auto.tfvars
│ └── secrets.auto.tfvars
├── 微服务1
│ ├── terragrunt.hcl
│ └── variables.auto.tfvars
└── 微服务2
├── terragrunt.hcl
└── variables.auto.tfvars
就这样,代码方面一切都准备好了,而且大部分情况下运行得非常顺利。Terraform 是一个用于创建和管理你的应用程序将要运行的基础架构的工具。你可以用声明性的方式定义资源及其规格,它会帮你绘制依赖图,构建所有资源,并且维护一个状态以确保当前状态与期望状态的一致性。然而,但这不适用于软件管理。Terraform 被设计用来创建资源本身,而是不管理运行在这些资源上的软件。
我们将大部分工作负载运行在Kubernetes上,部分运行在VM中,这意味着我们需要管理多个集群和独立的服务器,并且需要一定程度的软件定制。例如,我们希望使用软件来构建GitOps管道(如Flux),增强集群的网络功能(如Istio),或在VM上启动服务(比如内部工具Redash和Airflow等)。
在确保绝对完整的前提下,需要一种一致、可靠且安全的方式来安装、配置和管理软件。这时,Ansible 就派上用场了。尽管当时有一些托管资源的 Terraform 提供器可用,但由于这些提供器的成熟程度,加上团队的能力和对 Ansible 的熟悉程度,我们主要用 Ansible 来处理特定的定制任务,比如软件安装。
Ansible
Ansible 是一种 IT 自动化(IaC)工具,可以配置系统、部署软件,还可以处理更复杂的 IT 任务,例如持续集成部署或零停机滚动更新。Ansible 执行一系列预定义的步骤,更注重自动化过程本身,而不是最终的结果状态。
虽然 Terraform 和 Ansible 并不互相排斥,但它们都是可以用于基础设施即代码(IaC)的工具。Terraform 采用的是 声明式方法,非常适合用来根据配置文件来配置、修改、管理和销毁基础设施资源。Ansible 主要是一个配置管理工具,采用的是 声明式方法,在需要按特定顺序执行特定步骤时表现出色,例如安装或更新软件、配置运行时环境、更新系统配置等。
将 Terraform 与 Ansible 结合使用,创建了一个灵活的工作流程,用于启动新的基础设施并配置所需的硬件和软件。我们利用 Terraform 的 local-exec 配置器在模块内执行 Ansible playbook,并使用模板根据 Terraform 变量自定义 playbook。这种混合方法使我们能够快速为集群启动依赖以支持工作负载。此外,因为 playbook 集成在模块中,我们能够确保跨环境的一致性,简化了维护和故障排除,因为所有配置的资源都将按照相同的方式配置……或者不完全是这样 :)
一切准备就绪,这意味着我们有一个良好的开端,但等我们完成时,我们开始遇到来自其他领域的限制。
议题WET 代码
我们大多数的微服务都需要各种类型的资源,例如 RDS 实例或 S3 存储桶。因此,我们决定从基于资源的设计转变为基于微服务的设计。然而,在不同模块中重复定义相同类型的资源的做法不仅导致了 WET 代码,还造成了配置的一致性问题。当需要进行这种更改时(例如,强制所有 S3 存储桶启用静态加密),这导致了巨大的认知负担在确保代码一致性时。
一统天下的模块
将不同的资源打包成可重用的模块并包含业务逻辑是一种正确的方式(我们仍然认为这是一个正确的方向)。然而,管理多个由不同团队提出不同需求的环境,这些需求都涉及到同一微服务的资源分配和配置,导致了适应各种可能场景所需大量业务逻辑的产生。
例如,让我们考虑一个需要云存储和一个服务账号来访问它的、一个PostgreSQL数据库的和一个CDN的单个微服务。一些可能的场景包括:
- QA团队:我们需要使用在X环境中创建的共享桶,这样就不用每次上传文件了
- 开发团队:我们需要使用在Y环境中创建的公共的CDN,这样就不用管理多个源站了
- 生产环境:需要资源分离和隔离机制
运行 Ansible 变得让人害怕
随着我们基础设施需求的增长和产品架构变得越来越复杂,我们遇到了一个问题,由 Ansible 进行的自定义和配置变得越来越难以管理。我们有一个超过 1000 行且包含 100 多个 Ansible 任务的 playbook,这使得控制和追踪变更变得很难。与 Terraform 不同,Ansible 并没有提供一种明确的方式来判断哪些任务会修改资源。这使得每次在生产环境中运行 Ansible 时都会感到焦虑,尤其是随着越来越多的关键软件被 Ansible 管理时。
当时我们做出了决定:
- 将可行的部分移到Terraform提供者中:此时,Kubernetes和Helm操作员已经足够成熟,所以我们开始使用它们,不再直接运行_kubectl_命令或安装Helm图表。
- 从Terraform中移出Kubernetes服务的安装过程:在可能和适当的情况下,我们将Kubernetes服务的安装流程移至由Flux管理的GitOps工作流中,就像其他Kubernetes服务一样。
- 将剩余任务拆分成为独立剧本:为了简化管理和提高灵活性,我们将剩余的任务拆分为更小的剧本,专注于执行简单且独立的任务,以便我们可以根据需要分别运行它们。
文档
随着模块变得越来越复杂,它们变得难以理解、更新,甚至在新环境中设置资源。这一点很明显,我们缺少相关文档来清楚地概述功能、所需的输入变量值以及预期的结果或输出。
升级到 v1.x.x 版本之后,我们处于一个很好的位置来评估当前结构的局限性以及我们遇到的问题。
我们之前采用的基于微服务的IaC组织方式已经证明,在创建必要的构建块并融入所需的企业逻辑以满足我们的业务需求方面是有益的。这种方法使得添加或更新微服务变得简单。例如,如果微服务X需要使用一个NoSQL数据库,我们只需扩展其模块以包含Mongo集群,并在所有部署环境中应用此变更。然而,当我们需要在所有微服务中进行类似更改(比如升级所有NoSQL数据库)时,这会变得越来越麻烦。随着公司的发展,我们不得不尽快解决这些问题,因为代码库变得越来越大且难以维护,实现更复杂的需求也越来越有挑战性。
我们来看看现在的架构:
子模块们作为核心资源为了处理我们之前遇到的问题,我们决定通过引入[子模块]来增加一层抽象层。
为了开始开发儿童模块,我们需要制定一些明确的规则以确保一致性和统一性:
- 将我们始终需要一起部署的 Terraform 资源打包在一起。例如,一个 PostgreSQL RDS 实例,它配有一个参数、一个子网和一个安全组。
- 设置并强制执行特定资源在整个基础设施中适用的默认值。例如,S3 存储桶必须阻止公共访问,拒绝 HTTP 请求,并使用服务器端加密。
- 不得包含任何业务逻辑。
此处,“业务逻辑”指的是我们如何根据环境和应用的具体情况配置 Terraform 资源,以及如何将这些资源打包的方式。所有业务逻辑应保留在根模块中。 - 包含属于单一提供者的资源。
一个子模块应当仅包含来自同一提供商的资源。这在我们为这类子模块所决定的文件结构中更加清晰地体现出来。
以下结构表示使用Terraform的模块,包含不同云服务提供商和资源类型:
modules-terraform/
├── aiven
│ └── kafka
│ ├── README.md
│ ├── main.tf
│ ├── outputs.tf
│ ├── variables.tf
│ └── versions.tf
├── aws
│ └── db_instance
│ ├── README.md
│ ├── main.tf
│ ├── outputs.tf
│ ├── variables.tf
│ └── versions.tf
└── gcp
└── storage_bucket
├── README.md
├── main.tf
├── outputs.tf
├── variables.tf
└── versions.tf
子模块可以组合起来形成更全面的子模块。例如,一个创建 Postgres 数据库的 AWS RDS 实例,我们有一个包含标准 Postgres 配置的子模块,无论服务提供商是谁,我们都需要这些配置。然后 RDS 子模块会使用这个子模块,它包含了所有与 RDS 相关的标准配置(如日志记录、SSL、静态数据加密等),并利用 Postgres 子模块来处理所有特定于 Postgres 的标准配置。
所以,我们新的结构体系加上这些子模块可以表示如下:
Terragrunt根模块(root模块)用于资源捆绑和业务流程逻辑
Terraform 的根模块会创建或实例化多个子模块,并负责为特定的微服务提供所有资源,以及根据环境和微服务的具体需求来提供相应的“业务逻辑”。
比如说,假设我们要创建一个RDS数据库用于一个微服务应用。
我们这边的要求是:
- 为每个环境/应用应用命名约定(业务逻辑)
云基础设施的命名约定为:
<环境名>-<服务名>-<资源类型>-<随机ID> - 在整个基础设施中应用标准标签(业务逻辑)
标签应当始终包含 name 、 provisioner 、 team 、 application 、 environment 、 organization - 从远程状态使用VPC安全组(业务逻辑)
- 一次性创建所有必需资源(子模块)
创建一个RDS PostgreSQL实例、一个强制SSL的参数组、一个子网组和一个允许所有出站流量的安全组。
在这种情况下,为了满足始终一起提供这些资源的要求,所有资源将由子模块创建。子模块还将处理不强制SSL的验证,因为对所有数据库实例而言,SSL是一个强制性的安全要求。
variable "db_parameter_group_parameters" {
description = "描述"
type = "类型"
default = "默认值"
validation {
condition = alltrue(
[
对于 parameter in var.db_parameter_group_parameters :
(
不包含(["rds.force_ssl"], parameter["name"])
)
]
)
error_message = "您不能覆盖force_ssl参数。"
}
}
有了我们新的结构,只需要40行代码就能轻松搞定。
locals {
identifier = (var.microservice_pg_identifier == null
? "${var.env_name}-${var.microservice_name}-pg-${random_id.id.hex}"
: var.microservice_pg_identifier
)
db_parameter_group_name = "${var.env_name}-${var.microservice_name}-postgres-${element(split(".", var.microservice_pg_engine_version), 0)}"
db_subnet_group_name = "${var.env_name}-${var.microservice_name}-${var.aws_region}-db-subnet"
db_security_group_name = "${var.env_name}-${var.microservice_name}-${var.aws_region}-db-sg"
tags = {
name = local.identifier
provisioner = "terraform"
team = var.team_name
app = var.microservice_name
env = var.env_name
org = var.org_name
}
}
resource "random_id" "id" {
byte_length = 2
}
module "microservice_pg" {
source = "../../../../modules-terraform/aws/db_instance"
identifier = local.identifier
engine_version = var.microservice_pg_engine_version
allocated_storage = var.microservice_pg_allocated_storage
vpc_security_group_ids = [
data.terraform_remote_state.vpc.outputs.postgres_security_group_production_id,
data.terraform_remote_state.vpc.outputs.postgres_security_group_staging_id,
data.terraform_remote_state.vpc.outputs.postgres_security_group_services_id,
]
backup_retention_period = var.microservice_pg_backup_retention_period
db_subnet_group_name = local.db_subnet_group_name
db_subnet_group_subnet_ids = data.terraform_remote_state.vpc.outputs.vpc_id
db_parameter_group_name = local.db_parameter_group_name
db_parameter_group_family = var.microservice_pg_parameter_group_family
db_security_group_name = local.db_security_group_name
db_security_group_vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id
tags = local.tags
}
配置
在实时配置中,Terragrunt 用于设置全局配置变量,生成所需的provider版本,并管理被白名单化的IP。
Terraform版本管理
为了管理我们Terraform提供程序的版本,我们使用Terragrunt在每个模块中生成_versions.tf_文件。在我们的根Terragrunt配置中,我们添加一个_generate_块,并在_locals_块中,我们解码包含当前使用的提供程序版本的YAML文件。这使我们能够一次性更新所有模块中的提供程序版本。如果我们希望特定模块使用不同版本的提供程序,我们可以在该模块的配置中进行覆盖。
根目录 terragrunt.hcl
locals {
provider_version = yamldecode(file("provider_versions.yaml"))
[...]
}
generate "versions" {
path = "versions.tf"
if_exists = "overwrite"
内容 = <<EOF
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "${local.provider_version.aws}"
}
[...]
}
}
EOF
}
provider_versions.yaml (配置文件)
aws: "3.74.1" # 参见 https://registry.terraform.io/providers/hashicorp/aws/3.74.1 获取更多详情
google: "3.90.1" # 参见 https://registry.terraform.io/providers/hashicorp/google/3.90.1 获取更多详情
等等
还有Ansible呢?
如上文所述,我们决定简化使用Ansible,并尽可能将操作转移到更适合的IaC或GitOps操作中。在我们的IaC代码中,我们仅使用Ansible,进行:
- 安装Istio。在我们进行重构时,Istio唯一可用的适合生产环境的安装方法是istioctl,而且从_istioctl_切换到Helm图表仍然需要删除并重新安装,这在我们的生产环境中需要仔细规划。
- 配置和管理虚拟机。像Ansible、Chef和Puppet这样的流程工具仍然是配置和维护虚拟机的最佳选择。Ansible的一个优势是可以作为客户端运行,因此虚拟机不需要任何配置管理工具访问它。
我们不想对此过多解释,因为它超出了本文的范围,不过请继续关注我们的后续更新。
接下来将有一篇文章,描述我们的IaC CI/CD。
共同学习,写下你的评论
评论加载中...
作者其他优质文章