在 Terraform Cloud 上使用 GitOps 构建 CI/CD 管道

目录
我叫寺冈,是一名基础设施工程师。
正如标题所示,本文将介绍 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”)所用系统的开发。
他们将使用 SRE 编写的 Terraform 模块(如下所述)在 PRJ A 的 AWS 账户中构建该系统。
B队
该团队成员包括参与开发项目 B(以下简称“PRJ B”)所用系统的成员。
他们将使用 SRE 编写的 Terraform 模块(如下所述)在 PRJ B 的 AWS 账户中构建该系统。
SRE
这个团队由站点可靠性工程师 (SRE) 组成,旨在协助其他团队开发系统。
鉴于他们的角色,他们也可以被称为平台团队。A
团队和 B 团队负责编写 PRJ 中使用的 Terraform 模块。
他们还负责管理 Terraform Cloud 本身的配置,例如稍后将要介绍的 WorkSpace。
GitLab
它管理由 A 团队、B 团队和 SRE 编写的 Terraform 代码。
在 Terraform Cloud 中,管理这些源代码的服务称为版本控制系统 (VCS) 提供程序。
在本例中,我们使用 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 中 PRJ B 的工作区。
其概览与 PRJ A 的工作区相同。
私有模块注册表
Terraform Registry ( *6 相同
编写的 Terraform 模块都在这里进行管理。
AWS 云(PRJ A)
这是 PRJ A 的 AWS 账户。A
团队将使用此账户进行构建。
AWS 云(PRJ B)
这是 PRJ 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 推送 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 文件中,您可以编写模块接收的变量结构。
请务必为每个变量编写描述和默认值。
原因稍后会解释。
变量.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
提供商“aws” { access_key = var.access_key Secret_key = var.secret_key 区域 = var.region Should_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 控制台的“设置”>“VCS 提供商”项中添加 GitLab。
您可以通过在 Terraform Cloud 控制台的“设置”>“模块”>“添加模块”中导入 GitLab。
您刚刚添加的 VCS 提供程序将会显示出来,请选择它。

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

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

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

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

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

为项目 B(SRE)创建 WordSpace
创建一个 Terraform Cloud WordSpace 并将其移交给 B 团队。
您可以通过 Terraform Cloud 控制台,依次选择“Wordspaces”>“新建工作区”来创建一个 WordSpace。
由于我只想先创建工作区,稍后再添加设置,因此我选择“无 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 上管理的远程后端中创建的工作区。
后端.tf
Terraform { 后端 "remote" { 主机名 = "app.terraform.io" 组织 = "组织名称" 工作区 { 前缀 = "team_b_prj_b_prod" } } }
提供商.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 上进行管理,因此两者都不会在本地准备。
变量.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 由于我们将使用 WebHook,因此需要提前在 Slack 上进行设置。

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

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

选择合适的存储库。

点击“更新VCS设置”。

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

Terraform 计划将在 Terraform Cloud 上手动执行。
计划完成后,不会执行 Apply 操作,计划将处于等待审批状态,如下所示。
如果您想要执行 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