Terraform-模块

分類 devops, Terraform

1.6.1. Terraform模块

到目前为止我们介绍了一些代码书写的知识,但我们创建的所有资源和数据源的代码都是我们在代码文件中编写出来的。我们有没有办法不通过复制粘贴代码从而直接使用别人编写好的 Terraform 代码来创建一组资源呢?

Terraform 对此给出的答案就是模块 (Module)。简单来讲模块就是包含一组 Terraform 代码的文件夹,我们之前篇章中编写的代码实际上也是在模块中。要想真正理解模块的功能,我们需要去体验一下模块的使用。

Terraform 模块是编写高质量 Terraform 代码,提升代码复用性的重要手段,可以说,一个成熟的生产环境应该是由数个可信成熟的模块组装而成的。我们将在本章介绍关于模块的知识。

1.6.1.1. 创建模块

实际上所有包含 Terraform 代码文件的文件夹都是一个 Terraform 模块。我们如果直接在一个文件夹内执行 terraform apply 或者 terraform plan 命令,那么当前所在的文件夹就被称为根模块(root module)。我们也可以在执行 Terraform 命令时通过命令行参数指定根模块的路径。

1.6.1.1.1. 模块结构

旨在被重用的模块与我们编写的根模块使用的是相同的 Terraform 代码和代码风格规范。一般来讲,在一个模块中,会有:

  • 一个 README 文件,用来描述模块的用途。文件名可以是 README 或者 README.md,后者应采用 Markdown 语法编写。可以考虑在 README 中用可视化的图形来描绘创建的基础设施资源以及它们之间的关系。README 中不需要描述模块的输入输出,因为工具会自动收集相关信息。如果在 README 中引用了外部文件或图片,请确保使用的是带有特定版本号的绝对 URL 路径以防止未来指向错误的版本
  • 一个 LICENSE 描述模块使用的许可协议。如果你想要公开发布一个模块,最好考虑包含一个明确的许可证协议文件,许多组织不会使用没有明确许可证协议的模块
  • 一个 examples 文件夹用来给出一个调用样例(可选)
  • 一个 variables.tf 文件,包含模块所有的输入变量。输入变量应该有明确的描述说明用途
  • 一个 outputs.tf 文件,包含模块所有的输出值。输出值应该有明确的描述说明用途
  • 嵌入模块文件夹,出于封装复杂性或是复用代码的目的,我们可以在 modules 子目录下建立一些嵌入模块。所有包含 README 文件的嵌入模块都可以被外部用户使用;不含 README 文件的模块被认为是仅在当前模块内使用的(可选)
  • 一个 main.tf,它是模块主要的入口点。对于一个简单的模块来说,可以把所有资源都定义在里面;如果是一个比较复杂的模块,我们可以把创建的资源分布到不同的代码文件中,但引用嵌入模块的代码还是应保留在 main.tf
  • 其他定义了各种基础设施对象的代码文件(可选)

如果模块含有多个嵌入模块,那么应避免它们彼此之间的引用,由根模块负责组合它们。

由于 examples/ 的代码经常会被拷贝到其他项目中进行修改,所有在 examples/ 代码中引用本模块时使用的引用路径应使用外部调用者可以使用的路径,而非相对路径。

一个最小化模块推荐的结构是这样的:

1
2
3
4
5
6
$ tree minimal-module/
.
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf

一个更完整一些的模块结构可以是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ tree complete-module/
.
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── ...
├── modules/
│ ├── nestedA/
│ │ ├── README.md
│ │ ├── variables.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ ├── nestedB/
│ ├── .../
├── examples/
│ ├── exampleA/
│ │ ├── main.tf
│ ├── exampleB/
│ ├── .../

1.6.1.1.2. 避免过深的模块结构

我们刚才提到可以在 modules/ 子目录下创建嵌入模块。Terraform 倡导"扁平"的模块结构,只应保持一层嵌入模块,防止在嵌入模块中继续创建嵌入模块。应将嵌入模块设计成易于组合的结构,使得在根模块中可以通过组合各个嵌入模块创建复杂的基础设施。

1.6.2.1. 引用模块

在 Terraform 代码中引用一个模块,使用的是 module 块。

每当在代码中新增、删除或者修改一个 module 块之后,都要执行 terraform init 或是 terraform get 命令来获取模块代码并安装到本地磁盘上。

1.6.2.1.1. 模块源

module 块定义了一个 source 参数,指定了模块的源;Terraform 目前支持如下模块源:

  • 本地路径
  • Terraform Registry
  • GitHub
  • Bitbucket
  • 通用Git、Mercurial仓库
  • HTTP地址
  • S3 buckets
  • GCS buckets

我们后面会一一讲解这些模块源的使用。source 使用的是 URL 风格的参数,但某些源支持在 source 参数中通过额外参数指定模块版本。

出于消除重复代码的目的我们可以重构我们的根模块代码,将一些拥有重复模式的代码重构为可反复调用的嵌入模块,通过本地路径来引用。

许多的模块源类型都支持从当前系统环境中读取认证信息,例如环境变量或系统配置文件。我们在介绍模块源的时候会介绍到这方面的信息。

我们建议每个模块把期待被重用的基础设施声明在各自的根模块位置上,但是直接引用其他模块的嵌入模块也是可行的。

1.6.2.1.1.1. 本地路径

使用本地路径可以使我们引用同一项目内定义的子模块:

1
2
3
module "consul" {
source = "./consul"
}

一个本地路径必须以 ./ 或者 ../ 为前缀来标明要使用的本地路径,以区别于使用 Terraform Registry 路径。

本地路径引用模块和其他源类型有一个区别,本地路径引用的模块不需要下载相关源代码,代码已经存在于本地相关路径的磁盘上了。

1.6.2.1.1.2. Terraform Registry

Registry 目前是 Terraform 官方力推的模块仓库方案,采用了 Terraform 定制的协议,支持版本化管理和使用模块。

官方提供的公共仓库保存和索引了大量公共模块,在这里可以很容易地搜索到各种官方和社区提供的高质量模块。

读者也可以通过 Terraform Cloud 服务维护一个私有模块仓库,或是通过实现 Terraform 模块注册协议来实现一个私有仓库。

公共仓库的的模块可以用 <NAMESPACE>/<NAME>/<PROVIDER> 形式的源地址来引用,在公共仓库上的模块介绍页面上都包含了确切的源地址,例如:

1
2
3
4
module "consul" {
source = "hashicorp/consul/aws"
version = "0.1.0"
}

对于那些托管在其他仓库的模块,在源地址头部添加 <HOSTNAME>/ 部分,指定私有仓库的主机名:

1
2
3
4
module "consul" {
source = "app.terraform.io/example-corp/k8s-cluster/azurerm"
version = "1.1.0"
}

如果你使用的是 SaaS 版本的 Terraform Cloud,那么托管在上面的私有仓库的主机名是 app.terraform.io。如果使用的是私有部署的 Terraform 企业版,那么托管在上面的私有仓库的主机名就是 Terraform 企业版服务的主机名。

模块仓库支持版本化。你可以在 module 块中指定模块的版本约束。

如果要引用私有仓库的模块,你需要首先通过配置命令行工具配置文件来设置访问凭证。

1.6.2.1.1.3. GitHub

Terraform 发现 source 参数的值如果是以 github.com 为前缀时,会将其自动识别为一个 GitHub 源:

1
2
3
module "consul" {
source = "github.com/hashicorp/example"
}

上面的例子里会自动使用 HTTPS 协议克隆仓库。如果要使用 SSH 协议,那么请使用如下的地址:

1
2
3
module "consul" {
source = "git@github.com:hashicorp/example.git"
}

GitHub 源的处理与后面要介绍的通用 Git 仓库是一样的,所以他们获取 git 凭证和通过 ref 参数引用特定版本的方式都是一样的。如果要访问私有仓库,你需要额外配置 git 凭证。

1.6.2.1.1.4. Bitbucket

Terraform 发现 source 参数的值如果是以 bitbucket.org 为前缀时,会将其自动识别为一个 Bitbucket 源:

1
2
3
module "consul" {
source = "bitbucket.org/hashicorp/terraform-consul-aws"
}

这种捷径方法只针对公共仓库有效,因为 Terraform 必须访问 ButBucket API 来了解仓库使用的是 Git 还是 Mercurial 协议。

Terraform 根据仓库的类型来决定将它作为一个 Git 仓库还是 Mercurial 仓库来处理。后面的章节会介绍如何为访问仓库配置访问凭证以及指定要使用的版本号。

1.6.2.1.1.5. 通用 Git 仓库

可以通过在地址开头加上特殊的 git:: 前缀来指定使用任意的 Git 仓库。在前缀后跟随的是一个合法的 Git URL

使用 HTTPS 和 SSH 协议的例子:

1
2
3
4
5
6
7
module "vpc" {
source = "git::https://example.com/vpc.git"
}

module "storage" {
source = "git::ssh://username@example.com/storage.git"
}

Terraform 使用 git clone 命令安装模块代码,所以 Terraform 会使用本地 Git 系统配置,包括访问凭证。要访问私有 Git 仓库,必须先配置相应的凭证。

如果使用了 SSH 协议,那么会自动使用系统配置的 SSH 证书。通常情况下我们通过这种方法访问私有仓库,因为这样可以不需要交互式提示就可以访问私有仓库。

如果使用 HTTP/HTTPS 协议,或是其他需要用户名、密码作为凭据,你需要配置 Git 凭据存储来选择一个合适的凭据源。

默认情况下,Terraform 会克隆默认分支。可以通过 ref 参数来指定版本:

1
2
3
module "vpc" {
source = "git::https://example.com/vpc.git?ref=v1.2.0"
}

ref 参数会被用作 git checkout 命令的参数,可以是分支名或是 tag 名。

使用 SSH 协议时,我们更推荐 ssh:// 的地址。你也可以选择 scp 风格的语法,故意忽略 ssh:// 的部分,只留 git::,例如:

1
2
3
module "storage" {
source = "git::username@example.com:storage.git"
}

1.6.2.1.1.6. 通用 Mercurial 仓库

可以通过在地址开头加上特殊的 hg:: 前缀来指定使用任意的 Mercurial 仓库。在前缀后跟随的是一个合法的 Mercurial URL

1
2
3
module "vpc" {
source = "hg::http://example.com/vpc.hg"
}

Terraform 会通过运行 hg clone 命令从 Mercurial 仓库安装模块代码,所以 Terraform 会使用本地 Mercurial 系统配置,包括访问凭证。要访问私有 Mercurial 仓库,必须先配置相应的凭证。

如果使用了 SSH 协议,那么会自动使用系统配置的 SSH 证书。通常情况下我们通过这种方法访问私有仓库,因为这样可以不需要交互式提示就可以访问私有仓库。

类似 Git 源,我们可以通过 ref 参数指定非默认的分支或者标签来选择特定版本:

1
2
3
module "vpc" {
source = "hg::http://example.com/vpc.hg?ref=v1.2.0"
}

1.6.2.1.1.7. HTTP 地址

当我们使用 HTTP 或 HTTPS 地址时,Terraform 会向指定 URL 发送一个 GET 请求,期待返回另一个源地址。这种间接的方法使得 HTTP 可以成为一个更复杂的模块源地址的指示器。

然后 Terraform 会再发送一个 GET 请求到之前响应的地址上,并附加一个查询参数 terraform-get=1,这样服务器可以选择当 Terraform 来查询时可以返回一个不一样的地址。

如果相应的状态码是成功的(200 范围的成功状态码),Terraform 就会通过以下位置来获取下一个访问地址:

  • 响应头部的 X-Terraform-Get
  • 如果响应内容是一个 HTML 页面,那么会检查名为 terraform-get 的 html meta 元素:
1
2
<meta name="terraform-get"
content="github.com/hashicorp/example" />

不管用哪种方式返回的地址,Terraform 都会像本章提到的其他的源地址那样处理它。

如果 HTTP/HTTPS 地址需要认证凭证,可以在 HOME 文件夹下配置一个 .netrc 文件,详见相关文档

也有一种特殊情况,如果 Terraform 发现地址有着一个常见的存档文件的后缀名,那么 Terraform 会跳过 terraform-get=1 重定向的步骤,直接将响应内容作为模块代码使用。

1
2
3
module "vpc" {
source = "https://example.com/vpc-module.zip"
}

目前支持的后缀名有:

  • zip
  • tar.bz2tbz2
  • tar.gztgz
  • tar.xztxz

如果 HTTP 地址不以这些文件名结尾,但又的确指向模块存档文件,那么可以使用 archive 参数来强制按照这种行为处理地址:

1
2
3
module "vpc" {
source = "https://example.com/vpc-module?archive=zip"
}

1.6.2.1.1.8. S3 Bucket

你可以把模块存档保存在 AWS S3 桶里,使用 s3:: 作为地址前缀,后面跟随一个 S3 对象访问地址

1
2
3
module "consul" {
source = "s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"
}

Terraform 识别到 s3:: 前缀后会使用 AWS 风格的认证机制访问给定地址。这使得这种源地址也可以搭配其他提供了 S3 协议兼容的对象存储服务使用,只要他们的认证方式与 AWS 相同即可。

保存在 S3 桶内的模块存档文件格式必须与上面 HTTP 源提到的支持的格式相同,Terraform 会下载并解压缩模块代码。

模块安装器会从以下位置寻找AWS凭证,按照优先级顺序排列:

  • AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY 环境变量
  • HOME 目录下 .aws/credentials 文件内的默认 profile
  • 如果是在 AWS EC2 主机内运行的,那么会尝试使用搭载的 IAM 主机实例配置。

1.6.2.1.1.9. GCS Bucket

你可以把模块存档保存在谷歌云 GCS 储桶里,使用 gcs:: 作为地址前缀,后面跟随一个 GCS 对象访问地址

1
2
3
module "consul" {
source = "gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip"
}

模块安装器会使用谷歌云 SDK 的凭据来访问 GCS。要设置凭据,你可以:

  • 通过 GOOGLE_APPLICATION_CREDENTIALS 环境变量配置服务账号的密钥文件
  • 如果是在谷歌云主机上运行的 Terraform,可以使用默认凭据。访问相关文档获取完整信息
  • 可以使用命令行 gcloud auth application-default login 设置

1.6.2.1.2. 直接引用子文件夹中的模块

引用版本控制系统或是对象存储服务中的模块时,模块本身可能存在于存档文件的一个子文件夹内。我们可以使用特殊的 // 语法来指定 Terraform 使用存档内特定路径作为模块代码所在位置,例如:

  • hashicorp/consul/aws//modules/consul-cluster
  • git::https://example.com/network.git//modules/vpc
  • https://example.com/network-module.zip//modules/vpc
  • s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/network.zip//modules/vpc

如果源地址中包含又参数,例如指定特定版本号的 ref 参数,那么把子文件夹路径放在参数之前:

  • git::https://example.com/network.git//modules/vpc?ref=v1.2.0

Terraform 会解压缩整个存档文件后,读取特定子文件夹。所以,对于一个存在于子文件夹中的模块来说,通过本地路径引用同一个存档内的另一个模块是安全的。

1.6.2.1.3. 使用模块

我们刚才介绍了如何用 source 指定模块源,下面我们继续讲解如何在代码中使用一个模块。

我们可以把模块理解成类似函数,如同函数有输入参数表和输出值一样,我们之前介绍过 Terraform 代码有输入变量和输出值。我们在 module 块的块体内除了 source 参数,还可以对该模块的输入变量赋值:

1
2
3
4
5
module "servers" {
source = "./app-cluster"

servers = 5
}

在这个例子里,我们将会创建 ./app-cluster 文件夹下 Terraform 声明的一系列资源,该模块的 servers 输入变量的值被我们设定成了5。

在代码中新增、删除或是修改一个某块的 source,都需要重新运行 terraform init 命令。默认情况下,该命令不会升级已安装的模块(例如 source 未指定版本,过去安装了旧版本模块代码,那么执行 terraform init 不会自动更新到新版本);可以执行 terraform init -upgrade 来强制更新到最新版本模块。

1.6.2.1.4. 访问模块输出值

在模块中定义的资源和数据源都是被封装的,所以模块调用者无法直接访问它们的输出属性。然而,模块可以声明一系列输出值,来选择性地输出特定的数据供模块调用者使用。

举例来说,如果 ./app-cluster 模块定义了名为 instance_ids 的输出值,那么模块的调用者可以像这样引用它:

1
2
3
4
5
resource "aws_elb" "example" {
# ...

instances = module.servers.instance_ids
}

1.6.2.1.5. 其他的模块元参数

除了 source 以外,目前 Terraform 还支持在 module 块上声明其他一些可选元参数:

  • version:指定引用的模块版本,在后面的部分会详细介绍
  • countfor_each:这是 Terraform 0.13 开始支持的特性,类似 resourcedata,我们可以创建多个 module 实例
  • providers:通过传入一个 map 我们可以指定模块中的 Provider 配置,我们将在后面详细介绍
  • depends_on:创建整个模块和其他资源之间的显式依赖。直到依赖项创建完毕,否则声明了依赖的模块内部所有的资源及内嵌的模块资源都会被推迟处理。模块的依赖行为与资源的依赖行为相同

除了上述元参数以外,lifecycle 参数目前还不能被用于模块,但关键字被保留以便将来实现。

1.6.2.1.6. 模块版本约束

使用 registry 作为模块源时,可以使用 version 元参数约束使用的模块版本:

1
2
3
4
5
6
module "consul" {
source = "hashicorp/consul/aws"
version = "0.0.5"

servers = 3
}

version 元参数的格式与 Provider 版本约束的格式一致。在满足版本约束的前提下,Terraform 会使用当前已安装的最新版本的模块实例。如果当前没有满足约束的版本被安装过,那么会下载符合约束的最新的版本。

version 元参数只能配合 registry 使用,公共的或者私有的模块仓库都可以。其他类型的模块源可能支持版本化,也可能不支持。本地路径模块不支持版本化。

1.6.2.1.7. 多实例模块

可以通过在 module 块上声明 for_each 或者 count 来创造多实例模块。在使用上 module 上的 for_eachcount 与资源、数据源块上的使用是一样的。

1
2
3
4
5
6
# my_buckets.tf
module "bucket" {
for_each = toset(["assets", "media"])
source = "./publish_bucket"
name = "${each.key}_bucket"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# publish_bucket/bucket-and-cloudfront.tf
variable "name" {} # this is the input parameter of the module

resource "aws_s3_bucket" "example" {
# Because var.name includes each.key in the calling
# module block, its value will be different for
# each instance of this module.
bucket = var.name

# ...
}

resource "aws_iam_user" "deploy_user" {
# ...
}

这个例子定义了一个位于 ./publish_bucket 目录下的本地子模块,模块创建了一个 S3 存储桶,封装了桶的信息以及其他实现细节。

我们通过 for_each 参数声明了模块的多个实例,传入一个 map 或是 set 作为参数值。另外,因为我们使用了 for_each,所以在 module 块里可以使用 each 对象,例子里我们使用了 each.key。如果我们使用的是 count 参数,那么我们可以使用 count.index

子模块里创建的资源在执行计划或UI中的名称会以 module.module_name[module index] 作为前缀。如果一个模块没有声明 count 或者 for_each,那么资源地址将不包含 module index。

在上面的例子里,./publish_bucket 模块包含了 aws_s3_bucket.example 资源,所以两个 S3 桶实例的名字分别是module.bucket["assets"].aws_s3_bucket.example 以及 module.bucket["media"].aws_s3_bucket.example

1.6.2.1.8. 模块内的 Provider

当代码中声明了多个模块时,资源如何与 Provider 实例关联就需要特殊考虑。

每一个资源都必须关联一个 Provider 配置。不像 Terraform 其他的概念,Provider 配置在 Terraform 项目中是全局的,可以跨模块共享。Provider 配置声明只能放在根模块中。

Provider 有两种方式传递给子模块:隐式继承,或是显式通过 module 块的 providers 参数传递。

一个旨在被复用的模块不允许声明任何 provider 块,只有使用"代理 Provider"模式的情况除外,我们后面会介绍这种模式。

出于向前兼容 Terraform 0.10 及更早版本的考虑,Terraform 目前在模块代码中只用到了 Terraform 0.10 及更早版本的功能时,不会针对模块代码中声明 provider 块报错,但这是一个不被推荐的遗留模式。一个含有自己的 provider 块定义的遗留模块与 for_eachcountdepends_on 等 0.13 引入的新特性是不兼容的。

Provider 配置被用于相关资源的所有操作,包括销毁远程资源对象以及更新状态信息等。Terraform 会在状态文件中保存针对最近用来执行所有资源变更的 Provider 配置的引用。当一个 resource 块被删除时,状态文件中的相关记录会被用来定位到相应的配置,因为原来包含 provider 参数(如果声明了的话)的 resource 块已经不存在了。

这导致了,你必须确保删除所有相关的资源配置定义以后才能删除一个 Provider 配置。如果 Terraform 发现状态文件中记录的某个资源对应的 Provider 配置已经不存在了会报错,要求你重新给出相关的 Provider 配置。

1.6.2.1.9. 模块内的 Provider 版本限制

虽然 Provider 配置信息在模块间共享,每个模块还是得声明各自的模块需求,这样 Terraform 才能决定一个适用于所有模块配置的 Provider 版本。

为了定义这样的版本约束要求,可以在 terraform 块中使用 required_providers 块:

1
2
3
4
5
6
7
8
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 2.7.0"
}
}
}

有关 Provider 的 source 和版本约束的信息我们已经在前文中有所记述,在此不再赘述。

1.6.2.1.10. 隐式 Provider 继承

为了方便,在一些简单的代码中,一个子模块会从调用者那里自动地继承默认的 Provider 配置。这意味着显式 provider 块声明仅位于根模块中,并且下游子模块可以简单地声明使用该类型 Provider 的资源,这些资源会自动关联到根模块的 Provider 配置上。

例如,根模块可能只含有一个 provider 块和一个 module 块:

1
2
3
4
5
6
7
provider "aws" {
region = "us-west-1"
}

module "child" {
source = "./child"
}

子模块可以声明任意关联 aws 类型 Provider 的资源而无需额外声明 Provider 配置:

1
2
3
resource "aws_s3_bucket" "example" {
bucket = "provider-inherit-example"
}

当每种类型的 Provider 都只有一个实例时我们推荐使用这种方式。

要注意的是,只有 Provider 配置会被子模块继承,Provider 的 source 或是版本约束条件则不会被继承。每一个模块都必须声明各自的 Provider 需求条件,这在使用非 HashiCorp 的 Provider 时尤其重要。

1.6.2.1.11. 显式传递 Provider

当不同的子模块需要不同的 Provider 实例,或者子模块需要的 Provider 实例与调用者自己使用的不同时,我们需要在 module 块上声明 providers 参数来传递子模块要使用的 Provider 实例。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# The default "aws" configuration is used for AWS resources in the root
# module where no explicit provider instance is selected.
provider "aws" {
region = "us-west-1"
}

# An alternate configuration is also defined for a different
# region, using the alias "usw2".
provider "aws" {
alias = "usw2"
region = "us-west-2"
}

# An example child module is instantiated with the alternate configuration,
# so any AWS resources it defines will use the us-west-2 region.
module "example" {
source = "./example"
providers = {
aws = aws.usw2
}
}

module 块里的 providers 参数类似 resource 块里的 provider 参数,区别是前者接收的是一个 map 而不是单个 string,因为一个模块可能含有多个不同的 Provider。

providersmap 的键就是子模块中声明的 Provider 需求中的名字,值就是在当前模块中对应的 Provider 配置的名字。

如果 module 块内声明了 providers 参数,那么它将重载所有默认的继承行为,所以你需要确保给定的 map 覆盖了子模块所需要的所有 Provider。这避免了显式赋值与隐式继承混用时带来的混乱和意外。

额外的 Provider 配置(使用 alias 参数的)将永远不会被子模块隐式继承,所以必须显式通过 providers 传递。比如,一个模块配置了两个 AWS 区域之间的网络打通,所以需要配置一个源区域 Provider 和目标区域 Provider。这种情况下,根模块代码看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
provider "aws" {
alias = "usw1"
region = "us-west-1"
}

provider "aws" {
alias = "usw2"
region = "us-west-2"
}

module "tunnel" {
source = "./tunnel"
providers = {
aws.src = aws.usw1
aws.dst = aws.usw2
}
}

子目录 ./tunnel 必须包含像下面的例子那样声明"Provider 代理",声明模块调用者必须用这些名字传递的 Provider 配置:

1
2
3
4
5
6
7
provider "aws" {
alias = "src"
}

provider "aws" {
alias = "dst"
}

./tunnel 模块中的每一种资源都应该通过 provider 参数声明它使用的是 aws.src 还是 aws.dst

1.6.2.1.12. Provider 代理配置块

一个 Provider 代理配置只包含 alias 参数,它就是一个模块间传递 Provider 配置的占位符,声明了模块期待显式传递的额外(带有 alias 的)Provider 配置。

需要注意的是,一个完全为空的 Provider 配置块也是合法的,但没有必要。只有在模块内需要带 alias 的 Provider 时才需要代理配置块。如果模块中只是用默认 Provider 时请不要声明代理配置块,也不要仅为了声明 Provider 版本约束而使用代理配置块。

模块元参数

1.6.3.1. 模块元参数

在 Terraform 0.13 之前,模块在使用上存在一些限制。例如我们通过模块来创建 EC2 主机,可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module "ec2_instance" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

如果我们要创建两台这样的主机怎么办?在 Terraform 0.13 之前的版本中,由于 Module 不支持元参数,所以我们只能手动拷贝模块代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
module "ec2_instance_0" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance-0"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

module "ec2_instance_1" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance-1"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

自从 Terraform 0.13 开始,模块也像资源一样,支持countfor_eachdepends_on三种元参数。比如我们可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module "ec2_instance" {
count = 2
source = "terraform-aws-modules/ec2-instance/aws"
version = "~> 3.0"

name = "single-instance-${count.index}"

ami = "ami-ebd02392"
instance_type = "t2.micro"
key_name = "user1"
monitoring = true
vpc_security_group_ids = ["sg-12345678"]
subnet_id = "subnet-eddcdzz4"

tags = {
Terraform = "true"
Environment = "dev"
}
}

要注意的是 Terraform 0.13 之后在模块上声明depends_on,列表中也可以传入另一个模块。声明depends_on的模块中的所有资源的创建都会发生在被依赖的模块中所有资源创建完成之后。

1.6.4.1. 重构

请注意,本节介绍的通过 moved 块进行模块重构的功能是从 Terraform v1.1 开始被引入的。如果要在之前的版本进行这样的操作,必须通过 terraform state mv 命令来完成。

对于一些旨在被人复用的老模块来说,最初的模块结构和资源名称可能会逐渐变得不再合适。例如,我们可能发现将以前的一个子模块分割成两个单独的模块会更合理,这需要将现有资源的一个子集移动到新的模块中。

Terraform 将以前的状态与新代码进行比较,资源与每个模块或资源的唯一地址相关联。因此,默认情况下,移动或重命名对象会被 Terraform 理解为销毁旧地址的对象并在新地址创建新的对象。

当我们在代码中添加 moved 块以记录我们移动或重命名对象过去的地址时,Terraform 会将旧地址的现有对象视为现在属于新地址。

1.6.4.1.1. moved 块语法

moved 块只包含 fromto 参数,没有名称:

1
2
3
4
moved {
from = aws_instance.a
to = aws_instance.b
}

上面的例子演示了模块先前版本中的 aws_instance.a 如今以 aws_instance.b 的名字存在。

在为 aws_instance.b 创建新的变更计划之前,Terraform 会首先检查当前状态中是否存在地址为 aws_instance.a 的记录。如果存在该记录,Terraform 会将之重命名为 aws_instance.b 然后继续创建变更计划。最终生成的变更计划中该对象就好像一开始就是以 aws_instance.b 的名字被创建的,防止它在执行变更时被删除。

fromto 的地址使用一种特殊的地址语法,该语法允许选定模块、资源以及子模块中的资源。下面是几种不同的重构场景中所需要的地址语法:

1.6.4.1.2. 重命名一个资源

考虑模块代码中这样一个资源:

1
2
3
4
5
resource "aws_instance" "a" {
count = 2

# (resource-type-specific configuration)
}

第一次应用该代码时 Terraform 会创建 aws_instance.a[0] 以及 aws_instance.a[1]

如果随后我们修改了该资源的名称,并且把旧名字记录在一个 moved 块里:

1
2
3
4
5
6
7
8
9
10
resource "aws_instance" "b" {
count = 2

# (resource-type-specific configuration)
}

moved {
from = aws_instance.a
to = aws_instance.b
}

当下一次应用使用了该模块的代码时,Terraform 会把所有地址为 aws_instance.a 的对象看作是一开始就以 aws_instance.b 的名字创建的:aws_instance.a[0] 会被看作是 aws_instance.b[0]aws_instance.a[1] 会被看作是 aws_instance.b[1]

新创建的模块实例中,因为从来就不存在 aws_instance.a,于是会忽略 moved 块而像通常那样直接创建 aws_instance.b[0] 以及 aws_instance.b[1]

1.6.4.1.3. 为资源添加 count 或 for_each 声明

一开始代码中有这样一个单实例资源:

1
2
3
resource "aws_instance" "a" {
# (resource-type-specific configuration)
}

应用该代码会使得 Terraform 创建了一个地址为 aws_instance.a 的资源对象。

随后我们想要在该资源上添加 for_each 来创建多个实例。为了保持先前关联到 aws_instance.a 的资源对象不受影响,我们必须添加一个 moved 块来指定新代码中原先的对象实例所关联的键是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
locals {
instances = tomap({
big = {
instance_type = "m3.large"
}
small = {
instance_type = "t2.medium"
}
})
}

resource "aws_instance" "a" {
for_each = local.instances

instance_type = each.value.instance_type
# (other resource-type-specific configuration)
}

moved {
from = aws_instance.a
to = aws_instance.a["small"]
}

上面的代码会防止 Terraform 在变更计划中销毁已经存在的 aws_instance.a 对象,并且将其看作是以 aws_instance.a["small"] 的地址创建的。

moved 块的两个地址中的至少一个包含实例键时,如上例中的 ["small"],Terraform 将这两个地址理解为引用资源的特定实例而不是整个资源。这意味着您可以使用 moved 在键之间切换以及在 countfor_each 之间切换时添加和删除键。

下面的例子演示了几种其他类似的记录了资源实例键变更的合法 moved 块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Both old and new configuration used "for_each", but the
# "small" element was renamed to "tiny".
moved {
from = aws_instance.b["small"]
to = aws_instance.b["tiny"]
}

# The old configuration used "count" and the new configuration
# uses "for_each", with the following mappings from
# index to key:
moved {
from = aws_instance.c[0]
to = aws_instance.c["small"]
}
moved {
from = aws_instance.c[1]
to = aws_instance.c["tiny"]
}

# The old configuration used "count", and the new configuration
# uses neither "count" nor "for_each", and you want to keep
# only the object at index 2.
moved {
from = aws_instance.d[2]
to = aws_instance.d
}

注意:当我们在原先没有声明 count 的资源上添加 count 时,Terraform 会自动将原先的对象移动到第 0 个位置,除非我们通过一个 moved 块显式声明该资源。然而,我们建议使用 moved 块显式声明资源的移动,使得读者在未来阅读模块的代码时能够更清楚地了解到这些变更。

1.6.4.1.4. 重命名对模块的调用

我们可以用类似重命名资源的方式来重命名对模块的调用。假设我们开始用以下代码调用一个模块:

1
2
3
4
5
module "a" {
source = "../modules/example"

# (module arguments)
}

当应用该代码时,Terraform 会在模块内声明的资源路径前面加上一个模块路径前缀 module.a。比方说,模块内的 aws_instance.example 的完整地址为 module.a.aws_instance.example

如果我们随后打算修改模块名称,我们可以直接修改 module 块的标签,并且在一个 moved 块内部记录该变更:

1
2
3
4
5
6
7
8
9
10
module "b" {
source = "../modules/example"

# (module arguments)
}

moved {
from = module.a
to = module.b
}

当下一次应用包含该模块调用的代码时,Terraform 会将所有路径前缀为 module.a 的对象看作从一开始就是以 module.b 为前缀创建的。module.a.aws_instance.example 会被看作是 module.b.aws_instance.example

该例子中的 moved 块中的两个地址都代表对模块的调用,而 Terraform 识别出将原模块地址中所有的资源移动到新的模块地址中。如果该模块声明时使用了 count 或是 for_each,那么该移动也将被应用于所有的实例上,不需要逐个指定。

1.6.4.1.5. 为模块调用添加 count 或 for_each 声明

考虑一下单实例的模块:

1
2
3
4
5
module "a" {
source = "../modules/example"q

# (module arguments)
}

应用该段代码会导致 Terraform 创建的资源地址都拥有 module.a 的前缀。

随后如果我们可能需要再通过添加 count 来创建多个资源实例。为了保留先前的 aws_instance.a 实例不受影响,我们可以添加一个 moved 块来设置在新代码中该实例的对应的键。

1
2
3
4
5
6
7
8
9
10
11
module "a" {
source = "../modules/example"
count = 3

# (module arguments)
}

moved {
from = module.a
to = module.a[2]
}

上面的代码引导 Terraform 将所有 module.a 中的资源看作是从一开始就是以 module.a[2] 的前缀被创建的。结果就就是,Terraform 生成的变更计划中只会创建 module.a[0] 以及 module.a[1]

moved 块的两个地址中的至少一个包含实例键时,例如上面例子中的 [2]那样,Terraform 会理解将这两个地址理解为对模块的特定实例的调用而非对模块所有实例的调用。这意味着我们可以使用 moved 块在不同键之间切换来添加或是删除键,该机制可用于 countfor_each,或删除模块上的这种声明。

1.6.4.1.6. 将一个模块分割成多个模块

随着模块提供的功能越来越多,最终模块可能变得过大而不得不将之拆分成两个独立的模块。

我们看一下下面的这个例子:

1
2
3
4
5
6
7
8
9
10
11
resource "aws_instance" "a" {
# (other resource-type-specific configuration)
}

resource "aws_instance" "b" {
# (other resource-type-specific configuration)
}

resource "aws_instance" "c" {
# (other resource-type-specific configuration)
}

我们可以将该模块分割为三个部分:

  • aws_instance.a 现在归属于模块 “x”。
  • aws_instance.b 也属于模块 “x”。
  • aws_instance.c 现在归属于模块 “y”。

要在不替换绑定到旧资源地址的现有对象的情况下实现此重构,我们需要:

  1. 编写模块 “x”,将属于它的两个资源拷贝过去。
  2. 编写模块 “y”,将属于它的一个资源拷贝过去。
  3. 编辑原有模块代码,删除这些资源,只包含有关迁移现有资源的非常简单的配置代码。

新的模块 “x” 和 “y” 应该只包含 resource 块:

1
2
3
4
5
6
7
8
9
# module "x"

resource "aws_instance" "a" {
# (other resource-type-specific configuration)
}

resource "aws_instance" "b" {
# (other resource-type-specific configuration)
}
1
2
3
4
5
# module "y"

resource "aws_instance" "c" {
# (other resource-type-specific configuration)
}

而原有模块则被修改成只包含有向下兼容逻辑的垫片,调用两个新模块,并使用 moved 块定义哪些资源被移动到新模块中去了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
module "x" {
source = "../modules/x"

# ...
}

module "y" {
source = "../modules/y"

# ...
}

moved {
from = aws_instance.a
to = module.x.aws_instance.a
}

moved {
from = aws_instance.b
to = module.x.aws_instance.b
}

moved {
from = aws_instance.c
to = module.y.aws_instance.c
}

当一个原模块的调用者升级模块版本到这个“垫片”版本时,Terraform 会注意到这些 moved 块,并将那些关联到老地址的资源对象看作是从一开始就是由新模块创建的那样。

该模块的新用户可以选择使用这个垫片模块,或是独立调用两个新模块。我们需要通知老模块的现有用户老模块已被废弃,他们将来的开发中需要独立使用这两个新模块。

多模块重构的场景是不多见的,因为它违反了父模块将其子模块视为黑盒的典型规则,不知道在其中声明了哪些资源。这种妥协的前提是假设所有这三个模块都由同一个人维护并分布在一个模块包中。

为避免独立模块之间的耦合,Terraform 只允许声明在同一个目录下的模块间的移动。换句话讲,Terraform 不允许将资源移动到一个 source 地址不是本地路径的模块中去。

Terraform 使用定义 moved 块的模块实例的地址的地址来解析 moved 块中的相对地址。例如,如果上面的原模块已经是名为 module.original 的子模块,则原模块中对 module.x.aws_instance.a 的引用在根模块中将被解析为 module.original.module.x.aws_instance.a。一个模块只能针对它自身或是它的子模块中的资源声明 moved 块。

如果需要引用带有 countfor_each 元参数的模块中的资源,则必须指定要使用的特定实例键以匹配资源配置的新位置:

1
2
3
4
moved {
from = aws_instance.example
to = module.new[2].aws_instance.example
}

1.6.4.1.7. 删除 moved 块

随着时间的推移,一些老模块可能会积累大量 moved 块。

删除 moved 块通常是一种破坏性变更,因为删除后所有使用旧地址引用的对象都将被删除而不是被移动。我们强烈建议保留历史上所有的 moved 块来保存用户从任意版本升级到当前版本的升级路径信息。

如果我们决定要删除 moved 块,需要谨慎行事。对于组织内部的私有模块来说删除 moved 块可能是安全的,因为我们可以确认所有用户都已经使用新版本模块代码运行过 terraform apply 了。

如果我们需要多次重命名或是移动一个对象,我们建议使用串联moved 块来记录完整的变更信息,新的块引用已有的块:

1
2
3
4
5
6
7
8
9
moved {
from = aws_instance.a
to = aws_instance.b
}

moved {
from = aws_instance.b
to = aws_instance.c
}

像这样记录下移动的序列可以使 aws_instance.a 以及 aws_instance.b 两种地址的资源都得到成功更新,Terraform 会将他们视作从一开始就是以 aws_instance.c 的地址创建的。

1.6.4.1.8. 删除模块

注意:removed 块是在 Terraform v1.7 引入的功能。对于早期的 Terraform 版本,您可以使用 terraform state rm 命令来处理。

要从 Terraform 中删除模块,只需从 Terraform 代码中删除模块调用即可。

默认情况下,删除模块块后,Terraform 将计划销毁由该模块中声明的所有资源。这是因为当您删除模块调用时,该模块的代码将不再包含在我们当前的 Terraform 代码中。

有时我们可能希望从 Terraform 代码中删除模块而不破坏它管理的实际基础设施对象。在这种情况下,资源将从 Terraform 状态中删除,但真正的基础设施对象不会被销毁。

要声明模块已从 Terraform 配置中删除,但不应销毁其托管对象,请从配置中删除 module 块并将其替换为 removed 块:

1
2
3
4
5
6
7
removed {
from = module.example

lifecycle {
destroy = false
}
}

from 参数是要删除的模块的地址,不带任何实例键(例如 module.example[1])。

lifecycle 块是必需的。 destroy 参数确定 Terraform 是否会尝试销毁模块管理的对象。 false 值表示 Terraform 将从状态中删除资源而不破坏它们。

1.6.5.1. 设计新模块的模式

Terraform 模块是独立的基础设施即代码片段,抽象了基础设施部署的底层复杂性。Terraform 用户通过使用预置的配置代码加速采用 IaC,并降低了使用门槛。所以,模块的作者应尽量遵循诸如清晰的代码结构以及 DRY(“Dont’t Repeat Yourself”)原则的代码最佳实践。

本篇指导讨论了模块架构的原则,用以帮助读者编写易于组合、易于分享及重用的基础设施模块。这些架构建议对使用任意版本 Terraform 的企业都有助益,某些诸如“私有模块注册表(Registry)”的模式仅在 Terraform Cloud 以及企业版中才能使用。(本文不对相关内容进行翻译)

本文是对 Terraform 模块文档的补充和扩展。

通过阅读文本,读者可以:

  1. 学习有关 Terraform 模块创建的典型工作流程和基本原则。
  2. 探索遵循这些原则的示例场景。
  3. 学习如何通过协作改进 Terraform 模块
  4. 了解如何创建一套使用模块的工作流程。

1.6.5.1.1. 模块创建的工作流

要创建一个新模块,第一步是寻找一个早期采纳者团队,收集他们的需求。

与这支早期采纳团队一起工作使我们可以通过使用输入变量以及输出值来确保模块足够灵活,从而打磨模块的功能。此外,还可以用最小的代码变更代价吸纳其他有类似需求的团队加入进来。这消除了代码重复,并缩短了交付时间。

完成以上任务后,需要谨记两点:

  1. 将需求范围划分成合适的模块。
  2. 创建模块的最小可行产品(Minimum Viable Product, MVP)

1.6.5.1.1.1. 将需求范围划分成合适的模块

创建新 Terraform 模块时最具挑战的方面之一是决定要包含哪些基础设施资源。

模块设计应该是有主见的,并且被设计成能很好地完成一个目标。如果一个模块的功能或目的很难解释,那么这个模块可能太复杂了。在最初确定模块的范围时,目标应当足够小且简单,易于开始编写。

当构建一个模块时,需要考虑以下三个方面:

  • 封装:一组始终被一起部署的基础设施资源 在模块中包含更多的基础设施资源简化了终端用户部署基础设施的工作,但会使得模块的目的与需求变得更难理解。
  • 职责:限制模块职责的边界 如果模块中的基础设施资源由多个组来负责,使用该模块可能会意外违反职责分离原则。模块中仅包含职责边界内的一组资源可以提升基础设施资源的隔离性,并保护我们的基础设施。
  • 变化频率:隔离长短生命周期基础设施资源 举例来说,数据库基础设施资源相对来说较为静态,而团队可能在一天内多次部署更新应用程序服务器。在同一个模块中同时管理数据库与应用程序服务器使得保存状态数据的重要基础设施没有必要地暴露在数据丢失的风险之中。

1.6.5.1.1.2. 创建模块的最小可行产品

如同所有类型的代码一样,模块的开发永远不会完成,永远会有新的模块需求以及变更。拥抱变化,最初的模块版本应致力于满足最小可行产品(MVP)的标准。以下是在设计最小可行产品时需要谨记的指导清单:

  • 永远致力于交付至少可以满足 80% 场景的模块
  • 模块中永远不要处理边缘场景。边缘场景是很少见的。一个模块应该是一组可重用的代码。
  • 在最小可行产品中避免使用条件表达式。最小可行产品应缩小范围,不应该同时完成多种任务。
  • 模块应该只将最常被修改的参数公开为输入变量。一开始时,模块应该只提供最可能需要的输入变量。

尽可能多输出

在最小可行产品中输出尽可能多的信息,哪怕目前没有用户需要这些信息。这使得那些通常使用多个模块的终端用户在使用该模块时更加轻松,可以使用一个模块的输出作为下一个模块的输入。

请记住在模块的 README 文档中记录输出值的文档。

1.6.5.1.2. 探索遵循这些原则的一个示例场景

某团队想要通过 Terraform 创建一套包含 Web 层应用、App 层应用的基础设施。

他们想要使用一个专用的 VPC,并遵循传统的三层架构设计。他们的 Web 层应用需要一个自动伸缩组(AutoScaling Group)。他们的 App 层服务需要一个自动伸缩组,一个 S3 存储桶以及一个数据库。下面的架构图描述了期望的结果:

该场景中,一个负责从零开始撰写 Terraform 代码的团队,负责编写一组用以配置基础设施及应用的模块。负责应用程序的团队成员将使用这些模块来配置他们需要的基础设施。

请注意,虽然该示例使用了 AWS 命名,但所描述的模式适用于所有云平台。

经过对应用程序团队的需求进行审核,模块团队将该应用基础设施分割成如下模块:网络、Web、App、数据库、路由,以及安全。

当 Terraform 模块团队完成模块开发后,他们应该将模块导入到私有模块注册表中,并且向对应的团队成员宣传模块的使用方法。举例来说,负责网络的团队成员将会使用开发的网络模块来部署和配置相应的应用程序网络。

1.6.5.1.2.1. 网络模块

网络模块负责网络基础设施。它包含了网络访问控制列表(ACL)以及 NAT 网关。它也可以包含应用程序所需的 VPC、子网、对等连接以及 Direct Connect 等。

该模块包含这些资源是因为它们需要特定权限并且变化频率较低

  1. 只有应用程序团队中有权创建或修改网络资源的成员可以使用该模块。
  2. 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。

网络模块返回一组其他工作区(Workspace)以及模块可以使用的输出值。如果 VPC 的创建过程是由多个方面组成的,我们可能最终会需要将该模块进一步切割成拥有不同功能的不同模块。

1.6.5.1.2.2. 应用程序模块

本场景中有两个应用程序模块 —— 一个是 Web 层模块,另一个是 App 层模块。

Terraform 模块团队完成这两个模块的开发后,它们应被分发给对应的团队成员来部署他们的应用。随着应用程序团队的成员变得越来越熟悉 Terraform 代码,它们可以提出基础设施方面的增强建议,或是通过 Pull Request 配合他们自己的应用代码发布提交对基础设施的变更请求。

Web 模块

Web 模块创建和管理运行 Web 应用程序所需的基础设施。它包含了负载均衡器和自动伸缩组,同时也可以包含应用程序中使用的 EC2 虚拟机实例、S3 存储桶、安全组,以及日志系统。该模块接收一个通过 Packer 预构建的包含最新 Web 层应用发布版本代码的虚拟机镜像的 AMI ID 作为输入。

该模块包含这些资源是因为它们是高度封装的,并且它们变化频率较高

  1. 此模块中的资源高度内聚,并且与 Web 应用程序紧密相关(例如,此模块需要一个包含最新 Web 层应用程序代码版本的 AMI)。结果就是它们被编制进同一个模块,这样 Web 应用团队的成员们就可以轻松地部署它们。
  2. 该模块的资源变更频率较高(每次发布更新版本都需要更新对应基础设施资源)。通过将它们组合在单独的模块中,我们降低了将其他模块的资源暴露在没有必要的数据丢失的风险中的可能性。

App 模块

App 模块创建和管理运行 App 层应用所需的基础设施。它包含了负载均衡器和自动伸缩组,同时也包含了应用程序中使用的 EC2 虚拟机实例、S3 存储桶、安全组,以及日志系统。该模块接收一个通过 Packer 预构建的包含最新 App 层应用发布版本代码的虚拟机镜像的 AMI ID 作为输入。

该模块包含这些资源是因为它们是高度封装的,并且它们变化频率较高

  1. 此模块中的资源高度内聚,并且与 App 应用程序紧密相关。结果就是它们被编制进同一个模块,这样 App 层应用团队的成员们就可以轻松地部署它们。
  2. 该模块的资源变更频率较高(每次发布更新版本都需要更新对应基础设施资源)。通过将它们组合在单独的模块中,我们降低了将其他模块的资源暴露在没有必要的数据丢失的风险中的可能性。

数据库模块

数据库模块创建并管理了运行数据库所需的基础设施资源。它包含了应用程序所需的 RDS 实例,也包含了所有关联的存储、备份以及日志资源。

该模块包含这些资源是因为它们需要特定权限并且变化频率较低

  1. 只有应用程序团队中有权创建或修改数据库资源的成员可以使用该模块。
  2. 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。

路由模块

路由模块创建并管理网络路由所需的基础设施资源。它包含了公共托管区域(Hosted Zone)、Route 53 以及路由表,也可以包含私有托管区域。

该模块包含这些资源是因为它们需要特定权限并且变化频率较低

  1. 只有应用程序团队中有权创建或修改路由资源的成员可以使用该模块。
  2. 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。

安全模块

安全模块创建并管理所有安全所需的基础设施资源。它包含一组 IAM 资源,也可以包含安全组(Security Group)及多因素认证(MFA)。

该模块包含这些资源是因为它们需要特定权限并且变化频率较低

  1. 只有应用程序团队中有权创建或修改 IAM 或是安全资源的成员可以使用该模块。
  2. 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。

1.6.5.1.3. 创建模块的提示

除了范围界定之外,我们在创建模块时还应牢记以下几点:

1.6.5.1.3.1. 嵌套模块

嵌套模块是指在当前模块中对另一个模块的引用。嵌套模块可以是外部的,也可以是当前工作空间内的。使用嵌套模块是一项强大的功能;然而我们必须谨慎实践以避免引入错误。

对于所有类型的嵌套模块,请考虑以下事项:

  • 嵌套模块可以加速开发速度,但可能会引发未知以及意料之外的结果。请在文档中清晰地记录输入变量、模块行为以及输出值。
  • 通常来说,不要让主模块的嵌套深度超过两层。常用且简单的工具模块,例如专门用来定义 Tag 的模块,则不受此限制制约。
  • 嵌套模块必须包含必要的用来创建指定的资源配置的输入参数以及输出值。
  • 输入参数以及输出值的命名应遵循一致的命名约定,以使得模块可以更容易地被分享,以及将一个模块的的输出值作为另一个模块的输入参数。
  • 嵌套模块可能会导致代码冗余。必须同时在父模块与嵌套模块中声明输入参数和输出值。

嵌套的外部模块

当我们需要使用那些定义了被多个应用程序堆栈、应用程序和团队复用的标准化资源的通用模块时,嵌套的外部模块会很有用。外部模块通被集中管理和版本化控制,以使得消费者在使用新版本之前可以对其进行验证。当我们依赖或希望使用位于外部的子模块时,请注意以下几点:

  • 外部模块必须被独立维护,并可供任何需要调用它的模块使用。使用模块注册表可以确保这一点。
  • 根据模块注册要求,嵌套模块将拥有自己的版本控制代码仓库,独立于调用模块进行版本控制。
  • 对嵌套模块的变更可能会影响调用模块,即使调用模块的调用代码及版本没有发生变化,这会破坏调用代码的信任。
  • 对调用模块如何使用外部模块在文档中进行记录,使得模块行为以及调用关系可以被轻松理解。
  • 对外部模块的变更应该是向后兼容的。如果向后兼容是不可能的,则应清楚地记录需要对任何调用模块进行的更改,并将之分发给所有模块使用者以避免意外。

嵌套的嵌入模块

在当前工作空间中嵌入一个模块使得我们能够清晰地分离模块的逻辑组件,或是创建可在调用模块执行期间多次调用的可重用代码块。在下面的例子中,ec2-instance 是一个嵌入模块,根模块的 main.tf 引用了该模块:

1
2
3
4
5
root-module-directory
├── README.md
├── main.tf
└── ec2-instances
└── main.tf

如果我们需要或者倾向于使用嵌入模块,需要考虑以下几点:

  • 在“根模块”中添加嵌入模块意味着子模块与根模块被放在一起进行版本控制。
  • 任何影响两个模块间兼容性的变更都会被快速发现,因为它们必须被一同测试和发布。
  • (嵌入的)子模块不能被代码树之外的其他模块调用,所以可能会增加重复的代码。举例来说,如果嵌入的 ec2-instance 模块是用来创建一台被用在多个地方的标准化的计算实例,该模块无法以这种形式被分享。

标签化模块名并记录在文档中

为我们的模块创建并遵循一个命名约定将使得模块易于理解与使用。这将促进模块的采用和贡献。以下是一个用以提升模块元素一致性的建议列表:

  • 使用一个对人类来说一致且易于理解的模块命名约定。举例来说:
terraform cloud provider function full name
terraform aws consul cluster terraform-aws-consul_cluser
terraform aws security module terraform-aws-security
terraform azure database terraform-azure-database
  • 使用人类可以理解的输入变量命名约定。模块是编写一次并多次使用的代码,因此请完整命名所有内容以提升可读性,并在编写代码时在文档中进行记录。
  • 对所有模块进行文档记录。确保文档中包含有:
    • 必填的输入变量:这些输入变量应该是经过深思熟虑后的选择。如果这些输入变量值未定义,模块运行将失败。只在必要时为这些输入变量设置默认值。例如 var.vpc_id 永远不应该有默认值,因为每次使用模块时值都会不同。
    • 可选的输入变量:这些输入变量应该有一个合理的,适用于大多数场景的默认值,同时又可以根据需求进行调整。公告输入变量的默认值。例如 var.elb_idle_timeout 会有一个合理的默认值,但调用者也可以根据需求修改它的值。
    • 输出值:列出模块的所有输出值,并将重要的输出和信息性的输出包装在对用户友好的输出模板中。

定义并使用一个一致的模块结构

虽然模块结构是一个品味问题,我们应当将模块的结构记录在文档中,并且在我们的所有模块之间保持统一的结构。为了要维持模块结构的一致:

  • 定义一组模块必须包含的 .tf 文件,定义它们应包含哪些内容
  • 为模块定义一个 .gitignore(或类似作用的)文件
  • 创建供样例代码所使用的输入变量值的标准方式(例如一个 terraform.tfvars.example 文件)
  • 使用具有固定子目录的一致的目录结构,即使它们可能是空的
  • 所有模块目录都必须包含一个 README 文件详细记述目录存在的目的以及如何使用其中的文件

1.6.5.1.4. 模块的协作

随着团队模块的开发工作,简化我们的协作。

  1. 为每个模块创建路线图
  2. 从用户处收集需求信息,并按受欢迎程度进行优先级排序。
    • 不使用模块的最常见原因是“它不符合我的要求”。收集这些需求并将它们添加到路线图或对用户的工作流程提出建议。
    • 检查每一项需求以确认它引用的用例是否正确。
    • 公布和维护需求列表。分享该列表并让用户参与列表管理过程。
    • 不要为边缘用例排期。
  3. 将每一个决策记录进文档。
  4. 在公司内部采用开源社区原则。一些用户希望尽可能高效地使用这些模块,而另一些用户则希望帮助创建这些模块。
    • 创建一个社区
    • 维护一份清晰和公开的贡献指引
    • 最终,我们将允许可信的社区成员获得某些模块的所有权

1.6.5.1.5. 使用源代码控制系统追踪模块

一个 Terraform 模块应遵守所有良好的代码实践:

  • 将模块置于源代码控制中以管理版本发布、协作、变更的审计跟踪。
  • 为所有 main 分支的发布版本建立版本标签,记录文档(最起码在 CHANGELOGREADME 中记录)。
  • main 分支的所有变更进行代码审查
  • 鼓励模块的用户通过版本标签引用模块
  • 为每一个模块指派一位负责人
  • 一个代码仓库只负责一个模块
    • 这对于模块的幂等性和作为库的功能至关重要。
    • 我们应该对模块打上版本标签或是版本化控制。打上版本标签或是版本化的模块应该是不可变的。
    • 发布到私有模块注册表的模块必须要有版本标签。

1.6.5.1.6. 开发一套模块消费工作流

定义和宣传一套消费者团队使用模块时应遵循的可重复工作流程。这个工作流程,就像模块本身一样,应该考虑到用户的需求。

1.6.5.1.6.1. 阐明团队应该如何使用模块

  • 分散的安全性:如果每个模块都在自己的存储库中进行版本控制,则可以使用存储库 RBAC 来管理谁拥有写访问权限,从而允许相关团队管理相关的基础设施(例如网络团队拥有对网络模块的写访问权限)。
  • 培育代码社区:鉴于上述建议,模块开发的最佳实践是允许对存储在私有模块存储库中的模块的所有模块存储库提出 Pull Request。这促进了组织内的代码社区,保持模块内容的相关性和最大的灵活性,并有助于保持模块注册表的长期有效性。

留言與分享

Terraform 文件与目录

分類 devops, Terraform

Terraform 文件与目录

1.5.1. Terraform 文件与目录

Terraform 语言的代码存储在文件扩展名为 .tf 的纯文本文件中。该语言还有一种基于 JSON 的变体,以 .tf.json 文件扩展名命名(本书精力所限将不涉及这方面的内容)。 包含 Terraform 代码的文件通常称为配置文件。

1.5.1.1. 文本编码

配置文件必须始终使用 UTF-8 编码,并且按照惯例通常使用 Unix 样式行结束符 (LF),而不是 Windows 样式行结束符 (CRLF),尽管两者都被接受。

1.5.1.2. 文件夹与模块

同一个文件夹中一起保存的一组 .tf 以及 .tf.json 文件组成一个模块

Terraform 模块仅由直接隶属于该目录的配置文件组成;子目录被视为完全独立的模块,并且不会自动包含在配置中。

Terraform 执行模块中的所有配置文件,最终将整个模块视为单个代码文件。将各个块分成不同的文件纯粹是为了方便读者和维护者,对模块的行为没有影响。

Terraform 模块可以使用模块调用将其他模块显式包含到配置中。这些子模块可以来自本地目录(嵌套在父模块的目录中,或磁盘上的任意其他位置),也可以来自 Terraform 注册表等外部源。

1.5.1.3. 根模块

Terraform 始终在单一的根模块的上下文中运行。完整的 Terraform 配置由根模块和子模块树组成(其中包括根模块调用的模块、以及由这些模块调用的任何模块等)。

1.5.1.1. 重载(Override) 文件

Terraform 通常会加载目录中的所有 .tf.tf.json 文件,并假设每个文件中配置的对象是不同的。如果两个文件尝试定义同一对象,Terraform 将返回错误。

在某些极少数情况下,能够很方便地重载某一文件中声明配置的对象的指定部分。例如,可以使用 JSON 语法以编程方式生成的文件部分重载那些使用 HCL 语法人工编辑的 Terraform 配置文件。

对于这些不常见的场景,Terraform 对名称以 _override.tf_override.tf.json 结尾的配置文件进行特殊处理。这种特殊处理也适用于字面名称为 override.tfoverride.tf.json 的文件。

Terraform 最初在加载配置时跳过这些重载文件,然后依次处理每个文件(按字典顺序)。对于重载文件中定义的每个顶级块,Terraform 尝试查找与该块对应的已定义对象,然后将重载块的内容合并到现有对象中。

请确保仅在特殊情况下使用重载文件。过度使用重载文件会损害可读性,因为仅查看原始文件的读者无法在不查阅所有重载文件的情况下轻松地认识到这些文件的某些部分已被重载。使用重载文件时,请在原文件中对重载文件修改的每一个块添加注释警告。

1.5.1.1.1. 示例

假设我们有一个名为 example.tf 的文件中包含如下内容:

1
2
3
4
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = "ami-408c7f28"
}

同时,创建名为 override.tf 的文件:

1
2
3
resource "aws_instance" "web" {
ami = "foo"
}

Terraform 会将后者合并到前者中,其行为就好比我们一开始就写成这样:

1
2
3
4
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = "foo"
}

1.5.1.1.2. 合并规则

不同的块类型所对应的合并行为略有不同,某些块内的一些特殊结构以特殊方式合并。

适用于大多数情况的一般规则是:

  • 重载文件中的顶级块与具有相同块头的普通配置文件中的块合并。块头是块类型及其后面的所有标签。
  • 顶级块中的属性值将被替换为重载块中的同名属性值。
  • 顶级块中的内嵌块全部都会被重载块中相同类型的块替换。任何未出现在重载块中的块类型仍保留在原始块中。
  • 内嵌块的内容不会合并。
  • 合并后的块仍必须符合所有适用于该类型块的验证规则。

如果多个重载文件定义相同的顶级块,则重载效果会叠加,后面的块优先于前面的块。重载首先按文件名(按字典顺序)然后按每个文件中的位置进行处理。

以下部分描述了适用于某些顶级块类型中的特定参数的特殊合并行为。

1.5.1.1.2.1. 合并 resource 和 data 块

resource 块内,所有 lifecycle 块的内容都会按参数进行合并。例如,如果重载块仅设置了 create_before_destroy 参数,则原始块中 ignore_changes 参数会被保留。

如果重载 resource 块包含一个或多个 provisioner 块,则原始块中的所有 provisioner 块都将被忽略。

如果重载 resource 块包含 connection 块,则它完全取代原始块中存在的所有 connection 块。

不允许在重载块中声明 depends_on 元参数,那将会返回错误。

1.5.1.1.2.2. 合并 variable 块

variable 块内的参数按照上述的标准方式合并,但由于 typedefault 参数之间存在相互作用,有一些特殊规则。

如果原始块定义了 default 值并且重载块更改了变量的 type,Terraform 会尝试将默认值转换为重载的类型,如果无法进行这样的转换,则会产生错误。

相反,如果原始块定义 type 并且重载块更改 default 值,则重载的默认值必须与原始块定义的类型兼容。

1.5.1.1.2.3. 合并 output 块

不允许在重载块中声明 depends_on 元参数,那将会返回错误。

1.5.1.1.2.4. 合并 locals 块

每个 locals 块定义了键值对。重载是在逐个值进行的,不论它们定义在哪个 locals 块中。

1.5.1.1.2.5. 合并 terraform 块

terraform 块内的配置在合并时会有不同的规则。

如果设置了 required_providers 参数,则其值将以 Provider 的尺度进行合并,这允许重载块调整单个 Provider 的约束,而不影响其他 Provider 的约束。

required_versionrequired_providers 设置中,所有重载的约束都会彻底替换原始块中同一组件的约束。如果原始块和重载块都设置了 required_version,则原始块中的约束将被彻底忽略。

重载文件中定义 Backend(cloudbackend)的块始终优先于原始配置中定义 Backend 的块。也就是说,如果在原始配置中设置了 cloud 块,并在重载文件中设置了 backend 块,则 Terraform 将在合并时使用重载文件中指定的 backend 块。同样,如果在原始配置中设置了 backend 块,并且在重载文件中设置了 cloud 块,则 Terraform 将在合并时使用重载文件中指定的 cloud 块。

1.5.2.1. 依赖锁(Dependency Lock)文件

注意:该功能是自 Terraform 0.14 起引入的一项功能。 Terraform 的更早期版本不跟踪依赖项,因此本节与这些早期版本无关。

Terraform 配置文件中可以引用来自其自身代码库之外的两种不同类型的外部依赖项:

  • Providers,它们是 Terraform 的插件,用以与各种外部系统交互。
  • 模块,允许将一组 Terraform 配置(用 Terraform 语言编写)拆分为可重用的抽象组件。

这两种类型的依赖都可以独立于 Terraform 本身以及依赖于它们的代码进行发布和更新。因此,Terraform 必须确定这些依赖项的哪些版本可能与当前配置兼容以及当前选择使用哪些版本。

配置本身内的版本约束决定了哪些版本的依赖项可能与当前配置兼容,但在为每个依赖项选定的特定版本后,Terraform 会记住它在依赖锁文件中所做的决定,以便它可以(默认情况下)在将来再次做出相同的决定。

目前,依赖锁文件仅追踪 Provider 的依赖项。 Terraform 不会保存远程模块的版本选择,因此 Terraform 将始终选择满足指定版本约束的最新可用模块版本。我们可以使用精确的版本约束来确保 Terraform 始终选择相同版本的模块。

1.5.2.1.1. 锁文件的位置

依赖锁文件是记录的是整个配置的依赖,而不是配置代码中使用每个单独的模块的依赖。因此,Terraform 在运行 Terraform 时的当前工作目录中创建该文件,并假设该文件存在于此,同时该目录也包含了配置根模块的 .tf 文件的。

依赖锁文件的名字始终是 .terraform.lock.hcl,此名称旨在表示它是 Terraform 缓存在工作目录的 .terraform 子目录中的各种项目的锁定文件。

每次运行 terraform init 命令时,Terraform 都会自动创建或更新依赖项锁定文件。我们应该将此文件包含在版本控制存储库中,以便我们可以在代码审查过程中对那些外部依赖项的更改进行审查,就像对配置本身的更改进行审查一样。

依赖锁定文件使用与 Terraform 语言相同的语法,但依赖锁定文件本身并不是 Terraform 语言配置文件。它以后缀 .hcl 而不是 .tf 命名,以显示这种差异。

1.5.2.1.2. 安装依赖的行为

terraform init 正在安装配置所需的所有 Provider 程序时,Terraform 会同时参考配置中的版本约束以及锁定文件中记录的版本选择。

如果某个 Provider 程序没有在锁文件中找到对应的记录,Terraform 将选择与给定版本约束匹配的最新可用版本,然后更新锁定文件以保存该选择。

如果某个 Provider 程序已在锁文件中保存了一个版本选择记录,Terraform 将始终重新选择该版本进行安装,即使有更新的版本可用。我们可以通过在运行 terraform init 时添加 -upgrade 选项来重载该行为,在这种情况下,Terraform 将忽略现有选择并再次选择与版本约束匹配的最新可用版本。

如果某次 terraform init 调用对锁文件进行了更改,Terraform 会在其输出中提到这一点:

1
2
3
Terraform has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your
version control system if they represent changes you intended to make.

当我们看到这样的消息时,我们可以使用版本控制系统来查看 Terraform 提到的那些在文件中的变更,如果变更的确是我们有意为之的,那我们可以将这些变更发送至团队的常规代码审核流程进行审查。

1.5.2.1.2.1. 验证校验和

Terraform 还将验证它安装的每个包是否与之前在锁定文件中记录的校验和至少一个相匹配(如果有),如果没有校验和匹配,则返回错误:

1
2
3
4
5
Error: Failed to install provider

Error while installing hashicorp/azurerm v2.1.0: the current package for
registry.terraform.io/hashicorp/azurerm 2.1.0 doesn't match any of the
checksums previously recorded in the dependency lock file.

此校验和验证采用初次使用时信任原则。当我们第一次添加新的 Provider 程序时,我们可以通过各种方式或相关法规要求的所有方法对其进行验证,然后确信如果未来运行 terraform init 时,在安装相同 Provider 程序时遇到不匹配的包时 Terraform 会返回错误。

“初次使用时”模型有两个特殊考虑因素:

  • 如果我们从源注册表安装 Provider 程序时,该 Provider 程序提供了使用加密签名算法签名的校验和,那么只要有一个校验和匹配,Terraform 就会将所有已签名的校验和视为有效。因此,锁文件将包含该 Provider 所适用的所有平台的所有不同包的校验和。

在这种情况下,terraform init 的输出将包括对校验和进行签名的密钥的指纹,以及像这样的信息 (signed by a HashiCorp partner, key ID DC9FC6B1FCE47986)。在提交包含签名校验和的锁文件之前,最好先确认我们能够信任给该密钥的持有者,或者检索并验证指定 Provider 版本的所有可用包的集合。

  • 如果我们首次安装 Provider 时使用的是替代安装方法(例如文件系统或网络镜像),Terraform 将无法验证除运行 terraform init 的平台之外的其他所有平台的校验和,因此它不会记录其他平台的校验和,导致该配置将无法在任何其他平台上使用。

为了避免此问题,您可以使用 terraform providers lock 命令在锁文件中预先记录各种不同平台的校验和,这样我们将来可以调用 terraform init 命令来验证我们选择的镜像中可用的包是否与该 Provider 源注册表提供的官方包相匹配。

1.5.2.1.3. 理解锁文件的变更

由于依赖锁文件主要由 Terraform 本身自动维护,而不是手动更新,因此我们的版本控制系统可能会向我们显示该文件已更改。

Terraform 可能会对我们的锁文件进行几种不同类型的变更,我们可能需要了解这些更改才能查看建议的变更。以下各节将描述这些常见变更。

1.5.2.1.3.1. 新 Provider 的依赖

如果我们向配置中任意模块的 Provider 的声明配置节中添加新条目,或者添加了包含新 Provider 程序依赖项的外部模块,terraform init 将通过选择满足配置中所有版本约束的最新版本的 Provider 程序,并将这条决策保存为依赖锁文件中的一个新的 Provider 程序块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
--- .terraform.lock.hcl 2020-10-07 16:12:07.539570634 -0700
+++ .terraform.lock.hcl 2020-10-07 16:12:15.267487237 -0700
@@ -6,6 +6,26 @@
]
}

+provider "registry.terraform.io/hashicorp/azurerm" {
+ version = "2.30.0"
+ constraints = "~> 2.12"
+ hashes = [
+ "h1:FJwsuowaG5CIdZ0WQyFZH9r6kIJeRKts9+GcRsTz1+Y=",
+ "h1:c/ntSXrDYM1mUir2KufijYebPcwKqS9CRGd3duDSGfY=",
+ "h1:yre4Ph76g9H84MbuhZ2z5MuldjSA4FsrX6538O7PCcY=",
+ "zh:04f0a50bb2ba92f3bea6f0a9e549ace5a4c13ef0cbb6975494cac0ef7d4acb43",
+ "zh:2082e12548ebcdd6fd73580e83f626ed4ed13f8cdfd51205d8696ffe54f30734",
+ "zh:246bcc449e9a92679fb30f3c0a77f05513886565e2dcc66b16c4486f51533064",
+ "zh:24de3930625ac9014594d79bfa42d600eca65e9022b9668b54bfd0d924e21d14",
+ "zh:2a22893a576ff6f268d9bf81cf4a56406f7ba79f77826f6df51ee787f6d2840a",
+ "zh:2b27485e19c2aaa9f15f29c4cff46154a9720647610171e30fc6c18ddc42ec28",
+ "zh:435f24ce1fb2b63f7f02aa3c84ac29c5757cd29ec4d297ed0618423387fe7bd4",
+ "zh:7d99725923de5240ff8b34b5510569aa4ebdc0bdb27b7bac2aa911a8037a3893",
+ "zh:7e3b5d0af3b7411dd9dc65ec9ab6caee8c191aee0fa7f20fc4f51716e67f50c0",
+ "zh:da0af4552bef5a29b88f6a0718253f3bf71ce471c959816eb7602b0dadb469ca",
+ ]
+}
+
provider "registry.terraform.io/newrelic/newrelic" {
version = "2.1.2"
constraints = "~> 2.1.1"

新的锁文件条目记录了几条信息:

  • version:Terraform 根据配置中的版本约束所选择的版本。
  • constraints:Terraform 在进行此选择时所遵守的所有的版本约束。 (Terraform 实际上并不使用此信息来做出安装决策,而是保存该信息以帮助向人类读者解释之前的决策是如何做出的。)
  • hashes:给定 Provider 程序在不同平台上有效的安装包所对应的一组校验和。这些哈希值的含义在下面的新 Provider 程序包校验和中有进一步的解释。

1.5.2.1.3.2. 现存 Provider 的新版本

如果我们运行 terraform init -upgrade 来命令 Terraform 在遵守配置的版本约束匹配的前提下将 Provider 升级到更新的版本,那么 Terraform 可能会为 Provider 程序选择较新的版本并更新其现有的 Provider 程序块,以体现该变更。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
--- .terraform.lock.hcl 2020-10-07 16:44:25.819579509 -0700
+++ .terraform.lock.hcl 2020-10-07 16:43:42.785665945 -0700
@@ -7,22 +7,22 @@
}

provider "registry.terraform.io/hashicorp/azurerm" {
- version = "2.1.0"
- constraints = "~> 2.1.0"
+ version = "2.0.0"
+ constraints = "2.0.0"
hashes = [
- "h1:EOJImaEaVThWasdqnJjfYc6/P8N/MRAq1J7avx5ZbV4=",
- "zh:0015b491cf9151235e57e35ea6b89381098e61bd923f56dffc86026d58748880",
- "zh:4c5682ba1e0fc7e2e602d3f103af1638f868c31fe80cc1a884a97f6dad6e1c11",
- "zh:57bac885b108c91ade4a41590062309c832c9ab6bf6a68046161636fcaef1499",
- "zh:5810d48f574c0e363c969b3f45276369c8f0a35b34d6202fdfceb7b85b3ac597",
- "zh:5c6e37a44462b8662cf9bdd29ce30523712a45c27c5d4711738705be0785db41",
- "zh:64548940a3387aa3a752e709ee9eb9982fa820fe60eb60e5f212cc1d2c58549e",
- "zh:7f46749163da17330bbb5293dc825333c86304baa0a7c6256650ac536b4567c8",
- "zh:8f8970f2df75ac43ffdd112055ee069d8bd1030f7eb4367cc4cf494a1fa802c3",
- "zh:9ad693d00dc5d7d455d06faba70e716bce727c6706f7293288e87fd7956b8fe0",
- "zh:b6e3cb55e6aec62b47edd0d2bd5e14bd6a2bcfdac65930a6e9e819934734c57b",
- "zh:d6a3f3b9b05c28ecf3919e9e7afa185805a6d7442fc4b3eedba749c2731d1f0e",
- "zh:d81fb624a357c57c7ea457ce543d865b39b12f26c2edd58a2f7cd43326c91010",
+ "h1:bigGXBoRbp7dv79bEEn+aaju8575qEXHQ57XHVPJeB8=",
+ "zh:09c603c8904ca4a5bc19e82335afbc2837dcc4bee81e395f9daccef2f2cba1c8",
+ "zh:194a919d4836d6c6d4ce598d0c66cce00ddc0d0b5c40d01bb32789964d818b42",
+ "zh:1f269627df4e266c4e0ef9ee2486534caa3c8bea91a201feda4bca525005aa0a",
+ "zh:2bae3071bd5f8e553355c4b3a547d6efe1774a828142b762e9a4e85f79be7f63",
+ "zh:6c98dfa5c3468e8d02e2b3af7c4a8a14a5d469ce5a642909643b413a17ca338b",
+ "zh:7af78f61666fd45fbf428161c061ea2623162d601b79dc71d6a5158756853ffa",
+ "zh:883c2df86ae9ba2a5c167cf5c2c7deca0239171a224d6d335f0fd6dd9c283830",
+ "zh:a2028379078577d8ff5ecfca6e8a8b25a25ffb1686de0ee52a7fe8011783488b",
+ "zh:abe6ef399552fd3861a454a839cd978c1d15735658fdc00f9054435aff0f4620",
+ "zh:c30b1bf14077913c3cdf34979b1434dbb1353cb5995eb3956b191c50538b64a9",
+ "zh:ca64ae2ad9793e5631e3b0b9327f7cb22cb5d8e9de57be7d85821791b1d5a375",
+ "zh:fffe56904a38109bb8d613b02808a177c3ddfac19f03b3aac799281fea38f475",
]
}

选择新的 Provider 程序版本的对锁文件进行的修改主要是更改了 provider 块中 version 的值。如果升级伴随着对配置文件中声明的版本约束的变更,Terraform 还将在 constraints 中记录该变更。

由于每个版本都有自己的一组分发包,因此切换到新版本也往往会替换 hashes 中的所有值,以体现新版本包的校验和。

1.5.2.1.3.3. 新 Provider 程序包校验和

我们可能在 provider 块中看到的一个很细微的变化是添加了以前未记录的新校验和,即使 provider 块中的所有其他内容都没有更改:

1
2
3
4
5
6
7
8
9
10
--- .terraform.lock.hcl 2020-10-07 17:24:23.397892140 -0700
+++ .terraform.lock.hcl 2020-10-07 17:24:57.423130253 -0700
@@ -10,6 +10,7 @@
version = "2.1.0"
constraints = "~> 2.1.0"
hashes = [
+ "h1:1xvaS5D8B8t6J6XmXxX8spo97tAzjhacjedFX1B47Fk=",
"h1:EOJImaEaVThWasdqnJjfYc6/P8N/MRAq1J7avx5ZbV4=",
"zh:0015b491cf9151235e57e35ea6b89381098e61bd923f56dffc86026d58748880",
"zh:4c5682ba1e0fc7e2e602d3f103af1638f868c31fe80cc1a884a97f6dad6e1c11",

hashes 中添加的新的校验和代表 Terraform 在不同哈希方案之间逐渐过渡。这些值上的 h1:zh: 前缀代表不同的哈希方案,每个方案都代表使用不同的算法计算校验和。如果现有方案存在某种局限性或者新方案提供了一些相当明显的收益,有时我们可能会引入新的哈希方案。

目前支持两种哈希方案:

  • zh:: 代表“zip hash”,这是一种遗留哈希格式,是 Terraform Provider 程序注册表协议的一部分,因此用于直接从源注册表安装的 Provider 程序。

此哈希方案捕记录下注册表中索引的每个官方 .zip 包的 SHA256 哈希值。这是验证从注册表安装的官方发布包的有效方案,但它无法验证来自其他 Provider 程序安装方法的包,例如文件系统中指向解压后的安装包的镜像。

  • h1:: 代表“hash schema 1”,它是当前首选的哈希方案。

Hash Schema 1 也是 SHA256 哈希,但它是根据提供 Provider 安装包内包含的内容计算得出的,而不是根据其包含的 .zip 文件的内容计算得出的。因此,该方案的优点是可以针对官方 .zip 文件、具有相同内容的解压目录或包含相同文件但可能具有不同元数据或压缩方案的重新压缩 .zip 文件计算出相同的校验和。

由于 zh: 方案的局限性,Terraform 将在计算出相应的 h1: 校验和时适时添加它们,这就是导致在上面所示的示例更改中添加第二个 h1: 校验和的原因。

Terraform 只会在安装包与已有哈希值之一相匹配时,才会把新计算出的哈希值添加到锁文件中。在上面的示例中,Terraform 在与生成原始 h1: 校验和的平台不同的平台安装了 hashcorp/azurerm 包,但能够匹配到之前记录的 zh: 校验和的其中之一,确认 zh: 校验和匹配后,Terraform 保存相应的 h1: 校验和,以便逐步从旧方案迁移到新方案。

首次安装某个 Provider 程序时(没有已有的 provider 块),Terraform 将使用 Provider 插件开发人员的加密签名(通常涵盖所有可用包)所涵盖适用于所有受支持平台上的该 Provider 程序版本校验集合来预先填充 hashes。但是,由于 Provider 程序注册表协议仍然使用 zh: 方案,因此初始集将主要包含使用该方案的哈希值,然后当我们在不同平台上安装软件包时,Terraform 将适时升级该哈希值。

如果我们希望避免在新目标平台上使用配置时不断添加新的 h1: 哈希值,或者如果因为我们是从镜像安装的 Provider 程序,因此无法提供官方签名的校验和,您可以使用 terraform providers lock 命令要求 Terraform 预填充一组选定平台的哈希值:

1
2
3
4
5
terraform providers lock \
-platform=linux_arm64 \
-platform=linux_amd64 \
-platform=darwin_amd64 \
-platform=windows_amd64

上述命令将下载并验证所有四个给定平台上所有我们指定的 Provider 程序的官方软件包,然后在锁文件中记录每个软件包的 zh:h1: 校验和,从而避免 Terraform 在之后只能得到 h1: 校验和这种情况。有关此命令的更多信息,请参阅 terraform providers lock 命令。

1.5.2.1.3.4. 那些不再被使用的 Provider

为了确定是否仍然存在对某个 Provider 程序的依赖,Terraform 使用两个事实来源:配置代码和状态。如果我们从配置和状态中删除对某个 Provider 程序的最后一个依赖项,则 terraform init 将从锁文件中删除该 Provider 的现存记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
--- .terraform.lock.hcl 2020-10-07 16:12:07.539570634 -0700
+++ .terraform.lock.hcl 2020-10-07 16:12:15.267487237 -0700
@@ -6,26 +6,6 @@
]
}

-provider "registry.terraform.io/hashicorp/azurerm" {
- version = "2.30.0"
- constraints = "~> 2.12"
- hashes = [
- "h1:FJwsuowaG5CIdZ0WQyFZH9r6kIJeRKts9+GcRsTz1+Y=",
- "h1:c/ntSXrDYM1mUir2KufijYebPcwKqS9CRGd3duDSGfY=",
- "h1:yre4Ph76g9H84MbuhZ2z5MuldjSA4FsrX6538O7PCcY=",
- "zh:04f0a50bb2ba92f3bea6f0a9e549ace5a4c13ef0cbb6975494cac0ef7d4acb43",
- "zh:2082e12548ebcdd6fd73580e83f626ed4ed13f8cdfd51205d8696ffe54f30734",
- "zh:246bcc449e9a92679fb30f3c0a77f05513886565e2dcc66b16c4486f51533064",
- "zh:24de3930625ac9014594d79bfa42d600eca65e9022b9668b54bfd0d924e21d14",
- "zh:2a22893a576ff6f268d9bf81cf4a56406f7ba79f77826f6df51ee787f6d2840a",
- "zh:2b27485e19c2aaa9f15f29c4cff46154a9720647610171e30fc6c18ddc42ec28",
- "zh:435f24ce1fb2b63f7f02aa3c84ac29c5757cd29ec4d297ed0618423387fe7bd4",
- "zh:7d99725923de5240ff8b34b5510569aa4ebdc0bdb27b7bac2aa911a8037a3893",
- "zh:7e3b5d0af3b7411dd9dc65ec9ab6caee8c191aee0fa7f20fc4f51716e67f50c0",
- "zh:da0af4552bef5a29b88f6a0718253f3bf71ce471c959816eb7602b0dadb469ca",
- ]
-}
-
provider "registry.terraform.io/newrelic/newrelic" {
version = "2.1.2"
constraints = "~> 2.1.1"

如果我们稍后为同一 Provider 程序添加新的引用并再次运行 terraform init,Terraform 会将其视为全新的 Provider 程序,因此不一定会选择之前选择的相同版本,并且无法验证校验和是否保持不变。

注意:在 Terraform v1.0 及更早版本中,terraform init 不会自动从锁文件中删除现在不需要的 Provider 程序,而只是忽略它们。如果您之前使用早期版本的 Terraform 时删除了提供 Provider 依赖项,然后升级到 Terraform v1.1 或更高版本,那么您可能会看到错误“missing or corrupted provider plugins”,指向过时的锁文件条目。如果是这样,请使用新的 Terraform 版本运行 terraform init 以整理那些不需要的条目,然后重试之前的操作。

1.5.3.1. 测试文件

某些的 Terraform 命令(例如 testinitvalidate)会针对配置代码加载 Terraform 测试文件。

测试文件包含 Terraform 测试执行的代码。有关 Terraform 测试命令的更多信息,请参阅命令:test。有关语法和 Terraform 测试文件语言的更多信息,请参阅测试

1.5.3.1.1. 文件扩展名

测试文件的扩展名是 .tftest.hcl 以及 .tftest.json

1.5.3.1.2. 测试文件的位置

Terraform 加载根模块目录中的所有测试文件。

Terraform 还会加载测试目录中的测试文件。我们可以通过在那些会加载测试文件的命令后添加 -test-directory 参数来修改测试目录的位置。默认测试目录是根模块目录下的 tests 目录。

留言與分享

Terraform-代码-类型

分類 devops, Terraform

1.4.1.1. 类型

表达式的结果是一个值。所有的值都有一个类型,这个类型决定了这个值可以在哪里使用以及可以对它应用哪些转换。

Terraform 的某些类型之间存在隐式类型转换规则,如果无法隐式转换类型,那么不同类型数据间的赋值将会报错。

Terraform 类型分为原始类型、复杂类型,以及 null

1.4.1.1.1. 原始类型

原始类型分三类:stringnumberbool

  • string 代表一组 Unicode 字符串,例如:"hello"
  • number 代表数字,可以为整数,也可以为小数。
  • bool 代表布尔值,要么为 true,要么为 falsebool 值可以被用做逻辑判断。

numberbool 都可以和 string 进行隐式转换,当我们把 numberbool 类型的值赋给 string 类型的值,或是反过来时,Terraform 会自动替我们转换类型,其中:

  • true 值会被转换为 "true",反之亦然
  • false 值会被转换为 "false",反之亦然
  • 15 会被转换为 "15"3.1415 会被转换为 "3.1415",反之亦然

1.4.1.1.2. 复杂类型

复杂类型是一组值所组成的符合类型,有两类复杂类型。

一种是集合类型。一个集合包含了一组同一类型的值。集合内元素的类型成为元素类型。一个集合变量在构造时必须确定集合类型。集合内所有元素的类型必须相同。

Terraform 支持三种集合:

  • list(...):列表是一组值的连续集合,可以用下标访问内部元素,下标从 0 开始。例如名为 llistl[0] 就是第一个元素。list 类型的声明可以是 list(number)list(string)list(bool)等,括号中的类型即为元素类型。

  • map(...):字典类型(或者叫映射类型),代表一组键唯一的键值对,键类型必须是 string,值类型任意。map(number) 代表键为 string 类型而值为 number 类型,其余类推。map 值有两种声明方式,一种是类似 {"foo": "bar", "bar": "baz"},另一种是 {foo="bar", bar="baz"}。键可以不用双引号,但如果键是以数字开头则例外。多对键值对之间要用逗号分隔,也可以用换行符分隔。推荐使用 = 号(Terraform 代码规范中规定按等号对齐,使用等号会使得代码在格式化后更加美观)

  • set(...):集合类型,代表一组不重复的值。

以上集合类型都支持通配类型缩写,例如 list 等价于 list(any)map 等价于 map(any)set 等价于 set(any)any 代表支持任意的元素类型,前提是所有元素都是一个类型。例如,将 list(number) 赋给 list(any) 是合法的,list(string) 赋给 list(any) 也是合法的,但是 list 内部所有的元素必须是同一种类型的。

第二种复杂类型是结构化类型。一个结构化类型允许多个不同类型的值组成一个类型。结构化类型需要提供一个 schema 结构信息作为参数来指明元素的结构。

Terraform 支持两种结构化类型:

  • object(...):对象是指一组由具有名称和类型的属性所构成的符合类型,它的 schema 信息由 { \<KEY\>=\<TYPE\>, \<KEY\>=\<TYPE\>,...} 的形式描述,例如 object({age=number, name=string}),代表由名为 "age“ 类型为number,以及名为 "name" 类型为 string 两个属性组成的对象。赋给 object 类型的合法值必须含有所有属性值,但是可以拥有多余的属性(多余的属性在赋值时会被抛弃)。例如对于 object({age=number,name=string}) 来说,{ age=18 } 是一个非法值,而 { age=18, name="john", gender="male" } 是一个合法值,但赋值时 gender 会被抛弃
  • tuple(...):元组类似 list,也是一组值的连续集合,但每个元素都有独立的类型。元组同 list 一样,也可以用下标访问内部元素,下标从 0 开始。元组 schema 用 [\<TYPE\>, \<TYPE\>, ...] 的形式描述。元组的元素数量必须与 schema 声明的类型数量相等,并且每个元素的类型必须与元组 schema 相应位置的类型相等。例如,tuple([string, number, bool]) 类型的一个合法值可以是 ["a", 15, true]

复杂类型也支持隐式类型转换。

Terraform 会尝试转换相似的类型,转换规则有:

  • objectmap:如果一个 map 的键集合含有 object 规定的所有属性,那么 map 可以被转换为 objectmap 里多余的键值对会被抛弃。由 map -> object -> map 的转换可能会丢失数据。
  • tuplelist:当一个 list 元素的数量正好等于一个 tuple 声明的长度时,list 可以被转换为 tuple。例如:值为 ["18", "true", "john"]list 转换为 tuple([number,bool, string]) 的结果为 [18, true, "john"]
  • settuple:当一个 list 或是 tuple 被转换为一个 set,那么重复的值将被丢弃,并且值原有的顺序也将丢失。如果一个 set 被转换到 list 或是 tuple,那么元素将按照以下顺序排列:如果 set 的元素是 string,那么将按照字段顺序排列;其他类型的元素不承诺任何特定的排列顺序。

复杂类型转换时,元素类型将在可能的情况下发生隐式转换,类似上述 listtuple 转换举的例子。

如果类型不匹配,Terraform 会报错,例如我们试图把object({name = ["Kristy", "Claudia", "Mary Anne", "Stacey"], age = 12})转换到 map(string) 类型,这是不合法的,因为 name 的值为 list,无法转换为 string

1.4.1.1.3. any

any 是 Terraform 中非常特殊的一种类型约束,它本身并非一个类型,而只是一个占位符。每当一个值被赋予一个由 any 约束的复杂类型时,Terraform 会尝试计算出一个最精确的类型来取代 any

例如我们把 ["a", "b", "c"] 赋给 list(any),它在 Terraform 中实际的物理类型首先被编译成 tuple([string, string, string]),然后 Terraform 认为 tuplelist 相似,所以会尝试将它转换为 list(string)。然后 Terraform 发现 list(string) 符合 list(any) 的约束,所以会用 string 取代 any,于是赋值后最终的类型是 list(string)

由于即使是 list(any),所有元素的类型也必须是一样的,所以某些类型转换到 list(any) 时会对元素进行隐式类型转换。例如将 ["a", 1, "b"] 赋给 list(any),Terraform 发现 1 可以转换到 "1",所以最终的值是 ["a", "1", "b"],最终的类型会是 list(string)。再比如我们想把 ["a", \[\], "b"] 转换成 list(any),由于 Terraform 无法找到一个一个合适的目标类型使得所有元素都能成功隐式转换过去,所以 Terraform 会报错,要求所有元素都必须是同一个类型的。

声明类型时如果不想有任何的约束,那么可以用 any

1
2
3
variable "no_type_constraint" {
type = any
}

这样的话,Terraform 可以将任何类型的数据赋予它。

1.4.1.1.4. null

存在一种特殊值是无类型的,那就是 nullnull 代表数据缺失。如果我们把一个参数设置为 null,Terraform 会认为你忘记为它赋值。如果该参数有默认值,那么 Terraform 会使用默认值;如果没有又恰巧该参数是必填字短,Terraform 会报错。null 在条件表达式中非常有用,你可以在某项条件不满足时跳过对某参数的赋值。

1.4.1.1.5. object 的 optional 成员

自 Terraform 1.3 开始,我们可以在 object 类型定义中使用 optional 修饰属性。

在 1.3 之前,如果一个 variable 的类型为 object,那么使用时必须传入一个结构完全相符的对象。例如:

1
2
3
4
5
6
7
variable "an_object" {
type = object({
a = string
b = string
c = number
})
}

如果我们想传入一个对象给 var.an_object,但不准备给 bc 赋值,我们必须这样:

1
2
3
4
5
{
a = "a"
b = null
c = null
}

传入的对象必须完全匹配类型定义的结构,哪怕我们不想对某些属性赋值。这使得我们如果想要定义一些比较复杂,属性比较多的 object 类型时会给用户在使用上造成一些麻烦。

Terraform 1.3 允许我们为一个属性添加 optional 声明,还是用上面的例子:

1
2
3
4
5
6
7
variable "with_optional_attribute" {
type = object({
a = string # a required attribute
b = optional(string) # an optional attribute
c = optional(number, 127) # an optional attribute with default value
})
}

在这里我们将 b 声明为 optional,如果传入的对象没有 b,则会使用 null 作为值;c 不但声明为 optional 的,还添加了 127 作为默认值,传入的对象如果没有 c,那么会使用 127 作为它的值。

optional 修饰符有这样两个参数:

  • 类型:(必填)第一个参数标明了属性的类型
  • 默认值:(选填)第二个参数定义了 Terraform 在对象中没有定义该属性值时使用的默认值。默认值必须与类型参数兼容。如果没有指定默认值,Terraform 会使用 null 作为默认值。

一个包含非 null 默认值的 optional 属性在模块内使用时可以确保不会读到 null 值。当用户没有设置该属性,或是显式将其设置为 null 时,Terraform 会使用默认值,所以模块内无需再次判断该属性是否为 null

Terraform 采用自上而下的顺序来设置对象的默认值,也就是说,Terraform 会先应用 optional 修饰符中的指定的默认值,然后再为其中可能存在的内嵌对象设置默认值。

1.4.1.1.5.1. 例子:带有 optional 属性和默认值的内嵌结构

下面的例子演示了一个输入变量,用来描述一个存储了静态网站内容的存储桶。该变量的类型包含了一系列的 optional 属性,包括 website,不但其自身是 optional 的,其内部包含了数个 optional 的属性以及默认值。

1
2
3
4
5
6
7
8
9
10
11
variable "buckets" {
type = list(object({
name = string
enabled = optional(bool, true)
website = optional(object({
index_document = optional(string, "index.html")
error_document = optional(string, "error.html")
routing_rules = optional(string)
}), {})
}))
}

以下给出一个样例 terraform.tfvars 文件,为 var.buckets 定义了三个存储桶:

  • production 配置了一条重定向的路由规则
  • archived 使用了默认配置,但被关闭了
  • docs 使用文本文件取代了索引页和错误页

production 桶没有指定索引页和错误页,archived 桶完全忽略了网站配置。Terraform 会使用 bucket 类型约束中指定的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
buckets = [
{
name = "production"
website = {
routing_rules = <<-EOT
[
{
"Condition" = { "KeyPrefixEquals": "img/" },
"Redirect" = { "ReplaceKeyPrefixWith": "images/" }
}
]
EOT
}
},
{
name = "archived"
enabled = false
},
{
name = "docs"
website = {
index_document = "index.txt"
error_document = "error.txt"
}
},
]

该配置会产生如下的 variable 值:

  • productiondocs 桶,Terraform 会将 enabled 设置为 true。Terraform 会同时使用默认值配置 website,然后使用 docs 中指定的值来覆盖默认值。
  • archiveddocs 桶,Terraform 会将 routing_rules 设置为 null。当 Terraform 没有读取到 optional 的属性,并且属性上没有设置默认值时,Terraform 会将这些属性设置为 null
  • 对于 archived 桶,Terraform 会将 website 属性设置为 buckets 类型约束中定义的默认值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
tolist([
{
"enabled" = true
"name" = "production"
"website" = {
"error_document" = "error.html"
"index_document" = "index.html"
"routing_rules" = <<-EOT
[
{
"Condition" = { "KeyPrefixEquals": "img/" },
"Redirect" = { "ReplaceKeyPrefixWith": "images/" }
}
]

EOT
}
},
{
"enabled" = false
"name" = "archived"
"website" = {
"error_document" = "error.html"
"index_document" = "index.html"
"routing_rules" = tostring(null)
}
},
{
"enabled" = true
"name" = "docs"
"website" = {
"error_document" = "error.txt"
"index_document" = "index.txt"
"routing_rules" = tostring(null)
}
},
])

1.4.1.1.5.2. 例子:有条件地设置一个默认属性

有时我们需要根据其他数据的值来动态决定是否要为一个 optional 参数设置值。在这种场景下,发起调用的 module 块可以使用条件表达式搭配 null 来动态地决定是否设置该参数。

还是上一个例子中的 variable "buckets" 的例子,使用下面演示的例子可以根据新输入参数 var.legacy_filenames 的值来有条件地覆盖 website 对象中 index_document 以及 error_document 的设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
variable "legacy_filenames" {
type = bool
default = false
nullable = false
}

module "buckets" {
source = "./modules/buckets"

buckets = [
{
name = "maybe_legacy"
website = {
error_document = var.legacy_filenames ? "ERROR.HTM" : null
index_document = var.legacy_filenames ? "INDEX.HTM" : null
}
},
]
}

var.legacy_filenames 设置为 true 时,调用会覆盖 document 的文件名。当它的值为 false 时,调用不会指定这两个文件名,这样就会使得模块使用定义的默认值。

1.4.2.1. 配置语法

这里讲的仍然是 HCL 的语法,但我们只讲一些关键语法。如果读者有兴趣了解完整信息可以访问 HCL 语法规约

HCL 的语法由两个关键元素构成:参数(Argument)与块(Block)

1.4.2.1.1. 参数

HCL 中的参数就是将一个值赋给一个特定的名称:

1
image_id = "abc123"

等号前的标识符就是参数名,等号后的表达式就是参数值。参数赋值时 Terraform 会检查类型是否匹配。参数名是确定的,参数值可以是确定的字面量硬编码,也可以是一组表达式,用以通过其他的值加以计算得出结果值。

1.4.2.1.2. 块

一个块是包含一组其他内容(参数和块)的容器,例如:

1
2
3
4
5
6
7
resource "aws_instance" "example" {
ami = "abc123"

network_interface {
# ...
}
}

一个块有一个类型(上面的例子里类型就是 resource)。每个块类型都定义了类型关键字后面要跟多少标签,例如 resource 块规定了后面要跟两个标签 —— 在例子里就是 aws_instanceexample。一个块类型可以规定任意多个标签,也可以没有标签,比如内嵌的 network_interface 块。

在块类型及其后续标签之后,就是块体。块体必须被包含在一对花括号中间。在块体中可以进一步定义各种参数和其他的块。

Terraform 规范定义了有限个顶级块类型,也就是可以游离于任何其他块独立定义在配置文件中的块。大部分的 Terraform 功能(例如 resource, variable, output, data等)都是顶级块。

1.4.2.1.3. 标识符(Identifiers)

参数名、块类型名以及其他 Terraform 规范中定义的结构的名称,例如 resourcevariable 等,都是标识符。

合法的标识符可以包含字母、数字、下划线(_)以及连字符(-)。标识符首字母不可以为数字。

要了解完整的标识符规范,请访问 Unicode 标识符语法

1.4.2.1.4. 注释

Terraform支持三种注释:

  • # 单行注释,其后的内容为注释
  • // 单行注释,其后的内容为注释
  • /**/,多行注释,可以注释多行

默认情况下单行注释优先使用 #。自动化格式整理工具会自动把 // 替换成 #

1.4.2.1.5. 编码以及换行

Terraform 配置文件必须始终使用 UTF-8 编码。分隔符必须使用 ASCII 符号,其他标识符、注释以及字符串字面量均可使用非 ASCII 字符。

Terraform 兼容 Unix 风格的换行符(LF)以及 Windows 风格的换行符(CRLF),但是理想状态下应使用 Unix 风格换行符。

1.4.3.1. 输入变量

在前面的例子中,我们在代码中都是使用字面量硬编码的,如果我们想要在创建、修改基础设施时动态传入一些值呢?比如说在代码中定义 Provider 时用变量替代硬编码的访问密钥,或是由创建基础设施的用户来决定创建什么样尺寸的主机?我们需要的是输入变量。

如果我们把一组 Terraform 代码想像成一个函数,那么输入变量就是函数的入参。输入变量用 variable 块进行定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
variable "image_id" {
type = string
}

variable "availability_zone_names" {
type = list(string)
default = ["us-west-1a"]
}

variable "docker_ports" {
type = list(object({
internal = number
external = number
protocol = string
}))
default = [
{
internal = 8300
external = 8300
protocol = "tcp"
}
]
}

这些都是合法的输入参数定义。紧跟 variable 关键字的就是变量名。在一个 Terraform 模块(同一个文件夹中的所有 Terraform 代码文件,不包含子文件夹)中变量名必须是唯一的。我们在代码中可以通过var.<NAME>的方式引用变量的值。有一组关键字不可以被用作输入变量的名字:

  • source
  • version
  • providers
  • count
  • for_each
  • lifecycle
  • depends_on
  • locals

输入变量只能在声明该变量的目录下的代码中使用。

输入变量块中可以定义一些属性。

1.4.3.1.1. 类型 (type)

可以在输入变量块中通过 type 定义类型,例如:

1
2
3
4
5
6
variable "name" {
type = string
}
variable "ports" {
type = list(number)
}

定义了类型的输入变量只能被赋予符合类型约束的值。

1.4.3.1.2. 默认值 (default)

默认值定义了当 Terraform 无法获得一个输入变量得到值的时候会使用的默认值。例如:

1
2
3
4
variable "name" {
type = string
default = "John Doe"
}

当 Terraform 无法通过其他途径获得name的值时,var.name 的值为 "John Doe"

1.4.3.1.3. 描述 (description)

可以在输入变量中定义一个描述,简单地向调用者描述该变量的意义和用法:

1
2
3
4
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."
}

如果在执行 terraform plan 或是 terraform apply 时 Terraform 不知道某个输入变量的值,Terraform 会在命令行界面上提示我们为输入变量设置一个值。例如上面的输入变量代码,执行 terraform apply 时:

1
2
3
4
5
$ terraform apply
var.image_id
The id of the machine image (AMI) to use for the server.

Enter a value:

为了使得代码的使用者能够准确理解输入变量的意义和用法,我们应该站在代码使用者而非代码维护者的角度编写输入变量的描述。描述并不是注释!

1.4.3.1.4. 断言 (validation)

输入变量的断言是 Terraform 0.13.0 开始引入的新功能,在过去,Terraform 只能用类型约束确保输入参数的类型是正确的,曾经有不少人试图通过奇技淫巧来实现更加复杂的变量校验断言。如今 Terraform 终于正式添加了相关的功能。

1
2
3
4
5
6
7
8
9
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."

validation {
condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}

condition 参数是一个 bool 类型的参数,我们可以用一个表达式来定义如何界定输入变量是合法的。当 conditiontrue 时输入变量合法,反之不合法。condition 表达式中只能通过 var.\<NAME\> 引用当前定义的变量,并且它的计算不能产生错误。

假如表达式的计算产生一个错误是输入变量验证的一种判定手段,那么可以使用 can 函数来判定表达式的执行是否抛错。例如:

1
2
3
4
5
6
7
8
9
10
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."

validation {
# regex(...) fails if it cannot find a match
condition = can(regex("^ami-", var.image_id))
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}

上述例子中,如果输入的 image_id 不符合正则表达式的要求,那么 regex 函数调用会抛出一个错误,这个错误会被 can 函数捕获,输出 false

condition 表达式如果为 false,Terraform 会返回 error_message 中定义的错误信息。error_message 应该完整描述输入变量校验失败的原因,以及输入变量的合法约束条件。

1.4.3.1.5. 临时输入变量(ephemeral)

注意:临时输入变量是 Terraform v1.10 开始引入的功能

将变量设置为 ephemeral 的结果是,该输入值在运行时可用,但 Terraform 不会在状态和计划文件中记录这种临时值。将输入变量标记为 ephemeral 变量对于仅需要暂时存在的数据非常有用,例如短生命周期的令牌或会话标识符。

要将输入变量标记为临时变量,只通过将 ephemeral 参数设置为 true

1
2
3
4
variable "session_token" {
type = string
ephemeral = true
}

临时变量在当前 Terraform 运行期间可用,并且 Terraform 不会将它们存储在状态或计划文件中。因此,与 sensitive 输入不同,Terraform 确保临时值在当前 Terraform 运行结束后无法读取。

您只能在特定上下文中引用临时变量,否则 Terraform 会返回错误。以下是引用临时变量的有效上下文:

1.4.3.1.6. 在命令行输出中隐藏值 (sensitive)

该功能于 Terraform v0.14.0 开始引入。

将变量设置为 sensitive 可以防止我们在配置文件中使用变量时 Terraform 在 planapply 命令的输出中展示与变量相关的值。

Terraform 仍然会将敏感数据记录在状态文件中,任何可以访问状态文件的人都可以读取到明文的敏感数据值。

声明一个变量包含敏感数据值需要将 sensitive 参数设置为 true

1
2
3
4
5
6
7
8
9
10
11
12
variable "user_information" {
type = object({
name = string
address = string
})
sensitive = true
}

resource "some_resource" "a" {
name = var.user_information.name
address = var.user_information.address
}

任何使用了敏感变量的表达式都将被视为敏感的,因此在上面的示例中,resource “some_resource” “a”的两个参数也将在计划输出中被隐藏:

1
2
3
4
5
6
7
8
9
Terraform will perform the following actions:

# some_resource.a will be created
+ resource "some_resource" "a" {
+ name = (sensitive)
+ address = (sensitive)
}

Plan: 1 to add, 0 to change, 0 to destroy.

在某些情况下,我们会在嵌套块中使用敏感变量,Terraform 可能会将整个块视为敏感的。这发生在那些包含有要求值是唯一的内嵌块的资源中,公开这种内嵌块的部分内容可能会暗示兄弟块的内容。

1
2
3
4
5
6
7
# some_resource.a will be updated in-place
~ resource "some_resource" "a" {
~ nested_block {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
}

Provider 还可以将资源属性声明为敏感属性,这将导致 Terraform 将其从常规输出中隐藏。

如果打算使用敏感值作为输出值的一部分,Terraform 将要求您将输出值本身标记为敏感值,以确认确实打算将其导出。

1.4.3.1.6.1. Terraform 可能暴露敏感变量的情况

sensitive 变量是一个以配置文件为中心的概念,值被不加混淆地发送给 Provider。如果该值被包含在错误消息中,则 Provider 报错时可能会暴露该值。例如,即使 "foo" 是敏感值,Provider 也可能返回以下错误:"Invalid value 'foo' for field"

如果将资源属性用作、或是作为 Provider 定义的资源 ID 的一部分,则 apply 将公开该值。在下面的示例中,前缀属性已设置为 sensitive 变量,但随后该值("jae")作为资源 ID 的一部分公开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  # random_pet.animal will be created
+ resource "random_pet" "animal" {
+ id = (known after apply)
+ length = 2
+ prefix = (sensitive)
+ separator = "-"
}

Plan: 1 to add, 0 to change, 0 to destroy.

...

random_pet.animal: Creating...
random_pet.animal: Creation complete after 0s [id=jae-known-mongoose]

1.4.3.1.7. 禁止输入变量为空 (nullable)

该功能自 Terraform v1.1.0 开始被引入

输入变量的 nullable 参数控制模块调用者是否可以将 null 赋值给变量。

1
2
3
4
variable "example" {
type = string
nullable = false
}

nullable 的默认值为 true。当 nullabletrue 时,null 是变量的有效值,并且模块代码必须始终考虑变量值为 null 的可能性。将 null 作为模块输入参数传递将覆盖输入变量上定义的默认值。

nullable 设置为 false 可确保变量值在模块内永远不会为空。如果 nullablefalse 并且输入变量定义有默认值,则当模块输入参数为 null 时,Terraform 将使用默认值。

nullable 参数仅控制变量的直接值可能为 null 的情况。对于集合或对象类型的变量,例如列表或对象,调用者仍然可以在集合元素或属性中使用 null,只要集合或对象本身不为 null

1.4.3.1.8. 对输入变量赋值

1.4.3.1.8.1. 命令行参数

对输入变量赋值有几种途径,一种是在调用 terraform plan 或是 terraform apply 命令时以参数的形式传入:

1
2
3
$ terraform apply -var="image_id=ami-abc123"
$ terraform apply -var='image_id_list=["ami-abc123","ami-def456"]'
$ terraform plan -var='image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}'

可以在一条命令中使用多个 -var 参数。

1.4.3.1.8.2. 参数文件

第二种方法是使用参数文件。参数文件的后缀名可以是 .tfvars 或是 .tfvars.json.tfvars 文件使用 HCL 语法,.tfvars.json 使用 JSON 语法。

.tfvars 为例,参数文件中用 HCL 代码对需要赋值的参数进行赋值,例如:

1
2
3
4
5
image_id = "ami-abc123"
availability_zone_names = [
"us-east-1a",
"us-west-1c",
]

后缀名为 .tfvars.json 的文件用一个 JSON 对象来对输入变量赋值,例如:

1
2
3
4
{
"image_id": "ami-abc123",
"availability_zone_names": ["us-west-1a", "us-west-1c"]
}

调用 terraform 命令时,通过 -var-file 参数指定要用的参数文件,例如:

1
terraform apply -var-file="testing.tfvars"
1
terraform apply -var-file="testing.tfvars.json"

有两种情况,你无需指定参数文件:

  • 当前模块内有名为 terraform.tfvars 或是 terraform.tfvars.json 的文件
  • 当前模块内有一个或多个后缀名为 .auto.tfvars 或是 .auto.tfvars.json 的文件

Terraform 会自动使用这两种自动参数文件对输入参数赋值。

1.4.3.1.8.3. 环境变量

可以通过设置名为 TF_VAR_<NAME> 的环境变量为输入变量赋值,例如:

1
2
3
$ export TF_VAR_image_id=ami-abc123
$ terraform plan
...

在环境变量名大小写敏感的操作系统上,Terraform 要求环境变量中的 <NAME> 与 Terraform 代码中定义的输入变量名大小写完全一致。

环境变量传值非常适合在自动化流水线中使用,尤其适合用来传递敏感数据,类似密码、访问密钥等。

1.4.3.1.8.4. 交互界面传值

在前面介绍断言的例子中我们看到过,当我们从命令行界面执行 terraform 操作,Terraform 无法通过其他途径获取一个输入变量的值,而该变量也没有定义默认值时,Terraform 会进行最后的尝试,在交互界面上要求我们给出变量值。

1.4.3.1.9. 输入变量赋值优先级

当上述的赋值方式同时存在时,同一个变量可能会被赋值多次。Terraform 会使用新值覆盖旧值。

Terraform 加载变量值的顺序是:

  1. 环境变量
  2. terraform.tfvars 文件(如果存在的话)
  3. terraform.tfvars.json 文件(如果存在的话)
  4. 所有的 .auto.tfvars 或者 .auto.tfvars.json 文件,以字母顺序排序处理
  5. 通过 -var 或是 -var-file 命令行参数传递的输入变量,按照在命令行参数中定义的顺序加载

假如以上方式均未能成功对变量赋值,那么 Terraform 会尝试使用默认值;对于没有定义默认值的变量,Terraform 会采用交互界面方式要求用户输入一个。对于某些 Terraform 命令,如果执行时带有 -input=false 参数禁用了交互界面传值方式,那么就会报错。

重要提示:在 Terraform 0.12 及更高版本中,类型为 mapobject 的输入变量的读取行为与其他变量相同:后找到的值会覆盖之前的值。这与 Terraform 的早期版本不同,早期版本会合并 map,而不是覆盖它们。

1.4.3.1.9.1. Terraform 测试中的输入变量值

Terraform 测试文件中,您可以在 variable 块中指定变量值,这些 variable 块可以嵌套在 run 块中,也可以直接在文件中定义。

以这种方式定义的变量在测试执行期间优先于所有其他机制,其中在 run 块中定义的变量优先于在文件中定义的变量。

1.4.3.1.10. 复杂类型传值

通过参数文件传值时,可以直接使用 HCL 或是 JSON 语法对复杂类型传值,例如 listmap

对于某些场景下必须使用 -var 命令行参数,或是环境变量传值时,可以用单引号引用 HCL 语法的字面量来定义复杂类型,例如:

1
export TF_VAR_availability_zone_names='["us-west-1b","us-west-1d"]'

由于采用这种方法需要手工处理引号的转义,所以这种方法比较容易出错,复杂类型的传值建议尽量通过参数文件。

1.4.4.1. 输出值

我们在介绍输入变量时提到过,如果我们把一组 Terraform 代码想像成一个函数,那么输入变量就是函数的入参;函数可以有入参,也可以有返回值,同样的,Terraform 代码也可以有返回值,这就是输出值。

大部分语言的的函数只支持无返回值或是单返回值,但是 Terraform 支持多返回值。在当前模块 apply 一段 Terraform 代码,运行成功后命令行会输出代码中定义的返回值。另外我们也可以通过 terraform output 命令来输出当前模块对应的状态文件中的返回值。

1.4.4.1.1. 输出值的声明

输出值的声明使用输出块,例如:

1
2
3
output "instance_ip_addr" {
value = aws_instance.server.private_ip
}

output 关键字后紧跟的就是输出值的名称。在当前模块内的所有输出值的名字都必须是唯一的。output 块内的 value 参数即为输出值,它可以像是上面的例子里那样某个 resource 的输出属性,也可以是任意合法的表达式。

输出值只有在执行 terraform apply 后才会被计算,光是执行 terraform plan 并不会计算输出值。

Terraform 代码中无法引用本目录下定义的输出值。

output 块还有一些可选的属性:

1.4.4.1.1.1. 描述 description

1
2
3
4
output "instance_ip_addr" {
value = aws_instance.server.private_ip
description = "The private IP address of the main server instance."
}

与输入变量的description类似,我们不再赘述。

1.4.4.1.1.2. 临时值(ephemeral)—— 避免将值存储到状态或计划文件中

注意:临时输出值是 Terraform v1.10 开始引入的功能

我们可以在子模块中将 output 标记为 ephemeral,以在模块之间传递临时值,同时避免将这些值保留到状态或计划文件中。这对于管理我们不想存储在 Terraform 状态文件中的凭据、令牌或其他临时资源非常有用。

我们可以通过将 ephemeral 属性设置为 true 将子模块中的输出标记为临时输出值:

1
2
3
4
5
6
7
# modules/db/main.tf

output "secret_id" {
value = aws_secretsmanager_secret.secret_id
description = "Temporary secret ID for accessing database in AWS."
ephemeral = true
}

Terraform 可以在 planapply 操作期间访问 output 块的值。在 planapply 操作结束时,Terraform 不会保存任何临时输出的值。

我们只能在特定上下文中引用临时输出,否则 Terraform 会返回错误。以下是引用临时输出的有效上下文:

注意:我们不可以在根模块中将 output 声明为 ephemeral

1.4.4.1.1.3. 在命令行输出中隐藏值 sensitive

一个输出值可以标记 sensitivetrue,表示该输出值含有敏感信息。被标记 sensitive 的输出值只是在执行 terraform apply 命令成功后会打印 "<sensitive>" 以取代真实的输出值,执行 terraform output 时也会输出"<sensitive>",但仍然可以通过执行 terraform output -json 看到实际的敏感值。

需要注意的是,标记为 sensitive 输出值仍然会被记录在状态文件中,任何有权限读取状态文件的人仍然可以读取到敏感数据。

1.4.4.1.1.4. depends_on

关于 depends_on 的内容将在 resource 章节里详细介绍,所以这里我们只是粗略地介绍一下。

Terraform 会解析代码所定义的各种 dataresource,以及他们之间的依赖关系,例如,创建虚拟机时用的 image_id 参数是通过 data 查询而来的,那么虚拟机实例就依赖于这个镜像的 data,Terraform 会首先创建 data,得到查询结果后,再创建虚拟机 resource。一般来说,dataresource 之间的创建顺序是由 Terraform 自动计算的,不需要代码的编写者显式指定。但有时有些依赖关系无法通过分析代码得出,这时我们可以在代码中通过 depends_on 显式声明依赖关系。

一般 output 很少会需要显式依赖某些资源,但有一些特殊场景,例如某些资源的属性必须在另外一些资源被创建后才能被读取,这种情况下我们可以通过 depends_on 来显式声明依赖关系。

depends_on 的用法如下:

1
2
3
4
5
6
7
8
9
10
output "instance_ip_addr" {
value = aws_instance.server.private_ip
description = "The private IP address of the main server instance."

depends_on = [
# Security group rule must be created before this IP address could
# actually be used, otherwise the services will be unreachable.
aws_security_group_rule.local_access,
]
}

我们不鼓励针对 output 定义depends_on,只能作为最后的手段加以应用。如果不得不针对 output 定义depends_on,请务必通过注释说明原因,方便后人进行维护。

1.4.4.1.1.5. 断言 precondition

output 块从 Terraform v1.2.0 开始也可以包含一个 precondition 块。

output 块上的 precondition 对应于 variable 块中的 validation 块。validation 块检查输入变量值是否符合模块的要求,precondition 则确保模块的输出值满足某种要求。我们可以通过 precondition 来防止 Terraform 把一个不合法的输入值写入状态文件。我们可以在合适的场景下通过 precondition 来保护上一次 apply 留下的合法的输出值。

Terraform 在计算输出值的 value 表达式之前执行 precondition 检查,这可以防止 value 表达式中的潜在错误被激发。

1.4.5.1. 局部值

有时我们会需要用一个比较复杂的表达式计算某一个值,并且反复使用之,这时我们把这个复杂表达式赋予一个局部值,然后反复引用该局部值。如果说输入变量相当于函数的入参,输出值相当于函数的返回值,那么局部值就相当于函数内定义的局部变量。

局部值通过 locals 块定义,例如:

1
2
3
4
locals {
service_name = "forum"
owner = "Community Team"
}

一个 locals 块可以定义多个局部值,也可以定义任意多个 locals 块。赋给局部值的可以是更复杂的表达式,也可以是其他 dataresource 的输出、输入变量,甚至是其他的局部值:

1
2
3
4
5
6
7
8
9
10
11
12
locals {
# Ids for multiple sets of EC2 instances, merged together
instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id)
}

locals {
# Common tags to be assigned to all resources
common_tags = {
Service = local.service_name
Owner = local.owner
}
}

引用局部值的表达式是 local.<NAME> (注意,虽然局部值定义在 locals 块内,但引用是务必使用 local 而不是 locals),例如:

1
2
3
4
5
resource "aws_instance" "example" {
# ...

tags = local.common_tags
}

局部值只能在同一模块内的代码中引用。

局部值可以帮助我们避免重复复杂的表达式,提升代码的可读性,但如果过度使用也有可能增加代码的复杂度,使得代码的维护者更难理解所使用的表达式和值。适度使用局部值,仅用于反复引用同一复杂表达式的场景,未来当我们需要修改该表达式时局部值将使得修改变得相当轻松。

1.4.5.1.1. 临时(Ephemeral)局部值

注意:临时局部值是 Terraform v1.10 开始引入的功能

如果局部值的表达式中引用了临时值,则本地值会隐式地变为临时值。例如,您可以创建引用临时输入变量 service_token 的局部值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
variable "service_name" {
type = string
default = "forum"
}

variable "environment" {
type = string
default = "dev"
}

variable "service_token" {
type = string
ephemeral = true
}

locals {
service_tag = "${var.service_name}-${var.environment}"
session_token = "${var.service_name}:${var.service_token}"
}

表达式 local.session_token 的值隐式地成为了临时值,因为它依赖于临时输入变量 var.service_token

1.4.6.1. 资源

资源是 Terraform 最重要的组成部分,而本节亦是本教程最重要的一节。资源通过 resource 块来定义,一个 resource 可以定义一个或多个基础设施资源对象,例如 VPC、虚拟机,或是 DNS 记录、Consul 的键值对数据等。

1.4.6.1.1. 资源语法

资源通过 resource 块定义,我们首先讲解通过 resource 块定义单个资源对象的场景。

1
2
3
4
5
6
7
8
resource "aws_vpc" "main" {
cidr_block = var.base_cidr_block
}

<BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" {
# Block body
<IDENTIFIER> = <EXPRESSION> # Argument
}
  • 是其他内容的容器,通常代表某种对象的配置,比如资源。块有一个块类型,可以有零个或多个标签,有一个包含任意数量的参数和嵌套块的块体。Terraform 的大部分功能都是由配置文件中的顶级块控制的。
  • 参数 为一个名称赋值。它们出现在块内。
  • 表达式 表示一个值,可以是字面量,也可以是引用和组合其他值。它们出现在参数的值中,或者在其他表达式中。

Terraform 是一种声明式语言,描述的是一个期望的资源状态,而不是达到期望状态所需要的步骤。块的顺序和它们所在的文件通常不重要;Terraform 只在确定操作顺序时考虑资源之间的隐式和显式关系。

在下面的例子里:

1
2
3
4
resource "aws_instance" "web" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
}

紧跟 resource 关键字的是资源类型,在上面的例子里就是 aws_instance。后面是资源的 Local Name,例子里就是 web。Local Name 可以在同一模块内的代码里被用来引用该资源,但类型加 Local Name 的组合在当前模块内必须是唯一的,不同类型的两个资源 Local Name 可以相同。随后的花括号内的内容就是块体,创建资源所用到的各种参数的值就在块体内定义。例子中我们定义了虚拟机所使用的镜像 id 以及虚拟机的尺寸。

请注意:资源名称必须以字母或下划线开头,只能包含字母、数字、下划线(_)和连字符(-)。

1.4.6.1.2. 资源类型

每个资源都与一个资源类型相关联,资源类型决定了它管理的基础设施对象的类型,以及资源支持的参数和其他属性。

1.4.6.1.2.1. Providers

Provider 是 Terraform 用以提供一组资源类型的插件。每个资源类型都是由一个 Provider 实现的。Provider 提供了管理单个云或本地基础设施平台的资源。Provider 与 Terraform 分开发布,但 Terraform 可以在初始化工作目录时自动安装大多数 Provider。

要管理资源,Terraform 模块必须指定所需的 Provider。有关更多信息,请参阅Provider 的声明

大部分 Provider 需要一些配置来访问远程 API,这些配置是在根模块中配置的。有关更多信息,请参阅Provider 配置

根据一个 resource 块的类型名,Terraform 通常可以确定使用哪个 Provider。按照约定,资源类型名以其 Provider 的首选 Local Name 开头。当使用一个 Provider 的多个配置或非首选的本地 Provider 名称时,你必须使用 provider 元参数 来手动选择一个 Provider 配置。

1.4.6.1.2.2. 资源参数

不同资源定义了不同的可赋值的属性,官方文档将之称为参数(Argument),有些参数是必填的,有些参数是可选的。使用某项资源前可以通过阅读相关文档了解参数列表以及他们的含义、赋值的约束条件。

参数值可以是简单的字面量,也可以是一个复杂的表达式。

1.4.6.1.2.3. 资源类型的文档

每一个 Terraform Provider 都有自己的文档,用以描述它所支持的资源类型种类,以及每种资源类型所支持的属性列表。

大部分公共的 Provider 都是通过 Terraform Registry 连带文档一起发布的。当我们在 Terraform Registry 站点上浏览一个 Provider 的页面时,我们可以点击 “Documentation” 链接来浏览相关文档。Provider 的文档都是版本化的,我们可以选择特定版本的 Provider 文档。

需要注意的是,Provider 文档曾经是直接托管在 terraform.io 站点上的,也就是 Terraform 核心主站的一部分,有些 Provider 的文档目前依然托管在那里,但目前 Terraform Registry 才是所有公共 Provider 文档的主站。

1.4.6.1.3. 资源的行为

一个 resource 块声明了作者想要创建的一个确切的基础设施对象,并且设定了各项属性的值。如果我们正在编写一个新的 Terraform 代码文件,那么代码所定义的资源仅仅只在代码中存在,并没有与之对应的实际的基础设施资源存在。

对一组 Terraform 代码执行 terraform apply 可以创建、更新或者销毁实际的基础设施对象,Terraform 会制定并执行变更计划,以使得实际的基础设施符合代码的定义。

每当 Terraform 按照一个 resource 块创建了一个新的基础设施对象,这个实际的对象的 id 会被保存进 Terraform 状态中,使得将来 Terraform 可以根据变更计划对它进行更新或是销毁操作。如果一个 resource 块描述的资源在状态文件中已有记录,那么 Terraform 会比对记录的状态与代码描述的状态,如果有必要,Terraform 会制定变更计划以使得资源状态能够符合代码的描述。

这种行为适用于所有资源而无关其类型。创建、更新、销毁一个资源的细节会根据资源类型而不同,但是这个行为规则却是普适的。

1.4.6.1.4. 访问资源输出属性

资源不但可以通过参数传值,成功创建的资源还对外输出一些通过调用 API 才能获得的只读数据,经常包含了一些我们在实际创建一个资源之前无法获知的数据,比如云主机的 id 等,官方文档将之称为属性(Attribute)。我们可以在同一模块内的代码中引用资源的属性来创建其他资源或是表达式。在表达式中引用资源属性的语法是<RESOURCE TYPE>.<NAME>.<ATTRIBUTE>

要获取一个资源类型输出的属性列表,我们可以查阅对应的 Provider 文档,一般在文档中会专门记录资源的输出属性列表。

1.4.6.1.4.1. 敏感的资源属性

在为资源类型定义架构时,Provider 开发着可以将某些属性标记为 sensitive,在这种情况下,Terraform 将在展示涉及该属性的计划时显示占位符标记(sensitive) 而不是实际值。

标记为 sensitive 的 Provider 属性的行为类似于声明为 sensitive 的输入变量,Terraform 将隐藏计划中的值,还将隐藏从该值派生出的任何其他敏感值。但是,该行为存在一些限制,如 Terraform 可能暴露敏感变量。

如果使用资源属性中的敏感值作为输出值的一部分,Terraform 将要求将输出值本身标记为 sensitive,以确认确实打算将其导出。

Terraform 仍会在状态中记录敏感值,因此任何可以访问状态数据的人都可以以明文形式访问敏感值。

注意:Terraform 从 v0.15 开始将从敏感资源属性派生的值视为敏感值本身。早期版本的 Terraform 将隐藏敏感资源属性的直接值,但不会自动隐藏从敏感资源属性派生的其他值。

1.4.6.1.5. 资源的依赖关系

我们在介绍输出值的depends_on的时候已经简单介绍过了依赖关系。一般来说在 Terraform 代码定义的资源之间不会有特定的依赖关系,Terraform 可以并行地对多个无依赖关系的资源执行变更,默认情况下这个并行度是 10。

然而,创建某些资源所需要的信息依赖于另一个资源创建后输出的属性,又或者必须在某些资源成功创建后才可以被创建,这时资源之间就存在依赖关系。

大部分资源间的依赖关系可以被 Terraform 自动处理,Terraform 会分析 resource 块内的表达式,根据表达式的引用链来确定资源之间的引用,进而计算出资源在创建、更新、销毁时的执行顺序。大部分情况下,我们不需要显式指定资源之间的依赖关系。

然而,有时候某些依赖关系是无法从代码中推导出来的。例如,Terraform 必须要创建一个访问控制权限资源,以及另一个需要该权限才能成功创建的资源。后者的创建依赖于前者的成功创建,然而这种依赖在代码中没有表现为数据引用关联,这种情况下,我们需要用 depends_on 来显式声明这种依赖关系。

1.4.6.1.6. 元参数

resource 块支持几种元参数声明,这些元参数可以被声明在所有类型的 resource 块内,它们将会改变资源的行为:

  • depends_on:显式声明依赖关系
  • count:创建多个资源实例
  • for_each:迭代集合,为集合中每一个元素创建一个对应的资源实例
  • provider:指定非默认 Provider 实例
  • lifecycle:自定义资源的生命周期行为
  • provisionerconnection:在资源创建后执行一些额外的操作

下面我们将逐一讲解他们的用法。

1.4.6.1.6.1. depends_on

使用 depends_on 可以显式声明资源之间哪些 Terraform 无法自动推导出的隐含的依赖关系。只有当资源间确实存在依赖关系,但是彼此间又没有数据引用的场景下才有必要使用 depends_on

使用 depends_on 的例子是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
resource "aws_iam_role" "example" {
name = "example"

# assume_role_policy is omitted for brevity in this example. See the
# documentation for aws_iam_role for a complete example.
assume_role_policy = "..."
}

resource "aws_iam_instance_profile" "example" {
# Because this expression refers to the role, Terraform can infer
# automatically that the role must be created first.
role = aws_iam_role.example.name
}

resource "aws_iam_role_policy" "example" {
name = "example"
role = aws_iam_role.example.name
policy = jsonencode({
"Statement" = [{
# This policy allows software running on the EC2 instance to
# access the S3 API.
"Action" = "s3:*",
"Effect" = "Allow",
}],
})
}

resource "aws_instance" "example" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"

# Terraform can infer from this that the instance profile must
# be created before the EC2 instance.
iam_instance_profile = aws_iam_instance_profile.example

# However, if software running in this EC2 instance needs access
# to the S3 API in order to boot properly, there is also a "hidden"
# dependency on the aws_iam_role_policy that Terraform cannot
# automatically infer, so it must be declared explicitly:
depends_on = [
aws_iam_role_policy.example,
]
}

我们来分段解释一下这个场景,首先我们声明了一个 AWS IAM 角色,将角色绑定在一个主机实例配置文件上:

1
2
3
4
5
6
7
8
9
10
11
12
13
resource "aws_iam_role" "example" {
name = "example"

# assume_role_policy is omitted for brevity in this example. See the
# documentation for aws_iam_role for a complete example.
assume_role_policy = "..."
}

resource "aws_iam_instance_profile" "example" {
# Because this expression refers to the role, Terraform can infer
# automatically that the role must be created first.
role = aws_iam_role.example.name
}

虚拟机的声明代码中的这个赋值使得 Terraform 能够判断出虚拟机依赖于主机实例配置文件:

1
2
3
4
5
6
7
resource "aws_instance" "example" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"

# Terraform can infer from this that the instance profile must
# be created before the EC2 instance.
iam_instance_profile = aws_iam_instance_profile.example

至此,Terraform 规划出的创建顺序是 IAM 角色 -> 主机实例配置文件 -> 主机实例。但是我们又为这个 IAM 角色添加了对 S3 存储服务的完全控制权限:

1
2
3
4
5
6
7
8
9
10
11
12
resource "aws_iam_role_policy" "example" {
name = "example"
role = aws_iam_role.example.name
policy = jsonencode({
"Statement" = [{
# This policy allows software running on the EC2 instance to
# access the S3 API.
"Action" = "s3:*",
"Effect" = "Allow",
}],
})
}

也就是说,虚拟机实例由于绑定了主机实例配置文件,从而在运行时拥有了一个 IAM 角色,而这个 IAM 角色又被赋予了 S3 的权限。但是虚拟机实例的声明代码中并没有引用 S3 权限的任何输出属性,这将导致 Terraform 无法理解他们之间存在依赖关系,进而可能会并行地创建两者,如果虚拟机实例被先创建了出来,内部的程序开始运行时,它所需要的 S3 权限却还没有创建完成,那么就将导致程序运行错误。为了确保虚拟机创建时 S3 权限一定已经存在,我们可以用 depends_on 显式声明它们的依赖关系:

1
2
3
4
5
6
7
# However, if software running in this EC2 instance needs access
# to the S3 API in order to boot properly, there is also a "hidden"
# dependency on the aws_iam_role_policy that Terraform cannot
# automatically infer, so it must be declared explicitly:
depends_on = [
aws_iam_role_policy.example,
]

depends_on 的赋值必须是包含同一模块内声明的其他资源名称的列表,不允许包含其他表达式,例如不允许使用其他资源的输出属性,这是因为 Terraform 必须在计算资源间关系之前就能理解列表中的值,为了能够安全地完成表达式计算,所以限制只能使用资源实例的名称。

depends_on 只能作为最后的手段使用,如果我们使用 depends_on,我们应该用注释记录我们使用它的原因,以便今后代码的维护者能够理解隐藏的依赖关系。

1.4.6.1.6.2. count

一般来说,一个 resource 块定义了一个对应的实际基础设施资源对象。但是有时候我们希望创建多个相似的对象,比如创建一组虚拟机。Terraform 提供了两种方法实现这个目标:countfor_each

count 参数可以是任意自然数,Terraform 会创建 count 个资源实例,每一个实例都对应了一个独立的基础设施对象,并且在执行 Terraform 代码时,这些对象是被分别创建、更新或者销毁的:

1
2
3
4
5
6
7
8
9
10
resource "aws_instance" "server" {
count = 4 # create four similar EC2 instances

ami = "ami-a1b2c3d4"
instance_type = "t2.micro"

tags = {
Name = "Server ${count.index}"
}
}

我们可以在 resource 块中的表达式里使用 count 对象来获取当前的 count 索引号。count 对象只有一个属性:

  • count.index:代表当前对象对应的 count 下标索引(从 0 开始)

如果一个 resource 块定义了 count 参数,那么 Terraform 会把这种多资源实例对象与没有 count 参数的单实例资源对象区别开:

  • 访问单资源实例对象:<TYPE>.<NAME>(例如:aws_instance.server)
  • 访问多资源实例对象:<TYPE>.<NAME>[<INDEX>] (例如:aws_instance.server[0]aws_instance.server[1])

声明了 countfor_each 的资源必须使用下标索引或者键来访问。

count 参数可以是任意自然数,然而与 resource 的其他参数不同,count 的值在 Terraform 进行任何远程资源操作(实际的增删改查)之前必须是已知的,这也就意味着赋予 count 参数的表达式不可以引用任何其他资源的输出属性(例如由其他资源对象创建时返回的一个唯一的 ID)。

1.4.6.1.6.3. for_each

for_each 是 Terraform 0.12.6 开始引入的新特性。一个 resource 块不允许同时声明 countfor_eachfor_each 参数可以是一个 map 或是一个 set(string),Terraform 会为集合中每一个元素都创建一个独立的基础设施资源对象,和 count 一样,每一个基础设施资源对象在执行 Terraform 代码时都是独立创建、修改、销毁的。

使用 map 的例子:

1
2
3
4
5
6
7
8
resource "azurerm_resource_group" "rg" {
for_each = {
a_group = "eastus"
another_group = "westus2"
}
name = each.key
location = each.value
}

使用 set(string) 的例子:

1
2
3
4
resource "aws_iam_user" "the-accounts" {
for_each = toset( ["Todd", "James", "Alice", "Dottie"] )
name = each.key
}

我们可以在声明了 for_each 参数的 resource 块内使用 each 对象来访问当前的迭代器对象:

  • each.keymap 的键,或是 set 中的值
  • each.valuemap 的值,或是 set 中的值

如果 for_each 的值是一个 set,那么 each.keyeach.value 是相等的。

使用 for_each 时,map 的所有键、set 的所有 string 值都必须是已知的,也就是状态文件中已有记录的值。所以有时候我们可能需要在执行 terraform apply 时添加 -target 参数,实现分步创建。另外,for_each 所使用的键集合不能够包含或依赖非纯函数,也就是反复执行会返回不同返回值的函数,例如 uuidbcrypttimestamp 等。

当一个 resource 声明了 for_each 时,Terraform 会把这种多资源实例对象与没有 count 参数的单资源实例对象区别开:

  • 访问单资源实例对象:<TYPE>.<NAME>(例如:aws_instance.server)
  • 访问多资源实例对象:<TYPE>.<NAME>[<KE>] (例如:aws_instance.server["ap-northeast-1"]aws_instance.server["ap-northeast-2"])

声明了countfor_each 的资源必须使用下标索引或者键来访问。

由于 Terraform 没有用以声明 set 的字面量,所以我们有时需要使用 toset 函数把 list(string) 转换为 set(string)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
locals {
subnet_ids = toset([
"subnet-abcdef",
"subnet-012345",
])
}

resource "aws_instance" "server" {
for_each = local.subnet_ids

ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
subnet_id = each.key # note: each.key and each.value are the same for a set

tags = {
Name = "Server ${each.key}"
}
}

在这里我们用 toset 把一个 list(string) 转换成了 set(string),然后赋予 for_each。在转换过程中,list 中所有重复的元素会被抛弃,只剩下不重复的元素,例如 toset(["b", "a", "b"]) 的结果只有"a""b",并且 set 的元素没有特定顺序。

如果我们要把一个输入变量赋予 for_each,我们可以直接定义变量的类型约束来避免显式调用 toset 转换类型:

1
2
3
4
5
6
7
8
9
variable "subnet_ids" {
type = set(string)
}

resource "aws_instance" "server" {
for_each = var.subnet_ids

# (and the other arguments as above)
}

1.4.6.1.6.4. 在 for_each 和 count 之间选择

如果创建的资源实例彼此之间几乎完全一致,那么 count 比较合适。如果彼此之间的参数差异无法直接从 count 的下标派生,那么使用 for_each 会更加安全。

在 Terraform 引入 for_each 之前,我们经常使用 count.index 搭配 length 函数和 list 来创建多个资源实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
variable "subnet_ids" {
type = list(string)
}

resource "aws_instance" "server" {
# Create one instance for each subnet
count = length(var.subnet_ids)

ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
subnet_id = var.subnet_ids[count.index]

tags = {
Name = "Server ${count.index}"
}
}

这种实现方法是脆弱的,因为资源仍然是以他们的下标而不是实际的字符串值来区分的。如果我们从 subnet_ids 列表的中间移除了一个元素,那么从该位置起后续所有的 aws_instance 都会发现它们的 subnet_id 发生了变化,结果就是所有后续的 aws_instance 都需要更新。这种场景下如果使用 for_each 就更为妥当,如果使用 for_each,那么只有被移除的 subnet_id 对应的 aws_instance 会被销毁。

1.4.6.1.6.5. provider

关于 provider 的定义我们在前面介绍 Provider 的章节已经提到过了,如果我们声明了同一类型 Provider 的多个实例,那么我们在创建资源时可以通过指定 provider 参数选择要使用的 Provider 实例。如果没有指定 provider 参数,那么 Terraform 默认使用资源类型名中第一个单词所对应的 Provider 实例,例如 google_compute_instance 的默认 Provider 实例就是 googleaws_instance 的默认 Provider 就是 aws

指定 provider 参数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# default configuration
provider "google" {
region = "us-central1"
}

# alternate configuration, whose alias is "europe"
provider "google" {
alias = "europe"
region = "europe-west1"
}

resource "google_compute_instance" "example" {
# This "provider" meta-argument selects the google provider
# configuration whose alias is "europe", rather than the
# default configuration.
provider = google.europe

# ...
}

provider参数期待的赋值是<PROVIDER>或是<PROVIDER>.<ALIAS>,不需要双引号。因为在Terraform开始计算依赖路径图时,provider关系必须是已知的,所以除了这两种以外的表达式是不被接受的。

1.4.6.1.6.6. lifecycle

通常一个资源对象的生命周期在前面“资源的行为”一节中已经描述了,但是我们可以用 lifecycle 块来定一个不一样的行为方式,例如:

1
2
3
4
5
6
7
resource "azurerm_resource_group" "example" {
# ...

lifecycle {
create_before_destroy = true
}
}

lifecycle 块和它的内容都属于元参数,可以被声明于任意类型的资源块内部。Terraform 支持如下几种 lifecycle

  • create_before_destroy (bool):默认情况下,当 Terraform 需要修改一个由于服务端 API 限制导致无法直接升级的资源时,Terraform 会删除现有资源对象,然后用新的配置参数创建一个新的资源对象取代之。create_before_destroy 参数可以修改这个行为,使得 Terraform 首先创建新对象,只有在新对象成功创建并取代老对象后再销毁老对象。这并不是默认的行为,因为许多基础设施资源需要有一个唯一的名字或是别的什么标识属性,在新老对象并存时也要符合这种约束。有些资源类型有特别的参数可以为每个对象名称添加一个随机的前缀以防止冲突。Terraform 不能默认采用这种行为,所以在使用 create_before_destroy 前你必须了解每一种资源类型在这方面的约束。
  • prevent_destroy (bool):这个参数是一个保险措施,只要它被设置为 true 时,Terraform 会拒绝执行任何可能会销毁该基础设施资源的变更计划。这个参数可以预防意外删除关键资源,例如错误地执行了 terraform destroy,或者是意外修改了资源的某个参数,导致 Terraform 决定删除并重建新的资源实例。在 resource 块内声明了 prevent_destroy = true 会导致无法执行 terraform destroy,所以对它的使用要节制。需要注意的是,该措施无法防止我们删除 resource 块后 Terraform 删除相关资源,因为对应的 prevent_destroy = true 声明也被一并删除了。
  • ignore_changes (list(string)):默认情况下,Terraform 检测到代码描述的配置与真实基础设施对象之间有任何差异时都会计算一个变更计划来更新基础设施对象,使之符合代码描述的状态。在一些非常罕见的场景下,实际的基础设施对象会被 Terraform 之外的流程所修改,这就会使得 Terraform 不停地尝试修改基础设施对象以弥合和代码之间的差异。这种情况下,我们可以通过设定 ignore_changes 来指示 Terraform 忽略某些属性的变更。ignore_changes 的值定义了一组在创建时需要按照代码定义的值来创建,但在更新时不需要考虑值的变化的属性名,例如:
1
2
3
4
5
6
7
8
9
10
11
resource "aws_instance" "example" {
# ...

lifecycle {
ignore_changes = [
# Ignore changes to tags, e.g. because a management agent
# updates these based on some ruleset managed elsewhere.
tags,
]
}
}

你也可以忽略 map 中特定的元素,例如 tags["Name"],但是要注意的是,如果你是想忽略 map 中特定元素的变更,那么你必须首先确保 map 中含有这个元素。如果一开始 map 中并没有这个键,而后外部系统添加了这个键,那么 Terraform 还是会把它当成一次变更来处理。比较好的方法是你在代码中先为这个键创建一个占位元素来确保这个键已经存在,这样在外部系统修改了键对应的值以后 Terraform 会忽略这个变更。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
resource "aws_instance" "example" {
# ...

tags = {
# Initial value for Name is overridden by our automatic scheduled
# re-tagging process; changes to this are ignored by ignore_changes
# below.
Name = "placeholder"
}

lifecycle {
ignore_changes = [
tags["Name"],
]
}
}

除了使用一个 list(string),也可以使用关键字 all ,这时 Terraform 会忽略资源一切属性的变更,这样 Terraform 只会创建或销毁一个对象,但绝不会尝试更新一个对象。你只能在 ignore_changes 里忽略所属的 resource 的属性,ignore_changes 不可以赋予它自身或是其他任何元参数。

  • replace_triggered_by (包含资源引用的列表):强制 Terraform 在引用的资源或是资源属性发生变更时替换声明该块的父资源,值为一个包含了托管资源、实例或是实例属性引用表达式的列表。当声明该块的资源声明了 count 或是 for_each 时,我们可以在表达式中使用 count.index 或是 each.key 来指定引用实例的序号。

replace_triggered_by 可以在以下几种场景中使用:

  • 如果表达式指向多实例的资源声明(例如声明了 count 或是 for_each 的资源),那么这组资源中任意实例发生变更或被替换时都将引发声明 replace_triggered_by 的资源被替换
  • 如果表达式指向单个资源实例,那么该实例发生变更或被替换时将引发声明 replace_triggered_by 的资源被替换
  • 如果表达式指向单个资源实例的单个属性,那么该属性值的任何变化都将引发声明 replace_triggered_by 的资源被替换

我们在 replace_triggered_by 中只能引用托管资源。这允许我们在不引发强制替换的前提下修改这些表达式。

1
2
3
4
5
6
7
8
9
10
resource "aws_appautoscaling_target" "ecs_target" {
# ...
lifecycle {
replace_triggered_by = [
# Replace `aws_appautoscaling_target` each time this instance of
# the `aws_ecs_service` is replaced.
aws_ecs_service.svc.id
]
}
}

lifecycle 配置影响了 Terraform 如何构建并遍历依赖图。作为结果,lifecycle 内赋值仅支持字面量,因为它的计算过程发生在 Terraform 计算的极早期。这就是说,例如 prevent_destroycreate_before_destroy 的值只能是 true 或者 falseignore_changesreplace_triggered_by 的列表内只能是硬编码的属性名。

1.4.6.1.6.7. Precondition 与 Postcondition

请注意,Precondition 与 Postcondition 是从 Terraform v1.2.0 开始被引入的功能。

lifecycle 块中声明 preconditionpostcondition 块可以为资源、数据源以及输出值创建自定义的验证规则。

Terraform 在计算一个对象之前会首先检查该对象关联的 precondition,并且在对象计算完成后执行 postcondition 检查。Terraform 会尽可能早地执行自定义检查,但如果表达式中包含了只有在 apply 阶段才能知晓的值,那么该检查也将被推迟执行。

每一个 preconditionpostcondition 块都需要一个 condition 参数。该参数是一个表达式,在满足条件时返回 true,否则返回 false。该表达式可以引用同一模块内的任意其他对象,只要这种引用不会产生环依赖。在 postcondition 表达式中也可以使用 self 对象引用声明 postcondition 的资源实例的属性。

如果 condition 表达式计算结果为 false,Terraform 会生成一条错误信息,包含了 error_message 表达式的内容。如果我们声明了多条 preconditionpostcondition,Terraform 会返回所有失败条件对应的错误信息。

下面的例子演示了通过 postcondition 检测调用者是否不小心传入了错误的 AMI 参数:

1
2
3
4
5
6
7
8
9
10
11
data "aws_ami" "example" {
id = var.aws_ami_id

lifecycle {
# The AMI ID must refer to an existing AMI that has the tag "nomad-server".
postcondition {
condition = self.tags["Component"] == "nomad-server"
error_message = "tags[\"Component\"] must be \"nomad-server\"."
}
}
}

resourcedata 块中的 lifecycle 块可以同时包含 preconditionpostcondition 块。

  • Terraform 会在计算完 countfor_each 元参数后执行 precondition 块。这使得 Terraform 可以对每一个实例独立进行检查,并允许在表达式中使用 each.keycount.index 等。Terraform 还会在计算资源的参数表达式之前执行 precondition 检查。precondition 可以用来防止参数表达式计算中的错误被激发。
  • Terraform 在计算和执行对一个托管资源的变更之后执行 postcondition 检查,或是在完成数据源读取后执行它关联的 postcondition 检查。postcondition 失败会阻止其他依赖于此失败资源的其他资源的变更。

在大多数情况下,我们不建议在同一配置文件中同时包含表示同一个对象的 data 块和 resource 块。这样做会使得 Terraform 无法理解 data 块的结果会被 resource 块的变更所影响。然而,当我们需要检查一个 resource 块的结果,恰巧该结果又没有被资源直接输出时,我们可以使用 data 块并在块中直接使用 postcondition 来检查该对象。这等于告诉 Terraform 该 data 块是用来检查其他什么地方定义的对象的,从而允许 Terraform 以正确的顺序执行操作。

1.4.6.1.6.8. provisioner 和 connection

某些基础设施对象需要在创建后执行特定的操作才能正式工作。比如说,主机实例必须在上传了配置或是由配置管理工具初始化之后才能正常工作。

像这样创建后执行的操作可以使用预置器(Provisioner)。预置器是由 Terraform 所提供的另一组插件,每种预置器可以在资源对象创建后执行不同类型的操作。

使用预置器需要节制,因为他们采取的操作并非 Terraform 声明式的风格,所以 Terraform 无法对他们执行的变更进行建模和保存。

预置器也可以声明为资源销毁前执行,但会有一些限制。

作为元参数,provisionerconnection 可以声明在任意类型的 resource 块内。

举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
resource "aws_instance" "web" {
# ...

provisioner "file" {
source = "conf/myapp.conf"
destination = "/etc/myapp.conf"

connection {
type = "ssh"
user = "root"
password = var.root_password
host = self.public_ip
}
}
}

我们在 aws_instance 中定义了类型为 file 的预置器,该预置器可以本机文件或文件夹拷贝到目标机器的指定路径下。我们在预置器内部定义了connection块,类型是ssh。我们对connectionhost赋值self.public_ip,在这里self代表预置器所在的母块,也就是aws_instance.web,所以self.public_ip代表着aws_instance.web.public_ip,也就是创建出来的主机的公网ip。

file 类型预置器支持 sshwinrm 两种类型的 connection

预置器根据运行的时机分为两种类型,创建时预置器以及销毁时预置器。

1.4.6.1.7. 创建时预置器

默认情况下,创建时资源对象会运行预置器,在对象更新、销毁时则不会运行。预置器的默认行为是为了引导一个系统。

如果创建时预置器失败了,那么资源对象会被标记污点(我们将在介绍 terraform taint 命令时详细介绍)。一个被标记污点的资源在下次执行 terraform apply 命令时会被销毁并重建。Terraform 的这种设计是因为当预置器运行失败时标志着资源处于半就绪的状态。由于 Terraform 无法衡量预置器的行为,所以唯一能够完全确保资源被正确初始化的方式就是删除重建。

我们可以通过设置 on_failure 参数来改变这种行为。

1.4.6.1.8. 销毁时预置器

如果我们设置预置器的 when 参数为 destroy,那么预置器会在资源被销毁时执行:

1
2
3
4
5
6
7
8
resource "aws_instance" "web" {
# ...

provisioner "local-exec" {
when = destroy
command = "echo 'Destroy-time provisioner'"
}
}

销毁时预置器在资源被实际销毁前运行。如果运行失败,Terraform 会报错,并在下次运行 terraform apply 操作时重新执行预置器。在这种情况下,需要仔细关注销毁时预置器以使之能够安全地反复执行。

注意:销毁时预置器不会在 resource 块配置了 create_before_destroy = true 时运行。

销毁时预置器只有在存在于代码中的情况下才会在销毁时被执行。如果一个 resource 块连带内部的销毁时预置器块一起被从代码中删除,那么被删除的预置器在资源被销毁时不会被执行。要解决这个问题,我们需要使用多个步骤来绕过这个限制:

  • 修改资源声明代码,添加 count = 0 参数
  • 执行 terraform apply,运行删除时预置器,然后删除资源实例
  • 删除 resource
  • 重新执行 terraform apply,此时应该不会有任何变更需要执行

该限制在未来将会得到解决,但目前来说我们必须节制使用销毁时预置器。

注意:一个被标记污点的 resource 块内的销毁时预置器不会被执行。这包括了因为创建时预置器失败或是手动使用 terraform taint 命令标记污点的资源。

1.4.6.1.9. 预置器失败行为

默认情况下,预置器运行失败会导致terraform apply执行失败。可以通过设置on_failure参数来改变这一行为。可以设置的值为:

  • continue:忽视错误,继续执行创建或是销毁
  • fail:报错并终止执行变更(这是默认行为)。如果这是一个创建时预置器,则在对应资源对象上标记污点

样例:

1
2
3
4
5
6
7
8
resource "aws_instance" "web" {
# ...

provisioner "local-exec" {
command = "echo The server's IP address is ${self.private_ip}"
on_failure = continue
}
}

1.4.6.1.10. 删除资源

注意:removed 块是在 Terraform v1.7 引入的功能。对于早期的 Terraform 版本,您可以使用 terraform state rm 命令来处理。

要从 Terraform 中删除资源,只需从 Terraform 代码中删除 resource 块即可。

默认情况下,删除 resource 块后,Terraform 将计划销毁该资源管理的所有实际基础设施对象。

有时,我们可能希望从 Terraform 配置中删除资源,而不破坏它管理的实际基础设施对象。在这种情况下,资源将从 Terraform 状态中删除,但真正的基础设施对象不会被破坏。

要声明资源已从 Terraform 配置中删除,但不应销毁其托管对象,请从配置中删除 resource 块并将其替换为 removed 块:

1
2
3
4
5
6
7
removed {
from = aws_instance.example

lifecycle {
destroy = false
}
}

from 参数是您要删除的资源的地址,没有任何实例键(例如 aws_instance.example[1])。

lifecycle 块是必需的。 destroy 参数确定 Terraform 是否会尝试销毁资源管理的对象。 false 值表示 Terraform 将从状态中删除资源而不销毁实际的远程资源。

removed 块还可以包含销毁时预置器,以便即使 resource 块已被删除,预制器也可以保留在代码中。

1
2
3
4
5
6
7
8
9
10
11
12
removed {
from = aws_instance.example

lifecycle {
destroy = true
}

provisioner "local-exec" {
when = destroy
command = "echo 'Instance ${self.id} has been destroyed.'"
}
}

与普通的销毁时预置器中的引用规则相同,仅允许使用 count.indexeach.keyself。预置器必须指定 when = destroy,并且 removed 块必须声明 destroy = true 才能执行预置器。

1.4.6.1.11. 本地资源

虽然大部分资源类型都对应的是通过远程基础设施 API 控制的一个资源对象,但也有一些资源对象他们只存在于 Terraform 进程自身内部,用来计算生成某些结果,并将这些结果保存在状态中以备日后使用。

比如说,我们可以用 tls_private_key 生成公私钥,用 tls_self_signed_cert 生成自签名证书,或者是用 random_id 生成随机 id。虽不像其他“真实”基础设施对象那般重要,但这些本地资源也可以成为连接其他资源有用的黏合剂。

本地资源的行为与其他类型资源是一致的,但是他们的结果数据仅存在于 Terraform 状态文件中。“销毁”这种资源只是将结果数据从状态中删除。

1.4.6.1.12. 操作超时设置

有些资源类型提供了特殊的 timeouts 内嵌块参数,它允许我们配置我们允许操作持续多长时间,超时将被认定为失败。比如说,aws_db_instance 资源允许我们分别为 createupdatedelete 操作设置超时时间。

超时完全由资源对应的 Provider 来处理,但支持超时设置的 Provider 一般都遵循相同的传统,那就是由一个名为 timeouts 的内嵌块参数定义超时设置,timeouts 块内可以分别设置不同操作的超时时间。超时时间由 string 描述,比如 "60m" 代表 60 分钟,"10s" 代表 10 秒,"2h" 代表 2 小时。

1
2
3
4
5
6
7
8
resource "aws_db_instance" "example" {
# ...

timeouts {
create = "60m"
delete = "2h"
}
}

可配置超时的操作类别由每种支持超时设定的资源类型自行决定。大部分资源类型不支持设置超时。使用超时前请先查阅相关文档。

1.4.7.1. 数据源

数据源允许查询或计算一些数据以供其他地方使用。使用数据源可以使得 Terraform 代码使用在 Terraform 管理范围之外的一些信息,或者是读取其他 Terraform 代码保存的状态。

每一种 Provider 都可以在定义一些资源类型的同时定义一些数据源。

1.4.7.1.1. 使用数据源

数据源通过一种特殊的资源访问:data 资源。数据源通过 data 块声明:

1
2
3
4
5
6
7
8
9
data "aws_ami" "example" {
most_recent = true

owners = ["self"]
tags = {
Name = "app-server"
Tested = "true"
}
}

一个 data 块请求 Terraform 从一个指定的数据源 aws_ami 读取指定数据并且把结果输出到 Local Name 为 example 的实例中。我们可以在同一模块内的代码中通过数据源名称来引用数据源,但无法从模块外部直接访问数据源。

同资源类似,一个数据源类型以及它的名称一同构成了该数据源的标识符,所以数据源类型加名称的组合在同一模块内必须是唯一的。

data 块体({} 中间的内容)是传给数据源的查询条件。查询条件参数的种类取决于数据源的类型,在上述例子中,most_recentownerstags 都是定义查询 aws_ami 数据源时使用的查询条件。

与数据源这种特殊资源不同的是,我们在上一节介绍的主要资源(使用 resource 块定义的)是一种“托管资源”。这两种资源都可以接收参数并对外输出属性,但托管资源会触发 Terraform 对基础设施对象进行增删改操作,而数据源只会触发读取操作。简单来说,我们一般说的“资源”就是特指托管资源。

1.4.7.1.2. 数据源参数

每一种数据源资源都关联到一种外部数据源,数据源类型决定了它接收的查询参数以及输出的数据。每一种数据源类型都属于一个 Provider。大部分 data 块内的数据源参数都是由对应的数据源类型定义的,这些参数的赋值可以使用完整的 Terraform 表达式能力或其他 Terraform 语言的功能。

然而类似资源,Terraform 也为所有类型的数据源定义了一些元参数。这些元参数的限制和功能我们将在后续节当中叙述。

1.4.7.1.3. 数据源行为

如果数据源的查询参数涉及到的表达式只引用了字面量或是在执行 terraform plan 时就已知的数据(比如输入变量),那么数据源会在执行 Terraform 的 “refersh” 阶段时被读取,然后 Terraform 会构建变更计划。这保证了在制定变更计划时 Terraform 可以使用这些数据源的返回数据。

如果查询参数的表达式引用了那些只有执行部分执行变更计划以后才能知晓的数据,比如另一个还未被创建的托管资源的输出,那么数据源的读取操作会被推迟到 “apply” 阶段。以下几种情况下 Terraform 会推迟数据源的读取:

  • 给定的参数中至少有一个是一个托管资源的属性或是其他值,Terraform 在执行步骤之前无法预测。
  • data 块内的查询参数引用了一个还未被创建的托管资源的输出。
  • data 块内声明的 preconditionpostcondition 直接或间接地依赖了一个在当前计划中有变更的托管资源。

任何引用该数据源输出的表达式的值在执行到数据源被读取完之前都是未知的。

1.4.7.1.4. 本地数据源

虽然绝大多数数据源都对应了一个通过远程基础设施 API 访问的外部数据源,但是也有一些特殊的数据源仅存在于 Terraform 进程内部,计算并对外输出一些数据。

比如说,本地数据源有 template_filelocal_fileaws_iam_policy_document 等。

本地数据源的行为与其他数据源完全一致,但他们输出的结果数据只是临时存在于 Terraform 运行时,每次计算一个新的变更计划时这些值都会被重新计算。

1.4.7.1.5. 数据源的依赖关系

数据源有着与资源一样的依赖机制,我们也可以在 data 块内设置 depends_on 元参数来显式声明依赖关系,在此不再赘述。

注意:在 Terraform 0.12 及更早版本中,由于 data 会将尚不知晓值的读取推迟到 Apply 阶段,因此将 dependent_ondata 一起使用将强制将数据的读取推迟到 Apply 阶段,因此,使用 depends_ondata 数据源配置永远无法收敛。由于这种行为,我们不建议对 data 使用 depends_on

1.4.7.1.6. Precondition 与 Postcondition

您可以使用 preconditionpostcondition 块来指定有关 data 如何运行的假设和验证。以下实力创建一个 postcondition 来检查 AMI 是否具有正确的标签:

1
2
3
4
5
6
7
8
9
10
11
data "aws_ami" "example" {
id = var.aws_ami_id

lifecycle {
# The AMI ID must refer to an existing AMI that has the tag "nomad-server".
postcondition {
condition = self.tags["Component"] == "nomad-server"
error_message = "tags[\"Component\"] must be \"nomad-server\"."
}
}
}

自定义条件检查可以声明对数据的假设,帮助未来的维护人员了解代码的设计和意图。它们还可以更早地在上下文中返回有关错误的有用信息,帮助使用者更轻松地诊断其配置中的问题。

1.4.7.1.7. 生命周期

同资源不一样,数据源目前的 lifecycle 块中只支持 preconditionpostcondition 块。

1.4.7.1.8. 多数据源实例

与资源一样,数据源也可以通过设置 countfor_each 元参数来创建一组多个数据源实例,并且 Terraform 也会把每个数据源实例单独创建并读取相应的外部数据,对 count.indexeach 的使用也是一样的,在 countfor_each 之间选择的原则也是一样的。

1.4.7.1.9. 指定特定 Provider 实例

同资源一样,数据源也可以通过 provider 元参数指定使用特定 Provider 实例,在此不再赘述。

1.4.7.1.10. 例子

一个数据源定义例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Find the latest available AMI that is tagged with Component = web
data "aws_ami" "web" {
filter {
name = "state"
values = ["available"]
}

filter {
name = "tag:Component"
values = ["web"]
}

most_recent = true
}

1.4.7.1.11. 引用数据源

引用数据源数据的语法是data.<TYPE>.<NAME>.<ATTRIBUTE>

1
2
3
4
resource "aws_instance" "web" {
ami = data.aws_ami.web.id
instance_type = "t1.micro"
}

1.4.8.1. 表达式

表达式用来在配置文件中进行一些计算。最简单的表达式就是字面量,比如 "hello",或者 5Terraform 也支持一些更加复杂的表达式,比如引用其他 resource 的输出值、数学计算、布尔条件计算,以及一些内建的函数。

Terraform 配置中很多地方都可以使用表达式,但某些特定的场景下限制了可以使用的表达式的类型,例如只准使用特定数据类型的字面量,或是禁止使用 resource 的输出值。

您可以通过运行 terraform console 命令,从 Terraform 表达式控制台测试 Terraform 表达式的行为。

我们在类型章节中已经基本介绍了类型以及类型相关的字面量,下面我们来介绍一些其他的表达式。

1.4.8.1.1. 下标和属性

listtuple 可以通过下标访问成员,例如 local.list[3]var.tuple[2]mapobject 可以通过属性访问成员,例如 local.object.attrnamelocal.map.keyname。由于 map 的键是用户定义的,可能无法成为合法的 Terraform 标识符,所以访问 map 成员时我们推荐使用方括号:local.map["keyname"]

1.4.8.1.2. 引用命名值

Terraform 中定义了多种命名值,表达式中的每一个命名值都关联到一个具体的值,我们可以用单一命名值作为一个表达式,或是组合多个命名值来计算出一个新值。

命名值有如下种类:

  • <RESOURCE TYPE>.<NAME>:表示一个资源对象。凡是不符合后面列出的命名值模式的表达式都会被 Terraform 解释为一个托管资源。如果资源声明了 count 元参数,那么该表达式表示的是一个对象实例的 list。如果资源声明了 for_each 元参数,那么该表达式表示的是一个对象实例的 map
  • var.<NAME>:表示一个输入变量
  • local.<NAME>:表示一个局部值
  • module.<MODULE_NAME>.<OUTPUT_NAME>:表示一个模块的一个输出值
  • data.<DATA_TYPE>.<NAME>:表示一个数据源实例。如果数据源声明了 count 元参数,那么该表达式表示的是一个数据源实例 list。如果数据源声明了 for_each 元参数,那么该表达式表示的是一个数据源实例 map
  • path.module:表示当前模块在文件系统中的路径
  • path.root:表示根模块(调用 Terraform 命令行执行的代码文件所在的模块)在文件系统中的路径
  • path.cwd:表示当前工作目录的路径。一般来说该路径等同于 path.root,但在调用 Terraform 命令行时如果指定了代码路径,那么二者将会不同。
  • terraform.workspace:当前使用的 Workspace (我们在状态管理的"状态的隔离存储"中介绍过)

虽然这些命名表达式可以使用 .<NAME> 号来访问对象的各种属性,但实际上他们实际类型并不是我们在类型章节里提到过的 object。两者的区别在于,object 同时支持使用 .<NAME> 或者 ["<NAME>"] 两种方式访问对象成员属性,而上述命名表达式仅支持 .<NAME>

1.4.8.1.3. 局部命名值

在某些特定表达式或上下文当中,有一些特殊的命名值可以被使用,他们是局部命名值。几种比较常见的局部命名值有:

  • count.index:表达当前 count 下标序号
  • each.key:表达当前 for_each 迭代器实例
  • self:在预置器中指代声明预置器的资源

1.4.8.1.4. 命名值的依赖关系

构建资源或是模块时经常会使用含有命名值的表达式赋值,Terraform 会分析这些表达式并自动计算出对象之间的依赖关系。

1.4.8.1.5. 引用资源输出属性

最常见的引用类型就是引用一个 resourcedata 块定义的对象的输出属性。由于这些资源与数据源对象结构可能非常复杂,所以对它们的输出属性的引用表达式也可能非常复杂。

比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
resource "aws_instance" "example" {
ami = "ami-abc123"
instance_type = "t2.micro"

ebs_block_device {
device_name = "sda2"
volume_size = 16
}
ebs_block_device {
device_name = "sda3"
volume_size = 20
}
}

aws_instance 文档列出了该类型所支持的所有输入参数和内嵌块,以及对外输出的属性列表。所有这些不同的资源类型 Schema 都可以在引用中使用,如下所示:

  • ami 参数可以在可以在其他地方用 aws_instance.example.ami 表达式来引用
  • id 属性可以用 aws_instance.example.id 的表达式来引用
  • 内嵌的 ebs_block_device 参数可以通过后面会介绍的展开表达式(splat expression)来访问,比如我们获取所有的 ebs_block_devicedevice_name 列表:aws_instance.example.ebs_block_device[*].device_name
  • aws_instance 类型里的内嵌块并没有任何输出属性,但如果 ebs_block_device 添加了一个名为 "id" 的输出属性,那么可以用 aws_instance.example.ebs_block_device[*].id 表达式来访问含有所有 id 的列表
  • 有时多个内嵌块会各自包含一个逻辑键来区分彼此,类似用资源名访问资源,我们也可以用内嵌块的名字来访问特定内嵌块。假如 aws_instance 类型有一个假想的内嵌块类型 device 并规定 device 可以赋予这样的一个逻辑键,那么代码看起来就会是这样的:
1
2
3
4
5
6
device "foo" {
size = 2
}
device "bar" {
size = 4
}

我们可以使用键来访问特定块的数据,例如:aws_instance.example.device["foo"].size

要获取一个 device 名称到 device 大小的映射,可以使用 for 表达式:

1
{for k, device in aws_instance.example.device : k => device.size}

当一个资源声明了 count 参数,那么资源本身就成了一个资源对象列表而非单个资源。这种情况下要访问资源输出属性,要么使用展开表达式,要么使用下标索引:

  • aws_instance.example[*].id:返回所有 instance 的 id 列表
  • aws_instance.example[0].id:返回第一个 instance的 id

当一个资源声明了 for_each 参数,那么资源本身就成了一个资源对象字典而非单个资源。这种情况下要访问资源的输出属性,要么使用特定键,要么使用 for 表达式:

  • aws_instance.example["a"].id:返回 "a" 对应的实例的 id
  • [for value in aws_instance.example: value.id]:返回所有 instance 的 id

注意不像使用 count,使用 for_each 的资源集合不能直接使用展开表达式,展开表达式只能适用于列表。你可以把字典转换成列表后再使用展开表达式:

  • values(aws_instance.example)[*].id

1.4.8.1.6. 尚不知晓的值

当 Terraform 在计算变更计划时,有些资源输出属性无法立即求值,因为他们的值取决于远程API的返回值。比如说,有一个远程对象可以在创建时返回一个生成的唯一 id,Terraform 无法在创建它之前就预知这个值。

为了允许在计算变更阶段就能计算含有这种值的表达式,Terraform 使用了一个特殊的"尚不知晓(unknown value)"占位符来代替这些结果。大部分时候你不需要特意理会它们,因为 Terraform 语言会自动处理这些尚不知晓的值,比如说使两个尚不知晓的值相加得到的会是一个尚不知晓的值。

然而,有些情况下表达式中含有尚不知晓的值会有明显的影响:

  • count 元参数不可以为尚不知晓,因为变更计划必须明确地知晓到底要维护多少个目标实例
  • 如果尚不知晓的值被用于数据源,那么数据源在计算变更计划阶段就无法读取,它会被推迟到执行阶段读取。这种情况下,在计划阶段该数据源的一切输出均为尚不知晓
  • 如果声明 module 块时传递给模块输入变量的表达式使用了尚不知晓值,那么在模块代码中任何使用了该输入变量值的表达式的值都将是尚不知晓
  • 如果模块输出值表达式中含有尚不知晓值,任何使用该模块输出值的表达式都将是尚不知晓
  • Terraform 会尝试验证尚不知晓值的数据类型是否合法,但仍然有可能无法正确检查数据类型,导致执行阶段发生错误

尚不知晓值在执行 terraform plan 时会被输出为 “(not yet known)”。

1.4.8.1.7. 算数和逻辑操作符

一个操作符是一种用以转换或合并一个或多个表达式的表达式。操作符要么是把两个值计算为第三个值,也就是二元操作符;要么是把一个值转换成另一个值,也就是一元操作符。

二元操作符位于两个表达式的中间,类似 1+2。一元操作符位于一个表达式的前面,类似 !true

Terraform 的 HCL 语言支持一组算数和逻辑操作符,它们的功能类似于 JavaScript 或 Ruby 里的操作符功能。

当一个表达式中含有多个操作符时,它们的优先级顺序为:

  1. !- (负号)
  2. */%
  3. +- (减号)
  4. >>=<<=
  5. ==!=
  6. &&
  7. ||

可以使用小括号覆盖默认优先级。如果没有小括号,高优先级操作符会被先计算,例如 1+2*3 会被解释成 1+(2*3) 而不是 (1+2)*3

不同的操作符可以按它们之间相似的行为被归纳为几组,每一组操作符都期待被给予特定类型的值。Terraform 会在类型不符时尝试进行隐式类型转换,如果失败则会抛错。

1.4.8.1.7.1. 算数操作符

  • a + b:返回 ab 的和
  • a - b:返回 ab 的差
  • a * b:返回 ab 的积
  • a / b:返回 ab 的商
  • a % b:返回 ab 的模。该操作符一般仅在 ab 是整数时有效
  • -a:返回 a-1 的商

1.4.8.1.7.2. 相等性操作符

  • a == b:如果 ab 类型与值都相等返回 true,否则返回 false
  • a != b:与 == 相反

1.4.8.1.7.3. 比较操作符

  • a < b:如果 ab 小则为 true,否则为 false
  • a > b:如果 ab 大则为 true,否则为 false
  • a <= b:如果 ab 小或者相等则为 true,否则为 false
  • a >= b:如果 ab 大或者相等则为 true,否则为 false

1.4.8.1.7.4. 逻辑操作符

  • a || bab 中有至少一个为 true 则为 true,否则为 false
  • a && ba 与比都为 true 则为 true,否则为 false
  • !a:如果 atrue 则为 false,如果 afalse 则为 true

1.4.8.1.8. 条件表达式

条件表达式是判断一个布尔表达式的结果以便于在后续两个值当中选择一个:

1
condition ? true_val : false_val

如果 condition 表达式为 true,那么结果是 true_value,反之则为 false_value

一个常见的条件表达式用法是使用默认值替代非法值:

1
var.a != "" ? var.a : "default-a"

(注:以上表达式目前推荐写为:coalesce(var.a, "default-a"))

如果输入变量 a 的值是空字符串,那么结果会是 default-a,否则返回输入变量 a 的值。

条件表达式的判断条件可以使用上述的任意操作符。供选择的两个值也可以是任意类型,但它们的类型必须相同,这样 Terraform 才能判断条件表达式的输出类型。

1.4.8.1.9. 函数调用

Terraform 支持在计算表达式时使用一些内建函数,函数调用表达式类似操作符,通用语法是:

1
<FUNCTION NAME>(<ARGUMENT 1>, <ARGUMENT 2>)

函数名标明了要调用的函数。每一个函数都定义了数量不等、类型不一的入参以及不同类型的返回值。

有些函数定义了不定长的入参表,例如,min 函数可以接收任意多个数值类型入参,返回其中最小的数值:

1
min(55, 3453, 2)

1.4.8.1.9.1. 展开函数入参

如果想要把列表或元组的元素作为参数传递给函数,那么我们可以使用展开符:

1
min([55, 2453, 2]...)

展开符使用的是三个独立的 . 号组成的 ...,不是 Unicode 中的省略号 。展开符是一种只能用在函数调用场景下的特殊语法。

有关完整的内建函数我们可能会在今后撰写相应的章节介绍。

1.4.8.1.10. for 表达式

for 表达式是将一种复杂类型映射成另一种复杂类型的表达式。输入类型值中的每一个元素都会被映射为一个或零个结果。

举例来说,如果 var.list 是一个字符串列表,那么下面的表达式将会把列表元素全部转为大写:

1
[for s in var.list : upper(s)]

在这里 for 表达式迭代了 var.list 中每一个元素(就是 s),然后计算了 upper(s),最后构建了一个包含了所有 upper(s) 结果的新元组,元组内元素顺序与源列表相同。

for 表达式周围的括号类型决定了输出值的类型。上面的例子里我们使用了方括号,所以输出类型是元组。如果使用的是花括号,那么输出类型是对象,for 表达式内部冒号后面应该使用以 => 符号分隔的表达式:

1
{for s in var.list : s => upper(s)}

该表达式返回一个对象,对象的成员属性名称就是源列表中的元素,值就是对应的大写值。

一个 for 表达式还可以包含一个可选的 if 子句用以过滤结果,这可能会减少返回的元素数量:

1
[for s in var.list : upper(s) if s != ""]

for 迭代的也可以是对象或者字典,这样的话迭代器就会被表示为两个临时变量:

1
[for k, v in var.map : length(k) + length(v)]

最后,如果返回类型是对象(使用花括号)那么表达式中可以使用 ... 符号实现 group by:

1
{for s in var.list : substr(s, 0, 1) => s... if s != ""}

1.4.8.1.11. 展开表达式(Splat Expression)

展开表达式提供了一种类似 for 表达式的简洁表达方式。比如说 var.list 包含一组对象,每个对象有一个属性 id,那么读取所有 idfor 表达式会是这样:

1
[for o in var.list : o.id]

与之等价的展开表达式是这样的:

1
var.list[*].id

这个特殊的 [*] 符号迭代了列表中每一个元素,然后返回了它们在 . 号右边的属性值。

展开表达式只能被用于列表(所以使用 for_each 参数的资源不能使用展开表达式,因为它的类型是字典)。然而,如果一个展开表达式被用于一个既不是列表又不是元组的值,那么这个值会被自动包装成一个单元素的列表然后被处理。

比如说,var.single_object[*].id 等价于 [var.single_object][*].id。大部分场景下这种行为没有什么意义,但在访问一个不确定是否会定义 count 参数的资源时,这种行为很有帮助,例如:

1
aws_instance.example[*].id

上面的表达式不论 aws_instance.example 定义了 count 与否都会返回实例的 id 列表,这样如果我们以后为 aws_instance.example 添加了 count 参数我们也不需要修改这个表达式。

1.4.8.1.11.1. 遗留的旧有展开表达式

曾经存在另一种旧的展开表达式语法,它是一种比较弱化的展开表达式,现在应该尽量避免使用。

这种旧的展开表达式使用 .* 而不是 [*]

1
var.list.*.interfaces[0].name

要特别注意该表达式与现有的展开表达式结果不同,它的行为等价于:

1
[for o in var.list : o.interfaces][0].name

而现有 [*] 展开表达式的行为等价于:

1
[for o in var.list : o.interfaces[0].name]

注意两者右方括号的位置。

1.4.8.1.12. dynamic 块

在顶级块,例如 resource 块当中,一般只能以类似 name = expression 的形式进行一对一的赋值。大部分情况下这已经够用了,但某些资源类型包含了可重复的内嵌块,无法使用表达式循环赋值:

1
2
3
4
5
6
7
resource  "aws_elastic_beanstalk_environment" "tfenvtest" {
name = "tf-test-name" # can use expressions here

setting {
# but the "setting" block is always a literal block
}
}

你可以用 dynamic 块来动态构建重复的 setting 这样的内嵌块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
resource "aws_elastic_beanstalk_environment" "tfenvtest" {
name = "tf-test-name"
application = "${aws_elastic_beanstalk_application.tftest.name}"
solution_stack_name = "64bit Amazon Linux 2018.03 v2.11.4 running Go 1.12.6"

dynamic "setting" {
for_each = var.settings
content {
namespace = setting.value["namespace"]
name = setting.value["name"]
value = setting.value["value"]
}
}
}

dynamic 可以在 resourcedataproviderprovisioner 块内使用。一个 dynamic 块类似于 for 表达式,只不过它产生的是内嵌块。它可以迭代一个复杂类型数据然后为每一个元素生成相应的内嵌块。在上面的例子里:

  • dynamic 的标签(也就是 "setting")确定了我们要生成的内嵌块种类
  • for_each 参数提供了需要迭代的复杂类型值
  • iterator 参数(可选)设置了用以表示当前迭代元素的临时变量名。如果没有设置 iterator,那么临时变量名默认就是 dynamic 块的标签(也就是 setting)
  • labels 参数(可选)是一个表示块标签的有序列表,用以按次序生成一组内嵌块。有 labels 参数的表达式里可以使用临时的 iterator 变量
  • 内嵌的 content 块定义了要生成的内嵌块的块体。你可以在 content 块内部使用临时的 iterator 变量

由于 for_each 参数可以是集合或者结构化类型,所以你可以使用 for 表达式或是展开表达式来转换一个现有集合的类型。

iterator 变量(上面的例子里就是 setting)有两个属性:

  • key:迭代容器如果是 map,那么就是当前元素的键;迭代容器如果是 list,那么就是当前元素在 list 中的下标序号;如果是由 for_each 表达式产出的 set,那么 keyvalue 是一样的,这时我们不应该使用 key
  • value:当前元素的值

一个 dynamic 块只能生成属于当前块定义过的内嵌块参数。无法生成诸如 lifecycleprovisioner 这样的元参数,因为 Terraform 必须在确保对这些元参数求值的计算是成功的。

for_each 的值必须是不为空的 map 或者 set。如果你需要根据内嵌数据结构或者多个数据结构的元素组合来声明资源实例集合,你可以使用 Terraform 表达式和函数来生成合适的值。

1.4.8.1.12.1. dynamic 块的最佳实践

过度使用 dynamic 块会导致代码难以阅读以及维护,所以我们建议只在需要构造可重用的模块代码时使用 dynamic 块。尽可能手写内嵌块。

1.4.8.1.13. 字符串字面量

Terraform 有两种不同的字符串字面量。最通用的就是用一对双引号包裹的字符,比如 "hello"。在双引号之间,反斜杠 \ 被用来进行转义。Terraform 支持的转义符有:

Sequence Replacement
\n 换行
\r 回车
\t 制表符
\" 双引号 (不会截断字符串)
\\ 反斜杠
\uNNNN 普通字符映射平面的Unicode字符(NNNN代表四位16进制数)
\UNNNNNNNN 补充字符映射平面的Unicode字符(NNNNNNNN代表八位16进制数)

另一种字符串表达式被称为 “heredoc” 风格,是受 Unix Shell 语言启发。它可以使用自定义的分隔符更加清晰地表达多行字符串:

1
2
3
4
<<EOT
hello
world
EOT

<< 标记后面直到行尾组成的标识符开启了字符串,然后 Terraform 会把剩下的行都添加进字符串,直到遇到与标识符完全相等的字符串为止。在上面的例子里,EOT 就是标识符。任何字符都可以用作标识符,但传统上标识符一般以 EO 开头。上面例子里的 EOT 代表"文本的结束(end of text)"。

上面例子里的 heredoc 风格字符串要求内容必须对齐行头,这在块内声明时看起来会比较奇怪:

1
2
3
4
5
6
block {
value = <<EOT
hello
world
EOT
}

为了改进可读性,Terraform 也支持缩进的 heredoc,只要把 << 改成 <<-

1
2
3
4
5
6
block {
value = <<-EOT
hello
world
EOT
}

上面的例子里,Terraform 会以最靠近行头的行作为基准来调整行头缩进,得到的字符串是这样的:

1
2
hello
world

heredoc 中的反斜杠不会被解释成转义,而只会是简单的反斜杠。

双引号和 heredoc 两种字符串都支持字符串模版,模版的形式是 ${...} 以及 %{...}。如果想要表达 ${ 或者 %{ 的字面量,那么可以重复第一个字符:$${%%{

1.4.8.1.14. 字符串模版

字符串模版允许我们在字符串中嵌入表达式,或是通过其他值动态构造字符串。

1.4.8.1.14.1. 插值(Interpolation)

一个 ${...} 序列被称为插值,插值计算花括号之间的表达式的值,有必要的话将之转换为字符串,然后插入字符串模版,形成最终的字符串:

1
"Hello, ${var.name}!"

上面的例子里,输入变量 var.name 的值被访问后插入了字符串模版,产生了最终的结果,比如:"Hello, Juan!"

1.4.8.1.14.2. 命令(Directive)

一个 %{...} 序列被称为命令,命令可以是一个布尔表达式或者是对集合的迭代,类似条件表达式以及 for 表达式。有两种命令:

  • if \<BOOL\> / else /endif 命令根据布尔表达式的结果在两个模版中选择一个:
1
"Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!"

else 部分可以省略,这样如果布尔表达结果为false那么就会插入空字符串。

  • for \<NAME\> in \<COLLECTION\> / endfor 命令迭代一个结构化对象或者集合,用每一个元素渲染模版,然后把它们拼接起来:
1
2
3
4
5
<<EOT
%{ for ip in aws_instance.example.*.private_ip }
server ${ip}
%{ endfor }
EOT

for 关键字后紧跟的名字被用作代表迭代器元素的临时变量,可以用来在内嵌模版中使用。

为了在不添加额外空格和换行的前提下提升可读性,所有的模版序列都可以在首尾添加 ~ 符号。如果有 ~ 符号,那么模版序列会去除字符串左右的空白(空格以及换行)。如果 ~ 出现在头部,那么会去除字符串左侧的空白;如果出现在尾部,那么会去除字符串右边的空白:

1
2
3
4
5
<<EOT
%{ for ip in aws_instance.example.*.private_ip ~}
server ${ip}
%{ endfor ~}
EOT

上面的例子里,命令符后面的换行符被忽略了,但是 server ${ip} 后面的换行符被保留了,这确保了每一个元素生成一行输出:

1
2
3
server 10.1.16.154
server 10.1.16.1
server 10.1.16.34

当使用模版命令时,我们推荐使用 heredoc 风格字符串,用多行模版提升可读性。双引号字符串内最好只使用插值。

1.4.8.1.15. Terraform 插值

Terraform 曾经只支持在表达式中使用插值,例如

1
2
3
4
resource "aws_instance" "example" {
ami = var.image_id
# ...
}

这种语法是在 Terraform 0.12 后才被支持的。在 Terraform 0.11 及更早的版本中,这段代码只能被写成这样:

1
2
3
4
resource "aws_instance" "example" {
ami = "${var.image_id}"
# ...
}

Terraform 0.12 保持了向前兼容,所以现在这样的代码也仍然是合法的。读者们也许会在一些 Terraform 代码和文档中继续看到这样的写法,但请尽量避免继续这样书写纯插值字符串,而是直接使用表达式。

1.4.9.1. 重载文件

一般来说 Terraform 会加载模块内所有的 .tf.tf.json 文件,并要求文件内定义了一组无重复的对象。如果两个文件尝试定义同一个对象,那么 Terraform 会报错。

在某些少见场景中,能够用单独的文件重载已有对象配置的特定部分将会十分有用。比如说,由工程师编写的配置文件能够在运行时被程序生成的 JSON 文件部分重载。

为支持这些少见场景,Terraform 会对后缀名为 override.tfoverride.tf.json 的代码文件进行特殊处理。对于名为 override.tfoverride.tf.json 的代码文件也会进行相同的特殊处理。

Terraform 一开始加载代码文件时会跳过这些重载文件,然后才会按照字典序一个一个处理重载文件。对重载文件中定义的所有顶级块(resourcedata等),Terraform 会尝试找到对应的已有对象并且将重载内容合并进已有对象。

重载文件只应使用于特殊场景,过度使用会使得读者在阅读原始代码文件时被迫还要阅读所有的重载文件才能理解对象配置,从而降低了代码的可读性。使用重载文件时,请在原始文件被重载的部分添加相应注释,提醒未来的读者哪些部分会被重载文件修改。

1.4.9.1.1. 例子

如果我们有一个名为 example.tf 的代码文件:

1
2
3
4
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = "ami-408c7f28"
}

然后我们创建一个名为 override.tf 的文件:

1
2
3
resource "aws_instance" "web" {
ami = "foo"
}

Terraform 随后会合并两者,实际的配置会是这样的:

1
2
3
4
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = "foo"
}

1.4.9.1.2. 合并行为

不同的块类型有着些微不同的合并行为,某些特定块内的特殊构造会以特殊形式被合并。

一般来说:

  • 重载文件内的顶级块会和普通文件内同类型同名的顶级块合并
  • 重载文件内的顶级块配置册参数会覆盖普通文件内对应块内的同名参数
  • 重载块内的内嵌块会取代普通文件内对应块内的所有同类型内嵌块。所有重载块内没有定义的内嵌块在普通文件内保持不变
  • 内嵌块的内容不会进行合并
  • 合并后的块仍然需要符合对应块类型的所有验证规则

如果有多个重载文件定义了同一个顶级块,那么重载效果是叠加的,后加载的重载块会在先前加载的重载块生效的基础上合并。重载操作首先按照文件名的字典序其次是在重载文件中的位置决定执行顺序。

有一些针对特定顶级块类型的特殊合并行为规则,我们将重载文件中定义的块称为重载块,重载块在普通文件中对应的块称为源块:

1.4.9.1.2.1. 合并 resource 块以及合并 data 块

resource 块内,所有 lifecycle 块的内容会按照参数逐条合并。比如说,一个重载块只定义了 create_before_destroy 参数而源块定义了 ignore_changes,那么 create_before_destroy 被合并的同时 igonore_changes 将会被保留。

如果重载的 resource 块包含了一个或多个 provisioner,那么源块内所有的 provisioner 会被忽略。

如果重载的 resource 块内包含了一个 connection 块,那么它将会完全覆盖所有源块内定义的 connection

不允许在重载块内定义 depends_on 参数,那将会引发一个错误。

1.4.9.1.2.2. 合并 variable 块

variable 块内参数的合并遵循上述的标准流程,但对于 typedefault 参数的处理会有一些特殊的考虑。

如果源块定义了 default 值而重载块修改了变量的 type,Terraform 会尝试将 default 值转换成新类型,如果转换失败则会报错。

同样的,如果源块定义了 type 参数而重载块修改了 default 值,那么新的 default 值必须能够被转换成原先的类型。

1.4.9.1.2.3. 合并 output 块

不允许在重载块内定义 depends_on 参数,这会引发一个错误。

1.4.9.1.2.4. 合并 locals 块

所有的 locals 块都定义了一个或多个命名值。针对 locals 的合并会是按照命名值的名字逐条执行的,不论命名值是在哪个 locals 块内被定义的。

1.4.9.1.2.5. 合并 terraform 块

如果重载块定义了 required_providers 参数,那么它的值会被逐条合并,这就允许重载块在不影响其他Provider的情况下调整单个 Provider 的版本约束。

重载块内的 requeired_versionrequired_providers 里的配置完全覆盖源块内的相应配置。如果源块和重载块都定义了 required_version,那么源块的配置会被完全忽略。

1.4.10.1. 代码风格规范

Terraform 推荐以下代码规范:

  • 使用两个空格缩进
  • 同一缩进层级的多个赋值语句以等号对齐:
1
2
ami           = "abc123"
instance_type = "t2.micro"
  • 当块体内同时有参数赋值以及内嵌块时,请先编写参数赋值,然后是内嵌块。参数与内嵌块之间空一行分隔
  • 对于同时包含参数赋值以及元参数赋值的块,请先编写元参数赋值语句,然后是参数赋值语句,之间空一行分隔。元参数块请置于块体的最后,空一行分隔:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
resource "aws_instance" "example" {
count = 2 # meta-argument first

ami = "abc123"
instance_type = "t2.micro"

network_interface {
# ...
}

lifecycle { # meta-argument block last
create_before_destroy = true
}
}

1.4.11.1. Checks

check 块是 Terraform 1.5 开始引入的新功能。

过去我们可以在 resource 块里的 lifecycle 块中验证基础设施的状态。check 块填补了在 Terraform apply 后验证基础设施状态这一功能中的一块空白。

check 块允许我们定义在每次 plan 以及 apply 操作后执行的自定义的验证。check 块定义的验证逻辑是作为 planapply 操作的最后一步执行的。

1.4.11.1.1. 语法

你可以定义一个包含本地名称的 check 块,其中可以定义一个 有限作用范围的 data,以及至少一个的断言

下面的例子演示了加载 Terraform 官网并验证 HTTP 返回状态码为 200

1
2
3
4
5
6
7
8
9
10
check "health_check" {
data "http" "terraform_io" {
url = "https://www.terraform.io"
}

assert {
condition = data.http.terraform_io.status_code == 200
error_message = "${data.http.terraform_io.url} returned an unhealthy status code"
}
}

1.4.11.1.1.1. 有限作用范围的数据源

我们可以在 check 块使用任意 Provider 提供的任意数据源作为一个有限作用范围的数据源。

一个 check 块可以配一个可选的内嵌(也叫有限作用范围)数据源。该 data 块和普通的 data 块行为类似,但你不能在定义它的 check 块以外引用它。另外,如果一个有限作用范围的数据源运行时触发了任意错误,这些错误将被标记为警告,不会阻止 Terraform 继续执行操作。

你可以使用有限作用范围的数据源在 resourcelifecycle 外验证相关基础设施片段的状态。在上面的例子里,如果 terraform_io 数据源在加载时发生错误,那么我们将会收到一个警告而不是中断执行的错误。

元参数

有限作用域的数据源支持 depends_onprovider 元参数,但不支持 countfor_each 元参数。

depends_on

depends_on 元参数配合有限作用域数据源可以提供非常强大的能力。

假设上述例子中的 Terraform 网站是我们即将用同一目录下的 Terraform 代码部署的,在第一次创建 Plan 时因为网站还没有被创建,所以验证会失败,Terraform 总是会在一开始显示一条让人分心的警告信息。

我们可以给该内嵌数据源添加 depends_on 来确保该数据源依赖于某项组成基础设施的必要资源,例如负载均衡器。这样对该数据源的检查结果将保持 known after apply 直到依赖项创建完成。该策略避免了在配置阶段产生无意义的警告信息,直到在 planapply 操作的合适阶段执行检查。

该策略的一个问题是如果有限作用域数据源所依赖的资源发生了变化,那么 check 块将返回 known after apply 直到 Terraform 完成了对被依赖资源的更新。在某些情况下,这种行为将会引发一些问题。

我们推荐只有在内嵌数据源依赖于某项资源,但又没有显式的引用其数据时使用 depends_on 元参数。

1.4.11.1.1.2. 断言

我们在 check 块中使用 assert 块定义自定义的断言条件。每个 check 块必须声明至少一个或更多的 assert 块。每个 assert 块都包含了一个 condition 属性与一个 error_message 属性。

与其他自定义检查(variable 中的 validation 以及 lifecycle 中的 preconditionpostcondition)不同,assert 的断言不会影响 Terraform 执行操作。失败的断言将以警告信息的形式输出而不会中断后续的操作。这与其他诸如 postcondition 这样的自定义检查形成了对比,因为它们的检查失败会立即终止后续的 plan 以及 apply 操作,返回错误信息。

assert 块中的断言条件表达式可以引用同一 check 块里的内嵌数据源数据,以及同一模块中的任意输入参数、资源、数据源、模块的输出值。

1.4.11.1.1.3. check 块的元参数

check 块目前不支持元参数。Terraform 团队目前正在收集有关这一功能的反馈。

1.4.11.1.2. 是使用 check 块还是其他自定义条件检查

check 块提供了 Terraform 中最灵活的验证功能。我们可以在其中引用输出值、输入参数、资源以及数据源的值。我们的确可以使用 check 块取代所有其他的自定义条件检查,但这并不意味着我们应该要这么做。

check 与其他检查最大的区别在于 check 块不会中断 Terraform 的执行。我们需要将这种非阻塞性的行为特点计入考量来决定采取何种检查。

1.4.11.1.2.1. 输出值与输入参数

输出值的 precondition 以及 输入变量的 validation都可以对输入输出值进行断言。

这些检查是用来阻止 Terraform 在数据有问题时继续执行的。

举例来说,如果输入参数的值是无效的那么任由 Terraform 执行整个配置文件并没有什么意义,这种情况下,check 块只会输出有关无效输入参数的警告,不会打断 Terraform 的执行,而 validation 块则会警告输入参数值非法,并终止 Terraform 执行 planapply 操作。

1.4.11.1.2.2. resource 块的 precondition 与 postcondition

check 块与 preconditionpostcondition 的区别更加微妙。

precondition 是自定义条件检查中最特殊的,因为它们是在资源的变更被计算或应用之前执行的检查。决定使用 precondition 还是 postcondition 的考量也适用于选择是使用 precondition 还是 check 块。

我们可以在 postconditioncheck 块之间互换来验证资源和数据源。例如,我们可以把上述例子中的 check 块改写成 postcondition,以下的 postcondition 块将会验证对 Terraform 网站的请求是否返回了状态码 200

1
2
3
4
5
6
7
8
9
10
data "http" "terraform_io" {
url = "https://www.terraform.io"

lifecycle {
postcondition {
condition = self.status_code == 200
error_message = "${self.url} returned an unhealthy status code"
}
}
}

checkpostcondition 块都在 planapply 操作中验证了 Terraform 网站是否返回 200 状态码,它们的区别是发生错误时的行为。

如果是 postcondition 失败,那么将无法继续执行。Terraform 会阻止任意后续的 planapply 操作。

我们推荐使用 check 块来验证基础设施的整体状态,仅在希望确保单一资源状态符合预期时使用 postcondition

1.4.12.1. Ephemeral 资源

临时(Ephemeral)资源是本质上是临时的(Temporary) Terraform 资源。临时资源具有独特的生命周期,Terraform 不会将它们存储在其状态文件中。每个 ephemeral 块描述一个或多个临时资源,例如临时密码或与另一个系统的连接。

ephemeral 块的声明包含了临时资源的类型以及本地名,就像 resource 块那样。 Terraform 使用临时资源的名称来引用同一模块中的该资源,但临时资源的名称在该模块的范围之外没有任何意义。

1.4.12.1.1. 生命周期

ephemeral 的生命周期与 resourcedata 不同。当 Terraform 创建临时资源时,它会执行以下步骤:

  1. 如果 Terraform 需要访问临时资源的结果,它将“打开”该临时资源。例如,如果 Terraform “打开”一个包含了 Vault 机密的临时资源,则 Vault 的 Provider 将获取租约并返回一个机密。
  2. 如果 Terraform 需要访问临时资源的时间比远程系统为机密设置的过期时间还长,Terraform 会要求 Provider 定期续约。例如,如果 Terraform 对包含了 Vault 机密的临时资源续约,则 Vault Provider 程序将调用 Vault 的租约续约 API 来延长到期时间。
  3. 一旦 Terraform 不再需要临时资源,Terraform 就会将其关闭。这种情况发生在依赖于某个临时资源的 Provider 完成当前 Terraform 运行阶段的所有工作之后。例如,关闭 Vault 机密临时资源意味着 Vault Provider 明确吊销租约,从而使得 Vault 立即撤销相关凭证。

Terraform 对于给定配置中的每个临时资源实例都遵循这些生命周期步骤。

1.4.12.1.2. 依赖图

临时资源对应了 Terraform 依赖关系图中的节点,其交互方式与 resourcedata 类似。例如,当 resourcedata 依赖于临时资源的属性时,Terraform 首先自动配置临时资源。

1.4.12.1.3. 临时资源的声明

ephemeral 块中的绝大多数参数是由您正在定义的临时资源类型所决定的。与 resourcedata 一样,Terraform 注册表中的每个 Provider 程序都包含其支持的临时资源(如果有)的文档。临时资源类型的文档列出了可用的参数以及应如何配置的格式。

临时资源由两部分组成:

  • 属性
  • 元参数

ephemeral 块的结构如下:

1
2
3
4
ephemeral "<resource_type>" "<resource_name>" {
<attributes>
<meta-arguments>
}

1.4.12.1.4. 引用临时资源

只允许在特定的临时上下文中引用临时资源,否则 Terraform 会返回错误。以下是可以引用临时资源的上下文:

1.4.12.1.5. 元参数

我们可以将在临时资源块内声明以下元参数,来更改这些资源的行为。以下元参数对于资源、数据源和临时资源的工作方式相同:

临时资源不支持 provisioner 元参数。

1.4.12.1.6. 示例

以下示例使用临时资源的凭据配置 postgresql Provider 程序。由于这些凭据由临时资源管理,因此 Terraform 不会将它们存储在状态或计划文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ephemeral "aws_secretsmanager_secret_version" "db_master" {
secret_id = data.aws_db_instance.example.master_user_secret[0].secret_arn
}

locals {
credentials = jsondecode(ephemeral.aws_secretsmanager_secret_version.db_master.secret_string)
}

provider "postgresql" {
host = data.aws_db_instance.example.address
port = data.aws_db_instance.example.port
username = local.credentials["username"]
password = local.credentials["password"]
}

留言與分享

1.3.2.1. Terraform 基础概念——状态管理

我们在第一章的末尾提过,当我们成功地执行了一次 terraform apply,创建了期望的基础设施以后,我们如果再次执行 terraform apply,生成的新的执行计划将不会包含任何变更,Terraform 会记住当前基础设施的状态,并将之与代码所描述的期望状态进行比对。第二次 apply 时,因为当前状态已经与代码描述的状态一致了,所以会生成一个空的执行计划。

1.3.2.1.1. 初探状态文件

在这里,Terraform 引入了一个独特的概念——状态管理,这是 Ansible 等配置管理工具或是自研工具调用 SDK 操作基础设施的方案所没有的。简单来说,Terraform 将每次执行基础设施变更操作时的状态信息保存在一个状态文件中,默认情况下会保存在当前工作目录下的 terraform.tfstate 文件里。例如我们之前在使用 LocalStack 模拟环境的代码中声明一个 data 和一个 resource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5.0"
}
}
}

provider "aws" {
access_key = "test"
secret_key = "test"
region = "us-east-1"
s3_use_path_style = false
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true

endpoints {
apigateway = "http://localhost:4566"
apigatewayv2 = "http://localhost:4566"
cloudformation = "http://localhost:4566"
cloudwatch = "http://localhost:4566"
dynamodb = "http://localhost:4566"
ec2 = "http://localhost:4566"
es = "http://localhost:4566"
elasticache = "http://localhost:4566"
firehose = "http://localhost:4566"
iam = "http://localhost:4566"
kinesis = "http://localhost:4566"
lambda = "http://localhost:4566"
rds = "http://localhost:4566"
redshift = "http://localhost:4566"
route53 = "http://localhost:4566"
s3 = "http://s3.localhost.localstack.cloud:4566"
secretsmanager = "http://localhost:4566"
ses = "http://localhost:4566"
sns = "http://localhost:4566"
sqs = "http://localhost:4566"
ssm = "http://localhost:4566"
stepfunctions = "http://localhost:4566"
sts = "http://localhost:4566"
}
}

data "aws_ami" "ubuntu" {
most_recent = true

filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727"]
}

filter {
name = "virtualization-type"
values = ["hvm"]
}

owners = ["099720109477"] # Canonical
}

resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"

tags = {
Name = "HelloWorld"
}
}

使用 terraform apply 后,我们可以看到 terraform.tfstate 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
{
"version": 4,
"terraform_version": "1.7.3",
"serial": 1,
"lineage": "159018e2-63f4-2dfa-ce0d-873a37a1e0a7",
"outputs": {},
"resources": [
{
"mode": "data",
"type": "aws_ami",
"name": "ubuntu",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"architecture": "x86_64",
"arn": "arn:aws:ec2:us-east-1::image/ami-1e749f67",
"block_device_mappings": [
{
"device_name": "/dev/sda1",
"ebs": {
"delete_on_termination": "false",
"encrypted": "false",
"iops": "0",
"snapshot_id": "snap-15bd5527",
"throughput": "0",
"volume_size": "15",
"volume_type": "standard"
},
"no_device": "",
"virtual_name": ""
}
],
"boot_mode": "",
"creation_date": "2024-02-20T13:52:42.000Z",
"deprecation_time": "",
"description": "Canonical, Ubuntu, 14.04 LTS, amd64 trusty image build on 2017-07-27",
"ena_support": false,
"executable_users": null,
"filter": [
{
"name": "name",
"values": [
"ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727"
]
},
{
"name": "virtualization-type",
"values": [
"hvm"
]
}
],
"hypervisor": "xen",
"id": "ami-1e749f67",
"image_id": "ami-1e749f67",
"image_location": "amazon/getting-started",
"image_owner_alias": "amazon",
"image_type": "machine",
"imds_support": "",
"include_deprecated": false,
"kernel_id": "None",
"most_recent": true,
"name": "ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727",
"name_regex": null,
"owner_id": "099720109477",
"owners": [
"099720109477"
],
"platform": "",
"platform_details": "",
"product_codes": [],
"public": true,
"ramdisk_id": "ari-1a2b3c4d",
"root_device_name": "/dev/sda1",
"root_device_type": "ebs",
"root_snapshot_id": "snap-15bd5527",
"sriov_net_support": "",
"state": "available",
"state_reason": {
"code": "UNSET",
"message": "UNSET"
},
"tags": {},
"timeouts": null,
"tpm_support": "",
"usage_operation": "",
"virtualization_type": "hvm"
},
"sensitive_attributes": []
}
]
},
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"ami": "ami-1e749f67",
"arn": "arn:aws:ec2:us-east-1::instance/i-288a34165ed2ad2f7",
"associate_public_ip_address": true,
"availability_zone": "us-east-1a",
"capacity_reservation_specification": [],
"cpu_core_count": null,
"cpu_options": [],
"cpu_threads_per_core": null,
"credit_specification": [],
"disable_api_stop": false,
"disable_api_termination": false,
"ebs_block_device": [],
"ebs_optimized": false,
"enclave_options": [],
"ephemeral_block_device": [],
"get_password_data": false,
"hibernation": false,
"host_id": "",
"host_resource_group_arn": null,
"iam_instance_profile": "",
"id": "i-288a34165ed2ad2f7",
"instance_initiated_shutdown_behavior": "stop",
"instance_lifecycle": "",
"instance_market_options": [],
"instance_state": "running",
"instance_type": "t3.micro",
"ipv6_address_count": 0,
"ipv6_addresses": [],
"key_name": "",
"launch_template": [],
"maintenance_options": [],
"metadata_options": [],
"monitoring": false,
"network_interface": [],
"outpost_arn": "",
"password_data": "",
"placement_group": "",
"placement_partition_number": 0,
"primary_network_interface_id": "eni-68899bf6",
"private_dns": "ip-10-13-239-41.ec2.internal",
"private_dns_name_options": [],
"private_ip": "10.13.239.41",
"public_dns": "ec2-54-214-132-221.compute-1.amazonaws.com",
"public_ip": "54.214.132.221",
"root_block_device": [
{
"delete_on_termination": true,
"device_name": "/dev/sda1",
"encrypted": false,
"iops": 0,
"kms_key_id": "",
"tags": {},
"throughput": 0,
"volume_id": "vol-6dde834f",
"volume_size": 8,
"volume_type": "gp2"
}
],
"secondary_private_ips": [],
"security_groups": [],
"source_dest_check": true,
"spot_instance_request_id": "",
"subnet_id": "subnet-dbb4c2f9",
"tags": {
"Name": "HelloWorld"
},
"tags_all": {
"Name": "HelloWorld"
},
"tenancy": "default",
"timeouts": null,
"user_data": null,
"user_data_base64": null,
"user_data_replace_on_change": false,
"volume_tags": null,
"vpc_security_group_ids": []
},
"sensitive_attributes": [],
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6MTIwMDAwMDAwMDAwMCwidXBkYXRlIjo2MDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
"dependencies": [
"data.aws_ami.ubuntu"
]
}
]
}
],
"check_results": null
}

我们可以看到,查询到的 data 以及创建的 resource 信息都被以 json 格式保存在 tfstate 文件里。

我们前面已经说过,由于 tfstate 文件的存在,我们在 terraform apply 之后立即再次 apply 是不会执行任何变更的,那么如果我们删除了这个 tfstate 文件,然后再执行 apply 会发生什么呢?Terraform 读取不到 tfstate 文件,会认为这是我们第一次创建这组资源,所以它会再一次创建代码中描述的所有资源。更加麻烦的是,由于我们前一次创建的资源所对应的状态信息被我们删除了,所以我们再也无法通过执行 terraform destroy 来销毁和回收这些资源,实际上产生了资源泄漏。所以妥善保存这个状态文件是非常重要的。

另外,如果我们对 Terraform 的代码进行了一些修改,导致生成的执行计划将会改变状态,那么在实际执行变更之前,Terraform 会复制一份当前的 tfstate 文件到同路径下的 terraform.tfstate.backup 中,以防止由于各种意外导致的 tfstate 损毁。

在 Terraform 发展的极早期,HashiCorp 曾经尝试过无状态文件的方案,也就是在执行 Terraform 变更计划时,给所有涉及到的资源都打上特定的 tag,在下次执行变更时,先通过 tag 读取相关资源来重建状态信息。但因为并不是所有资源都支持打 tag,也不是所有公有云都支持多 tag,所以 Terraform 最终决定用状态文件方案。

还有一点,HashiCorp 官方从未公开过 tfstate 的格式,也就是说,HashiCorp 保留随时修改 tfstate 格式的权力。所以不要试图手动或是用自研代码去修改 tfstate,Terraform 命令行工具提供了相关的指令(我们后续会介绍到),请确保只通过命令行的指令操作状态文件。

1.3.2.1.2. 极其重要的安全警示—— tfstate 是明文的

关于 Terraform 状态,还有极其重要的事,所有考虑在生产环境使用 Terraform 的人都必须格外小心并再三警惕:Terraform 的状态文件是明文的,这就意味着代码中所使用的一切机密信息都将以明文的形式保存在状态文件里。例如我们创建一个私钥文件:

1
2
3
resource "tls_private_key" "example" {
algorithm = "RSA"
}

执行了terraform apply后我们观察 tfstate 文件中相关段落:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"version": 4,
"terraform_version": "1.7.3",
"serial": 1,
"lineage": "dec42d6b-d61f-30b3-0b83-d5d8881c29ea",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "tls_private_key",
"name": "example",
"provider": "provider[\"registry.terraform.io/hashicorp/tls\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"algorithm": "RSA",
"ecdsa_curve": "P224",
"id": "d47d1465586d25322bb8ca16029fe4fb2ec001e0",
"private_key_openssh": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdz\nc2gtcnNhAAAAAwEAAQAAAQEAsdD9sJ/L5m8cRB19V20HA9BIqZwnT3id5JoMNUuW\nYn4sJTWHa/JHkQ5akrWH50aIdzQrQneIZXJyx1OMlqKbVxAc2+u/8qd2m2GrsWKZ\neqQcpNT7v76QowDcvdggad3Tezn3XU/eBukVj9i+lnN1ofyQzVEQAdvW8TpRdUL5\nb/bIsz1RzGWzUrWD8XTFLf2RuvvzhgBViuuWI0ns3WQMpa6Dcu+nWfGLCl26Wlph\nNmoUAv8wCay7KoynG58pJW+uqYA7lTx4tNMLhIW7rM4roYbXctkCi03PcW3x25O8\nyKSzYIi5xH7CQ7ggwXzcx4r06NXkaE9/LHuBSFJcIMvH8QAAA7jnMVXy5zFV8gAA\nAAdzc2gtcnNhAAABAQCx0P2wn8vmbxxEHX1XbQcD0EipnCdPeJ3kmgw1S5Zifiwl\nNYdr8keRDlqStYfnRoh3NCtCd4hlcnLHU4yWoptXEBzb67/yp3abYauxYpl6pByk\n1Pu/vpCjANy92CBp3dN7OfddT94G6RWP2L6Wc3Wh/JDNURAB29bxOlF1Qvlv9siz\nPVHMZbNStYPxdMUt/ZG6+/OGAFWK65YjSezdZAylroNy76dZ8YsKXbpaWmE2ahQC\n/zAJrLsqjKcbnyklb66pgDuVPHi00wuEhbusziuhhtdy2QKLTc9xbfHbk7zIpLNg\niLnEfsJDuCDBfNzHivTo1eRoT38se4FIUlwgy8fxAAAAAwEAAQAAAQEAleLv5ZFd\nY9mm/vfIrwg1UI6ioW4CaOfoWElOHyKfGlj2x0qu41wv3WM3D9G7REVdRPYRvQ5b\nSABIJiMUL+nTfXkUioDXpShqPyH+gyD09L8fcgYiS4fMDcrtR43GDNcyq/25uMtZ\nAYQ6a62tQc8Dik8GlDtPffGc5mxdO7X/4tLAObBPqO+lvGX2K/2hV2ql/a4fBVXR\nOMPc9A0eva2exifZyFo9vT9CCcW4iNY2BHY2hXAPI1gFpBnmnY2twFof4EvX6tfZ\nGjt20QCqTi41P8Obrfqi108zRAKtjJFeezNY+diVvxZaCDb/7ceFbFUrXq9u2UVD\nExn9joOLTJTEwQAAAIEAgOQ/mjRousgSenXW2nE2aq0m7oKQzhsF/8k5UPj6mym1\nvwUyC2gglTIOGVkUpj91L/Fh2nCuX5BLyzzIee0twRvT1Kj11BU6UoElStpR/JEC\n7trKWJrBddphBWHAuVcU5AQQPwI/9sg27q/9y16WTIQAJzx8GwGcDbgZj8/LbB4A\nAACBAMpVcX+2smqt8T9mbwU7e7ZaCQM0c3/7F2S2Z26Zl16k+8WPr6+CyJd3d2s2\nQkrmqVKJzDqPYidU0EmaNOrqytvUTUDK9KJgKsJuNC9ZbODqTCMA03ntr+hVcfdt\nd9F5fxyBJSrzpAhUQKadHs8jtAa1ENAhPmwKzvcJ51Gp4R3ZAAAAgQDg+s4nk926\njuLQGlweOi2HY6eeMQF8MOnXEhM8P7ErdRR73ql7GlhBNoGzuI6YTaqjLXBGNetj\nm4iziPLapbqBXSeia3y1JU71e1M+J342CxQwZRKTI9G/AB2AzspB4VfjAT9ZYJM1\nSFH7cDTejcrkfWko8TqyRLwtTPDa7Xlz2QAAAAAB\n-----END OPENSSH PRIVATE KEY-----\n",
"private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAsdD9sJ/L5m8cRB19V20HA9BIqZwnT3id5JoMNUuWYn4sJTWH\na/JHkQ5akrWH50aIdzQrQneIZXJyx1OMlqKbVxAc2+u/8qd2m2GrsWKZeqQcpNT7\nv76QowDcvdggad3Tezn3XU/eBukVj9i+lnN1ofyQzVEQAdvW8TpRdUL5b/bIsz1R\nzGWzUrWD8XTFLf2RuvvzhgBViuuWI0ns3WQMpa6Dcu+nWfGLCl26WlphNmoUAv8w\nCay7KoynG58pJW+uqYA7lTx4tNMLhIW7rM4roYbXctkCi03PcW3x25O8yKSzYIi5\nxH7CQ7ggwXzcx4r06NXkaE9/LHuBSFJcIMvH8QIDAQABAoIBAQCV4u/lkV1j2ab+\n98ivCDVQjqKhbgJo5+hYSU4fIp8aWPbHSq7jXC/dYzcP0btERV1E9hG9DltIAEgm\nIxQv6dN9eRSKgNelKGo/If6DIPT0vx9yBiJLh8wNyu1HjcYM1zKr/bm4y1kBhDpr\nra1BzwOKTwaUO0998ZzmbF07tf/i0sA5sE+o76W8ZfYr/aFXaqX9rh8FVdE4w9z0\nDR69rZ7GJ9nIWj29P0IJxbiI1jYEdjaFcA8jWAWkGeadja3AWh/gS9fq19kaO3bR\nAKpOLjU/w5ut+qLXTzNEAq2MkV57M1j52JW/FloINv/tx4VsVSter27ZRUMTGf2O\ng4tMlMTBAoGBAMpVcX+2smqt8T9mbwU7e7ZaCQM0c3/7F2S2Z26Zl16k+8WPr6+C\nyJd3d2s2QkrmqVKJzDqPYidU0EmaNOrqytvUTUDK9KJgKsJuNC9ZbODqTCMA03nt\nr+hVcfdtd9F5fxyBJSrzpAhUQKadHs8jtAa1ENAhPmwKzvcJ51Gp4R3ZAoGBAOD6\nzieT3bqO4tAaXB46LYdjp54xAXww6dcSEzw/sSt1FHveqXsaWEE2gbO4jphNqqMt\ncEY162ObiLOI8tqluoFdJ6JrfLUlTvV7Uz4nfjYLFDBlEpMj0b8AHYDOykHhV+MB\nP1lgkzVIUftwNN6NyuR9aSjxOrJEvC1M8NrteXPZAoGAIQf/5nSh/e51ov8LAtSq\nJqPeMsq+TFdmg0eP7Stf3dCbVa5WZRW5v5h+Q19xRR8Q52udjrXXtUoQUuO83dkE\n0wx+rCQ1+cgvUtyA4nX741/8m/5Hh/E4tXo1h8o0NFtcV//xXGi4D7AJeenOnMxc\nWHf4zbGPqj29efEA9YEBQkkCgYABhG+DgNHMAk6xTJw2b/oCob9tp7L03XeWRb7v\ndxaAzodW1oeaFvFlbzKsvZ/okw2FkDbjolV2FIR1gYTxyJBbcv9jbwomRpwjt7M2\nBhopzyVRtjzL1UAC48NPLRXcH+Lx2v5MYgRcJaK36WfR4G7v35CoAAh/T0tdmtk9\nAMEC8QKBgQCA5D+aNGi6yBJ6ddbacTZqrSbugpDOGwX/yTlQ+PqbKbW/BTILaCCV\nMg4ZWRSmP3Uv8WHacK5fkEvLPMh57S3BG9PUqPXUFTpSgSVK2lH8kQLu2spYmsF1\n2mEFYcC5VxTkBBA/Aj/2yDbur/3LXpZMhAAnPHwbAZwNuBmPz8tsHg==\n-----END RSA PRIVATE KEY-----\n",
"private_key_pem_pkcs8": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCx0P2wn8vmbxxE\nHX1XbQcD0EipnCdPeJ3kmgw1S5ZifiwlNYdr8keRDlqStYfnRoh3NCtCd4hlcnLH\nU4yWoptXEBzb67/yp3abYauxYpl6pByk1Pu/vpCjANy92CBp3dN7OfddT94G6RWP\n2L6Wc3Wh/JDNURAB29bxOlF1Qvlv9sizPVHMZbNStYPxdMUt/ZG6+/OGAFWK65Yj\nSezdZAylroNy76dZ8YsKXbpaWmE2ahQC/zAJrLsqjKcbnyklb66pgDuVPHi00wuE\nhbusziuhhtdy2QKLTc9xbfHbk7zIpLNgiLnEfsJDuCDBfNzHivTo1eRoT38se4FI\nUlwgy8fxAgMBAAECggEBAJXi7+WRXWPZpv73yK8INVCOoqFuAmjn6FhJTh8inxpY\n9sdKruNcL91jNw/Ru0RFXUT2Eb0OW0gASCYjFC/p0315FIqA16Uoaj8h/oMg9PS/\nH3IGIkuHzA3K7UeNxgzXMqv9ubjLWQGEOmutrUHPA4pPBpQ7T33xnOZsXTu1/+LS\nwDmwT6jvpbxl9iv9oVdqpf2uHwVV0TjD3PQNHr2tnsYn2chaPb0/QgnFuIjWNgR2\nNoVwDyNYBaQZ5p2NrcBaH+BL1+rX2Ro7dtEAqk4uNT/Dm636otdPM0QCrYyRXnsz\nWPnYlb8WWgg2/+3HhWxVK16vbtlFQxMZ/Y6Di0yUxMECgYEAylVxf7ayaq3xP2Zv\nBTt7tloJAzRzf/sXZLZnbpmXXqT7xY+vr4LIl3d3azZCSuapUonMOo9iJ1TQSZo0\n6urK29RNQMr0omAqwm40L1ls4OpMIwDTee2v6FVx92130Xl/HIElKvOkCFRApp0e\nzyO0BrUQ0CE+bArO9wnnUanhHdkCgYEA4PrOJ5Pduo7i0BpcHjoth2OnnjEBfDDp\n1xITPD+xK3UUe96pexpYQTaBs7iOmE2qoy1wRjXrY5uIs4jy2qW6gV0nomt8tSVO\n9XtTPid+NgsUMGUSkyPRvwAdgM7KQeFX4wE/WWCTNUhR+3A03o3K5H1pKPE6skS8\nLUzw2u15c9kCgYAhB//mdKH97nWi/wsC1Komo94yyr5MV2aDR4/tK1/d0JtVrlZl\nFbm/mH5DX3FFHxDna52Otde1ShBS47zd2QTTDH6sJDX5yC9S3IDidfvjX/yb/keH\n8Ti1ejWHyjQ0W1xX//FcaLgPsAl56c6czFxYd/jNsY+qPb158QD1gQFCSQKBgAGE\nb4OA0cwCTrFMnDZv+gKhv22nsvTdd5ZFvu93FoDOh1bWh5oW8WVvMqy9n+iTDYWQ\nNuOiVXYUhHWBhPHIkFty/2NvCiZGnCO3szYGGinPJVG2PMvVQALjw08tFdwf4vHa\n/kxiBFwlorfpZ9Hgbu/fkKgACH9PS12a2T0AwQLxAoGBAIDkP5o0aLrIEnp11tpx\nNmqtJu6CkM4bBf/JOVD4+psptb8FMgtoIJUyDhlZFKY/dS/xYdpwrl+QS8s8yHnt\nLcEb09So9dQVOlKBJUraUfyRAu7ayliawXXaYQVhwLlXFOQEED8CP/bINu6v/cte\nlkyEACc8fBsBnA24GY/Py2we\n-----END PRIVATE KEY-----\n",
"public_key_fingerprint_md5": "a9:cc:2d:0f:66:b9:a9:89:94:f1:da:8f:93:df:63:d5",
"public_key_fingerprint_sha256": "SHA256:ias4wauIC4J/zd/cVoXT6UUSUZLQXaxdpJfkGAfRDz0",
"public_key_openssh": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCx0P2wn8vmbxxEHX1XbQcD0EipnCdPeJ3kmgw1S5ZifiwlNYdr8keRDlqStYfnRoh3NCtCd4hlcnLHU4yWoptXEBzb67/yp3abYauxYpl6pByk1Pu/vpCjANy92CBp3dN7OfddT94G6RWP2L6Wc3Wh/JDNURAB29bxOlF1Qvlv9sizPVHMZbNStYPxdMUt/ZG6+/OGAFWK65YjSezdZAylroNy76dZ8YsKXbpaWmE2ahQC/zAJrLsqjKcbnyklb66pgDuVPHi00wuEhbusziuhhtdy2QKLTc9xbfHbk7zIpLNgiLnEfsJDuCDBfNzHivTo1eRoT38se4FIUlwgy8fx\n",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsdD9sJ/L5m8cRB19V20H\nA9BIqZwnT3id5JoMNUuWYn4sJTWHa/JHkQ5akrWH50aIdzQrQneIZXJyx1OMlqKb\nVxAc2+u/8qd2m2GrsWKZeqQcpNT7v76QowDcvdggad3Tezn3XU/eBukVj9i+lnN1\nofyQzVEQAdvW8TpRdUL5b/bIsz1RzGWzUrWD8XTFLf2RuvvzhgBViuuWI0ns3WQM\npa6Dcu+nWfGLCl26WlphNmoUAv8wCay7KoynG58pJW+uqYA7lTx4tNMLhIW7rM4r\noYbXctkCi03PcW3x25O8yKSzYIi5xH7CQ7ggwXzcx4r06NXkaE9/LHuBSFJcIMvH\n8QIDAQAB\n-----END PUBLIC KEY-----\n",
"rsa_bits": 2048
},
"sensitive_attributes": []
}
]
}
],
"check_results": null
}

可以看到不应该被第三方知晓的 private_key_opensshprivate_key_pemprivate_key_pem_pkcs8 是以明文形式被写在 tfstate 文件里的。这是 Terraform 从设计之初就确定的,并且在可见的未来不会有改善。不论你是在代码中明文硬编码,还是使用参数(variable,我们之后的章节会介绍),亦或是妙想天开地使用函数在运行时从外界读取,都无法改变这个结果。

解决之道有两种,一种是使用 Vault 或是 AWS Secret Manager 这样的动态机密管理工具生成临时有效的动态机密(比如有效期只有 5 分钟,即使被他人读取到,机密也早已失效);另一种就是我们下面将要介绍的 —— Terraform Backend。

1.3.2.1.3. 生产环境的 tfstate 管理方案—— Backend

到目前为止我们的 tfstate 文件是保存在当前工作目录下的本地文件,假设我们的计算机损坏了,导致文件丢失,那么 tfstate 文件所对应的资源都将无法管理,而产生资源泄漏。

另外如果我们是一个团队在使用 Terraform 管理一组资源,团队成员之间要如何共享这个状态文件?能不能把 tfstate 文件签入源代码管理工具进行保存?

tfstate 文件签入管代码管理工具是非常错误的,这就好比把数据库签入了源代码管理工具,如果两个人同时签出了同一份 tfstate,并且对代码做了不同的修改,又同时 apply 了,这时想要把 tfstate 签入源码管理系统可能会遭遇到无法解决的冲突。况且如果代码仓库是公开的,那么保存在 State 中的明文机密就会泄露。

为了解决状态文件的存储和共享问题,Terraform 引入了远程状态存储机制,也就是 Backend。Backend 是一种抽象的远程存储接口,如同 Provider 一样,Backend 也支持多种不同的远程存储服务:

  • local
  • remote
  • azurerm
  • consul
  • cos
  • gcs
  • http
  • kubernetes
  • oss
  • pg
  • s3

注意:在 Terraform 1.1.0 之前的版本中,Backend 分为标准增强两种。增强是一种特殊的 remote Backend,它既可以存储状态,也可以执行 Terraform 操作。但是这种分类已被移除。请参考使用 Terraform Cloud 了解关于存储状态、执行远程操作以及直接从 Terraform 调用 Terraform Cloud 的详细信息。

状态锁是指,当针对一个 tfstate 进行变更操作时,可以针对该状态文件添加一把全局锁,确保同一时间只能有一个变更被执行。不同的 Backend 对状态锁的支持不尽相同,实现状态锁的机制也不尽相同,例如 consul Backend就通过一个 .lock 节点来充当锁,一个 .lockinfo 节点来描述锁对应的会话信息,tfstate 文件被保存在 Backend 定义的路径节点内;s3 Backend 则需要用户传入一个 Dynamodb 表来存放锁信息,而 tfstate 文件被存储在 S3 存储桶里,等等等等。读者可以根据实际情况,挑选自己合适的 Backend。接下来我将以 consul 为范例为读者演示 Backend 机制。

1.3.2.1.4. Consul简介以及安装

Consul 是 HashiCorp 推出的一个开源工具,主要用来解决服务发现、配置中心以及 Service Mesh 等问题;Consul 本身也提供了类似 ZooKeeper、Etcd 这样的分布式键值存储服务,具有基于 Gossip 协议的最终一致性,所以可以被用来充当 Terraform Backend 存储。

安装 Consul 十分简单,如果你是 Ubuntu 用户:

1
2
3
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install consul

对于CentOS用户:

1
2
3
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install consul

对于Macos用户:

1
2
brew tap hashicorp/tap
brew install hashicorp/tap/consul

对于 Windows 用户,如果按照前文安装 Terraform 教程已经配置了 Chocolatey 的话:

1
choco install consul

安装完成后的验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$ consul
Usage: consul [--version] [--help] <command> [<args>]

Available commands are:
acl Interact with Consul's ACLs
agent Runs a Consul agent
catalog Interact with the catalog
config Interact with Consul's Centralized Configurations
connect Interact with Consul Connect
debug Records a debugging archive for operators
event Fire a new event
exec Executes a command on Consul nodes
force-leave Forces a member of the cluster to enter the "left" state
info Provides debugging information for operators.
intention Interact with Connect service intentions
join Tell Consul agent to join cluster
keygen Generates a new encryption key
keyring Manages gossip layer encryption keys
kv Interact with the key-value store
leave Gracefully leaves the Consul cluster and shuts down
lock Execute a command holding a lock
login Login to Consul using an auth method
logout Destroy a Consul token created with login
maint Controls node or service maintenance mode
members Lists the members of a Consul cluster
monitor Stream logs from a Consul agent
operator Provides cluster-level tools for Consul operators
peering Create and manage peering connections between Consul clusters
reload Triggers the agent to reload configuration files
resource Interact with Consul's resources
rtt Estimates network round trip time between nodes
services Interact with services
snapshot Saves, restores and inspects snapshots of Consul server state
tls Builtin helpers for creating CAs and certificates
troubleshoot CLI tools for troubleshooting Consul service mesh
validate Validate config files/directories
version Prints the Consul version
watch Watch for changes in Consul

安装完 Consul 后,我们可以启动一个测试版 Consul 服务:

1
$ consul agent -dev

Consul 会在本机 8500 端口开放 Http 终结点,我们可以通过浏览器访问 http://localhost:8500

Consul的GUI界面

图 1.3.2/1 - Consul的GUI界面

1.3.2.1.5. 使用 Backend

我们还是利用 LocalStack 来执行一段简单的 Terraform 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5.0"
}
}
backend "consul" {
address = "localhost:8500"
scheme = "http"
path = "localstack-aws"
}
}

provider "aws" {
access_key = "test"
secret_key = "test"
region = "us-east-1"
s3_use_path_style = false
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true

endpoints {
apigateway = "http://localhost:4566"
apigatewayv2 = "http://localhost:4566"
cloudformation = "http://localhost:4566"
cloudwatch = "http://localhost:4566"
docdb = "http://localhost:4566"
dynamodb = "http://localhost:4566"
ec2 = "http://localhost:4566"
es = "http://localhost:4566"
elasticache = "http://localhost:4566"
firehose = "http://localhost:4566"
iam = "http://localhost:4566"
kinesis = "http://localhost:4566"
lambda = "http://localhost:4566"
rds = "http://localhost:4566"
redshift = "http://localhost:4566"
route53 = "http://localhost:4566"
s3 = "http://s3.localhost.localstack.cloud:4566"
secretsmanager = "http://localhost:4566"
ses = "http://localhost:4566"
sns = "http://localhost:4566"
sqs = "http://localhost:4566"
ssm = "http://localhost:4566"
stepfunctions = "http://localhost:4566"
sts = "http://localhost:4566"
}
}

data "aws_ami" "ubuntu" {
most_recent = true

filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727"]
}

filter {
name = "virtualization-type"
values = ["hvm"]
}

owners = ["099720109477"] # Canonical
}

resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"

tags = {
Name = "HelloWorld"
}
}

terraform 节中,我们添加了 backend 配置节,指定使用 localhost:8500 为地址(也就是我们刚才启动的测试版 Consul 服务),指定使用 http 协议访问该地址,指定 tfstate 文件存放在 Consul 键值存储服务的 localstack-aws 路径下。

当我们执行完 terraform apply 后,我们访问 http://localhost:8500/ui/dc1/kv

Consul 中可以看到名为  的键

图 1.3.2/2 - Consul 中可以看到名为 `localstack-aws` 的键

可以看到 localstack-aws,点击进入:

键的内容

图 1.3.2/3 - 键的内容

可以看到,原本保存在工作目录下的 tfstate 文件的内容,被保存在了 Consul 的名为 localstack-aws 的键下。

让我们执行 terraform destroy 后,重新访问 http://localhost:8500/ui/dc1/kv

键依然存在

图 1.3.2/4 - 键依然存在

可以看到,localstack-aws 这个键仍然存在。让我们点击进去:

内容已被清空

图 1.3.2/5 - 内容已被清空

可以看到,它的内容为空,代表基础设施已经被成功销毁。

1.3.2.1.6. 观察锁文件

那么在这个过程里,锁究竟在哪里?我们如何能够体验到锁的存在?让我们对代码进行一点修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5.0"
}
}
backend "consul" {
address = "localhost:8500"
scheme = "http"
path = "localstack-aws"
}
}

provider "aws" {
access_key = "test"
secret_key = "test"
region = "us-east-1"
s3_use_path_style = false
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true

endpoints {
apigateway = "http://localhost:4566"
apigatewayv2 = "http://localhost:4566"
cloudformation = "http://localhost:4566"
cloudwatch = "http://localhost:4566"
docdb = "http://localhost:4566"
dynamodb = "http://localhost:4566"
ec2 = "http://localhost:4566"
es = "http://localhost:4566"
elasticache = "http://localhost:4566"
firehose = "http://localhost:4566"
iam = "http://localhost:4566"
kinesis = "http://localhost:4566"
lambda = "http://localhost:4566"
rds = "http://localhost:4566"
redshift = "http://localhost:4566"
route53 = "http://localhost:4566"
s3 = "http://s3.localhost.localstack.cloud:4566"
secretsmanager = "http://localhost:4566"
ses = "http://localhost:4566"
sns = "http://localhost:4566"
sqs = "http://localhost:4566"
ssm = "http://localhost:4566"
stepfunctions = "http://localhost:4566"
sts = "http://localhost:4566"
}
}

data "aws_ami" "ubuntu" {
most_recent = true

filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-trusty-14.04-amd64-server-20170727"]
}

filter {
name = "virtualization-type"
values = ["hvm"]
}

owners = ["099720109477"] # Canonical
}

resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"

tags = {
Name = "HelloWorld"
}
provisioner "local-exec" {
command = "sleep 1000"
}
}

这次的变化是我们在 aws_instance 的定义上添加了一个 local-exec 类型的 Provisioner。Provisioner 我们在后续的章节中会专门叙述,在这里读者只需要理解,Terraform 进程在成功创建了该资源后,会在执行 Terraform 命令行的机器上执行一条命令:sleep 1000,这个时间足以将 Terraform 进程阻塞足够长的时间,以便让我们观察锁信息了。如果读者正在使用 Windows,可以把 provisioner 改成这样:

1
2
3
4
provisioner "local-exec" {
command = "Start-Sleep -s 1000"
interpreter = ["PowerShell", "-Command"]
}

让我们执行terraform apply,这一次 apply 将会被 sleep 阻塞,而不会成功完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
data.aws_ami.ubuntu: Reading...
data.aws_ami.ubuntu: Read complete after 1s [id=ami-1e749f67]

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.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-1e749f67"
+ 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)
+ iam_instance_profile = (known after apply)
+ id = (known after apply)
+ instance_initiated_shutdown_behavior = (known after apply)
+ instance_lifecycle = (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
+ spot_instance_request_id = (known after apply)
+ subnet_id = (known after apply)
+ tags = {
+ "Name" = "HelloWorld"
}
+ tags_all = {
+ "Name" = "HelloWorld"
}
+ 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)
}

Plan: 1 to add, 0 to change, 0 to destroy.
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Provisioning with 'local-exec'...
aws_instance.web (local-exec): Executing: ["PowerShell" "-Command" "Start-Sleep -s 1000"]
aws_instance.web: Still creating... [20s elapsed]
...

让我们重新访问 http://localhost:8500/ui/dc1/kv

多了一个同名的文件夹

图 1.3.2/6 - 多了一个同名的文件夹

这一次情况发生了变化,我们看到除了localstack-aws这个键之外,还多了一个同名的文件夹。让我们点击进入文件夹:

localstack-aws 文件夹内部

图 1.3.2/7 - localstack-aws 文件夹内部

在这里我们成功观测到了 .lock.lockinfo 文件。让我们点击 .lock 看看:

 内容

图 1.3.2/8 - `.lock` 内容

Consul UI提醒我们,该键值对目前正被锁定,而它的内容是空的。让我们查看 .lockinfo 的内容:

 内容

图 1.3.2/9 - `.lockinfo` 内容

.lockinfo 里记录了锁 ID、我们执行的操作,以及其他的一些信息。

让我们另起一个新的命令行窗口,在同一个工作目录下尝试另一次执行 terraform apply

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ terraform apply
Acquiring state lock. This may take a few moments...

│ Error: Error acquiring the state lock

│ Error message: Lock Info:
│ ID: 11c859fd-d3e5-4eab-46d6-586b73133430
│ Path: localstack-aws
│ Operation: OperationTypeApply
│ Who: ***
│ Version: 1.7.3
│ Created: 2024-02-25 02:00:21.3700184 +0000 UTC
│ Info: consul session: 11c859fd-d3e5-4eab-46d6-586b73133430


│ Terraform acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and try
│ again. For most commands, you can disable locking with the "-lock=false"
│ flag, but this is not recommended.

可以看到,同时另一个人试图对同一个 tfstate 执行变更的尝试失败了,因为它无法顺利获取到锁。

让我们用 ctrl-c 终止原先被阻塞的 terraform apply 的执行,然后用 terraform force-unlock 11c859fd-d3e5-4eab-46d6-586b73133430 解锁:

1
2
3
4
5
6
7
8
9
10
11
12
$ terraform force-unlock 11c859fd-d3e5-4eab-46d6-586b73133430
Do you really want to force-unlock?
Terraform will remove the lock on the remote state.
This will allow local Terraform commands to modify this state, even though it
may still be in use. Only 'yes' will be accepted to confirm.

Enter a value: yes

Terraform state has been successfully unlocked!

The state has been unlocked, and Terraform commands should now be able to
obtain a new lock on the remote state.

然后重新访问 http://localhost:8500/ui/dc1/kv

重新访问Consul

图 1.3.2/10 - 重新访问Consul

可以看到,包含锁的文件夹消失了。

1.3.2.1.7. Backend 配置的动态赋值

有些读者会注意到,到目前为止我所写的代码里的配置项基本都是硬编码的,Terraform 是否支持运行时用变量动态赋值?答案是支持的,Terraform 可以通过 variable 变量来传值给 providerdataresource

但有一些例外,其中就有 backend 配置。backend 配置只允许硬编码,或者不传值。

这个问题是因为 Terraform 运行时本身设计的运行顺序导致的,一直到 2019 年 05 月官方才给出了解决方案,那就是“部分配置“(partial configuration)。

简单来说就是我们可以在 Terraform 代码的 backend 声明中不给出具体的配置:

1
2
3
4
5
6
7
8
9
10
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5.0"
}
}
backend "consul" {
}
}

而在另一个独立的文件中给出相关配置,例如我们在工作目录下创建一个名为 backend.hcl 的文件:

1
2
3
address = "localhost:8500"
scheme = "http"
path = "localstack-aws"

本质上我们就是把原本属于 backend consul 块的属性赋值代码搬迁到一个独立的 hcl 文件内,然后我们执行 terraform init 时附加 backend-config 参数:

1
$ terraform init -backend-config=backend.hcl

这样也可以初始化成功。通过这种打补丁的方式,我们可以复用他人预先写好的 Terraform 代码,在执行时把属于我们自己的 Backend 配置信息以独立的 backend-config 文件的形式传入来进行初始化。

1.3.2.1.8. Backend 的权限控制以及版本控制

Backend 本身并没有设计任何的权限以及版本控制,这方面完全依赖于具体的 Backend 实现。以 AWS S3 为例,我们可以针对不同的 Bucket 设置不同的 IAM,用以防止开发测试人员直接操作生产环境,或是给予部分人员对状态信息的只读权限;另外我们也可以开启 S3 的版本控制功能,以防我们错误修改了状态文件(Terraform 命令行有修改状态的相关指令)。

1.3.2.1.9. 状态的隔离存储

我们讲完 Backend,现在要讨论另一个问题。假设我们的 Terraform 代码可以创建一个通用的基础设施,比如说是云端的一个 EKS、AKS 集群,或者是一个基于 S3 的静态网站,那么我们可能要为很多团队创建并维护这些相似但要彼此隔离的 Stack,又或者我们要为部署的应用维护开发、测试、预发布、生产四套不同的部署。那么该如何做到不同的部署,彼此状态文件隔离存储和管理呢?

一种简单的方法就是分成不同的文件夹存储。

将代码复制到不同的文件夹中保存

图 1.3.2/11 - 将代码复制到不同的文件夹中保存

我们可以把不同产品不同部门使用的基础设施分成不同的文件夹,在文件夹内维护相同的代码文件,配置不同的 backend-config,把状态文件保存到不同的 Backend 上。这种方法可以给予最大程度的隔离,缺点是我们需要拷贝许多份相同的代码。

第二种更加轻量级的方法就是 Workspace。注意,Terraform 开源版的 Workspace 与 Terraform Cloud 云服务的 Workspace 实际上是两个不同的概念,我们这里介绍的是开源版的 Workspace。

Workspace 允许我们在同一个文件夹内,使用同样的 Backend 配置,但可以维护任意多个彼此隔离的状态文件。还是我们刚才那个使用测试 Consul 服务作为 Backend 的例子:

重新访问 Consul,目前有一个键

图 1.3.2/12 - 重新访问 Consul,目前有一个键

当前我们有一个状态文件,名字是 localstack-aws。然后我们在工作目录下执行这样的命令:

1
2
3
4
5
6
$ terraform workspace new feature1
Created and switched to workspace "feature1"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.

通过调用 workspace 命令,我们成功创建了名为 feature1 的 Workspace。这时我们观察 .terraform 文件夹:

1
2
3
4
5
6
7
8
..
├── environment
├── providers
│ └── registry.terraform.io
│ └── hashicorp
│ └── aws
│ └── 5.38.0
......

我们会发现多了一个 environment 文件,它的内容是 feature1。这实际上就是 Terraform 用来保存当前上下文环境使用的是哪个 Workspace 的文件。

Consul 中多了一个

图 1.3.2/13 - Consul 中多了一个 `localstack-aws-env:feature1`

重新观察 Consul 存储会发现多了一个文件:localstack-aws-env:feature1。这就是 Terraform 为 feature1 这个 Workspace 创建的独立的状态文件。让我们执行一下 apply,然后再看这个文件的内容:

此时  的内容

图 1.3.2/14 - 此时 `localstack-aws-env:feature1` 的内容

可以看到,状态被成功写入了 feature1 的状态文件。

我们可以通过以下命令来查询当前 Backend 下所有的 Workspace:

1
2
3
$ terraform workspace list
default
* feature1

我们有 defaultfeature1 两个 Workspace,当前我们工作在 feature1 上。我们可以用以下命令切换回 default

1
2
$ terraform workspace select default
Switched to workspace "default".

我们可以用以下命令确认我们成功切换回了 default

1
2
$ terraform workspace show
default

我们可以用以下命令删除 feature1

1
2
3
4
5
6
7
8
9
10
11
12
13
$ terraform workspace delete feature1

│ Error: Workspace is not empty

│ Workspace "feature1" is currently tracking the following resource instances:
│ - aws_instance.web

│ Deleting this workspace would cause Terraform to lose track of any associated remote objects, which would then require you to
│ delete them manually outside of Terraform. You should destroy these objects with Terraform before deleting the workspace.

│ If you want to delete this workspace anyway, and have Terraform forget about these managed objects, use the -force option to
disable this safety check.

Terraform 发现 feature1 还有资源没有被销毁,所以它拒绝了我们的删除请求。因为我们目前是使用 LocalStack 模拟的例子,所以不会有资源泄漏的问题,我们可以用以下命令强制删除 feature1

1
2
3
4
5
6
$ terraform workspace delete -force feature1
Deleted workspace "feature1"!
WARNING: "feature1" was non-empty.
The resources managed by the deleted workspace may still exist,
but are no longer manageable by Terraform since the state has
been deleted.

再观察 Consul 存储,就会发现 feature1 的状态文件被删除了:

 被删除了

图 1.3.2/15 - `localstack-aws-env:feature1` 被删除了

目前支持多工作区的 Backend 有:

  • AzureRM
  • Consulf
  • COS
  • GCS
  • Kubernetes
  • Local
  • OSS
  • Postgres
  • Remote
  • S3

1.3.2.1.10. 该使用哪种隔离

相比起多文件夹隔离的方式来说,基于 Workspace 的隔离更加简单,只需要保存一份代码,在代码中不需要为 Workspace 编写额外代码,用命令行就可以在不同工作区之间来回切换。

但是 Workspace 的缺点也同样明显,由于所有工作区的 Backend 配置是一样的,所以有权读写某一个 Workspace 的人可以读取同一个 Backend 路径下所有其他 Workspace;另外 Workspace 是隐式配置的(调用命令行),所以有时人们会忘记自己工作在哪个 Workspace 下。

Terraform 官方为 Workspace 设计的场景是:有时开发人员想要对既有的基础设施做一些变更,并进行一些测试,但又不想直接冒险修改既有的环境。这时他可以利用 Workspace 复制出一个与既有环境完全一致的平行环境,在这个平行环境里做一些变更,并进行测试和实验工作。

Workspace 对应的源代码管理模型里的主干——分支模型,如果团队希望维护的是不同产品之间不同的基础设施,或是开发、测试、预发布、生产环境,那么最好还是使用不同的文件夹以及不同的 backend-config 进行管理。

留言與分享

Terraform 基础概念-Provider

分類 devops, Terraform

1.3.1.1. Terraform 基础概念 —— Provider

Terraform 被设计成一个多云基础设施编排工具,不像 CloudFormation 那样绑定 AWS 平台,Terraform 可以同时编排各种云平台或是其他基础设施的资源。Terraform 实现多云编排的方法就是 Provider 插件机制。

Terraform通过RPC调用插件,插件代码通过调用SDK操作远程资源

图 1.3.1/1 - Terraform通过RPC调用插件,插件代码通过调用SDK操作远程资源

Terraform 使用的是 HashiCorp 自研的 go-plugin),本质上各个 Provider 插件都是独立的进程,与 Terraform 进程之间通过 Rpc 进行调用。Terraform 引擎首先读取并分析用户编写的 Terraform 代码,形成一个由 dataresource 组成的图(Graph),再通过 Rpc 调用这些 dataresource 所对应的 Provider 插件;Provider 插件的编写者根据 Terraform 所制定的插件框架来定义各种 dataresource,并实现相应的 CRUD 方法;在实现这些 CRUD 方法时,可以调用目标平台提供的 SDK,或是直接通过调用 Http(s) API来操作目标平台。

1.3.1.1.1. 下载 Provider

我们在第一章的小例子中,写完代码后在 apply 之前,首先我们执行了一次terraform initterraform init会分析代码中所使用到的 Provider,并尝试下载 Provider 插件到本地。如果我们观察执行完第一章例子的文件夹,我们会发现有一个 .terraform 文件夹,我们所使用的 AWS Provider 插件就被下载安装在里面。

1
2
3
4
5
6
7
8
.terraform
└── providers
└── registry.terraform.io
└── hashicorp
└── aws
└── 5.37.0
└── windows_amd64
└── terraform-provider-aws_v5.37.0_x5.exe

有的时候下载某些 Provider 会非常缓慢,或是在开发环境中存在许多的 Terraform 项目,每个项目都保有自己独立的插件文件夹非常浪费磁盘,这时我们可以使用插件缓存。

有两种方式可以启用插件缓存:

第一种方法是配置 TF_PLUGIN_CACHE_DIR 这个环境变量:

1
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"

第二种方法是使用 CLI 配置文件。Windows 下是在相关用户的 %APPDATA% 目录下创建名为 "terraform.rc" 的文件,Macos 和 Linux 用户则是在用户的 home 下创建名为 ".terraformrc" 的文件。在文件中配置如下:

1
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

当启用插件缓存之后,每当执行 terraform init 命令时,Terraform 引擎会首先检查期望使用的插件在缓存文件夹中是否已经存在,如果存在,那么就会将缓存的插件拷贝到当前工作目录下的 .terraform 文件夹内。如果插件不存在,那么 Terraform 仍然会像之前那样下载插件,并首先保存在插件文件夹中,随后再从插件文件夹拷贝到当前工作目录下的 .terraform 文件夹内。为了尽量避免同一份插件被保存多次,只要操作系统提供支持,Terraform 就会使用符号连接而不是实际从插件缓存目录拷贝到工作目录。

需要特别注意的是,Windows 系统下 plugin_cache_dir 的路径也必须使用 / 作为分隔符,应使用 C:/somefolder/plugin_cahce 而不是 C:\somefolder\plugin_cache

Terraform 引擎永远不会主动删除缓存文件夹中的插件,缓存文件夹的尺寸可能会随着时间而增长到非常大,这时需要手工清理。

1.3.1.1.2. 搜索 Provider

想要了解有哪些被官方接纳的 Provider,就是前往registry.terraform.io进行搜索:

registry.terraform.io上的插件页面

图 1.3.1/2 - registry.terraform.io上的插件页面

registry.terraform.io的搜索页面

图 1.3.1/3 - registry.terraform.io的搜索页面

一般来说,相关 Provider 如何声明,以及相关 dataresource 的使用说明,都可以在 registry 上查阅到相关文档。

registry.terraform.io 不但可以查询 Provider,也可以用来发布 Provider;并且它也可以用来查询和发布模块(Module),不过模块将是我们后续篇章讨论的话题。

1.3.1.1.3. Provider 的声明

一组 Terraform 代码要被执行,相关的 Provider 必须在代码中被声明。不少的 Provider 在声明时需要传入一些关键信息才能被使用,例如我们在第一章的例子中,必须给出访问密钥以及期望执行的 AWS 区域(Region)信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5.0"
}
}
}

provider "aws" {
access_key = "test"
secret_key = "test"
region = "us-east-1"
s3_use_path_style = false
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true

endpoints {
apigateway = "http://localhost:4566"
apigatewayv2 = "http://localhost:4566"
cloudformation = "http://localhost:4566"
cloudwatch = "http://localhost:4566"
dynamodb = "http://localhost:4566"
ec2 = "http://localhost:4566"
es = "http://localhost:4566"
elasticache = "http://localhost:4566"
firehose = "http://localhost:4566"
iam = "http://localhost:4566"
kinesis = "http://localhost:4566"
lambda = "http://localhost:4566"
rds = "http://localhost:4566"
redshift = "http://localhost:4566"
route53 = "http://localhost:4566"
s3 = "http://s3.localhost.localstack.cloud:4566"
secretsmanager = "http://localhost:4566"
ses = "http://localhost:4566"
sns = "http://localhost:4566"
sqs = "http://localhost:4566"
ssm = "http://localhost:4566"
stepfunctions = "http://localhost:4566"
sts = "http://localhost:4566"
}
}

在这段 Provider 声明中,首先在 terraform 块的 required_providers 里声明了本段代码必须要名为 aws 的 Provider 才可以执行,source = "hashicorp/aws"这一行声明了 aws 这个插件的源地址(Source Address)。一个源地址是全球唯一的,它指示了 Terraform 如何下载该插件。一个源地址由三部分组成:

1
[<HOSTNAME>/]<NAMESPACE>/<TYPE>

Hostname 是选填的,默认是官方的 registry.terraform.io,读者也可以构建自己私有的Terraform仓库。Namespace 是在 Terraform 仓库内注册的组织名,这代表了发布和维护插件的组织或是个人。Type 是代表插件的一个短名,在特定的 HostName/NamespaceType 必须唯一。

required_providers 中的插件声明还声明了该源码所需要的插件的版本约束,在例子里就是 version = "~>5.0"。Terraform 插件的版本号采用 MAJOR.MINOR.PATCH 的语义化格式,版本约束通常使用操作符和版本号表达约束条件,条件之间可以用逗号拼接,表达 AND 关联,例如 ">= 1.2.0, < 2.0.0"。可以采用的操作符有:

  • =(或者不加 =,直接使用版本号):只允许特定版本号,不允许与其他条件合并使用
  • !=:不允许特定版本号
  • \>,>=,<,<=:与特定版本号进行比较,可以是大于、大于等于、小于、小于等于
  • ~>:只允许 最右边 的版本号增加。这种格式被称为 悲观约束 操作符。例如,要允许在特定的 MINOR 版本中允许新的 PATCH 版本:
    • ~> 1.0.4:允许 Terraform 安装 1.0.51.0.10,但不允许 1.1.0
    • ~> 1.1:允许 Terraform 安装 1.21.10,但不允许 2.0

Terraform 会检查当前工作环境或是插件缓存中是否存在满足版本约束的插件,如果不存在,那么 Terraform 会尝试下载。如果 Terraform 无法获得任何满足版本约束条件的插件,那么它会拒绝继续执行任何后续操作。

可以用添加后缀的方式来声明预览版,例如:1.2.0-beta。预览版只能通过 "=" 操作符(或是忽略操作符)后接明确的版本号的方式来指定,不可以与>=~>等搭配使用。

  • 当依赖第三方模块时,需要指定特定版本,以确保只在您需要的时候进行更新。
  • 对于在您的组织内维护的模块,如果一致使用语义版本控制,或者有一个定义良好的发布流程可以避免不必要的更新,那么指定版本范围可能是合适的。
  • 可重用的模块应仅限制其 Terraform 和 Provider 的最低允许版本,例如 >= 0.12.0。这有助于避免已知的不兼容性,同时允许模块的用户在不改变模块的情况下升级到 Terraform 的新版本。
  • 根模块应使用 ~> 约束为它们依赖的每个 Provider 设置一个下限和上限版本。

以上建议来自于 HashiCorp 官方文档,笔者个人给出一条个人建议:

  • 可复用的模块不但应该限制 Provider 的最低版本,同时也应该限制 Provider 的 MAJOR 版本。例如,>= 1.5.0, < 2.0。这样可以避免在 Provider 的 MAJOR 版本升级时,因为不兼容性导致的问题,Provider 的 MAJOR 版本升级通常会伴随着不兼容的改动,不应该在未加测试的情况下轻易升级。

1.3.1.1.4. 内建 Provider

绝大多数 Provider 是以插件形式单独分发的,但是目前有一个 Provider 是内建于Terraform主进程中的,那就是 terraform_remote_state data source。该 Provider 由于是内建的,所以使用时不需要在 terraform 中声明 required_providers。这个内建Provider的源地址是 terraform.io/builtin/terraform。这个地址有时可能出现在 Terraform 的错误消息和其他输出中,以便无歧义地引用内建 Provider,而不是假设的第三方提供者,其类型名称为 “terraform”。

还存在一个源地址为 hashicorp/terraform 的 Provider,这是现在内置 Provider 的旧版本,被 Terraform 的旧版本使用。hashicorp/terraform 与 Terraform v0.11 或更高版本不兼容,因此永远不应在 required_providers 块中声明。

1.3.1.1.5. Provider 的配置

Provider 的配置是声明在根模块中的一组 Terraform 配置。(子模块接收来自于根模块的 Provider 配置,更多信息,请参阅模块的 provider 元参数

一个 Provider 配置是通过 provider 块来创建的:

1
2
3
4
provider "google" {
project = "acme-app"
region = "us-central1"
}

块头部设置的名称(例子中的 "google")就是要配置的 Provider 的Local Name。这个 Provider 必须已在 required_providers 块中声明。

块体({} 中间的内容)包含了 Provider 的配置参数。这些参数大多数是由 Provider 自己定义的;在这个例子中,projectregion 都是 google Provider 特有的。

你可以在这些配置的值当中使用表达式,但是只能引用在配置 Provider 时已知的值。这意味着你可以安全地引用输入变量,但是不能引用从 resource 返回的属性(一个例外是直接在配置中硬编码的 resource 参数)。

一个 Provider 的文档应该列出它所需要的配置参数。对那些注册在 Terraform Registry 上的 Provider 来说,每个 Provider 的页面上都有版本化的文档,可以通过 Provider 页头的 “Documentation” 链接访问。

一些 Provider 可以使用环境变量(或是其他替代配置源,例如 AWS 的虚拟机实例 Profile)作为某些配置参数的值;我们建议尽可能使用这种方式来避免将凭证保存于版本控制的 Terraform 代码中。

There are also two “meta-arguments” that are defined by Terraform itself and available for all provider blocks:

有两个由 Terraform 自身定义的“元参数”,对所有 provider 块都可用:

与 Terraform 语言中的许多其他对象不同,如果 provider 块的内容为空,则可以省略该块。Terraform 假定未显式配置的任何 Provider 程序都具有空的默认配置。

1.3.1.1.6. 多 Provider 实例

provider 块声明了 aws 这个 Provider 所需要的各项配置。在上文的代码示例中,provider "aws"required_providersaws = {...}块里的aws,都是 Provider 的 Local Name,一个 Local Name 是在一个模块中对一个 Provider 的唯一的标识。

你可以选择为同一个 Provider 定义多个配置,并且可以根据每个资源或每个模块来选择使用哪一个。这主要是为了支持云平台的多个区域;其他例子包括针对多个 Docker 主机,多个 Consul 主机等。

要为某一个 Provider 创建多个配置,包括具有相同提供者名称的多个 provider 块。对于每个额外的非默认配置,使用 alias 元参数提供额外的名称段。例如:

1
2
3
4
5
6
7
8
9
10
11
12
# The default provider configuration; resources that begin with `aws_` will use
# it as the default, and it can be referenced as `aws`.
provider "aws" {
region = "us-east-1"
}

# Additional provider configuration for west coast region; resources can
# reference this as `aws.west`.
provider "aws" {
alias = "west"
region = "us-west-2"
}

在模块内声明配置 alias 以从父模块接收备用的 provider 配置,需要在该 providerrequired_providers 条目中添加 configuration_aliases 参数。以下示例在包含的模块中声明了 mycloudmycloud.alternateprovider 配置名称:

1
2
3
4
5
6
7
8
9
terraform {
required_providers {
mycloud = {
source = "mycorp/mycloud"
version = "~> 1.0"
configuration_aliases = [ mycloud.alternate ]
}
}
}

1.3.1.1.6.1. 默认 Provider 配置

没有 alias 参数的 provider 块是该 provider 的 默认 配置。未设置 provider 元参数的资源将使用与资源类型名称的第一个单词匹配的默认 provider 配置。(例如,除非另有说明,否则 aws_instance 资源将使用默认的 aws provider 配置。)

如果 provider 的每个显式配置都有别名,Terraform 将使用隐含的空配置作为该 provider 的默认配置。(如果 provider 有任何必需的配置参数,当资源默认使用空配置时,Terraform 将引发错误。)

1.3.1.1.6.2. 引用备用 Provider 配置

当 Terraform 需要 provider 配置的名称时,它期望的是 <PROVIDER NAME>.<ALIAS> 形式的引用。在上面的例子中,aws.west 将引用 us-west-2 区域的 provider。

这些引用是特殊的表达式。像对其他命名实体(例如 var.image_id)的引用一样,它们不是字符串,不需要引号。但是它们只在 resourcedatamodule 块的特定元参数中有效,不能在任意表达式中使用。

1.3.1.1.6.3. 选择备用 Provider 配置

默认情况下,资源使用从资源类型名称的第一个单词推断出的默认 provider 配置(没有 alias 参数的配置)。

要为资源或数据源指定备用 provider 配置,将其 provider 元参数设置为 <PROVIDER NAME>.<ALIAS> 引用:

1
2
3
4
5
resource "aws_instance" "foo" {
provider = aws.west

# ...
}

要为子模块指定备用 provider 配置,使用其 providers 元参数指定应将哪些 provider 配置映射到模块内的哪些本地 provider 名称:

1
2
3
4
5
6
module "aws_vpc" {
source = "./aws_vpc"
providers = {
aws = aws.west
}
}

在传递 provider 时,模块有一些特殊要求;有关更多详细信息,请参见 模块 providers 元参数。在大多数情况下,只有 根模块 应定义 provider 配置,所有子模块都应从其父模块获取其 provider 配置。

留言與分享

编写代码只是软件开发的一小部分,更多的时间往往花在构建(build)和测试(test)。

为了提高软件开发的效率,构建和测试的自动化工具层出不穷。Travis CI 就是这类工具之中,市场份额最大的一个。

本文介绍 Travis CI 的基本用法。用好这个工具不仅可以提高效率,还能使开发流程更可靠和专业化,从而提高软件的价值。而且,它对于开源项目是免费的,不花一分钱,就能帮你做掉很多事情。

一、什么是持续集成?

Travis CI 提供的是持续集成服务(Continuous Integration,简称 CI)。它绑定 Github 上面的项目,只要有新的代码,就会自动抓取。然后,提供一个运行环境,执行测试,完成构建,还能部署到服务器。

持续集成指的是只要代码有变更,就自动运行构建和测试,反馈运行结果。确保符合预期以后,再将新代码"集成"到主干。

持续集成的好处在于,每次代码的小幅变更,就能看到运行结果,从而不断累积小的变更,而不是在开发周期结束时,一下子合并一大块代码。

二、使用准备

Travis CI 只支持 Github,不支持其他代码托管服务。这意味着,你必须满足以下条件,才能使用 Travis CI。

  • 拥有 GitHub 帐号
  • 该帐号下面有一个项目
  • 该项目里面有可运行的代码
  • 该项目还包含构建或测试脚本

如果这些条件都没问题,就可以开始使用 Travis CI 了。

首先,访问官方网站 travis-ci.org,点击右上角的个人头像,使用 Github 账户登入 Travis CI。

Travis 会列出 Github 上面你的所有仓库,以及你所属于的组织。此时,选择你需要 Travis 帮你构建的仓库,打开仓库旁边的开关。一旦激活了一个仓库,Travis 会监听这个仓库的所有变化。

三、.travis.yml

Travis 要求项目的根目录下面,必须有一个.travis.yml文件。这是配置文件,指定了 Travis 的行为。该文件必须保存在 Github 仓库里面,一旦代码仓库有新的 Commit,Travis 就会去找这个文件,执行里面的命令。

这个文件采用 YAML 格式。下面是一个最简单的 Python 项目的.travis.yml文件。

1
2
3

language: python
script: true

上面代码中,设置了两个字段。language字段指定了默认运行环境,这里设定使用 Python 环境。script字段指定要运行的脚本,script: true表示不执行任何脚本,状态直接设为成功。

Travis 默认提供的运行环境,请参考官方文档 。目前一共支持31种语言,以后还会不断增加。

下面是一个稍微复杂一点的.travis.yml

1
2
3
4
5

language: python
sudo: required
before_install: sudo pip install foo
script: py.test

上面代码中,设置了四个字段:运行环境是 Python,需要sudo权限,在安装依赖之前需要安装foo模块,然后执行脚本py.test

四、运行流程

Travis 的运行流程很简单,任何项目都会经过两个阶段。

  • install 阶段:安装依赖
  • script 阶段:运行脚本

4.1 install 字段

install字段用来指定安装脚本。

1
2

install: ./install-dependencies.sh

如果有多个脚本,可以写成下面的形式。

1
2
3
4

install:
- command1
- command2

上面代码中,如果command1失败了,整个构建就会停下来,不再往下进行。

如果不需要安装,即跳过安装阶段,就直接设为true

1
2

install: true

4.2、script 字段

script字段用来指定构建或测试脚本。

1
2

script: bundle exec thor build

如果有多个脚本,可以写成下面的形式。

1
2
3
4

script:
- command1
- command2

注意,scriptinstall不一样,如果command1失败,command2会继续执行。但是,整个构建阶段的状态是失败。

如果command2只有在command1成功后才能执行,就要写成下面这样。

1
2

script: command1 && command2

4.3 实例:Node 项目

Node 项目的环境需要写成下面这样。

1
2
3
4

language: node_js
node_js:
- "8"

上面代码中,node_js字段用来指定 Node 版本。

Node 项目的installscript阶段都有默认脚本,可以省略。

  • install默认值:npm install
  • script默认值:npm test

更多设置请看官方文档

4.4 部署

script阶段结束以后,还可以设置通知步骤(notification)和部署步骤(deployment),它们不是必须的。

部署的脚本可以在script阶段执行,也可以使用 Travis 为几十种常见服务提供的快捷部署功能。比如,要部署到 Github Pages,可以写成下面这样。

1
2
3
4
5
6
7

deploy:
provider: pages
skip_cleanup: true
github_token: $GITHUB_TOKEN # Set in travis-ci.org dashboard
on:
branch: master

其他部署方式,请看官方文档

4.5 钩子方法

Travis 为上面这些阶段提供了7个钩子。

  • before_install:install 阶段之前执行
  • before_script:script 阶段之前执行
  • after_failure:script 阶段失败时执行
  • after_success:script 阶段成功时执行
  • before_deploy:deploy 步骤之前执行
  • after_deploy:deploy 步骤之后执行
  • after_script:script 阶段之后执行

完整的生命周期,从开始到结束是下面的流程。

  1. before_install
  2. install
  3. before_script
  4. script
  5. aftersuccess or afterfailure
  6. [OPTIONAL] before_deploy
  7. [OPTIONAL] deploy
  8. [OPTIONAL] after_deploy
  9. after_script

下面是一个before_install钩子的例子。

1
2
3
4

before_install:
- sudo apt-get -qq update
- sudo apt-get install -y libxml2-dev

上面代码表示before_install阶段要做两件事,第一件事是要更新依赖,第二件事是安装libxml2-dev。用到的几个参数的含义如下:-qq表示减少中间步骤的输出,-y表示如果需要用户输入,总是输入yes

4.6 运行状态

最后,Travis 每次运行,可能会返回四种状态。

  • passed:运行成功,所有步骤的退出码都是0
  • canceled:用户取消执行
  • errored:before_installinstallbefore_script有非零退出码,运行会立即停止
  • failed :script有非零状态码 ,会继续运行

五、使用技巧

5.1 环境变量

.travis.ymlenv字段可以定义环境变量。

1
2
3
4
5

env:
- DB=postgres
- SH=bash
- PACKAGE_VERSION="1.0.*"

然后,脚本内部就使用这些变量了。

有些环境变量(比如用户名和密码)不能公开,这时可以通过 Travis 网站,写在每个仓库的设置页里面,Travis 会自动把它们加入环境变量。这样一来,脚本内部依然可以使用这些环境变量,但是只有管理员才能看到变量的值。具体操作请看官方文档

5.2 加密信息

如果不放心保密信息明文存在 Travis 的网站,可以使用 Travis 提供的加密功能。

首先,安装 Ruby 的包travis

1
2

$ gem install travis

然后,就可以用travis encrypt命令加密信息。

在项目的根目录下,执行下面的命令。

1
2

$ travis encrypt SOMEVAR=secretvalue

上面命令中,SOMEVAR是要加密的变量名,secretvalue是要加密的变量值。执行以后,屏幕上会输出如下信息。

1
2

secure: ".... encrypted data ...."

现在,就可以把这一行加入.travis.yml

1
2
3
4

env:
global:
- secure: ".... encrypted data ...."

然后,脚本里面就可以使用环境变量$SOMEVAR了,Travis 会在运行时自动对它解密。

travis encrypt命令的--add参数会把输出自动写入.travis.yml,省掉了修改env字段的步骤。

1
2

$ travis encrypt SOMEVAR=secretvalue --add

详细信息请看官方文档

5.3 加密文件

如果要加密的是文件(比如私钥),Travis 提供了加密文件功能。

安装命令行客户端以后,使用下面的命令登入 Travis CI。

1
2

$ travis login

然后,进入项目的根目录,使用travis encrypt-file命令加密那些想要加密的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

$ travis encrypt-file bacon.txt

encrypting bacon.txt for rkh/travis-encrypt-file-example
storing result as bacon.txt.enc
storing secure env variables for decryption

Please add the following to your build script (before_install stage in your .travis.yml, for instance):

openssl aes-256-cbc -K $encrypted_0a6446eb3ae3_key -iv $encrypted_0a6446eb3ae3_key -in bacon.txt.enc -out bacon.txt -d

Pro Tip: You can add it automatically by running with --add.

Make sure to add bacon.txt.enc to the git repository.
Make sure not to add bacon.txt to the git repository.
Commit all changes to your .travis.yml.

上面的代码对文件bacon.txt进行加密,加密后会生成bacon.txt.enc,该文件需要提交到代码库。此外,该命令还会生成一个环境变量$encrypted_0a6446eb3ae3_key,保存密钥,储存在 Travis CI,文件解密时需要这个环境变量。你需要把解密所需的openssl命令,写在.travis.ymlbefore_install字段里面。这些都写在上面的命令行提示里面。

--add参数可以自动把环境变量写入.travis.yml

1
2
3
4
5
6
7
8
9
10

$ travis encrypt-file bacon.txt --add

encrypting bacon.txt for rkh/travis-encrypt-file-example
storing result as bacon.txt.enc
storing secure env variables for decryption

Make sure to add bacon.txt.enc to the git repository.
Make sure not to add bacon.txt to the git repository.
Commit all changes to your .travis.yml.

详细信息请看官方文档,实际的例子可以参考下面两篇文章。

(完)

留言與分享

ansible教程-hands-on

分類 devops, ansible

Ansible 学习笔记

简介

  • 基于Python开发的自动化运维工具
  • 集合了众多运维工具(puppet、cfengine、chef、func、fabric)的优点
  • 基于模块工作,本身没有批量部署能力,批量部署由运行的模块实现
  • 提供自动化运维框架

作用

  • 批量系统配置
  • 批量程序部署
  • 批量运行命令

框架组成

  • Connection Plugins:负责和被监控端实现通信
  • Host Inventory:定义监控主机的配置文件
  • 模块
    • 核心模块
    • command模块
    • 自定义模块
  • 插件:完成记录日志邮件等功能
  • Playbook:剧本,非必需,可让节点一次性运行多个任务

架构图

Ansible架构图

架构字段解释

  • Ansible:核心程序
  • Host Inventory:主机清单(可定义主机组和主机)
  • 模块:实际执行任务的组件
  • Playbook:Yaml定义的剧本文件(类似shell脚本)
  • Connect Plugin:连接插件

工作原理

  1. 控制端通过inventory定义主机组
  2. 通过编写playbook或AD-HOC命令
  3. 使用SSH将模块推送到被控端
  4. 被控端执行任务(要求被控端有Python2环境)
  5. 任务完成后返回结果

命令执行过程

  1. 加载配置文件(/etc/ansible/ansible.cfg)
  2. 查找主机配置文件
  3. 加载对应模块文件
  4. 生成临时Python脚本并传输到远程主机
  5. 存储在远程主机的~/.ansible/tmp/目录
  6. 添加执行权限
  7. 执行并返回结果
  8. 删除临时文件

执行流程理解图

执行流程

特性

  • No Agents:无需在被控端安装客户端
  • No Server:无服务端架构
  • Modules in Any Languages:支持任意语言开发模块
  • YAML:使用YAML语言编写playbook
  • SSH by Default:默认基于SSH工作
  • Multi-tier Solution:支持多级指挥

配置文件

配置文件优先级(从高到低):

  1. 项目目录下的ansible.cfg
  2. 用户家目录下的.ansible.cfg
  3. 默认的/etc/ansible/ansible.cfg

主要配置项

1
2
3
4
5
6
7
8
9
10
11
#inventory = /etc/ansible/hosts      # 主机列表
#library = /usr/share/my_modules/ # 模块库目录
#remote_tmp = ~/.ansible/tmp # 远程临时目录
#local_tmp = ~/.ansible/tmp # 本地临时目录
#forks = 5 # 并发数
#sudo_user = root # 默认sudo用户
#ask_sudo_pass = True # 是否询问sudo密码
#ask_pass = True # 是否询问SSH密码
#remote_port = 22 # 远程端口
host_key_checking = False # 跳过主机指纹检查
log_path = /var/log/ansible.log # 日志路径

优点

  • 轻量级,客户端无需安装agent
  • 批量任务可写成脚本且无需分发
  • 基于Python,维护简单
  • 支持sudo

环境搭建

主机规划

主机 IP 角色
h1 192.168.50.60 master
h2 192.168.50.61 host1
h3 192.168.50.62 host2
h4 192.168.50.63 host3

SSH免密登录配置

1
2
ssh-keygen -t dsa -f ~/.ssh/id_dsa -P ""
ssh-copy-id -i ~/.ssh/id_dsa.pub root@192.168.50.61

安装

1
yum install -y ansible

主机清单配置

1
2
3
4
5
6
7
[hosts]
192.168.50.61
192.168.50.62
192.168.50.63

[local]
127.0.0.1

测试

1
2
ansible webservers -m command -a 'uptime'
ansible all -m ping

常用模块

setup模块

查看远程主机基本信息

1
ansible webservers -m setup

fetch模块

从主机获取文件

1
ansible 192.168.50.61 -m fetch -a 'src=/root/t2 dest=/root'

file模块

设置文件属性

选项:

  • force: 强制创建软链接(yes|no)
  • group: 文件属组
  • mode: 文件权限
  • owner: 文件属主
  • path: 文件路径(必选)
  • recurse: 递归设置属性(仅目录)
  • src: 源文件路径(仅state=link时)
  • dest: 链接目标路径(仅state=link时)
  • state:
    • directory: 创建目录
    • file: 不创建文件
    • link: 创建软链接
    • hard: 创建硬链接
    • touch: 创建空文件或更新修改时间
    • absent: 删除文件/目录

示例:

1
2
# 创建符号链接
ansible hosts -m file -a "src=/etc/resolv.conf dest=/root/resolv.conf state=link"

copy模块

复制文件到远程主机

选项:

  • backup: 覆盖前备份(yes|no)
  • content: 直接设置文件内容(替代src)
  • dest: 目标绝对路径(必选)
  • directory_mode: 递归设置目录权限
  • force: 强制覆盖(yes|no)
  • src: 本地源文件路径

示例:

1
ansible hosts -m copy -a "src=/etc/ansible/ansible.cfg dest=/tmp/ansible.cfg owner=root group=root mode=0755"

command模块

在远程主机执行命令

选项:

  • creates: 文件存在时不执行
  • free_form: Linux命令
  • chdir: 执行前切换目录
  • removes: 文件不存在时不执行
  • executable: 指定shell路径

示例:

1
2
ansible webservers -m command -a "ls -al /tmp"
ansible webservers -m command -a 'useradd abc'

shell模块

支持管道操作的命令执行

示例:

1
2
3
4
5
# 执行远程脚本
ansible hosts -m shell -a "/root/test.sh"

# 批量添加用户密码
ansible hosts -m shell -a 'useradd abc && echo 123 |passwd --stdin abc'

cron模块

管理计划任务

选项:

  • minute/hour/day/month/weekday: 时间设置
  • job: 任务命令
  • name: 任务名称
  • user: 执行用户
  • state: present(添加)|absent(删除)

示例:

1
2
3
4
5
# 创建任务
ansible all -m cron -a "minute='*/5' job='/usr/sbin/ntpdate 192.168.50.60 &>/dev/null' name='sync time'"

# 删除任务
ansible all -m cron -a "name='sync time' state=absent"

hostname模块

管理主机名

示例:

1
2
# 修改主机名
ansible 192.168.50.63 -m hostname -a "name=t4"

yum模块

软件包管理

示例:

1
2
3
4
5
# 安装tree
ansible all -m yum -a "name=tree"

# 卸载
ansible all -m yum -a 'name=tree state=absent'

service模块

服务管理

示例:

1
2
# 启动ntpdate服务
ansible all -m service -a "name=ntpdate state=started enabled=true"

group模块

用户组管理

示例:

1
2
# 添加系统组
ansible all -m group -a "name=gansible system=true"

user模块

用户管理

选项:

  • name: 用户名
  • state: present(新增)|absent(删除)
  • force: 删除时是否删除家目录
  • system: 是否系统用户
  • uid: 指定UID
  • shell: 指定shell
  • home: 指定家目录

示例:

1
2
# 添加系统用户
ansible all -m user -a "name=ccc system=true"

YAML语法

  • 数据序列化格式
  • 基本结构:
    1
    2
    3
    4
    key: value
    - item1
    - item2
    - item3
    例如: {name: jerry, age: 21}

Playbook

核心元素

  • Tasks: 任务列表
  • Variables: 变量
  • Templates: 模板文件
  • Handlers: 条件触发任务
  • Roles: 角色

基础组件

  • Hosts: 目标主机
  • remote_user: 执行用户
  • sudo_user: sudo用户
  • tasks: 任务列表

运行Playbook

1
2
3
4
5
6
7
8
# 语法检查
ansible-playbook --syntax-check playbook.yaml

# 测试运行
ansible-playbook -C playbook.yaml

# 实际运行
ansible-playbook playbook.yaml

示例

1. 添加用户和组

1
2
3
4
5
6
7
- hosts: all
remote_user: root
tasks:
- name: add a group
group: name=test system=test
- name: add a user
user: name=test group=test system=true

2. 配置HTTP服务

1
2
3
4
5
6
7
8
9
- hosts: hosts
remote_user: root
tasks:
- name: install http
yum: name=httpd state=latest
- name: install conf
copy: src=/root/httpd.conf dest=/etc/httpd/conf/httpd.conf.bak
- name: start
service: name=httpd state=reloaded enabled=true

更多模块

  • synchronize: 使用rsync同步文件
  • raw: 直接执行命令(类似shell)

模块帮助

# 列出所有模块
ansible-doc -l

# 查看模块帮助
ansible-doc -s MOD_NAME

留言與分享

ansible教程-概念

分類 devops, ansible

Ansible 概念

这些概念适用于 Ansible 的所有用途。在使用 Ansible 或阅读文档之前,您应该了解它们。

  • [控制节点]

  • [被管理节点]

  • [清单]

  • [剧本]

    • [剧集]

      • [角色]

      • [任务]

      • [处理器]

  • [模块]

  • [插件]

  • [集合]

[控制节点]

运行 Ansible CLI 工具(ansible-playbookansibleansible-vault 等)的机器。您可以使用任何满足软件要求的计算机作为控制节点——笔记本电脑、共享桌面和服务器都可以运行 Ansible。您也可以在称为执行环境的容器中运行 Ansible。

可以使用多个控制节点,但 Ansible 本身并不协调它们之间的工作,有关此类功能,请参见AAP

[被管理节点]

也称为“主机”,这些是您旨在用 Ansible 管理的目标设备(服务器、网络设备或任何计算机)。

除非您使用ansible-pull,否则通常不会在被管理节点上安装 Ansible,但这很少见,也不是推荐的设置。

[清单]

由一个或多个“清单源”提供的被管理节点列表。您的清单可以指定每个节点的特定信息,例如 IP 地址。它还用于分配组,这既允许在剧集中选择节点,也允许批量分配变量。

要了解有关清单的更多信息,请参阅使用清单部分。有时,清单源文件也称为“主机文件”。

[剧本]

它们包含剧集(这是 Ansible 执行的基本单元)。这既是“执行概念”,也是我们用来描述ansible-playbook操作的文件的方式。

剧本是用 YAML 编写的,易于阅读、编写、共享和理解。要了解有关剧本的更多信息,请参阅Ansible 剧本

[剧集]

Ansible 执行的主要上下文,此剧本对象将被管理节点(主机)映射到任务。剧集包含变量、角色和已排序的任务列表,并且可以重复运行。它基本上由对映射的主机和任务的隐式循环组成,并定义如何迭代它们。

[角色]

可在剧集内部使用的可重用 Ansible 内容(任务、处理器、变量、插件、模板和文件)的有限分发。

要使用任何角色资源,必须将角色本身导入剧集。

[任务]

应用于被管理主机的“操作”的定义。您可以使用ansibleansible-console(两者都会创建一个虚拟剧集)一次执行单个任务。

[处理器]

任务的一种特殊形式,仅在之前的任务发出通知并且导致“已更改”状态时才执行。

[模块]

Ansible 复制到并在每个被管理节点上执行(如有需要)的代码或二进制文件,以完成每个任务中定义的操作。

每个模块都有其特定的用途,从管理特定类型数据库上的用户到管理特定类型网络设备上的 VLAN 接口。

您可以使用任务调用单个模块,也可以在剧本中调用多个不同的模块。Ansible 模块分组在集合中。要了解 Ansible 包含多少集合,请参阅集合索引

[插件]

扩展 Ansible 核心功能的代码片段。插件可以控制您如何连接到被管理节点(连接插件)、操作数据(过滤器插件),甚至控制在控制台中显示的内容(回调插件)。

有关详细信息,请参阅使用插件

[集合]

Ansible 内容的分发格式,可以包含剧本、角色、模块和插件。您可以通过Ansible Galaxy安装和使用集合。

要了解有关集合的更多信息,请参阅使用 Ansible 集合

集合资源可以彼此独立且离散地使用。

留言與分享

ansible教程-入门篇

分類 devops, ansible

Ansible 简介

Ansible 提供开源自动化,可降低复杂性并在任何地方运行。使用 Ansible 可以自动执行几乎任何任务。以下是 Ansible 的一些常见用例

  • 消除重复并简化工作流程

  • 管理和维护系统配置

  • 持续部署复杂的软件

  • 执行零停机滚动更新

Ansible 使用简单的、人类可读的脚本(称为剧本)来自动化您的任务。您在剧本中声明本地或远程系统的期望状态。Ansible 确保系统保持在该状态。

作为自动化技术,Ansible 围绕以下原则设计

无代理架构

通过避免在 IT 基础设施上安装额外软件来降低维护开销。

简单性

自动化剧本使用简单的 YAML 语法,代码就像文档一样易于阅读。Ansible 也是分散式的,使用 SSH 和现有的操作系统凭据来访问远程机器。

可扩展性和灵活性

通过模块化设计轻松快速地扩展您自动化的系统,该设计支持各种操作系统、云平台和网络设备。

幂等性和可预测性

当系统处于您的剧本描述的状态时,即使多次运行剧本,Ansible 也不会更改任何内容。

使用 Ansible 开始自动化

通过创建自动化项目、构建清单和创建“Hello World”剧本开始使用 Ansible。

  1. 安装 Ansible。

    1
    pip install ansible
  2. 在您的文件系统上创建一个项目文件夹。

    1
    mkdir ansible_quickstart && cd ansible_quickstart

    使用单一目录结构可以更轻松地添加到源代码管理,以及重用和共享自动化内容。

构建清单

清单将受管节点组织在集中式文件中,这些文件为 Ansible 提供系统信息和网络位置。使用清单文件,Ansible 可以使用单个命令管理大量主机。

要完成以下步骤,您需要至少一个主机系统的 IP 地址或完全限定域名 (FQDN)。出于演示目的,主机可以在容器或虚拟机中本地运行。您还必须确保您的公共 SSH 密钥已添加到每个主机上的 authorized_keys 文件中。

继续 Ansible 入门并按如下步骤构建清单

  1. 在您在 上一步中创建的 ansible_quickstart 目录中创建一个名为 inventory.ini 的文件。

  2. inventory.ini 文件添加一个新的 [myhosts] 组,并指定每个主机系统的 IP 地址或完全限定域名 (FQDN)。

    1
    2
    3
    4
    [myhosts]
    192.0.2.50
    192.0.2.51
    192.0.2.52
  3. 验证您的清单。

    1
    ansible-inventory -i inventory.ini --list
  4. Ping 清单中的 myhosts 组。

    1
    ansible myhosts -m ping -i inventory.ini

    注意

    如果控制节点和受管节点上的用户名不同,请使用 ansible 命令传递 -u 选项。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    192.0.2.50 | SUCCESS => {
    "ansible_facts": {
    "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
    }
    192.0.2.51 | SUCCESS => {
    "ansible_facts": {
    "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
    }
    192.0.2.52 | SUCCESS => {
    "ansible_facts": {
    "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
    }

INI 或 YAML 格式的清单

您可以使用 INI 文件或 YAML 创建清单。在大多数情况下,例如上一步中的示例,对于少量受管节点,INI 文件简单易读。

随着受管节点数量的增加,使用 YAML 格式创建清单变得更合理。例如,以下是 inventory.ini 的等效项,它声明受管节点的唯一名称并使用 ansible_host 字段

1
2
3
4
5
6
7
8
myhosts:
hosts:
my_host_01:
ansible_host: 192.0.2.50
my_host_02:
ansible_host: 192.0.2.51
my_host_03:
ansible_host: 192.0.2.52

构建清单的技巧

  • 确保组名有意义且唯一。组名也区分大小写。

  • 避免在组名中使用空格、连字符和前导数字(使用 floor_19,而不是 19th_floor)。

  • 根据主机的**什么**、**哪里**和**何时**逻辑地将主机分组到您的清单中。

    什么

    根据拓扑结构对主机分组,例如:db、web、leaf、spine。

    哪里

    按地理位置对主机分组,例如:数据中心、区域、楼层、建筑物。

    何时

    按阶段对主机分组,例如:开发、测试、过渡、生产。

使用元组

使用以下语法创建一个元组来组织清单中的多个组

以下清单说明了数据中心的结构基础。此示例清单包含一个 network 元组,其中包括所有网络设备,以及一个 datacenter 元组,其中包括 network 组和所有 Web 服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
leafs:
hosts:
leaf01:
ansible_host: 192.0.2.100
leaf02:
ansible_host: 192.0.2.110

spines:
hosts:
spine01:
ansible_host: 192.0.2.120
spine02:
ansible_host: 192.0.2.130

network:
children:
leafs:
spines:

webservers:
hosts:
webserver01:
ansible_host: 192.0.2.140
webserver02:
ansible_host: 192.0.2.150

datacenter:
children:
network:
webservers:

创建变量

变量设置受管节点的值,例如 IP 地址、FQDN、操作系统和 SSH 用户,因此您无需在运行 Ansible 命令时传递它们。

变量可以应用于特定主机。

1
2
3
4
5
6
7
8
webservers:
hosts:
webserver01:
ansible_host: 192.0.2.140
http_port: 80
webserver02:
ansible_host: 192.0.2.150
http_port: 443

变量也可以应用于组中的所有主机。

1
2
3
4
5
6
7
8
9
10
webservers:
hosts:
webserver01:
ansible_host: 192.0.2.140
http_port: 80
webserver02:
ansible_host: 192.0.2.150
http_port: 443
vars:
ansible_user: my_server_user

创建剧本

剧本是 Ansible 用于部署和配置受管节点的自动化蓝图,采用 YAML 格式。

剧本

一系列定义 Ansible 执行操作顺序的剧目,自上而下,以实现总体目标。

剧目

一个有序的任务列表,映射到清单中的受管节点。

任务

对单个模块的引用,定义 Ansible 执行的操作。

模块

Ansible 在受管节点上运行的代码或二进制单元。Ansible 模块按集合分组,每个模块都有一个完全限定的集合名称 (FQCN)

完成以下步骤以创建用于 ping 主机并打印“Hello world”消息的剧本

  1. 在您之前创建的 ansible_quickstart 目录中创建一个名为 playbook.yaml 的文件,内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    - name: My first play
    hosts: myhosts
    tasks:
    - name: Ping my hosts
    ansible.builtin.ping:

    - name: Print message
    ansible.builtin.debug:
    msg: Hello world
  2. 运行您的剧本。

    1
    ansible-playbook -i inventory.ini playbook.yaml

Ansible 返回以下输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
PLAY [My first play] ****************************************************************************

TASK [Gathering Facts] **************************************************************************
ok: [192.0.2.50]
ok: [192.0.2.51]
ok: [192.0.2.52]

TASK [Ping my hosts] ****************************************************************************
ok: [192.0.2.50]
ok: [192.0.2.51]
ok: [192.0.2.52]

TASK [Print message] ****************************************************************************
ok: [192.0.2.50] => {
"msg": "Hello world"
}
ok: [192.0.2.51] => {
"msg": "Hello world"
}
ok: [192.0.2.52] => {
"msg": "Hello world"
}

PLAY RECAP **************************************************************************************
192.0.2.50: ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
192.0.2.51: ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
192.0.2.52: ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

在此输出中,您可以看到

  • 您为剧目和每个任务指定的名称。您应始终使用易于验证和排查剧本问题的描述性名称。

  • “收集事实”任务隐式运行。默认情况下,Ansible 会收集有关您的清单的信息,以便在剧本中使用。

  • 每个任务的状态。每个任务的状态为 ok,表示它已成功运行。

  • 剧目摘要,总结了每个主机上所有任务的结果。在此示例中,共有三个任务,因此 ok=3 表示每个任务都已成功运行。

恭喜,您已开始使用 Ansible!

留言與分享

makefile教程

分類 devops, make

简介

Linux的make程序用来自动化编译大型源码,很多时候,我们在Linux下编译安装软件,只需要敲一个make就可以全自动完成,非常方便。

make能自动化完成这些工作,是因为项目提供了一个Makefile文件,它负责告诉make,应该如何编译和链接程序。

Makefile相当于Java项目的pom.xml,Node工程的package.json,Rust项目的Cargo.toml,不同之处在于,make虽然最初是针对C语言开发,但它实际上并不限定C语言,而是可以应用到任意项目,甚至不是编程语言。此外,make主要用于Unix/Linux环境的自动化开发,掌握Makefile的写法,可以更好地在Linux环境下做开发,也可以为后续开发Linux内核做好准备。

在本教程中,我们将由浅入深,一步一步学习如何编写Makefile,完全针对零基础小白,只需要提前掌握如何使用Linux命令。


在Linux环境下,当我们输入make命令时,它就在当前目录查找一个名为Makefile的文件,然后,根据这个文件定义的规则,自动化地执行任意命令,包括编译命令。

Makefile这个单词,顾名思义,就是指如何生成文件。

我们举个例子:在当前目录下,有3个文本文件:a.txtb.txtc.txt

现在,我们要合并a.txtb.txt,生成中间文件m.txt,再用中间文件m.txtc.txt合并,生成最终的目标文件x.txt,整个逻辑如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────┐ ┌─────┐ ┌─────┐
│a.txt│ │b.txt│ │c.txt│
└─────┘ └─────┘ └─────┘
│ │ │
└───┬───┘ │
│ │
▼ │
┌─────┐ │
│m.txt│ │
└─────┘ │
│ │
└─────┬─────┘


┌─────┐
│x.txt│
└─────┘

根据上述逻辑,我们来编写Makefile

规则

Makefile由若干条规则(Rule)构成,每一条规则指出一个目标文件(Target),若干依赖文件(prerequisites),以及生成目标文件的命令。

例如,要生成m.txt,依赖a.txtb.txt,规则如下:

1
2
3
# 目标文件: 依赖文件1 依赖文件2
m.txt: a.txt b.txt
cat a.txt b.txt > m.txt

一条规则的格式为目标文件: 依赖文件1 依赖文件2 ...,紧接着,以Tab开头的是命令,用来生成目标文件。上述规则使用cat命令合并了a.txtb.txt,并写入到m.txt。用什么方式生成目标文件make并不关心,因为命令完全是我们自己写的,可以是编译命令,也可以是cpmv等任何命令。

#开头的是注释,会被make命令忽略。

1
注意:Makefile的规则中,命令必须以Tab开头,不能是空格。

类似的,我们写出生成x.txt的规则如下:

1
2
x.txt: m.txt c.txt
cat m.txt c.txt > x.txt

由于make执行时,默认执行第一条规则,所以,我们把规则x.txt放到前面。完整的Makefile如下:

1
2
3
4
5
x.txt: m.txt c.txt
cat m.txt c.txt > x.txt

m.txt: a.txt b.txt
cat a.txt b.txt > m.txt

在当前目录创建a.txtb.txtc.txt,输入一些内容,执行make

1
2
3
$ make
cat a.txt b.txt > m.txt
cat m.txt c.txt > x.txt

make默认执行第一条规则,也就是创建x.txt,但是由于x.txt依赖的文件m.txt不存在(另一个依赖c.txt已存在),故需要先执行规则m.txt创建出m.txt文件,再执行规则x.txt。执行完成后,当前目录下生成了两个文件m.txtx.txt

可见,Makefile定义了一系列规则,每个规则在满足依赖文件的前提下执行命令,就能创建出一个目标文件,这就是英文Make file的意思。

把默认执行的规则放第一条,其他规则的顺序是无关紧要的,因为make执行时自动判断依赖。

此外,make会打印出执行的每一条命令,便于我们观察执行顺序以便调试。

如果我们再次运行make,输出如下:

1
2
$ make
make: `x.txt' is up to date.

make检测到x.txt已经是最新版本,无需再次执行,因为x.txt的创建时间晚于它依赖的m.txtc.txt的最后修改时间。

1
make使用文件的创建和修改时间来判断是否应该更新一个目标文件。

修改c.txt后,运行make,会触发x.txt的更新:

1
2
$ make
cat m.txt c.txt > x.txt

但并不会触发m.txt的更新,原因是m.txt的依赖a.txtb.txt并未更新,所以,make只会根据Makefile去执行那些必要的规则,并不会把所有规则都无脑执行一遍。

在编译大型程序时,全量编译往往需要几十分钟甚至几个小时。全量编译完成后,如果仅修改了几个文件,再全部重新编译完全没有必要,用Makefile实现增量编译就十分节省时间。

当然,是否能正确地实现增量更新,取决于我们的规则写得对不对,make本身并不会检查规则逻辑是否正确。

伪目标

因为m.txtx.txt都是自动生成的文件,所以,可以安全地删除。

删除时,我们也不希望手动删除,而是编写一个clean规则来删除它们:

1
2
3
clean:
rm -f m.txt
rm -f x.txt

clean规则与我们前面编写的规则有所不同,它没有依赖文件,因此,要执行clean,必须用命令make clean

1
2
3
$ make clean
rm -f m.txt
rm -f x.txt

然而,在执行clean时,我们并没有创建一个名为clean的文件,所以,因为目标文件clean不存在,每次运行make clean,都会执行这个规则的命令。

如果我们手动创建一个clean的文件,这个clean规则就不会执行了!

如果我们希望makeclean不要视为文件,可以添加一个标识:

1
2
3
4
.PHONY: clean
clean:
rm -f m.txt
rm -f x.txt

此时,clean就不被视为一个文件,而是伪目标(Phony Target)。

大型项目通常会提供cleaninstall这些约定俗成的伪目标名称,方便用户快速执行特定任务。

一般来说,并不需要用.PHONY标识clean等约定俗成的伪目标名称,除非有人故意搞破坏,手动创建名字叫clean的文件。

执行多条命令

一个规则可以有多条命令,例如:

1
2
3
4
cd:
pwd
cd ..
pwd

执行cd规则:

1
2
3
4
5
6
$ make cd
pwd
/home/ubuntu/makefile-tutorial/v1
cd ..
pwd
/home/ubuntu/makefile-tutorial/v1

观察输出,发现cd ..命令执行后,并未改变当前目录,两次输出的pwd是一样的,这是因为make针对每条命令,都会创建一个独立的Shell环境,类似cd ..这样的命令,并不会影响当前目录。

解决办法是把多条命令以;分隔,写到一行:

1
2
cd_ok:
pwd; cd ..; pwd;

再执行cd_ok目标就得到了预期结果:

1
2
3
4
$ make cd_ok
pwd; cd ..; pwd
/home/ubuntu/makefile-tutorial/v1
/home/ubuntu/makefile-tutorial

可以使用\把一行语句拆成多行,便于浏览:

1
2
3
4
cd_ok:
pwd; \
cd ..; \
pwd

另一种执行多条命令的语法是用&&,它的好处是当某条命令失败时,后续命令不会继续执行:

1
2
cd_ok:
cd .. && pwd

控制打印

默认情况下,make会打印出它执行的每一条命令。如果我们不想打印某一条命令,可以在命令前加上@,表示不打印命令(但是仍然会执行):

1
2
3
no_output:
@echo 'not display'
echo 'will display'

执行结果如下:

1
2
3
4
$ make no_output
not display
echo 'will display'
will display

注意命令echo 'not display'本身没有打印,但命令仍然会执行,并且执行的结果仍然正常打印。

控制错误

make在执行命令时,会检查每一条命令的返回值,如果返回错误(非0值),就会中断执行。

例如,不使用-f删除一个不存在的文件会报错:

1
2
3
has_error:
rm zzz.txt
echo 'ok'

执行上述目标,输出如下:

1
2
3
4
$ make has_error
rm zzz.txt
rm: zzz.txt: No such file or directory
make: *** [has_error] Error 1

由于命令rm zzz.txt报错,导致后面的命令echo 'ok'并不会执行,make打印出错误,然后退出。

有些时候,我们想忽略错误,继续执行后续命令,可以在需要忽略错误的命令前加上-

1
2
3
ignore_error:
-rm zzz.txt
echo 'ok'

执行上述目标,输出如下:

1
2
3
4
5
6
$ make ignore_error
rm zzz.txt
rm: zzz.txt: No such file or directory
make: [ignore_error] Error 1 (ignored)
echo 'ok'
ok

make检测到rm zzz.txt报错,并打印错误,但显示(ignored),然后继续执行后续命令。

对于执行可能出错,但不影响逻辑的命令,可以用-忽略。

参考源码

可以从GitHub下载源码。

小结

编写Makefile就是编写一系列规则,用来告诉make如何执行这些规则,最终生成我们期望的目标文件。

查看官方手册:

编译C程序

C程序的编译通常分两步:

  1. 将每个.c文件编译为.o文件;
  2. 将所有.o文件链接为最终的可执行文件。

我们假设如下的一个C项目,包含hello.chello.hmain.c

hello.c内容如下:

1
2
3
4
5
6
7
#include <stdio.h>

int hello()
{
printf("hello, world!\n");
return 0;
}

hello.h内容如下:

1
int hello();

main.c内容如下:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include "hello.h"

int main()
{
printf("start...\n");
hello();
printf("exit.\n");
return 0;
}

注意到main.c引用了头文件hello.h。我们很容易梳理出需要生成的文件,逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌───────┐ ┌───────┐ ┌───────┐
│hello.c│ │main.c │ │hello.h│
└───────┘ └───────┘ └───────┘
│ │ │
│ └────┬────┘
│ │
▼ ▼
┌───────┐ ┌───────┐
│hello.o│ │main.o │
└───────┘ └───────┘
│ │
└───────┬──────┘


┌─────────┐
│world.out│
└─────────┘

假定最终生成的可执行文件是world.out,中间步骤还需要生成hello.omain.o两个文件。根据上述依赖关系,我们可以很容易地写出Makefile如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 生成可执行文件:
world.out: hello.o main.o
cc -o world.out hello.o main.o

# 编译 hello.c:
hello.o: hello.c
cc -c hello.c

# 编译 main.c:
main.o: main.c hello.h
cc -c main.c

clean:
rm -f *.o world.out

执行make,输出如下:

1
2
3
4
$ make
cc -c hello.c
cc -c main.c
cc -o world.out hello.o main.o

在当前目录下可以看到hello.omain.o以及最终的可执行程序world.out。执行world.out

1
2
3
4
$ ./world.out 
start...
hello, world!
exit.

与我们预期相符。

修改hello.c,把输出改为"hello, bob!\n",再执行make,观察输出:

1
2
3
$ make
cc -c hello.c
cc -o world.out hello.o main.o

仅重新编译了hello.c,并未编译main.c。由于hello.o已更新,所以,仍然要重新生成world.out。执行world.out

1
2
3
4
$ ./world.out 
start...
hello, bob!
exit.

与我们预期相符。

修改hello.h

1
2
// int 变为 void:
void hello();

以及hello.c,再次执行make

1
2
3
4
$ make
cc -c hello.c
cc -c main.c
cc -o world.out hello.o main.o

会触发main.c的编译,因为main.c依赖hello.h

执行make clean会删除所有的.o文件,以及可执行文件world.out,再次执行make就会强制全量编译:

1
2
3
4
5
$ make clean && make
rm -f *.o world.out
cc -c hello.c
cc -c main.c
cc -o world.out hello.o main.o

这个简单的Makefile使我们能自动化编译C程序,十分方便。

不过,随着越来越多的.c文件被添加进来,如何高效维护Makefile的规则?我们后面继续讲解。

参考源码

可以从GitHub下载源码。

小结

Makefile正确定义规则后,我们就能用make自动化编译C程序。



使用隐式规则

我们仍然以上一节的C项目为例,当我们添加越来越多的.c文件时,就需要编写越来越多的规则来生成.o文件。

实际上,有的同学可能发现了,即使我们把.o的规则删掉,也能正常编译:

1
2
3
4
5
6
# 只保留生成 world.out 的规则:
world.out: hello.o main.o
cc -o world.out hello.o main.o

clean:
rm -f *.o world.out

执行make,输出如下:

1
2
3
4
$ make
cc -c -o hello.o hello.c
cc -c -o main.o main.c
cc -o world.out hello.o main.o

我们没有定义hello.omain.o的规则,为什么make也能正常创建这两个文件?

因为make最初就是为了编译C程序而设计的,为了免去重复创建编译.o文件的规则,make内置了隐式规则(Implicit Rule),即遇到一个xyz.o时,如果没有找到对应的规则,就自动应用一个隐式规则:

1
2
xyz.o: xyz.c
cc -c -o xyz.o xyz.c

make针对C、C++、ASM、Fortran等程序内置了一系列隐式规则,可以参考官方手册查看。

对于C程序来说,使用隐式规则有一个潜在问题,那就是无法跟踪.h文件的修改。如果我们修改了hello.h的定义,由于隐式规则main.o: main.c并不会跟踪hello.h的修改,导致main.c不会被重新编译,这个问题我们放到后面解决。

参考源码

可以从GitHub下载源码。

小结

针对C、C++、ASM、Fortran等程序,make内置了一系列隐式规则,使用隐式规则可减少大量重复的通用编译规则。

查看官方手册:



当我们在Makefile中重复写很多文件名时,一来容易写错,二来如果要改名,要全部替换,费时费力。

编程语言使用变量(Variable)来解决反复引用的问题,类似的,在Makefile中,也可以使用变量来解决重复问题。

以上一节的Makefile为例:

1
2
3
4
5
world.out: hello.o main.o
cc -o world.out hello.o main.o

clean:
rm -f *.o world.out

编译的最终文件world.out重复出现了3次,因此,完全可以定义一个变量来替换它:

1
2
3
4
5
6
7
TARGET = world.out

$(TARGET): hello.o main.o
cc -o $(TARGET) hello.o main.o

clean:
rm -f *.o $(TARGET)

变量定义用变量名 = 值或者变量名 := 值,通常变量名全大写。引用变量用$(变量名),非常简单。

注意到hello.o main.o这个“列表”也重复了,我们也可以用变量来替换:

1
2
3
4
5
6
7
8
OBJS = hello.o main.o
TARGET = world.out

$(TARGET): $(OBJS)
cc -o $(TARGET) $(OBJS)

clean:
rm -f *.o $(TARGET)

如果有一种方式能让make自动生成hello.o main.o这个“列表”,就更好了。注意到每个.o文件是由对应的.c文件编译产生的,因此,可以让make先获取.c文件列表,再替换,得到.o文件列表:

1
2
3
4
5
6
7
8
9
10
# $(wildcard *.c) 列出当前目录下的所有 .c 文件: hello.c main.c
# 用函数 patsubst 进行模式替换得到: hello.o main.o
OBJS = $(patsubst %.c,%.o,$(wildcard *.c))
TARGET = world.out

$(TARGET): $(OBJS)
cc -o $(TARGET) $(OBJS)

clean:
rm -f *.o $(TARGET)

这样,我们每添加一个.c文件,不需要修改Makefile,变量OBJS会自动更新。

思考:为什么我们不能直接定义OBJS = $(wildcard *.o)make列出所有.o文件?

内置变量

我们还可以用变量$(CC)替换命令cc

1
2
$(TARGET): $(OBJS)
$(CC) -o $(TARGET) $(OBJS)

没有定义变量CC也可以引用它,因为它是make的内置变量(Builtin Variables),表示C编译器的名字,默认值是cc,我们也可以修改它,例如使用交叉编译时,指定编译器:

1
2
CC = riscv64-linux-gnu-gcc
...

自动变量

Makefile中,经常可以看到$@$<这样的变量,这种变量称为自动变量(Automatic Variable),它们在一个规则中自动指向某个值。

例如,$@表示目标文件,$^表示所有依赖文件,因此,我们可以这么写:

1
2
world.out: hello.o main.o
cc -o $@ $^

在没有歧义时可以写$@,也可以写$(@),有歧义时必须用括号,例如$(@D)

为了更好地调试,我们还可以把变量打印出来:

1
2
3
4
5
world.out: hello.o main.o
@echo '$$@ = $@' # 变量 $@ 表示target
@echo '$$< = $<' # 变量 $< 表示第一个依赖项
@echo '$$^ = $^' # 变量 $^ 表示所有依赖项
cc -o $@ $^

执行结果输出如下:

1
2
3
4
$@ = world.out
$< = hello.o
$^ = hello.o main.o
cc -o world.out hello.o main.o

参考源码

可以从GitHub下载源码。

小结

使用变量可以让Makefile更加容易维护。

查看官方手册:

使用模式规则

前面我们讲了使用隐式规则可以让make在必要时自动创建.o文件的规则,但make的隐式规则的命令是固定的,对于xyz.o: xyz.c,它实际上是:

1
$(CC) $(CFLAGS) -c -o $@ $<

能修改的只有变量$(CC)$(CFLAGS)。如果要执行多条命令,使用隐式规则就不行了。

这时,我们可以自定义模式规则(Pattern Rules),它允许make匹配模式规则,如果匹配上了,就自动创建一条模式规则。

我们修改上一节的Makefile如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
OBJS = $(patsubst %.c,%.o,$(wildcard *.c))
TARGET = world.out

$(TARGET): $(OBJS)
cc -o $(TARGET) $(OBJS)

# 模式匹配规则:当make需要目标 xyz.o 时,自动生成一条 xyz.o: xyz.c 规则:
%.o: %.c
@echo 'compiling $<...'
cc -c -o $@ $<

clean:
rm -f *.o $(TARGET)

make执行world.out: hello.o main.o时,发现没有hello.o文件,于是需要查找以hello.o为目标的规则,结果匹配到模式规则%.o: %.c,于是make自动根据模式规则为我们动态创建了如下规则:

1
2
3
hello.o: hello.c
@echo 'compiling $<...'
cc -c -o $@ $<

查找main.o也是类似的匹配过程,于是我们执行make,就可以用模式规则完成编译:

1
2
3
4
5
6
$ make
compiling hello.c...
cc -c -o hello.o hello.c
compiling main.c...
cc -c -o main.o main.c
cc -o world.out hello.o main.o

模式规则的命令完全由我们自己定义,因此,它比隐式规则更灵活。

但是,模式规则仍然没有解决修改hello.h头文件不会触发main.c重新编译的问题,这个依赖问题我们继续放到后面解决。

最后注意,模式规则是按需生成,如果我们在当前目录创建一个zzz.o文件,因为make并不会在执行过程中用到它,所以并不会自动生成zzz.o: zzz.c这个规则。

参考源码

可以从GitHub下载源码。

小结

使用模式规则可以灵活地按需动态创建规则,它比隐式规则更灵活。

查看官方手册:



前面我们讲了隐式规则和模式规则,这两种规则都可以解决自动把.c文件编译成.o文件,但都无法解决.c文件依赖.h文件的问题。

因为一个.c文件依赖哪个.h文件必须要分析文件内容才能确定,没有一个简单的文件名映射规则。

但是,要识别出.c文件的头文件依赖,可以用GCC提供的-MM参数:

1
2
$ cc -MM main.c
main.o: main.c hello.h

上述输出告诉我们,编译main.o依赖main.chello.h两个文件。

因此,我们可以利用GCC的这个功能,对每个.c文件都生成一个依赖项,通常我们把它保存到.d文件中,再用include引入到Makefile,就相当于自动化完成了每个.c文件的精准依赖。

我们改写上一节的Makefile如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 列出所有 .c 文件:
SRCS = $(wildcard *.c)

# 根据SRCS生成 .o 文件列表:
OBJS = $(SRCS:.c=.o)

# 根据SRCS生成 .d 文件列表:
DEPS = $(SRCS:.c=.d)

TARGET = world.out

# 默认目标:
$(TARGET): $(OBJS)
$(CC) -o $@ $^

# xyz.d 的规则由 xyz.c 生成:
%.d: %.c
rm -f $@; \
$(CC) -MM $< >$@.tmp; \
sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.tmp > $@; \
rm -f $@.tmp

# 模式规则:
%.o: %.c
$(CC) -c -o $@ $<

clean:
rm -rf *.o *.d $(TARGET)

# 引入所有 .d 文件:
include $(DEPS)

变量$(SRCS)通过扫描目录可以确定为hello.c main.c,因此,变量$(OBJS)赋值为hello.o main.o,变量$(DEPS)赋值为hello.d main.d

通过include $(DEPS)我们引入hello.dmain.d文件,但是这两个文件一开始并不存在,不过,make通过模式规则匹配到%.d: %.c,这就给了我们一个机会,在这个模式规则内部,用cc -MM命令外加sed.d文件创建出来。

运行make,首次输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ make
Makefile:31: hello.d: No such file or directory
Makefile:31: main.d: No such file or directory
rm -f main.d; \
cc -MM main.c >main.d.tmp; \
sed 's,\(main\)\.o[ :]*,\1.o main.d : ,g' < main.d.tmp > main.d; \
rm -f main.d.tmp
rm -f hello.d; \
cc -MM hello.c >hello.d.tmp; \
sed 's,\(hello\)\.o[ :]*,\1.o hello.d : ,g' < hello.d.tmp > hello.d; \
rm -f hello.d.tmp
cc -c -o hello.o hello.c
cc -c -o main.o main.c
cc -o world.out hello.o main.o

make会提示找不到hello.dmain.d,不过随后自动创建出hello.dmain.dhello.d内容如下:

1
hello.o hello.d : hello.c

上述规则有两个目标文件,实际上相当于如下两条规则:

1
2
hello.o : hello.c
hello.d : hello.c

main.d内容如下:

1
main.o main.d : main.c hello.h

因此,main.o依赖于main.chello.h,这个依赖关系就和我们手动指定的一致。

改动hello.h,再次运行make,可以触发main.c的编译:

1
2
3
4
5
6
7
$ make
rm -f main.d; \
cc -MM main.c >main.d.tmp; \
sed 's,\(main\)\.o[ :]*,\1.o main.d : ,g' < main.d.tmp > main.d; \
rm -f main.d.tmp
cc -c -o main.o main.c
cc -o world.out hello.o main.o

在实际项目中,对每个.c文件都可以生成一个对应的.d文件表示依赖关系,再通过include引入到Makefile,同时又能让make自动更新.d文件,有点蛋生鸡和鸡生蛋的关系,不过,这种机制能正常工作,除了.d文件不存在时会打印错误,有强迫症的同学肯定感觉不满意,这个问题我们后面解决。

参考源码

可以从GitHub下载源码。

小结

利用GCC生成.d文件,再用include引入Makefile,可解决一个.c文件应该如何正确触发编译的问题。

查看官方手册:

完善Makefile

上一节我们解决了自动生成依赖的问题,这一节我们对项目目录进行整理,把所有源码放入src目录,所有编译生成的文件放入build目录:

1
2
3
4
5
6
7
<project>
├── Makefile
├── build
└── src
├── hello.c
├── hello.h
└── main.c

整理Makefile,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
SRC_DIR = ./src
BUILD_DIR = ./build
TARGET = $(BUILD_DIR)/world.out

CC = cc
CFLAGS = -Wall

# ./src/*.c
SRCS = $(shell find $(SRC_DIR) -name '*.c')
# ./src/*.c => ./build/*.o
OBJS = $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS))
# ./src/*.c => ./build/*.d
DEPS = $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.d,$(SRCS))

# 默认目标:
all: $(TARGET)

# build/xyz.d 的规则由 src/xyz.c 生成:
$(BUILD_DIR)/%.d: $(SRC_DIR)/%.c
@mkdir -p $(dir $@); \
rm -f $@; \
$(CC) -MM $< >$@.tmp; \
sed 's,\($*\)\.o[ :]*,$(BUILD_DIR)/\1.o $@ : ,g' < $@.tmp > $@; \
rm -f $@.tmp

# build/xyz.o 的规则由 src/xyz.c 生成:
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c -o $@ $<

# 链接:
$(TARGET): $(OBJS)
@echo "buiding $@..."
@mkdir -p $(dir $@)
$(CC) -o $(TARGET) $(OBJS)

# 清理 build 目录:
clean:
@echo "clean..."
rm -rf $(BUILD_DIR)

# 引入所有 .d 文件:
-include $(DEPS)

这个Makefile定义了源码目录SRC_DIR、生成目录BUILD_DIR,以及其他变量,同时用-include消除了.d文件不存在的错误。执行make,输出如下:

1
2
3
4
5
$ make
cc -Wall -c -o build/hello.o src/hello.c
cc -Wall -c -o build/main.o src/main.c
buiding build/world.out...
cc -o ./build/world.out ./build/hello.o ./build/main.o

可以说基本满足编译需求,收工!

参考源码

可以从GitHub下载源码。

小结

除了基础的用法外,Makefile还支持条件判断,环境变量,嵌套执行,变量展开等各种功能,需要用到时可以查询官方手册



留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau