本章内容包括:
- 如何配置工作空间及其提供者
- Terraform块的基本语法
- 如何使用数据源查找数据
- 使用资源块管理基础设施
- 使用元参数改变资源的行为
- 使用模块创建可重用组件
在上一章中,我们简要介绍了Terraform和声明式语言。使用Terraform的声明式语言,你定义了系统的期望状态,然后Terraform根据这些定义构建和更新系统。可以将其比作建筑:建筑师制定计划,施工队根据该计划进行建设。在这种情况下,你是系统架构师,而Terraform则是你的施工队。
本章将讨论如何定义这些计划。就像建筑工人一样,Terraform有自己的语言,它会读取和解释这些语言来进行构建。在这里,我们不是在建造建筑,而是在构建由云提供商(如AWS、GCP或Azure)托管的相互关联的资源系统。随着系统的增长和发展,“蓝图”可以被更新,从而允许Terraform升级正在运行的系统,使其与这些蓝图保持一致。
在Terraform语言中定义基础设施有许多好处,除了让Terraform知道该构建什么之外,它还为设计决策提供了一个可信来源,并让其他开发人员清楚地看到正在构建的内容。即使他们以前没有使用过这些组件,他们也可以通过Terraform的语言识别出这些组件。
本章重点介绍Terraform语言的基本组件,特别是那些允许你实际启动和更新基础设施的组件。我们将从一个简单的Terraform模块示例开始,该模块启动一个AWS实例。从这里开始,我们将逐步拆解语言的不同组件,了解它们的功能以及它们如何相互作用。接下来的两章将进一步扩展语言的高级特性。
2.1 Hello World
虽然本书的重点是Terraform,但讨论Terraform时无法避免涉及基础设施和系统本身。Terraform是一个操作基础设施的框架。作为Terraform的开发人员,你将使用Terraform将自己的基础设施设计转换为代码。你是架构师,将各种组件结合起来,形成一个系统。
对于我们的第一个项目,我们将一起在AWS上启动一个虚拟机。我们将从实际的Terraform项目开始,接下来本章的内容将围绕这个项目的各个部分进行讲解。
2.1.1 调研与设计
在开始之前,我们需要做一些调研。我们想启动什么样的基础设施?这些资源依赖于什么?可以从几个方面入手:
我们想要启动的基础设施是AWS实例(在AWS云中运行的虚拟机),因此我们可以访问AWS网站获取相关文档。 大多数服务都有一个Web控制台,用来手动创建资源。这种手动创建的方式,亦称为ClickOps,虽然在生产环境中不适用,但对于学习非常有帮助。 Terraform提供者的文档也是学习不同系统的一个极好的地方。通过查找AWS实例资源文档(mng.bz/VVqr),我们可以看到哪些参数是可用的,哪些是必须提供的。
通过阅读文档,特别是Terraform资源的文档,我们可以确定启动实例的最低要求:
- ami——该参数用于指定Amazon机器镜像。它是AWS用来创建实例的模板,包括操作系统。
- instance_type——该参数告诉AWS应该运行哪种类型的实例。AWS有成百上千种不同的实例类型,每种类型的CPU数量、内存范围以及附加功能各不相同。
- subnet_id——这个参数告诉AWS应该在哪个网络中启动实例。它不是启动实例所必需的,但如果我们没有提供,AWS将会在默认子网中启动实例,这可能不是我们想要的位置。
这三个值是启动该实例所需的,但它们从哪里来呢?在某些情况下,这些值可以通过模式或参数动态查找。通过这种方式查找Amazon机器镜像(AMI)值非常常见。其他时候,配置来自用户自己,这部分我们将在第3章讨论。
2.1.2 创建项目
在接下来的几节中,以及在本书中,我们将会在终端中运行命令。如果你使用的是Windows,你可能需要安装Windows子系统Linux(WSL),因为许多为Terraform构建的工具在WSL中运行最佳。
首先,我们需要创建我们的新项目。在终端中,我们将创建一个新文件夹并初始化一个Git仓库,以便随着代码的发展保存我们的更改。未来,我们将讨论如何从现有模板启动项目,但现在让我们从一个空的Git仓库开始,展示项目的结构:
$ mkdir terraform_aws_modules
$ cd terraform_aws_modules
$ git init
接下来,我们将创建几个Terraform文件:main.tf、lookups.tf和providers.tf。我们可以随意命名这些文件,只要它们以.tf扩展名结尾,Terraform就会在制定计划时读取它们。然而,最好使用有意义的名称来命名文件,这样可以让其他人更容易理解你的代码。我们的provider文件将包含我们将要使用的提供者的基本配置,例如我们希望使用的提供者版本。我们将在lookups.tf文件中放置一些数据源,以便将一些配置值引入我们的代码,最后,主要的逻辑将放在main.tf文件中。在终端中,我们可以使用touch命令创建所有文件,或者使用你最喜欢的IDE来创建它们:
$ touch {main,lookups,providers}.tf
如果你使用的是Windows的Powershell,你应该使用new-item命令。
现在我们已经创建了Terraform文件,接下来就可以开始构建我们的程序了。
2.1.3 设置提供者
我们需要做的第一件事是告诉Terraform我们要使用哪些提供者。由于这个第一个项目旨在启动AWS实例,我们将需要使用AWS提供者来授予Terraform访问所需资源的权限。为此,我们必须编写第一段Terraform代码。
在上一章中,我们提到过Terraform和HashiCorp配置语言(HCL)。Terraform的HCL将配置分组在块中,不同的块类型用于配置不同的内容。这里我们将使用terraform块,这个块用于配置Terraform本身。这类似于许多JavaScript项目使用的package.json文件,或者Python项目使用的projects.toml文件。
就像这些语言使用各自的配置文件来定义它们使用的依赖项一样,我们也可以使用terraform块来设置所需的依赖项。我们将使用terraform块中的子块required_providers来告诉Terraform我们要安装AWS提供者。我们还将使用provider块来告诉AWS提供者我们希望登录哪个AWS区域来创建实例。我们将在本章后面讨论required_providers和provider块之间的区别,但主要要知道的是,required_providers告诉Terraform它需要安装哪些提供者,而provider块用于配置提供者本身。
列出2.1 配置所需的提供者
terraform { ①
required_providers { ②
aws = { ③
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" { ④
region = "us-east-1" ⑤
}
① Terraform设置块定义工作区设置。
② 这个子块告诉Terraform要安装哪些提供者。
③ 这个项目使用AWS提供者。
④ 提供者块用于配置提供者。
⑤ 通常将此设置为变量,而不是硬编码。
对于HashiCorp命名空间中的提供者,可以跳过required_providers块,因为Terraform会自动识别这些资源并安装正确的提供者。然而,这不被认为是一个好的实践。如果你没有显式定义提供者,你就无法指定要使用哪个版本,这可能会导致代码崩溃。
现在我们已经告诉Terraform使用AWS提供者(mng.bz/xKJq),我们就可以访问所有AWS资源和数据源了。
2.1.4 获取配置值
在我们的调研中,我们了解到AWS实例资源需要三个参数:AMI、实例类型和一个可选的子网。
其中最简单的就是实例类型。目前,我们仍处于开发阶段,因此我们可以将其硬编码为较便宜的实例系列。t3.micro实例类型是一个不错的选择,因为它非常便宜。一般来说,硬编码不是一个好的做法,在下一章中,我们将介绍可以让开发人员自己定义该值的输入变量。
另外两个参数稍微复杂一些。我们还需要确保使用最新的AMI,因为较新的机器镜像具有重要的bug修复和安全更新。因此,我们确实不想将AMI硬编码。使用基础设施即代码的一个优点是我们可以动态查找这些值,而不是硬编码它们,这样每次更新发布时,我们就不需要发布新版本的代码。
硬编码子网也是一个问题。如果我们硬编码了子网,那么我们只能在具有该特定子网的账户中运行这段代码,这意味着我们的代码将无法重用。通常,我们应该始终尽量让代码具有可移植性。
Terraform通过数据源提供了解决这些问题的方法。数据源是专门用于查找数据的特殊块。它们是只读的,永远不能实际进行更改,但它们可以用于执行查找并暴露可以在程序中重用的数据。
我们将创建的第一个数据源是查找AMI。为此,我们使用aws_ami数据源。为了使用它,我们需要设置一些参数,以便它查找我们想要的值:
- owners 是我们想要从中获取AMI镜像的账户列表。我个人偏爱Ubuntu操作系统,因此我查找了Ubuntu发布者的ID,以使用其公共AMI。
- most_recent 告诉过滤器我们想要最新发布的AMI。这将为我们提供最新的bug修复和安全补丁。由于我们将其设置为
true,这意味着每次发布新的AMI时,该数据源都会返回一个新的AMI。 - filter 是一个子块,这意味着我们可以对对象设置多个过滤器。我们将设置的第一个过滤器告诉数据源只拉取可以在我们使用的AWS EC2基础设施上启动的镜像。第二个过滤器告诉数据源只拉取与我们使用的名称匹配的镜像,这些镜像通常具有发布者特定的格式。
所有这些参数的组合为我们提供了一个单一的AMI,可以用来启动实例。
我们还需要查找我们的子网ID。为了查找子网ID,我们可以使用aws_subnets数据源。不过,这里有一点麻烦——要查找子网,我们需要提供一个虚拟私有云(VPC)。这就是基础设施构建如何级联成更大系统的一个例子——为了创建资源A,你需要B,而B可能又需要C。现在,我们可以使用aws_vpc数据源来查找该区域的默认VPC,然后将其传递给我们的子网。
我们将把所有这些数据源放入之前创建的lookups.tf文件中。
列出 2.2 使用Terraform查找数据
data "aws_vpc" "default" { ①
default = true
}
data "aws_subnets" "default" {
filter { ②
name = "vpc-id"
values = [data.aws_vpc.default.id] ③
}
}
data "aws_ami" "ubuntu" {
owners = ["099720109477"] ④
most_recent = true
filter { ⑤
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
}
① 数据源是只读查找。
② 这个数据块使用过滤器子块来限制返回哪些子网。
③ id属性是通过aws_vpc数据查找计算得出的。
④ 这是Ubuntu的AWS账户ID。
⑤ 块可以有子块。
有了这些配置,我们就可以获取到启动实例所需的值。
2.1.5 创建实例
现在进入真正的核心部分,我们将使用Terraform资源块来创建我们的aws_instance,并使用我们从数据查找中获取的值。在main.tf文件中,我们需要添加一个资源块来表示我们的aws_instance,然后将数据源中的属性映射到资源块的参数。
ami参数来自aws_ami数据源(mng.bz/AQ2E)。我们将该数据源的id属性传递给ami参数。
我们的subnet_id稍微复杂一些。我们用于查找的aws_subnets数据源返回的是一个子网ID的列表,而不是单个ID。我们将使用它找到的第一个子网并将其传递给subnet_id。在这里也可以添加额外的过滤器,例如按标签过滤。在包含公共和私有子网的更复杂网络中,使用和按标签过滤是一种常见的模式。
最后,我们将instance_type字段硬编码为我们可以用于测试的最便宜的实例。由于Terraform管理基础设施,而基础设施通常需要花费金钱,因此在选择时需要注意成本。在这种情况下,这意味着默认使用性能较低的基础设施,以防开发人员意外地花费比预期更多的钱。在第3章中,我们将讨论如何允许用户覆盖我们在这里做出的选择,从而选择更强大的机器。
列出 2.3 使用代码定义AWS实例
resource "aws_instance" "hello_world" { ①
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.default.ids[0]
instance_type = "t3.micro" ②
}
① 资源块映射到特定的基础设施。
② 这通常应该设置为变量,而不是硬编码。
到这里,我们的AWS实例已经定义好,准备启动了。
2.1.6 运行Terraform
现在我们要真正运行我们的代码并启动实例!
在开始之前,我们需要确保我们可以访问AWS账户。Terraform提供者没有标准的配置集,而是各自使用自己的配置系统。这意味着我们需要访问AWS提供者文档(mng.bz/ZlZj)了解它是如何处理配置的。AWS的Terraform提供者使用与其他AWS工具(如Boto3或AWS命令行界面CLI)相同的标准配置,因此我们可以安装AWS CLI并运行命令aws configure,然后通过这种方式输入我们的凭证。
完成这一步后,我们需要初始化工作区。此时,Terraform会下载任何已配置的提供者或模块。
列出 2.4 使用CLI初始化Terraform
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 4.0"...
- Installing hashicorp/aws v4.41.0...
- Installed hashicorp/aws v4.41.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control
repository so that Terraform can guarantee to make the same selections by
default when you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to
see any changes that are required for your infrastructure. All Terraform
commands should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget,
other commands will detect it and remind you to do so if necessary.
接下来,我们运行计划命令,以便确认Terraform将执行预期的操作。特别是,我们希望看到Terraform会为我们创建一个单一的资源——一个AWS实例。
列出 2.5 使用CLI规划Terraform
data.aws_vpc.default: Reading...
data.aws_ami.ubuntu: Reading...
data.aws_ami.ubuntu: Read complete after 0s [id=ami-0cb81cb394fc2e305]
data.aws_vpc.default: Read complete after 0s [id=vpc-cc5449a4]
data.aws_subnets.default: Reading...
data.aws_subnets.default: Read complete after 0s [id=us-east-2]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.hello_world will be created
+ resource "aws_instance" "hello_world" {
+ ami = "ami-0cb81cb394fc2e305"
+ arn = (known after apply)
+ associate_public_ip_address = (known after apply)
+ availability_zone = (known after apply)
+ cpu_core_count = (known after apply)
+ cpu_threads_per_core = (known after apply)
+ disable_api_stop = (known after apply)
+ disable_api_termination = (known after apply)
+ ebs_optimized = (known after apply)
+ get_password_data = false
+ host_id = (known after apply)
+ host_resource_group_arn = (known after apply)
+ id = (known after apply)
+ instance_initiated_shutdown_behavior = (known after apply)
+ instance_state = (known after apply)
+ instance_type = "t3.micro"
+ ipv6_address_count = (known after apply)
+ ipv6_addresses = (known after apply)
+ key_name = (known after apply)
+ monitoring = (known after apply)
+ outpost_arn = (known after apply)
+ password_data = (known after apply)
+ placement_group = (known after apply)
+ placement_partition_number = (known after apply)
+ primary_network_interface_id = (known after apply)
+ private_dns = (known after apply)
+ private_ip = (known after apply)
+ public_dns = (known after apply)
+ public_ip = (known after apply)
+ secondary_private_ips = (known after apply)
+ security_groups = (known after apply)
+ source_dest_check = true
+ subnet_id = "subnet-b96b6ed1"
+ tags = {
+ "CreatedBy" = "terraform"
}
+ tags_all = {
+ "CreatedBy" = "terraform"
}
+ tenancy = (known after apply)
+ user_data = (known after apply)
+ user_data_base64 = (known after apply)
+ user_data_replace_on_change = false
+ vpc_security_group_ids = (known after apply)
+ capacity_reservation_specification {
+ capacity_reservation_preference = (known after apply)
+ capacity_reservation_target {
+ capacity_reservation_id = (
➥known after apply)
+ capacity_reservation_resource_group_arn = (
➥known after apply)
}
}
+ ebs_block_device {
+ delete_on_termination = (known after apply)
+ device_name = (known after apply)
+ encrypted = (known after apply)
+ iops = (known after apply)
+ kms_key_id = (known after apply)
+ snapshot_id = (known after apply)
+ tags = (known after apply)
+ throughput = (known after apply)
+ volume_id = (known after apply)
+ volume_size = (known after apply)
+ volume_type = (known after apply)
}
+ enclave_options {
+ enabled = (known after apply)
}
+ ephemeral_block_device {
+ device_name = (known after apply)
+ no_device = (known after apply)
+ virtual_name = (known after apply)
}
+ maintenance_options {
+ auto_recovery = (known after apply)
}
+ metadata_options {
+ http_endpoint = (known after apply)
+ http_put_response_hop_limit = (known after apply)
+ http_tokens = (known after apply)
+ instance_metadata_tags = (known after apply)
}
+ network_interface {
+ delete_on_termination = (known after apply)
+ device_index = (known after apply)
+ network_card_index = (known after apply)
+ network_interface_id = (known after apply)
}
+ private_dns_name_options {
+ enable_resource_name_dns_a_record = (known after apply)
+ enable_resource_name_dns_aaaa_record = (known after apply)
+ hostname_type = (known after apply)
}
+ root_block_device {
+ delete_on_termination = (known after apply)
+ device_name = (known after apply)
+ encrypted = (known after apply)
+ iops = (known after apply)
+ kms_key_id = (known after apply)
+ tags = (known after apply)
+ throughput = (known after apply)
+ volume_id = (known after apply)
+ volume_size = (known after apply)
+ volume_type = (known after apply)
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ aws_instance_arn = (known after apply)
────────────────────────────────────────────────────────────────────────────
Saved the plan to: plan.tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "plan.tfplan"
该计划显示了Terraform打算执行的操作。在这种情况下,我们正在创建一个单一的资源,并且由于这是第一次运行,我们不会销毁或更改任何现有资源。由于我们的计划没有错误,且似乎在应用我们想要的配置,因此可以继续执行它。
列出 2.6 使用CLI执行terraform apply
aws_instance.hello_world: Creating...
aws_instance.hello_world: Still creating... [10s elapsed]
aws_instance.hello_world: Creation complete after 12s [
➥id=i-01792587739c8e453]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
成功!我们的程序已经创建了一个资源——一个ID为i-01792587739c8e453的AWS实例。
这是一个非常简单的Terraform示例,但它也非常强大。它可以在多个AWS区域和账户中启动完全相同的资源,而无需做任何更改。任何数量的开发人员都可以在自己的账户中使用它:尽管是一个人编写的代码,但成百上千的人可以从中启动基础设施。通过将实例类型暴露为变量,开发人员还可以在不修改任何内容的情况下自行更改实例类型。
对于单个实例来说,这很有趣,但对于整个软件堆栈,它就成了任何开发人员工具箱中的一项宝贵工具。启动一个实例和启动一个包含多个容器化服务、API网关、流式队列、内容分发网络和数据库的系统之间的唯一区别,是定义系统所投入的时间。一旦编写完成,该系统可以像这个单一实例脚本一样被反复启动。
在本章的剩余部分,我们将逐步解析脚本中讨论的所有组件,从块语法到各个块类型。
2.2 块语法
Terraform HCL围绕一个叫做“块”的构造构建。在我们之前的例子中,我们有几种类型的块:
- terraform设置
- provider(提供者)
- 数据源
- 资源(resource)
块是Terraform的主要语言构造,就像JavaScript、Python或Bash中的语句是主要语言构造一样。当你编写Terraform HCL时,你将创建一个块或在现有块中编写内容。
一个有用的思考方式是,块是Terraform语言中的名词。它们代表具体的项目,例如配置或基础设施。这回归到声明式语言的特点:定义某物应该是什么样子,而不是应该采取的操作。你在Terraform中的大部分时间将会是创建名词(通过块),然后描述或将这些名词链接在一起。
本章从许多方面讨论了所有不同类型的块及其对语言的贡献。所有块遵循相同的基本结构,无论它们做什么。这种结构包括定义块的外层部分,以及改变块行为的参数和子块。
列出 2.7 HCL块组件
type "label" { ①
parameter1 = "value" ②
parameter2 = "value" ③
parameter3 = true ④
parameter4 = 12 ⑤
parameter5 = { ⑥
key = "value"
}
parameter6 = null ⑦
subblock { ⑧
sub_parameter1 = "value"
sub_parameter2 = "value"
sub_parameter3 = "value"
}
subblock { ⑨
sub_parameter1 = "value"
sub_parameter2 = "value"
sub_parameter3 = "value"
}
}
① 块类型和标签组成一个标识符。
② 参数可以是各种类型……
③ ……包括字符串……
④ ……布尔值……
⑤ ……数字……
⑥ ……和对象。
⑦ 参数也可以设置为null类型。
⑧ 块可以包含子块。
⑨ 可以有多个相同类型的子块。
每个块的第一个词是块的类型,定义了所有其他内容如何被解释。标签包含在随后的引号中。块的其余部分被括号包围,包含参数和子块。块还可以包含其他属性,这些属性可以被其他块引用。
列出 2.8 使用实际资源的HCL示例
data "aws_subnets" "main" { ①
filter { ②
name = "vpc-id"
values = ["vpc-9ba9b5c6db85a9918"] ③
}
}
resource "aws_instance" "hello_world" { ④
ami = "ami-0eeb7197a00865f8a" ⑤
subnet_id = data.aws_subnets.main.ids[0] ⑥
}
① 数据源有两个标签。
② 数据源常常使用子块来进行过滤。
③ 这通常会是一个变量,稍后我们将讨论。
④ 资源也有两个标签。
⑤ 这通常也会是一个变量。
⑥ 该参数通过数据属性设置。
这是一个包含所有这些组件的实际示例。这里有两个不同的块:一个数据块和一个资源块。它们都有两个标签。第一个标签告诉Terraform块的子类型,第二个标签则为其添加一个唯一的标识符。data.aws_subnets.main块有一个类型为filter的子块,其中包含参数。resource.aws_instance.hello_world块仅包含参数,其中subnet_id参数获取data.aws_subnets.main.ids属性的值。
2.2.1 块类型
块类型是最重要的,因为它定义了块的目的以及如何解释其他组件。
如果你还记得第一章,Terraform是基于一种叫做HCL(HashiCorp配置语言)的语言构建的。Terraform并不是唯一一个基于HCL构建的工具。本书专注于Terraform HCL,但重要的是要记住,其他HashiCorp的产品也使用HCL,并且它们有着适应不同目的的不同HCL风格:Packer、Consul和Nomad是HashiCorp其他程序,它们也使用HCL,但具有非常不同的HCL风格。Packer是一个配置管理工具,用于创建机器镜像(比如我们在Hello World示例中使用的AMI),它有一套与Terraform完全不同的块类型。
你不需要了解这些其他工具才能使用Terraform,本书只关注Terraform HCL。然而,值得知道的是,除非你专门使用Terraform,否则Terraform块不可用,因此如果你使用其他程序,你需要了解它们特有的HCL风格。
截至本书写作时,Terraform HCL有12种不同的块类型,每种块都有自己的目的:
- Terraform:用于配置Terraform和当前工作区。
- Provider:允许进行提供者特定的设置。
- Resource:创建和更新相应的基础设施。这些块有来自提供者的子类型,可能有数十万种资源子类型。
- Data:类似于资源,但为只读并查找现有的基础设施组件。像资源一样,这些块有来自提供者的子类型。
- Variable:允许外部输入传递到程序或模块中。
- Locals:包含作用域限定在模块内的内部变量。
- Module:一个抽象,使得HCL代码可以重复使用。
- Import:将现有基础设施引入Terraform的一种方式。
- Moved:用于重构,可以更改资源的名称。
- Removed:允许标记某个项目为已删除,而不导致其被销毁。
- Check:用于验证已部署的基础设施。
- Output:一种方式,可以将模块内的数据共享给其他模块或工作区。
terraform和provider块通常在开始新项目时设置——它们基本上告诉Terraform你将与哪些供应商合作(即提供者)以及其他项目配置。resource块可能是Terraform中最重要的组件。正是这些块实际创建和更新你的基础设施。所有其他块本质上是支持resource块的,或者通过提供创建基础设施所需的配置值,或者帮助将资源块组织成可重用的组件。
2.2.2 标签和子类型
当你创建一个块的实例时,你需要一种方式来引用它。这很重要,因为某些块的属性或输出可以在其他块的参数中使用。如果你使用数据块来查找AMI,如第2.1节中所示,那么你需要一种方式来引用这个数据块,以便将AMI值传递给后续的资源块。因此,块的身份必须是唯一的,因为Terraform必须知道引用的是哪个块。
Terraform块有多种方法来定义其标签。标签是块之间的最大区别之一。标签也是不同块类型之间差异的第一个地方——不同的块类型以不同的方式处理标签。块标签有几种策略。
无标签
terraform设置块是两个完全不使用标签的块之一。每个模块只有一个terraform块,因此不需要区分它们。locals块(将在下一章讨论)是另一个不使用标签的块。
列出 2.9 没有标签的terraform块
terraform { ①
required_providers {
aws = { ②
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
① 这个块没有标签。
② 提供者块中的标签将与required_providers块中的键匹配。
单个标签
对于variable、provider、output和module块,只有一个标签字段。除了provider块,标签可以是用户希望的任何内容:provider块标签需要映射回Terraform required_providers块中的提供者。
列出 2.10 有单个标签的terraform块
provider "aws" { ①
region = "us-east-1"
}
① 这个块只有一个标签。
子类型和标签
data和resource块有两个标签,第一个作为子类型,第二个作为标识符。子类型非常重要,并且经常出现;它告诉Terraform数据块和资源块如何映射回它们的提供者以及它们应该执行的操作。这些通常被称为资源类型和数据源类型。
列出 2.11 带有子类型和标签的terraform块
resource "aws_instance" "hello_world" { ①
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.main.ids[0]
instance_type = var.instance_type
}
data "aws_vpc" "default" { ②
default = true
}
① 资源块有子类型和标签。
② 数据源块也有子类型和标签。
块类型和标签的组合形成了该特定块的唯一引用字符串。这个唯一的引用字符串必须在模块内是唯一的,因为它引用的是一个特定的块。它被用来在其他地方引用该块,主要是将该块的属性作为参数传递给其他块(见表2.1)。
表 2.1 块及其引用字符串示例
| 块类型 | 第一个标签或子类型 | 第二个标签 | 完整引用 |
|---|---|---|---|
| resource | aws_instance | hello_world | resource.aws_instance.hello_world |
| data | aws_vpc | default | data.aws_vpc.default |
| variable | instance_type | var.instance_type |
在某些情况下,引用的第一部分——块类型——会被省略。这发生在仅接受特定块类型的参数中。我们将在本章后面讨论的depends_on参数中看到这个例子,它接受一个引用其他块的列表作为参数。
2.2.3 参数和子块
如果块可以看作是名词,那么参数和子块则是形容词。每个块都有一组参数和子块,用来修改它们的行为。
参数通过赋值来表示,其中参数有一个名称和一个值。每个块内的参数名称只能使用一次。换句话说,你不能在一个块内定义相同的参数多次。值可以是任何Terraform表达式:本章中的示例使用的是简单的赋值表达式,但我们将在第4章讨论更复杂的表达式类型。
列出 2.12 带有参数的Terraform资源
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id ①
subnet_id = data.aws_subnets.default.ids[0] ①
instance_type = var.instance_type ①
}
① 这些参数特定于aws_instance类型。
子块是嵌套在另一个块内的块。它们没有标签,并且与参数不同,子块通常可以多次使用。对于数据块来说,这通常用于提供动态数量的过滤器,因为不同的过滤器可能需要堆叠在一起以获得所需的结果。一个有时可能会让人困惑的地方是,子块看起来很像具有对象值的参数:它们的主要区别是参数只能在块内定义一次,并且在标签和值之间使用等号,而子块可以重复定义,并且没有赋值符号。
列出 2.13 带有多个子块的Terraform数据源
data "aws_ami" "ubuntu" {
owners = ["099720109477"] ①
most_recent = true
filter {
name = "virtualization-type" ②
values = ["hvm"] ②
}
filter { ③
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
}
① 参数可以与子块一起存在。
② 子块可以有自己的参数和子块。
③ 子块与参数不同,可以多次调用。
一些资源还利用子块让开发人员清晰地添加相同类型的多个配置。如果一个团队正在使用GCP(谷歌云)并想为他们的实例创建一个防火墙,他们会安装GCP提供者并使用google_compute_firewall资源。这个资源使用子块来允许开发人员为他们的防火墙添加任意数量的防火墙规则。其他块类型,如资源生命周期块,更多地将子块作为命名空间来将设置分组,同时为未来与新块参数的重叠做好准备。
列出 2.14 使用多个子块的GCP防火墙
resource "google_compute_network" "example" { ①
name = "example-network"
}
resource "google_compute_firewall" "example" {
name = "example-firewall"
network = google_compute_network.example.name ②
allow { ③
protocol = "icmp"
}
allow { ④
protocol = "tcp"
ports = ["80", "443"]
}
allow { ⑤
protocol = "udp"
ports = ["53"]
}
source_ranges = ["0.0.0.0/0"]
}
① 这个块创建了一个网络。
② 这里我们引用了上面创建的网络。
③ 这个规则允许服务器被ping。
④ 这个规则允许HTTP流量。
⑤ 这个规则允许基于UDP的DNS流量。
资源和数据源都从它们的提供者中获取参数和子块,而模块的参数由模块开发者定义(将在第3章中讨论)。其他内置于Terraform中的块具有更一致的参数集。
2.2.4 属性
块不仅接收参数;它们还导出属性。这使得一个块可以将数据传递给另一个块,例如一个数据源查找子网ID,并将其传递给AWS实例资源,以便将实例启动在该特定子网内。
大多数块,包括数据块和资源块,都会自动将它们的所有参数暴露为属性。块还可以暴露额外的只读属性。这些只读属性通常来自Terraform执行计划(plan)或应用(apply)时,从底层提供者获取的结果。(参见图2.1。)
虽然参数是作为属性传递的,但子块则不是。子块中的任何内容都不能作为属性访问。
2.2.5 顺序
在第一章中,我们简要提到了Terraform计划。这些计划是Terraform执行操作的顺序。在典型的编程语言中,代码是按照编写的顺序运行的。每个语句按照它们出现的顺序一个接一个地执行。
在Terraform中,块的书写顺序没有意义。块可以按任何顺序编写,并不会影响计划本身。在我们的Hello World示例中,输出完全可以放到文件的顶部或完全不同的文件中。使用多个文件是一种非常常见的模式,我们将在深入讨论模块时讲到这一点。当Terraform执行计划阶段时,它通过确定哪些资源暴露的属性后来被用作另一个资源的参数,从而创建所有资源的有向无环图。
列出 2.15 一个数据源被另一个数据源引用
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" { ①
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id] ②
}
}
① 这个块依赖于上面的块,因为它使用了来自上面块的属性。
② 该资源的参数是data.aws_vpc.default中的属性。
在Hello World示例中,我们使用来自data.aws_vpc.default块的属性来创建data.aws_subnets.default。虽然这看起来很简单,但实际上有很多事情在幕后发生。Terraform会识别这些块之间的依赖关系:它会绘制出这个关系,并意识到必须首先加载data.aws_vpc.default块,只有在加载完成后,它才能获取该属性块并使用它来查找data.aws_subnets.default块。这个依赖关系映射是Terraform的强大功能之一,特别是当你管理大量资源时。Terraform还有一种方式,使用depends_on元参数直接管理这种关系,我们将在本章后面讨论。
2.2.6 风格
Terraform对块的样式有非常具体的要求:
- 元参数(Meta arguments),我们将在本章稍后讨论,位于顶部。
- 特定于块的参数紧随其后。它们可以按组进行组织,将相关的参数放在一起,每组之间用一个空行分隔。
- 特定于块的子块位于之后。
- 最后,任何元参数子块应放在最后。
此外,参数块中的“等号”应该始终对齐,以便值能够对齐在一起。
列出 2.16 terraform块样式
resource "resource_type" "unique_resource_name" {
provider = aws.dns ①
string_parameter = "value" ②
integer_parameter = 134
boolean_parameter = true
object_arguments = { ③
key1 : "value" ④
key2 = "value" ⑤
key3 = "value"
}
subblock { ⑥
subargument = "value"
subargument2 = "another_value"
}
lifecycle { ⑦
ignore_changes = [object_arguments]
}
}
① 元参数放在顶部。
② 参数接下来应放置,并且所有的等号应对齐。
③ 注释参数可以用空行分隔。
④ 冒号和等号都有效。
⑤ 不过,等号被认为是最佳实践。
⑥ 资源或数据特定的子块位于之后。
⑦ 元参数子块始终放在最后。
尽管Terraform有一个建议的样式指南(mng.bz/RVGn),但Terraform本身在创建计划时会忽略这些代码风格指南。如果不遵循这些规则,运行Terraform时不会出现问题。然而,遵循这些标准将使其他人更容易参与到项目中。Terraform CLI还带有一个命令terraform fmt,它将自动应用Terraform中的格式化规则(但它不会更改参数的顺序)。
2.3 Terraform设置
terraform设置块是Terraform特定设置的容器。这个块包含了各种Terraform设置:
- 必需的提供者(Required providers):在我们的Hello World示例中看到过,基本上是一个依赖项列表。
- 后端和云配置:用于有集中式后端的工作区的项目。这些配置允许开发人员在同一基础设施上同时工作。
- 实验性语言特性:这些特性是你只有在想测试Terraform正在实验的功能时才需要关注的。这些特性在版本之间会快速变化,因此必须显式启用。
- Terraform版本要求:可以显式定义脚本将适用于哪些版本的Terraform。这可以防止你的代码在缺少所需功能的版本上运行。
- 提供者元数据:通常只需要由分发提供者的人使用,你很可能永远不会真正遇到。
列出 2.17 terraform设置块
terraform {
experiments = [example] ①
required_providers { ②
aws = {
version = "~> 5.0.0"
source = "hashicorp/aws"
}
}
cloud { ③
organization = "example_corp"
hostname = "app.terraform.io"
workspaces {
tags = ["my-app"]
}
}
backend "s3" { ④
bucket = "mybucket"
key = "path/to/my/key"
region = "us-east-1"
}
}
① 启用模块的可选实验。
② 为模块要求的提供者,详细讨论见第2.4.2节。
③ Terraform Cloud配置。
④ 后端配置。请注意,backend或cloud应仅使用其中一个。
2.3.1 后端和云块
Terraform使用后端来存储状态,以便团队可以在一个工作区内协作,即使他们使用不同的机器分布在不同地方。如果没有定义这两个块之一,Terraform会默认使用本地后端,它只是将状态作为JSON文件存储在本地计算机上,这对于本地开发很有用,但绝不应该用于生产系统。
backend是Terraform多年来使用的标准块。它支持多种后端;例如S3、GCS、AzureRM和Consul等。它还具有HashiCorp称之为“增强”后端的功能,远程(remote),它用于提供额外功能的后端,超出了仅仅存储状态的范畴。我们将在第6章深入探讨状态时进一步讨论这一点。
Terraform Cloud的用户还可以使用一个特别的配置,叫做cloud。它替代了backend块,目前只用于Terraform Cloud产品。cloud是一个较新的块,专门支持Terraform Cloud,包括自托管的Terraform Enterprise实例。目前,这个块仅供Terraform Cloud的客户使用,但未来可能会有其他提供商采纳它。
每个后端都有自己的参数和配置要求。使用特定后端的用户,阅读该后端的文档(mng.bz/6egy)是非常重要的。与提供者不同,后端是硬编码在Terraform中的。这意味着你不能直接编写自己的后端。但是,有一个http后端(mng.bz/1X7Z),开发人员可以使用它来创建自己的后端,遵循简单的REST API。创建自定义后端通常不是Terraform用户需要做的事情。
2.3.2 实验功能
Terraform和OpenTofu的开发人员定期从社区征求有关新功能的反馈,其中一种方式是通过使用实验功能。实验本质上是Terraform中的新功能,但它们还没有准备好用于常规生产环境。它们会随着Terraform一起发布,但默认是禁用的,以防开发人员不小心使用它们。terraform块中的experiments参数允许Terraform的用户选择启用这些实验功能。
列出 2.18 启用实验功能
terraform {
experiments = [
module_variable_optional_attrs ①
]
}
① 该实验已不再启用。
实验功能并不稳定。它们的API可能在不同版本之间发生变化,最终可能会被完全移除。实验功能通常是短暂的,因为它们要么被纳入Terraform作为正式功能,要么被丢弃。即使实验被采纳,它在最终版本中的实现方式也可能会发生变化。每当启用实验功能时,Terraform都会发出警告。
这意味着,使用实验功能的项目基本上会锁定在一个特定的Terraform版本上,并且无法安全地以自动化方式进行升级。实验功能是一个很好的方式,让你熟悉即将到来的新功能,并向HashiCorp和Terraform团队反馈这些功能在实践中的表现,但大多数生产项目中应该避免使用实验功能。你可以查看当前使用的Terraform版本的更新日志,查看哪些实验功能目前可用。
2.4 提供者
Terraform本身本质上只是一个用于确定创建或更新组件顺序的引擎,但它并不知道这些组件是什么。Terraform依赖于提供者来实现这一点。
在Terraform中,提供者类似于其他语言中的供应商软件开发工具包(SDK)。如果你使用Java或Python等语言,并且想要构建一个启动AWS实例的脚本,你必须下载AWS SDK,通常从一个注册表中获取并安装它。你还需要查看SDK如何运行正确的版本,确保它已正确配置,然后熟悉它暴露的功能和对象。
Terraform的提供者非常相似。你需要确保Terraform知道安装你需要的提供者,并且安装你想要的版本。你还需要像配置SDK一样配置提供者的凭证。可能还有一些其他设置需要配置,或者你可能希望有多个连接到该服务——就像SDK允许你创建多个客户端一样,Terraform也允许你配置提供者的多个实例(参见表2.2)。
表 2.2 块类型及其参考字符串示例
| 供应商 | Terraform | Python | Java |
|---|---|---|---|
| AWS | AWS provider (链接) | Boto3 (链接) | AWS SDK (链接) |
| GCP | Google provider (链接) | Libraries (链接) | Cloud SDK (链接) |
| DataDog | DataDog provider (链接) | DataDogPy (GitHub) | API Client (GitHub) |
每个提供者都与一个供应商或系统类型相关联。比如AWS提供者、GCP提供者以及成千上万的其他提供者。每个提供者由资源和数据源组成,这些资源和数据源通常与供应商提供的基础设施组件一一对应。从某种意义上说,提供者可以看作是一个库,但与其他语言提供函数或类不同,这个库提供的是组件。
当开发人员使用Terraform来创建一个系统时,他们首先要访问自己正在使用的供应商的提供者文档。许多项目会涉及多个供应商,因此会有多个提供者。例如,使用Linode部署机器、使用Wasabi托管数据以及使用DNSMadeEasy进行域名托管的人,如果想使用Terraform管理该系统,将需要Linode、Wasabi和DNSMadeEasy的提供者。
目前有成千上万的提供者,并且不断增加。对于流行的供应商,提供者会定期更新以支持新功能。每个数据源和资源都与特定的提供者相对应。通常,你可以通过查看资源名称来识别提供者,因为有很强的命名约定,资源和数据源的名称通常以提供者的名称为前缀(例如,aws_instance指向AWS提供者,而linode_instance则表示Linode是提供者)。
2.4.1 提供者注册表
公共Terraform提供者托管在Terraform注册表中(registry.terraform.io/browse/prov…)。当运行terraform init命令时,所需的提供者会从注册表中下载并配置,以便在本地使用。
Terraform注册表还托管提供者文档,大多数提供者的文档内容非常详细。事实上,当我学习一个我以前从未使用过的新服务时,我通常会首先查看提供者文档。仅仅查看提供者暴露的资源列表,实际上可以帮助建立该服务如何工作的心智模型。
2.4.2 必需的提供者
Terraform在terraform设置块中有一个特殊的块,用于告诉Terraform需要哪些提供者。
列出 2.19 要求AWS提供者
terraform { ①
required_providers { ②
aws = { ③
source = "hashicorp/aws" ④
version = "~> 4.0" ⑤
}
}
}
① terraform设置块定义工作区设置。
② 这个子块告诉Terraform需要安装哪些提供者。
③ 这个项目使用AWS提供者。
④ AWS提供者来自公共注册表和HashiCorp供应商。
⑤ 我们希望使用版本4系列中的最新版本。
如果Terraform看到一个没有在required_providers块中列出的资源或数据源,它将尝试通过资源的名称推断提供者。在这种情况下,它假设资源类型的第一部分是它所需的提供者的本地名称。由于Terraform还假设默认情况下提供者的命名空间是hashicorp,这意味着它会假设hashicorp/localname。例如,resource.random_id会假设来自hashicorp/random提供者,而resource.aws_instance会假设来自hashicorp/aws提供者。如前所述,最好始终使用required_providers块,并明确指定所需的提供者。
2.4.3 提供者配置
尽管提供者的要求在terraform块中定义,实际的提供者配置是在各自的提供者块中定义的。提供者有许多不同的配置和设置,具体取决于提供者是什么。也就是说,提供者配置通常有两个重要的目的:认证和范围界定。
为了使Terraform能够与供应商通信并发送命令,它需要进行认证,而每个供应商都有自己处理认证的方式。提供者必须将认证系统暴露给用户,一种实现方式就是通过提供者块暴露配置。
列出 2.20 使用提供者块配置Cloudflare认证
variable "cloudflare_api_token" { ①
}
provider "cloudflare" {
api_token = var.cloudflare_api_token ②
}
① 变量将在下一章讨论。
② Cloudflare需要API令牌才能连接。
许多提供者还允许使用文件或环境变量定义配置设置。这不是Terraform控制的内容,因此在首次使用新提供者时,请务必查看提供者文档。
尽管提供者块主要用于指定凭证,但它也可以提供额外的上下文,告诉提供者在哪里以及如何操作。AWS使用它来定义操作的区域,而GCP(它的提供者名为google)则使用项目和区域。
列出 2.21 使用提供者块配置AWS和GCP
provider "aws" { ①
region = "us-west-2" ②
}
provider "google" {
project = "example_project" ③
region = "us-central1" ④
}
① AWS从配置文件中获取其账户ID。
② AWS提供者需要知道操作的区域。
③ Google提供者需要知道操作的项目。
④ Google也需要一个区域。
每个提供者都有自己的配置系统。提供者通常通过提供者块暴露所有选项,但许多还可以接受环境变量。我们的Hello World示例依赖于AWS提供者,并依赖环境变量或AWS特定的配置文件来获取凭证,但我们确实在程序内部使用提供者块来设置区域。
由于大多数提供者有多种配置方式,因此提供者块并不总是必需的。许多提供者从特定文件或环境变量中读取其配置。在这些情况下,提供者块不会提供任何价值,可以安全跳过。
提供者块只能在根模块中使用,因为提供者是为整个工作区配置的,且配置位于根级别。换句话说,你只能在执行terraform或tofu命令的目录中的文件里定义提供者块。
2.4.4 提供者别名
你可能已经注意到前面的示例中存在一个问题。如果你想要一个单独的Terraform程序,连接到多个AWS区域或需要管理多个Cloudflare账户,该怎么办呢?到目前为止,我们只看到设置默认提供者的示例,但实际上可以使用提供者别名来定义多个提供者块。
使用提供者别名时,你为每个需要的连接创建一个提供者块。这就像使用SDK时创建独立的客户端一样;每个“别名”将有自己的设置。然后,我们可以告诉单独的数据和资源块使用我们的别名,而不是默认的提供者。
基于我们的示例,如果我们想要在另一个区域创建第二个EC2镜像,该怎么办?这是一个常见的做法,因为多区域部署可以作为主区域出现故障时的备份。为了做到这一点,我们需要创建一个别名,并使用它来进行第二个区域的VPC和子网查找。
列出 2.22 使用多个提供者和别名
provider "aws" { ①
region = "us-east-1"
}
provider "aws" {
alias = "west" ②
region = "us-west-2"
}
# 默认子网查找
data "aws_vpc" "default" { ③
default = true
}
data "aws_subnets" "default" { ③
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
# 备用子网查找
data "aws_vpc" "backup" {
provider = aws.west ④
default = true
}
data "aws_subnets" "backup" {
provider = aws.west ④
filter {
name = "vpc-id"
values = [data.aws_vpc.backup.id]
}
}
① 这个提供者块没有别名,因此它是默认的。
② 这个提供者块有别名,因此只有在指定时才使用。
③ 没有指定提供者,所以使用默认的“us-east-1”区域。
④ 使用指向“us-west-2”区域的AWS提供者。
这个示例从我们熟悉的Hello World示例开始,然后创建了一个指向不同区域的第二个提供者块。接着,我们添加了两个数据块,以便从第二个区域查找默认VPC并提取该VPC的子网。这个示例还预览了提供者元参数,它告诉我们这两个新数据块使用我们的提供者别名。
2.5 资源
Terraform的存在就是通过代码定义和管理基础设施。其他的一切都是为了简化这一过程。资源块就是实现这一目标的方式。开发人员创建的每个资源块代表他们想要使用Terraform启动和管理的实际基础设施组件。
每个资源块都有自己的类型。该资源类型通过提供者映射回特定类型的基础设施。例如,对于管理DNS的供应商,可能有一个表示域名的资源和另一个表示DNS记录的资源,而Git主机的提供者可能会有用于表示组织、仓库、问题和拉取请求的资源。大型云供应商的提供者会提供数千个资源,代表云供应商提供的实际基础设施组件。
从某种意义上说,这可能会让人感到不知所措:资源的数量确实有数十万。但是,如果你知道你计划使用哪些供应商,并且对你打算启动的系统类型有一定了解,那么很容易将这个列表缩小。就像大多数软件项目在编码之前需要有一个构思一样,提前规划出你希望系统是什么样子的也有助于明确你需要哪些资源。
2.5.1 资源使用
资源由其类型、标识符和参数组成。参数是特定于资源类型的。
列出 2.23 资源块结构
resource "resource_type" "unique_resource_name" {
string_argument = "value" ①
integer_argument = 134
boolean_argument = true
object_argument = { ②
"key" = "value"
}
lifecycle { ③
create_before_destroy = true ④
}
}
① 每个参数都有一个类型和值。
② 对象可以内联定义。
③ 资源也可以有子块,包括所有资源上的生命周期块。
④ 这个元参数在每个资源中都存在。
资源类型和名称结合起来为模块或工作区中的资源创建一个唯一标识符。这意味着,如果两个资源具有相同类型,它们不能有相同的名称,但两个不同类型的资源可以有相同的名称。
例如,在AWS上创建一个EC2实例时,使用的资源块在HCL中的示例如下:
列出 2.24 示例资源块
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnet_ids.main.ids[0]
instance_type = var.instance_type
}
在这里,我们创建了一个资源,类型是aws_instance,名称是hello_world,这两者结合起来形成了唯一标识符resource.aws_instance.hello_world。我们还为它指定了subnet_id参数(来自数据源)和instance_type参数(来自输入变量)。
2.6 数据源
Terraform不仅提供创建新资源的能力。你还可以使用另一类对象,称为数据源,来从Terraform外部获取信息。数据源与资源非常相似。它们具有类型、标识符、参数和属性,就像资源一样。主要的区别在于,它们不会创建或修改任何内容;相反,它们使用其参数来搜索和筛选来自提供者的数据,并将这些数据提供给程序的其余部分。
我们的Hello World示例中有三个数据源。前两个用于查找我们将实例启动到的子网。
列出 2.25 AWS VPC和子网的数据源
data "aws_vpc" "default" {
default = true ①
}
data "aws_subnets" "default" {
filter { ②
name = "vpc-id" ③
values = [data.aws_vpc.default.id] ④
}
}
① AWS在每个区域创建一个默认的VPC,我们希望我们的数据源使用它。
② AWS提供者使用filter字段,但其他提供者可能会使用不同的参数。
③ 我们希望通过VPC ID来筛选子网。
④ 现在我们可以获取默认VPC的所有子网。
data.aws_vpc.default对象使用default参数指定我们要使用区域的默认VPC,这是在用户创建账户时自动创建的。data.aws_subnets.default资源从data.aws_vpc.default中获取vpc_id。
这两个数据源都使用简单的参数来选择它们查找的数据。一些数据源更复杂,需要更多的筛选。我们使用的第三个数据源就是一个很好的例子。
列出 2.26 带过滤器的AWS AMI数据源
data "aws_ami" "ubuntu" {
most_recent = true
filter { ①
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
filter { ②
name = "virtualization-type"
values = ["hvm"]
}
# AWS Account for Canonical, makers of Ubuntu.
owners = ["099720109477"]
}
① 由于这是一个块而不是参数,所以它可以重复。
② 注意,filter块是这个资源类型特有的。
我们使用的aws_ami数据源,用于获取最新的AMI以供我们的aws_instance参数使用,它有一个额外的filter块,可以用来过滤更大的资源组。我们将其与一些标准参数结合,确保我们直接从Ubuntu的制造商获取最新的Ubuntu AMI。
你可能会问:如果数据源找不到匹配的资源会怎样?这取决于具体的数据源。对于大多数数据源,如aws_ami数据源,找不到匹配项会抛出错误并阻止计划继续。还有一些资源允许失败。一般来说,查找动态数量资源的数据源会返回零个结果。我们使用的aws_subnets数据源就是这个例子,因为如果没有配置子网,id属性可以是一个空列表。
2.7 元参数
数据、资源和模块块都有其特定的参数。例如,AWS实例可能有实例类型参数;对于AMI查找,可能有操作系统参数。这些参数非常具体,依赖于正在管理的基础设施类型。这些参数改变基础设施本身,它们映射到基础设施的配置字段来更改这些值。然而,有时开发人员希望改变Terraform处理某个块的方式:为此,Terraform提供了元参数。
元参数是每个数据或资源块始终会有的参数。它们对块类型是通用的,不管底层提供者是做什么的。这些参数内置于Terraform HCL语言本身。它们纯粹是为了在Terraform生成计划时提供额外的指令。这可能包括告诉Terraform忽略某些值的变化、改变资源创建的顺序,甚至在一个资源变化时强制替换某个资源。
例如,在启动EC2实例时(如我们的Hello World示例中),通常会查找最新的AMI版本并使用它作为镜像的基础。由于新AMI经常发布,这可能会导致Terraform在发布后替换已启动的实例。通常的做法是告诉Terraform忽略AMI的变化——这允许新实例始终使用最新镜像启动,但防止已经启动的实例被重新创建。另一个常见的方法是告诉Terraform在销毁旧实例之前创建替换资源——这有助于在服务切换到新实例时减少停机时间。在本节中,我们将讨论允许这种操作的元参数。
由于元参数会影响Terraform创建计划的方式,它们会在计划周期的非常早期被处理。许多这些字段必须有文字值,否则会抛出错误,而其他字段要求其值在计划时已知。这意味着它们不能使用依赖于在资源创建后才能知道的资源属性的值。如果某个参数的值是true或false,那么它必须是字面上的true或false,而不仅仅是一个求值为true或false的表达式。
2.7.1 提供者
在提供者部分,我们展示了如何使用提供者别名来允许使用不同设置连接到同一个供应商。我们的示例包括定义使用提供者参数的数据块。
提供者元参数旨在帮助开发人员,当开发人员使用相同类型的多个提供者配置时。基本上,如果你有多个GCP项目或AWS账户,并且它们配置为不同的提供者别名,你需要能够告诉Terraform在创建资源时使用哪个别名。由于Terraform总是有一个每种类型的默认提供者,因此该字段是可选的——它只有在你需要管理同一个供应商的多个连接时才有用。
2.7.2 生命周期
生命周期子块是一个包含多个参数的容器,这些参数影响Terraform如何管理资源。随着Terraform的发展,更多的参数被添加到这个块中。通过将它们放入子块中,Terraform允许语言在未来的版本中扩展,而不必担心新参数与供应商提供的参数冲突。
使用它的每个块可以定义生命周期块一次——对于同一资源或数据源不能声明多次。我们将在这里回顾一些常见的参数,并在书中的后面部分讨论一些更高级的选项。
create_before_destroy
当Terraform决定需要替换一个对象时,它默认先销毁现有对象再创建新的对象。这是一个非常好的默认行为,因为在销毁替换资源之前创建资源可能是危险的。许多类型的基础设施有独特的标识符或资源,不能被多个对象共享,尝试先创建替换对象会导致错误。一般来说,两个不同的身份和访问管理角色不能有相同的名称,你不能在两个不同的AWS实例之间共享弹性IP地址。在这两种情况下,create_before_destroy会导致错误——还有更多类似的例子。
然而,有时这种默认行为并不理想,特别是在高可用性环境中,其中即便短时间的资源丢失也可能造成问题。create_before_destroy参数为开发人员提供了更改此行为的能力,使其在销毁旧资源之前创建新资源。
列出 2.27 create_before_destroy 示例
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.default.ids[0]
instance_type = var.instance_type
lifecycle {
create_before_destroy = true ①
}
}
① 如果需要替换,将在销毁旧实例之前创建新实例。
prevent_destroy
prevent_destroy参数,乍一看,似乎非常直接——当设置为true时,创建的资源会使得任何销毁资源的计划自动失败。然而,这个参数应该非常少用,因为它存在一些问题:
- 和其他生命周期规则一样,它只能接受字面值(即硬编码的值)。无法为生产环境启用此参数并为开发环境禁用它。
- 它会防止销毁计划成功,这使得使用Terraform来创建临时开发环境变得更加困难。
- 删除资源块(删除资源的常见方式之一)也会移除此设置。因为删除块也会移除块的参数,所以Terraform在块不存在时不再认为它有
prevent_destroy设置。
列出 2.28 prevent_destroy 示例
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.default.ids[0]
instance_type = var.instance_type
lifecycle {
prevent_destroy = true ①
}
}
① 现在无法销毁此资源。
虽然在某些情况下这个字段非常有用(例如,可能出于合规性原因要防止删除某些日志),但通常,使用ignore_changes字段来防止资源被意外销毁是更好的做法。
ignore_changes
ignore_changes参数告诉Terraform,在只有列出的属性发生变化时,不更新资源。ignore_changes参数接受一个参数名称的列表,并告诉Terraform,当这些参数发生变化时不要更新资源,而是在更新时忽略这些字段。这基本上告诉Terraform,在创建后完全忽略特定的参数。
这是实践中最常用的生命周期块,因为有很多不同的情况会使用它。例如,一些属性强制要求替换而不是更新,这可能不是所需的。我们之前讨论了AMI的例子,当新AMI发布时,可能不希望替换已存在的实例。许多系统添加或删除资源的标签,能够忽略这些变化能防止错误的发生。这在像AWS Elastic Kubernetes Service或Elastic Container Service这样的编排系统中非常常见。
列出 2.29 忽略特定属性变化
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.default.ids[0]
instance_type = var.instance_type
lifecycle {
ignore_changes = [ami] ①
}
}
① 如果AMI发生变化,这个资源将不会被替换。
此参数还可以接受一个特殊值all,它告诉Terraform忽略对资源的任何更改。这基本上意味着Terraform将创建资源后不再更改或更新它。Terraform仍然会读取这些值以生成资源的属性,但它本质上使得资源在启动后变为只读。
列出 2.30 忽略所有变化示例
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.default.ids[0]
instance_type = var.instance_type
lifecycle {
ignore_changes = all ①
}
}
① 这个资源将永远不会被更改。注意,all关键字不在方括号内。
ignore_changes字段常用于防止资源被替换。由于只有某些字段需要替换(例如,你不能更改AWS实例的AMI而不完全替换实例),可以将这些字段添加到ignore_changes中,以防止资源被替换,同时仍然允许更新其他字段。这通常比使用prevent_destroy参数更可取,因为它不会防止生成销毁计划。
replace_triggered_by
replace_triggered_by参数允许你在其他资源变化时强制替换资源。该参数接受一个资源属性或资源本身的引用列表。对于属性来说,只有该特定属性变化时资源才会变化;而对于资源本身的引用,当触发资源发生变化时会导致替换。
在我们的Hello World示例中,我们公开了一个变量instance_type。当aws_instance的类型发生更改时,它会触发就地更新——关闭机器,改变实例类型,然后再启动相同的实例。我们可以使用replace_triggered_by字段,强制在该变量发生变化时替换实例而不是更新它。
列出 2.31 触发替换
resource "null_resource" "replace_instance" { ①
triggers = {
instance_type = var.instance_type ②
}
}
resource "aws_instance" "hello_world" {
ami = data.aws_ami.ubuntu.id
subnet_id = data.aws_subnets.default.ids[0]
instance_type = var.instance_type ③
lifecycle {
replace_triggered_by = [
null_resource.replace_instance ④
]
}
}
① null_resource是一个“仅状态”资源。这里它让我们在instance_type变化时触发替换。
② 当instance_type变量发生变化时,替换此资源。
③ 通常,修改此字段不会替换实例。
④ 此字段必须是资源或资源属性,不能是变量。
replace_triggered_by参数是Terraform语言中较新的功能之一。许多提供者暴露了具有相同目的的资源,尽管它们是特定于该提供者的。random提供者使用一个名为keepers的参数,而null提供者使用名为triggers的参数(这两个提供者将在第8章讨论)。这是提供者功能可以回到Terraform本身供所有资源使用的一个示例。
2.7.3 显式依赖关系
到目前为止,我们展示了Terraform如何通过属性和参数的链接来映射不同块之间的依赖关系。这些是隐式依赖关系——Terraform根据这些属性和参数推断出它们。也可以使用depends_on参数显式定义依赖关系。
列出 2.32 显式依赖关系的两个资源之间的关系
resource "aws_internet_gateway" "main" { ①
vpc_id = aws_vpc.main.id
}
resource "aws_nat_gateway" "example" {
subnet_id = aws_subnet.example.id
②
depends_on = [ ③
aws_internet_gateway.main ④
]
}
① 这个资源不会导出aws_nat_gateway所需要的任何内容。
② 没有参数需要从aws_internet_gateway.main资源获取属性。但是,NAT网关在没有互联网网关的情况下无法正常工作。
③ depends_on将块引用本身作为参数,而不是属性。
④ 只有资源块可以被依赖,因此块引用的资源部分被省略。
当两个资源之间有依赖关系,但不需要相互之间的信息时,唯一的选择就是使用depends_on参数。一个示例就是AWS的互联网网关(Internet Gateway)和网络地址转换(NAT)网关。这些资源协同工作,为私有子网提供互联网访问。然而,由于每个VPC只有一个互联网网关,因此NAT网关并没有关于它的参数——它只能使用一个,所以它会尝试使用那个。但问题是,没有互联网网关,NAT网关无法启动,如果Terraform尝试首先启动它,会导致错误。depends_on参数通过让开发人员显式定义这些资源之间的依赖关系来解决这个问题。
当在模块块中使用depends_on时,这个参数会传递给该模块中的所有资源。
2.8 模块
模块是Terraform允许开发人员共享和重用代码的主要方式。模块是一个非常复杂的话题,我们将在第3章详细讨论。现在,我们简要介绍模块块的使用方法。
模块有自己的块类型,用于在模块中创建资源。这个块与资源块非常相似,甚至接受一些相同的元参数,但模块是由使用Terraform HCL的开发人员编写的。
模块有一些其他块类型不使用的新元参数:
- Source:告诉Terraform从哪里获取模块,这是一个必需的参数。可以是指向托管注册表中的模块的URL、指向文件系统中模块的路径,或指向本地或远程仓库的Git引用。
- Version:告诉Terraform允许的版本范围。这个参数在源是注册表时使用,它允许开发人员将模块锁定到特定的版本范围。这样,开发人员可以控制模块何时更新,以确保新的更改不会破坏他们的期望。
- Providers:允许开发人员指定从外部模块传递哪些提供程序别名。
假设我们想在我们的VPC中添加一个虚拟私有网络服务,以便我们可以将笔记本电脑连接到我们账户中的资源。我们可以使用别人已经构建的模块,而不是自己从头开始构建。我们可以从Terraform公共注册表中提取一个模块,该模块为我们构建虚拟私有网络。
列出 2.33 模块示例
h
复制
module "vpn" {
source = "tedivm/dev-vpn/aws" ①
version = "~> 1.0" ②
identifier = "my-vpn" ③
subnet_ids = data.aws_subnets.default.ids ④
}
① 这告诉Terraform从公共注册表下载tedivm/dev-vpn/aws模块。
② 模块可以像提供程序一样具有版本规范。
③ 模块有自己的参数,在这种情况下是用户提供的标识符。
④ 该模块还需要附加的子网列表。
模块从变量块获取其参数,从输出块获取其属性。第3章将详细讨论模块。
2.9 导入、移动和删除
在Terraform v1.5.0版本中,增加了两个新块以帮助重构:import和moved块。在Terraform v1.7.0中,增加了removed块。import块帮助用户将资源导入到Terraform中,或者从一个Terraform项目迁移到另一个项目,而moved块允许开发人员在单一的Terraform项目中移动资源。例如,如果某人以前在AWS控制台创建了一个资源,他们可以使用import块将该资源导入到Terraform中,而无需重新创建它。我们将在第9章详细讨论这些块。
import块最初是为了解决terraform import命令的局限性而添加的。将导入语句放入代码中使得导入可以在计划阶段进行审核,并且使得跨多个环境的导入过程更容易自动化。例如,如果你正在将一个项目迁移到Terraform中,并且有预发布和生产环境,import块将允许你将要导入的资源编写成代码,然后(从Terraform v1.6开始)使用变量甚至数据源来确定要导入的资源。
一旦所有要导入的资源被导入,你可以删除import块。你不需要立即删除它们,因为一旦它们完成任务并且资源已保存,它们将基本上被忽略。但是,如果留下它们,会使得在没有资源可导入的新环境中重新使用你的Terraform代码变得更困难。
列出 2.34 导入块
import {
to = aws_instance.main ①
id = "i-1234567890abcdef0" ②
}
resource "aws_instance" "main" {
# Required Arguments
}
① to参数应该指向一个资源块。
② 在v1.5中,ID必须是硬编码的,但v1.6允许使用变量和属性。
moved块存在的目的是告诉Terraform,某个资源不再在代码中的相同位置。这可能发生在资源的代码被移动到模块中,或者开发人员决定重命名资源块时。moved资源让Terraform将现有资源与新位置关联,而不必重新创建它。
与import块不同,moved块在运行后留下也没有问题。如果Terraform没有找到要移动到现有状态的资源,它将创建一个新的资源来替代它。这使得它可以在没有任何冲突的情况下用于新项目。因此,这个块在已发布并准备好重用的模块中非常安全使用。
列出 2.35 移动块
moved {
from = module.bad_unclear_name ①
to = module.better_name ②
}
module "better_name" {
}
① from参数应该是旧的资源或模块位置。
② to参数应该指向新的资源位置。
到目前为止,我们已经介绍了Terraform程序的基础知识,从我们的示例开始,涵盖了所有核心组件。凭借这些知识,你可以开始使用Terraform定义基础设施。在接下来的章节中,我们将讨论变量以及它们如何在使用模块时提供很大的定制性。
总结
Terraform语言主要围绕着块(blocks)展开。
- terraform设置块用于配置提供程序和工作区设置。
- 数据源是只读块,用于查找数据。
- 资源块是Terraform存在的主要原因,用于管理基础设施。
- 数据源和资源可以被抽象成可重用的模块,这些模块是构建大型系统的基石。
- 生命周期参数可以改变资源的管理方式。