使用 Terraform Cloud 和 GitOps 构建 CI/CD 流水线

目录
大家好,我是Teraoka,一名基础设施工程师。
正如标题所示,这篇文章是关于Terraform Cloud的。
我会一步一步地讲解,从概述到实际应用。
Terraform 执行环境
运行 Terraform 时使用什么环境?
最基本的可能是本地环境。这种
下载页面 (*1下载二进制文件
直接在本地机器上运行 `terraform apply`。
如果您只是独自进行测试,这当然没问题,但实际构建系统时,几乎总是由多人协作完成的。
在这种情况下,如果每个人都在自己的本地环境中运行,就会出现以下问题:
问题
- 忘记推送到 Git 可能会导致每个人的本地代码存在差异。
- 无法共享 tfstate 文件
- 目前没有审查已编写代码的机制。
- 任何人都可以自由申请。
- 云平台的访问密钥必须在本地进行管理。
这些问题可以通过使用 Terraform Cloud 来解决。
什么是 Terraform Cloud?
Terraform 官方网站(*2以下引自
Terraform Cloud 是一款帮助团队协同使用 Terraform 的应用程序。它在一致且可靠的环境中管理 Terraform 运行,并提供对共享状态和密钥数据的便捷访问、用于审批基础设施变更的访问控制、用于共享 Terraform 模块的私有注册表、用于管理 Terraform 配置内容的详细策略控制等等。.
总而言之,Terraform Cloud 是一款帮助团队协同使用 Terraform 的应用程序,
它是一款 SaaS 服务,提供团队协作使用 Terraform 时所需的功能,例如:
主要特点
- 稳定可靠的 Terraform 执行环境
- 共享诸如状态文件(tfstate)和访问密钥之类的机密信息
- 基础设施变更审批的访问控制
- 用于共享 Terraform 模块的私有仓库
- 用于管理 Terraform 配置的策略控制
Terraform Cloud 基本免费使用,但部分功能需要付费套餐。
请确定团队需要哪些功能,并选择合适的套餐。
各套餐的功能和价格*3汇总在
请注意,下面描述的所有 Terraform Cloud 使用方法都可以使用免费套餐进行设置,因此
最好先从免费套餐开始,如果需要更多功能,以后再切换到付费套餐。
配置图

总结图中的人物和工作流程。
人物
A队
该团队由参与项目 A(以下简称“PRJ A”)所用系统开发的成员组成。
他们将在 PRJ A 的 AWS 账户中构建该系统,并使用下文所述的 SRE 编写的 Terraform 模块。
B队
该团队由参与项目 B(以下简称“PRJ B”)系统开发的成员组成。该系统
将基于 PRJ B 的 AWS 账户构建,并使用下文所述的 SRE 编写的 Terraform 模块。
SRE
这个团队由站点可靠性工程师 (SRE) 组成,致力于协助其他团队进行系统开发。
它也可以被称为平台团队。该
团队编写 A 团队和 B 团队在其项目中使用的 Terraform 模块。
他们还管理 Terraform Cloud 本身的设置,包括工作区(稍后会详细介绍)。
GitLab
它管理由 A 团队、B 团队和 SRE 编写的 Terraform 代码。
在 Terraform Cloud 中,这种源代码管理服务被称为 VCS Provider。
这次我们使用的是 GitLab 社区版。
当然,企业版和 GitHub 也同样支持。(*4)
存储库模块 VPC
这是一个用于管理SRE用于构建AWS VPC的Terraform模块的仓库。
在Terraform Cloud中,VCS提供程序内的仓库称为VCS仓库。
存储库 PRJ A
这是一个用于管理 A 团队为 A 项目编写的 Terraform 代码的存储库。
它的概览类似于存储库模块 VPC。
存储库 PRJ B
这是一个用于管理 A 团队为 B 项目编写的 Terraform 代码的存储库。
它的概览类似于存储库模块 VPC。
WorkSpace PRJ A
这是 Terraform Cloud 中 PRJ A 的工作区。
工作区是一个逻辑组,用于将 Terraform 代码中编写的配置划分
为有意义的单元,例如按 PRJ 或按服务划分。(*5)
工作空间 PRJ B
这是 Terraform Cloud 中项目 B 的工作区。
其概览与项目 A 的工作区相同。
私有模块注册表
Terraform Registry (*6相同
编写的 Terraform 模块都在这里进行管理。
AWS 云(PRJ A)
这是 A 项目的 AWS 账户。A
团队将在此账户上进行开发。
AWS 云(PRJ B)
这是项目 B 的 AWS 账户。B
团队将在此账户上进行开发。
工作流程
执行任务的人员或工具列在括号内。
1.推送模块代码(SRE)
SRE 人员在本地编写 Terraform 模块并将其推送到 Git。
代码模块化有助于 SRE 人员和团队之间合理划分工作。
2. 导入模块代码(SRE)
导入您推送到 Terraform Cloud 私有模块注册表的模块。
3. 创建工作区(SRE)
为每个团队创建一个 Terraform Cloud 工作区供其使用。
4. 推送项目基础设施代码(A 团队或 B 团队)
每个团队都使用 SRE 编写的模块,并将他们编写的 Terraform 代码推送到 Git。
5. VCS 提供商和自动规划(Terraform Cloud)
Terraform Cloud 会检测 Git 推送事件,并自动对推送的代码运行 `terraform plan` 命令。
这种机制使您能够充分利用 GitOps,实现从 Git 变更开始的 CI/CD 流程自动化。
6. 代码审查与批准(A组或B组)
Terraform 计划完成后,Terraform Cloud 将进入等待应用状态。
应用设置之前,请检查计划结果,并
在确认更改符合预期后批准应用。
7. 应用(Terraform Cloud)
这些更改实际上已应用于目标环境。
8. 通知(Terraform Cloud)
当 Terraform Cloud 执行某些操作时,它会通过 Slack 等方式通知您。
审查实施方案和工作流程
从 SRE 向 B 团队推送 Terraform 模块到
整个流程
作为前提条件,我们假设您已经创建了 Terraform Cloud 账户和组织。
提前前往注册页面 (*7请
编写 Terraform 模块(SRE)
这次,我们将编写一个用于构建 VPC 的模块。
目录结构如下:
$ tree . ├── README.md ├── examples │ └── vpc │ ├── main.tf │ ├── outputs.tf │ ├── provider.tf │ ├── terraform.tfstate │ ├── terraform.tfstate.backup │ ├── terraform.tfvars │ └── variables.tf ├── main.tf ├── outputs.tf └── variables.tf 2 个目录,11 个文件
目录根目录下的三个文件构成了模块本身。
由于该模块将由 A 团队或 B 团队编写的代码加载,因此建议
在 README.md 文件中包含模块的概述和规范,并
在 examples 部分提供具体的使用示例代码,以便其他人日后更轻松地使用。
在 main.tf 中,编写创建 VPC 的资源。
main.tf
resource "aws_vpc" "vpc" { cidr_block = var.vpc_config.cidr_block enable_dns_support = var.vpc_config.enable_dns_support enable_dns_hostnames = var.vpc_config.enable_dns_hostnames tags = { Name = var.vpc_config.name } } resource "aws_subnet" "public" { for_each = var.public_subnet_config.subnets vpc_id = aws_vpc.vpc.id availability_zone = each.key cidr_block = each.value map_public_ip_on_launch = true tags = { Name = "${var.public_subnet_config.name}-${substr(each.key, -2, 0)}" } } resource "aws_subnet" "dmz" { for_each = var.dmz_subnet_config.subnets vpc_id = aws_vpc.vpc.id availability_zone = each.key cidr_block = each.value map_public_ip_on_launch = false tags = { Name = "${var.dmz_subnet_config.name}-${substr(each.key, -2, 0)}" } } resource "aws_subnet" "private" { for_each = var.private_subnet_config.subnets vpc_id = aws_vpc.vpc.id availability_zone = each.key cidr_block = each.value map_public_ip_on_launch = false tags = { Name = "${var.private_subnet_config.name}-${substr(each.key, -2, 0)}" } } resource "aws_route_table" "public" { count = var.public_subnet_config.route_table_name != "" ? 1 : 0 vpc_id = aws_vpc.vpc.id tags = { Name = var.public_subnet_config.route_table_name } } resource "aws_route_table" "dmz" { count = var.dmz_subnet_config.route_table_name != "" ? 1 : 0 vpc_id = aws_vpc.vpc.id tags = { Name = var.dmz_subnet_config.route_table_name } } resource "aws_route_table" "private" { count = var.private_subnet_config.route_table_name != "" ? 1 : 0 vpc_id = aws_vpc.vpc.id tags = { Name = var.private_subnet_config.route_table_name } } resource "aws_internet_gateway" "igw" { count = var.public_subnet_config.internet_gateway_name != "" ? 1 : 0 vpc_id = aws_vpc.vpc.id tags = { Name = var.public_subnet_config.internet_gateway_name } } resource "aws_route" "public" { count = var.public_subnet_config.route_table_name != "" ? 1 : 0 route_table_id = aws_route_table.public[0].id destination_cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.igw[0].id depends_on = [aws_route_table.public] } resource "aws_route" "dmz" { count = var.dmz_subnet_config.route_table_name != "" ? 1 : 0 destination_cidr_block = "0.0.0.0/0" route_table_id = aws_route_table.dmz[0].id nat_gateway_id = aws_nat_gateway.natgw[0].id depends_on = [aws_route_table.dmz] } resource "aws_route_table_association" "public" { for_each = aws_subnet.public subnet_id = each.value.id route_table_id = aws_route_table.public[0].id } resource "aws_route_table_association" "dmz" { for_each = aws_subnet.dmz subnet_id = each.value.id route_table_id = aws_route_table.dmz[0].id } resource "aws_route_table_association" "private" { for_each = aws_subnet.private subnet_id = each.value.id route_table_id = aws_route_table.private[0].id } resource "aws_eip" "natgw" { count = var.dmz_subnet_config.route_table_name != "" ? 1 : 0 vpc = true tags = { Name = var.dmz_subnet_config.nat_gateway_name } } resource "aws_nat_gateway" "natgw" { count = var.dmz_subnet_config.route_table_name != "" ? 1 : 0 allocation_id = aws_eip.natgw[0].id subnet_id = aws_subnet.public[keys(aws_subnet.public)[0]].id tags = { Name = var.dmz_subnet_config.nat_gateway_name } depends_on = [aws_internet_gateway.igw] }
在 outputs.tf 中,编写输出,该输出会输出有关模块中创建的资源的信息。
outputs.tf
输出“vpc”{ 值 = aws_vpc.vpc } 输出“public_subnet”{ 值 = aws_subnet.public } 输出“dmz_subnet”{ 值 = aws_subnet.dmz } 输出“private_subnet”{ 值 = aws_subnet.private }
`variables.tf` 文件描述了模块将接收的变量结构。
务必为每个变量包含描述和默认值。
原因稍后会解释。
variables.tf
变量 "vpc_config" { description = "VPC 配置" type = object({ name = string cidr_block = string enable_dns_support = bool enable_dns_hostnames = bool }) default = { name = "" cidr_block = "" enable_dns_support = false enable_dns_hostnames = false } } 变量 "public_subnet_config" { description = "公共子网配置" type = object({ name = string route_table_name = string internet_gateway_name = string subnets = map(string) }) default = { name = "" route_table_name = "" internet_gateway_name = "" subnets = {} } } 变量 "dmz_subnet_config" { description = "DMZ 子网配置" type = object({ name = string route_table_name = string nat_gateway_name = string subnets = map(string) }) default = { name = "" route_table_name = "" nat_gateway_name = "" subnets = {} } } variable "private_subnet_config" { description = "私有子网配置" type = object({ name = string route_table_name = string subnets = map(string) }) default = { name = "" route_table_name = "" subnets = {} } }
以下部分提供了具体的代码示例。
内容涵盖模块的加载方式以及变量的传递方式。
例如,AWS 访问密钥应该从环境变量加载,而不是从 `terraform.tfvars` 文件中加载。
examples/provider.tf
provider "aws" { access_key = var.access_key secret_key = var.secret_key region = var.region assume_role { role_arn = var.role_arn } }
examples/variables.tf
变量“project”{ description = “项目名称” } 变量“environment”{ description = “环境” } 变量“access_key”{ description = “AWS 访问密钥” } 变量“secret_key”{ description = “AWS 私钥” } 变量“role_arn”{ description = “AWS 角色 ARN(用于承担角色)” } 变量“region”{ description = “AWS 区域” }
examples/terraform.tfvars
########################## # 项目 ########################### 项目 = "terraform-vpc-module" 环境 = "local" 区域 = "ap-northeast-1"
examples/main.tf
模块 "vpc" { source = "../../" vpc_config = { name = "vpc-${var.project}-${var.environment}" cidr_block = "10.0.0.0/16" enable_dns_support = true enable_dns_hostnames = true } public_subnet_config = { name = "subnet-${var.project}-${var.environment}-public" route_table_name = "route-${var.project}-${var.environment}-public" internet_gateway_name = "igw-${var.project}-${var.environment}" subnets = { ap-northeast-1a = "10.0.10.0/24" ap-northeast-1c = "10.0.11.0/24" ap-northeast-1d = "10.0.12.0/24" } } dmz_subnet_config = { name = "subnet-${var.project}-${var.environment}-dmz" route_table_name = "route-${var.project}-${var.environment}-dmz" nat_gateway_name = "nat-${var.project}-${var.environment}" subnets = { ap-northeast-1a = "10.0.20.0/24" ap-northeast-1c = "10.0.21.0/24" ap-northeast-1d = "10.0.22.0/24" } } private_subnet_config = { name = "subnet-${var.project}-${var.environment}-private" route_table_name = "route-${var.project}-${var.environment}-private" 子网 = { ap-northeast-1a = "10.0.30.0/24" ap-northeast-1c = "10.0.31.0/24" ap-northeast-1d = "10.0.32.0/24" } } }
examples/outputs.tf
输出“vpc_id”{值=module.vpc.vpc.id}
完成这些代码后,在 GitLab 中创建一个仓库,并将代码推送到 Master 分支。
在本例中,我们已经创建了一个名为“Terraform AWS Module VPC”的仓库。

标签被分配给 Git 提交。Terraform
Cloud 随后可以使用这些标签对模块进行版本控制。
$ git tag v1.0.0 $ git push origin v1.0.0
将模块导入 Terraform Cloud(SRE)
要从 GitLab 导入模块,
需要向 Terraform Cloud 添加 VCS 提供程序设置。
GitLab 的设置步骤(*8请参考
如果您使用的是其他版本控制系统 (VCS) 提供商,也有相应的设置步骤,请参考适用于您情况的步骤(*9)
之后,您需要将其添加到 Terraform Cloud 控制台的“设置”>“版本控制系统提供商”部分。
您可以通过在 Terraform Cloud 控制台中依次选择“设置”>“模块”>“添加模块”来添加它。
您刚刚添加的 VCS 提供程序将会显示出来,请选择它。

选定后,将显示 VCS 存储库,请选择您之前推送模块的存储库。

在确认屏幕上,单击“发布模块”。

发布完成后,您将看到模块的 README.md 文件和 Git 中标记的版本已加载。

您还可以看到需要传递给模块的变量列表。
如果您在定义变量时包含了描述和默认值,
则可以在此屏幕上查看详细信息,非常方便。

运行程序后,您还可以看到将要创建的资源列表,这很棒。

为项目 B(SRE)创建 WordSpace
创建一个 Terraform Cloud WordSpace 并将其传递给 B 团队。
您可以在 Terraform Cloud 控制台的“Wordspaces”>“新建工作区”下创建它。
由于我只想先创建工作区,稍后再添加设置,因此我选择“无 VCS 连接”。

输入工作区名称,然后点击“创建工作区”。
您可以使用任何您喜欢的名称格式,但使用类似“团队名称_项目名称_环境”这样的格式会更便于管理。

创建完成后,它将像这样出现在列表中。

为 PRJ B 创建一个代码仓库,并将 Terraform 代码(B 团队)推送上去。
我们将为 PRJ B 编写 Terraform 代码。B
团队将使用 SRE 团队已经编写好的模块。
目录结构
$ tree . ├── backend.tf ├── main.tf ├── outputs.tf ├── providers.tf └── variables.tf 0 个目录,5 个文件
首先,这里重要的文件是 backend.tf。
在这种情况下,运行 Terraform 后的状态文件 (tfstate) 将在 Terraform Cloud 上进行管理,因此
我们需要指定在远程后端创建的 WorkSpace。
后端.tf
Terraform { 后端 "remote" { 主机名 = "app.terraform.io" 组织 = "组织名称" 工作区 { 前缀 = "team_b_prj_b_prod" } } }
provider.tf
provider "aws" { access_key = var.access_key secret_key = var.secret_key region = var.region assume_role { role_arn = var.aws_role_arn } }
让我们来定义变量。
在变量中存储值时,可以使用 `terraform.tfvars` 或环境变量,但
这次我们将在 Terraform Cloud 上自行管理这些值,因此我们不会在本地进行任何准备工作。
variables.tf
##################### # 项目 ##################### 变量 "project" { description = "项目名称" } 变量 "environment" { description = "环境" } #################### # AWS 通用变量 #################### 变量 "access_key" { description = "AWS 访问密钥" } 变量 "secret_key" { description = "AWS 秘密密钥" } 变量 "role_arn" { description = "AWS 角色 ARN" } 变量 "region" { description = "AWS 区域" }
在 main.tf 中,指定源中导入到私有模块注册表中的模块。
main.tf
模块“vpc”{ source = "app.terraform.io/Org-Name/module-vpc/aws" version = "1.0.0" vpc_config = { name = "vpc-${var.project}-${var.environment}" cidr_block = "10.0.0.0/16" enable_dns_support = true enable_dns_hostnames = true } public_subnet_config = { name = "subnet-${var.project}-${var.environment}-public" route_table_name = "route-${var.project}-${var.environment}-public" internet_gateway_name = "igw-${var.project}-${var.environment}" subnets = { ap-northeast-1a = "10.0.10.0/24" ap-northeast-1c = "10.0.11.0/24" ap-northeast-1d = "10.0.12.0/24" } } dmz_subnet_config = { name = "subnet-${var.project}-${var.environment}-dmz" route_table_name = "route-${var.project}-${var.environment}-dmz" nat_gateway_name = "nat-${var.project}-${var.environment}" subnets = { ap-northeast-1a = "10.0.20.0/24" ap-northeast-1c = "10.0.21.0/24" ap-northeast-1d = "10.0.22.0/24" } } private_subnet_config = { name = "subnet-${var.project}-${var.environment}-private" route_table_name = "route-${var.project}-${var.environment}-private" subnets = { ap-northeast-1a = "10.0.30.0/24" ap-northeast-1c = "10.0.31.0/24" ap-northeast-1d = "10.0.32.0/24" } } }
outputs.tf
输出“vpc_id”{值=module.vpc.vpc.id}
写完之后,把它推送到 Git。

为 PRJ B(B 团队)在工作区中添加一个变量
从列表中选择您创建的工作区后,您将看到一个名为“变量”的项,
您可以在其中管理工作区中使用的变量的值。

其显著特点是,敏感信息(例如 AWS 访问密钥)会以敏感值的形式存储。
这样,您可以编辑该值,但它不会显示在屏幕上或 API 结果中,
这在添加需要隐藏的值时非常方便。
更改 PRJ B(B 团队)的工作区设置
在工作区设置中更改以下两个设置。
在通知设置中添加要向其发送通知的 Slack 频道的设置。
Terraform Cloud 流程(*8我们将参考
由于我们将使用 Webhooks,请确保事先在 Slack 端进行配置。

添加版本控制设置
将 PRJ B 的存储库注册为 VCS 存储库,以便在 WorkSpace 中读取。
从您的工作区,转到“设置”>“版本控制”。

选择您已预先注册的VCS提供商。

选择合适的存储库。

点击“更新VCS设置”。

稍等片刻,Terraform 代码将从代码库加载完毕。
由于变量已经设置好,我们点击“排队计划”按钮。

`terraform plan` 命令需要在 Terraform Cloud 上手动执行。
计划完成后,`Apply` 命令不会立即执行;而是会停留在待审批状态,如下所示。
要执行 `Apply` 命令,您需要检查计划结果并通过 `Confirm & Apply` 命令批准它。

一旦您批准,应用操作将按如下所示执行,并且设置将反映在目标环境中。

状态文件(tfstate)也在 Terraform Cloud 上进行安全管理。

由于我在通知设置中指定了 Slack 频道,通知功能就非常完美了。

检查 GitOps 是否可行(B 组)
我们已经确认手动运行时一切正常。
这次,我们希望它能响应 Git 推送自动运行。
让我们修改一些 Terraform 代码并检查其运行情况。
我们将更改 VPC 公有子网的部分名称并将其推送到 Git。
计划响应 Git 推送自动开始运行。

当然,这次它也在应用之前就停止了,只输出更改的部分作为差值。
没有问题,所以我们批准并应用。

申请已成功完成,看来没有问题。

概括
那么,您觉得怎么样?
团队使用 Terraform 时,需要考虑开头提到的那些问题。Terraform
Cloud 提供了一系列便捷的功能,能够很好地支持团队协作。
许多功能甚至在免费套餐中也可用,所以不妨试用一下。
参考网址
*1 https://www.terraform.io/downloads.html
*2 https://www.terraform.io/docs/cloud/index.html
*3 https://www.hashicorp.com/products/terraform/pricing/
*4 https://www.terraform.io/docs/cloud/vcs/index.html
*5 https://www.terraform.io/docs/cloud/workspaces/index.html
*6 https://registry.terraform.io
7 https://app.terraform.io/signup/account
*8 https://www.terraform.io/docs/cloud/vcs/gitlab-eece.html
*9 https://www.terraform.io/docs/cloud/vcs/index.html
*10 https://www.terraform.io/docs/cloud/workspaces/notifications.html#slack
0
