Terraform快速参考
Terraform用于实现基础设施即代码(infrastructure as code)—— 通过代码(配置文件)来描述基础设施的拓扑结构,并确保云上资源和此结构完全对应。Terraform有三个版本,我们主要关注Terraform CLI。
Terraform CLI主要包含以下组件:
- 命令行前端
- Terraform Language(以下简称TL,衍生自HashiCorp配置语言HCL)编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”(Configuration)来表示一整套描述基础设施的Terraform配置文件
- 针对各种云服务商的驱动(Provider),实现云资源的创建、更新和删除
云上资源不单单包括基础的IaaS资源,还可以是DNS条目、SaaS资源。事实上,通过开发Provider,你可以用Terraform管理任何资源。
Terraform会检查配置文件,并生成执行计划。计划描述了那些资源需要被创建、修改或删除,以及这些资源之间的依赖关系。Terraform会尽可能并行的对资源进行变更。当你更新了配置文件后,Terraform会生成增量的执行计划。
直接到https://www.terraform.io/downloads.html下载,存放到$PATH下即可。
使用选项 -chdir=DIR
使用 terraform -install-autocomplete安装自动完成脚本,使用 terraform -uninstall-autocomplete删除自动完成脚本。
很多子命令接受资源地址参数,下面是一些例子:
1 2 3 4 5 6 |
# 资源类型.资源名 aws_instance.foo # 资源类型.资源列表名[索引] aws_instance.bar[1] # 子模块foo的子模块bar中的 module.foo.module.bar.aws_instance.baz |
配置文件的路径可以通过环境变量 TF_CLI_CONFIG_FILE设置。非Windows系统中, $HOME/.terraformrc为默认配置文件路径。配置文件语法类似于TF文件:
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 |
# provider缓存目录 plugin_cache_dir = "$HOME/.terraform.d/plugin-cache" # disable_checkpoint = true # 存放凭证信息,包括模块仓库、支持远程操作的系统的凭证 credentials "app.terraform.io" { token = "xxxxxx.atlasv1.zzzzzzzzzzzzz" } # 改变默认安装逻辑 provider_installation { # 为example.com提供本地文件系统镜像,这样安装example.com/*/*的provider时就不会去网络上请求 # 默认路径是: # ~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target} # 例如: # ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1 filesystem_mirror { path = "/usr/share/terraform/providers" include = ["example.com/*/*"] } direct { exclude = ["example.com/*/*"] } # Terraform会在terraform init的时候,校验Provider的版本和checksum。Provider从Registry或者本地 # 目录下载Provider。当我们开发Provider的时候,常常需要方便的测试临时Provider版本,这种Provider还 # 没有关联版本号,也没有在Registry中注册Chencksum # 为了简化开发,可以配置dev_overrides,它能覆盖所有配置的安装方法 dev_overrides { "hashicorp.com/edu/hashicups-pf" = "$(go env GOBIN)" } } |
配置工作目录,为使用其它命令做好准备。
Terraform命令需要在一个编写了Terraform配置文件的目录(配置根目录)下执行,它会在此目录下存储设置、缓存插件/模块,以及(默认使用Local后端时)存储状态数据。此目录必须进行初始化。
初始化后,会生成以下额外目录/文件:
.terraform目录,用于缓存provider和模块
如果使用Local后端,保存状态的terraform.tfstate文件。如果使用多工作区,则是terraform.tfstate.d目录。
对配置的某些变更,需要重新运行初始化,包括provider需求的变更、模块源/版本约束的变更、后端配置的变更。需要重新初始化时,其它命令可能会无法执行并提示你进行初始化。
命令 terraform get可以仅仅下载依赖的模块,而不执行其它init子任务。
运行 terraform init -upgrade会强制拉取最新的、匹配约束的版本并更新依赖锁文件。
校验配置是否合法。
显示执行计划,即当前配置将请求(结合state)哪些变更。Terraform的核心功能时创建、修改、删除基础设施对象,使基础设施的状态和当前配置匹配。当我们说运行Terraform时,主要是指plan/apply/destroy这几个命令。
terraform plan命令评估当前配置,确定其声明的所有资源的期望状态。然后比较此期望状态和真实基础设施的当前状态。它使用state来确定哪些真实基础设施对象和声明资源的对应关系,并且使用provider的API查询每个资源的当前状态。当确定到达期望状态需要执行哪些变更后,Terraform将其打印到控制台,它并不会执行任何实际的变更操作。
terraform plan命令得到的计划可以保存起来,并被后续的terraform apply使用:
1 |
terraform plan -out=FILE |
plan命令支持两种备选的工作模式:
- 销毁模式:创建一个计划,其目标是销毁所有当前存在于配置中的远程对象,留下一个空白的state。对应选项 -destroy
- 仅刷新模式:创建一个计划,其目标仅仅是更新state和根模块的输出值,以便和从Terraform之外对基础设施对象的变更匹配。对应选项 -refresh-only
使用选项 -var 'NAME=VALUE'可以指定输入变量,该选项可以使用多次。
使用选项 -var-file=FILENAME可以从文件读取输入变量,某些文件会自动读取,参考输入变量一节。
选项 -parallelism=n限制操作最大并行度,默认10。
选项 | 说明 |
-refresh=false |
默认情况下,Terraform在检查配置变更之前,会将state和远程基础设施进行同步。此选项禁用此行为 该选项可能会提升性能,因为减少了远程API请求数量。但是可能会无法识别某些Terraform外部对基础设施资源的变更 |
-replace=ADDRESS |
提示Terraform去计划替换掉具有指定ADDRESS的单个资源。对于0.15.2+可用,老版本可以使用terraform taint命令代替 ADDRESS就是针对某个资源实例的引用表达式,例如 aws_instance.example[0] |
-target=ADDRESS | 提示Terraform仅仅针对指定ADDRESS的资源(以及它依赖的资源)指定执行计划 |
-input=false | 禁止Terraform交互式的提示用户提供根模块的输入变量,对于批处理方式运行Terraform很重要 |
应用执行计划,创建、更新设施对象。
apply会做plan的任何事情,并在其基础上,直接执行变更操作。默认情况下,apply即席的执行一次plan,你也可以直接使用已保存的plan
命令格式: terraform apply [options] [plan file]
选项 -auto-approve可以自动确认并执行所需操作,不需要人工确认。
如果指定plan file参数,则读取先前保存的计划并执行。
支持plan命令中关于计划模式的选项。
-input=false、-parallelism=n等选项含义和plan命令相同。特有选项:
选项 | 说明 |
-lock-timeout=DURATION | 对状态加锁的最大时间 |
删除先前创建的基础设施对象。
当前配置(+工作区)管理的所有资源都会被删除,destroy会使用状态数据确定哪些资源需要删除。
在交互式命令中估算Terraform表达式。
格式化配置文件
强制解除当前工作区的状态锁。当其它terraform进程锁定状态后,没有正常解锁时使用。如果其它进程仍然在运作,可能导致状态不一致。
安装或升级远程Terraform模块。格式: terraform get [options] PATH。
选项:
-update 检查已经下载的模块的新版本,如果存在匹配约束的新版本则更新
-no-color 禁用彩色输出
生成操作中包含步骤的图形化表示。
导入现有的基础设施对象,让其关联到配置中的资源定义。
获取并保存远程服务(例如模块私服)的登录凭证。
删除远程服务(例如模块私服)的登录凭证。
显示根模块的输出值。格式: terraform output [options] [NAME]
显示此模块依赖的providers
更新状态,使其和远程基础设施匹配。
显示当前状态或保存的执行计划。
将资源实例标记为“非功能完备(fully functional)”的。
所谓非功能完备,通常意味着资源创建过程出现问题,存在部分失败。此外taint子命令也可以强制将资源标记为非功能完备。
因为上述两种途径,进入tainted状态的资源,不会立即影响基础设施对象。但是在下一次的plan中,会销毁并重新创建对应基础设施对象。
命令格式: terraform [global options] taint [options] <address>
解除资源的tainted状态。
管理和切换工作区。
配置文件由若干块(Block)组成,块的语法如下:
1 2 3 4 5 |
# Block header, which identifies a block <BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" "..." { # Block body <IDENTIFIER> = <EXPRESSION> # Argument } |
块是一个容器,它的作用取决于块的类型。块常常用来描述某个资源的配置。
取决于块的类型,标签的数量可以是0-N个。对于resource块,标签数量为两个。某些特殊的块,可能支持任意数量的标签。某些内嵌的块,例如network_interface,则不支持标签。
块体中可以包含若干参数(Argument),或者其它内嵌的块。参数用于将一个表达式分配到一个标识符,常常对应某个资源的一条属性。表达式可以是字面值,或者引用其它的值,正是这种引用让Terraform能够识别资源依赖关系。
直接位于配置文件最外层的块,叫做顶级块(Top-level Block),Terraform支持有限种类的顶级块。大部分Terraform特性,例如resource,基于顶级块实现。
下面是一个例子:
1 2 3 |
resource "aws_vpc" "main" { cidr_block = var.base_cidr_block } |
在HCL语言中,Argument被称作Attribute。但是在TL中,Attribute术语另有它用。例如各种resource都具有一个名为id的属性,它可以被参数表达式饮用,但是不能被赋值(因而不是参数)。
参数其实一个赋值表达式,它将一个值分配给一个名称。
上下文决定了哪些参数可用,其类型是什么。不同的资源(由resource标签识别)支持不同的参数集。
参数名、块类型名、大部分Terraform特有结构的名字(例如resource名,即其第二标签),都是标识符。
标识符由字母、数字、-、_组成,第一个字符不能是数字。
#或者 //开头的是单行注释。 /**/作为多行注释的边界。
类型 | 说明 |
string |
Unicode字符序列,基本形式 "hello" |
number | 数字,形式 6.02 |
bool | true或 false |
list/tuple | 一系列的值,形式 ["us-west-1a", "us-west-1c"] |
map/object | 键值对,形式 {name = "Mabel", age = 52} |
空值使用 null表示。
转义字符:
\n 换行
\r 回车
\t 制表
\" 引号
\\ 反斜杠
\uNNNN Unicode字符
\UNNNNNNNN Unicode字符
注意,在Heredoc中反斜杠不用于转义,可以使用:
$${ 字符串插值标记${
%%{ 模板指令标记%{
支持Unix的Heredoc风格的字符串:
1 2 3 4 5 6 |
block { value = <<EOT hello world EOT } |
Heredoc还支持缩进编写:
1 2 3 4 5 6 |
block { value = <<-EOT hello world EOT } |
要将对象转换为JSON或YAML,可以调用函数:
1 2 3 4 |
example = jsonencode({ a = 1 b = "hello" }) |
不管在普通字符串格式,还是Heredoc中,都可以使用字符串模板。模板需要包围在 ${ 或 %{}中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# ${ ... } 中包含的是表达式 "Hello, ${var.name}!" # %{ ... } 则定义了一条模板指令,可以用于实现条件分支或循环 # %{if <BOOL>}/%{else}/%{endif} 条件分支 "Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!" # %{for <NAME> in <COLLECTION>} / %{endfor} 循环 <<EOT %{ for ip in aws_instance.example.*.private_ip } server ${ip} %{ endfor } EOT |
可以在模板指令开始或结尾添加 ~,用于去除前导或后缀的空白符:
1 2 3 4 5 6 7 |
<<EOT %{ for ip in aws_instance.example.*.private_ip ~} # 这后面的换行符被~去除 server ${ip} # 这后面的换行符被保留 %{ endfor ~} EOT |
<RESOURCE TYPE>.<NAME>引用指定类型、名称的资源。 其值可能是:
- 如果资源没有count/for_each参数,那么值是一个object,可以访问资源的属性
- 如果资源使用count参数,那么值是list
- 如果资源使用for_each参数,那么值是map
var.<NAME>引用指定名称的输入变量。如果变量使用type参数限定其类型,对此引用进行赋值时,Terraform会自动进行必要的类型转换。
local.<NAME>引用指定名称的本地值。本地值可以引用其它本地值,甚至是在同一个local块中,唯一的限制就是不得出现循环依赖。
module.<MODULE NAME>引用指定子模块声明的结果输出。如果module块(表示对目标模块的调用)没有count/for_each参数,则其值是一个object,可以直接访问某个输出值: module.<MODULE NAME>.<OUTPUT NAME>
如果module块使用了for_each,则引用的值是一个map,其key是for_each中的每个key,其值是子模块输出。
如果module块使用了count,则引用的值是一个list,其元素是子模块输出。
data.<DATA TYPE>.<NAME>引用指定类型的数据资源,取决于数据资源是否使用count/foreach,其值可能是object/list/map。
path.module 表达式所在模块的路径。
path.root表示当前配置根模块的路径。
path.cwd 表示当前工作模块的路径,默认情况下和path.root一样。但是Terraform CLI可以指定不同的工作目录。
terraform.workspace是当前选择的工作区的名称。
在特定的块内部,在特定的上下文下(例如使用count/for_each的情况下),可以引用一些特殊值:
count.index 使用count原参数时,表示当前索引
each.key /
each.value 使用for_each原参数时,表示当前迭代的键值
self可以在provisioner和connection块中使用,表示当前上下文资源
逻辑操作符:
!
&&
||
算数操作符:
*
/
%
+
-
比较操作符:
>, >=, <, <= ==, !=
函数调用:
1 2 3 4 |
<FUNCTION NAME>(<ARGUMENT 1>, <ARGUMENT 2>) # 支持参数展开 min([55, 2453, 2]...) |
1 2 3 |
condition ? true_val : false_val var.a != "" ? var.a : "default-a" |
使用for表达式可以通过转换一种复杂类型输出,生成另一个复杂类型结果。输入中的每个元素,可以对应结果的0-1个元素。任何表达式可以用于转换,下面是使用upper函数将列表转换为大写:
1 |
[for s in var.list : upper(s)] |
作为for表达式的输入的类型可以是list / set / tuple / map / object。可以为for声明两个临时符号,前一个表示index或key:
1 |
[for k, v in var.map : length(k) + length(v)] |
结果的类型取决于包围for表达式的定界符:
[] 表示生成的结果是元组
{} 表示生成的结果是object,你必须使用
=>符号:
{for s in var.list : s => upper(s)}
包含一个可选的if子句可以对输入元素进行过滤: [for s in var.list : upper(s) if s != ""]
示例:
1 2 3 4 5 6 7 8 9 10 11 12 |
variable "users" { type = map(object({ is_admin = boolean })) } locals { admin_users = { for name, user in var.users : name => user if user.is_admin } } |
for表达式可能将无序类型(map/object/set)转换为有序类型(list/tuple):
对于map/object,键/属性名的字典序,决定结果的顺序
对于set,如果值是字符串,则使用其字典序。如果值是其它类型,则结果的顺序可能随着Terraform的版本改变
如果输出是对象,通常要求键的唯一性。Terraform支持分组模式,允许键重复。要激活此模式,在表达式结尾添加 ...:
1 2 3 4 5 6 7 8 9 10 11 |
variable "users" { type = map(object({ role = string })) } locals { users_by_role = { for name, user in var.users : user.role => name... } } |
对于顶级块,表达式仅能在给参数赋值的时候,用在右侧。某些情况下,我们需要在特定条件下,重复、启用/禁用某个子块,表达式就没法实现了,此时可以利用dynamic块。
dynamic块可以遍历一个列表,为每个元素生成一个块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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" # 对于每个设置,生成一个setting子块。dynamic块的标签,对应生成的块类型 dynamic "setting" { # 迭代对象 for_each = var.settings # 子块的标签,可选 labels: [] # 子块的体(参数) content { namespace = setting.value["namespace"] # 块类型.key对应映射的键或者列表的索引,.value对应当前迭代的值 name = setting.value["name"] value = setting.value["value"] } } } |
注意dynamic块仅可能为resource、data、provider、provisioner块生成参数(子块),不能用于生成源参数块,例如lifecycle。
允许dynamic块的多级嵌套。
splat表达式提供了更简单语法,在某些情况下代替for表达式:
1 2 3 4 5 6 7 |
[for o in var.list : o.id] # 等价于 var.list[*].id [for o in var.list : o.interfaces[0].name] # 等价于 var.list[*].interfaces[0].name |
注意splat表达式仅仅用于列表,不能用于map/object
splat表达式还有个特殊用途,将单值转换为列表:
1 |
for_each = var.website[*] |
module/provider的作者可以利用类型约束来校验用户输入。Terraform的类型系统比较强大,比如他不但可以限制类型为map,还可以规定其中有哪些键,每个键的值是什么类型。
复杂类型可以将多个值组合为单个值,复杂类型由类型构造器(type constructor)定义,复杂类型分为两类:
- 集合类型:组合相似(类型)的值
- 结构类型:组合可能不同的值
集合类型包括map/list/set等,我们可以限制集合的成员类型:
1 2 3 4 5 6 7 |
# 字符串列表 list(string) # 数字列表 list(number) # 任意元素列表,等价于list list(any) |
结构类型包括object和tuple。
object定义了多个命名的属性,以及属性的类型:
1 2 3 4 5 6 7 8 9 10 |
object( { <KEY> = <TYPE>, <KEY> = <TYPE>, ... } ) # 示例 object({ name=string, age=number }) # 下面是符合此类型的实例 { name = "John" age = 52 } |
元组则是定义了限定元素个数、每个元素类型的列表:
1 2 3 |
tuple([string, number, bool]) # 下面是符合此类型的实例 ["a", 15, true] |
大部分情况下,相似的集合类型和结构类型的行为类似。Terraform文档某些时候也不去区分,这是由于以下类型转换行为:
- 可能的情况下,Terraform会自动在相似复杂类型之间进行转换:
- object和map是相似的,只要map包含object的schema所要求的键集合,即可自动转换。多余的键在转换过程中被丢弃,这意味着map - object - mapl两重转换可能丢失信息
- tuple和list是相似的,但是转换仅仅在list元素数量恰好满足tuple的schema时发生
- set和tuple/list是相似的:
- 当list/tuple转换为set,重复的值会被丢弃,元素顺序消失
- 当set转换为list/tuple,元素的顺序是任意的,一个例外是set(string),将会按照元素字典序生成list/tuple
- 可能的情况下,Terraform会自动转换复杂类型的元素的类型,如果元素是复杂类型,则递归的处理
每当提供的值,和要求的值类型不一致时,自动转换都会发生。
module/provider的作者应该注意不同类型的差别,特别是在限制输入方面能力的不同。
特殊关键字any用做尚未决定的类型的占位符,其本身并非一个类型。当解释类型约束的时候,Terraform会尝试寻找单个的实际类型,替换any关键字,并满足约束。
例如,对于list(any)这一类型约束,对于给定的值["a", "b", "c"]其实际类型是tuple([string, string, string]),当将该值赋值给list(any)变量时,Terraform分析过程如下:
- tuple和list是相似类型,因此应用上问的tuple-list转换规则
- tuple的元素类型是string,满足any约束,因此将其替换,结果类型是list(string)
从0.14开始,支持这一实验特性。可以在object类型约束中,标注某个属性为可选的:
1 2 3 4 5 6 |
variable "with_optional_attribute" { type = object({ a = string # 必须属性 b = optional(string) # 可选属性 }) } |
默认情况下,对于必须属性来说,如果类型转换时的源(例如map)不具备对应的键,会导致报错。
版本约束是一个特殊的字符串值,在引用module、使用provider时,或者通过terraform块的required_version时,需要用到版本约束:
1 2 3 4 5 6 7 8 |
# 版本范围区间 version = ">= 1.2.0, < 2.0.0" # 操作符 = 等价于无操作符,限定特定版本 != 排除特定版本 > >= < <= 限制版本范围 ~> 允许最右侧的版本号片段的变化 |
所有可用函数:https://www.terraform.io/docs/language/functions/index.html
资源是TL语言中最重要的元素,由resource块定义。正如其名字所示,资源声明了某种云上基础设施的规格,这些基础设施可以是虚拟机、虚拟网络、DNS记录,等等。
数据源是一种特殊的资源,由data块定义。
当你第一次为某个资源编写配置时,它值存在于配置文件中,尚未代表云上的某个真实基础设施对象。通过应用Terraform配置,触发创建/更新/销毁等操作,实现云上环境和配置文件的匹配。
当一个新的资源被创建后,对应真实基础设施对象的标识符被保存到Terraform的State中。这个标识符作为后续更新/删除的依据。对于State中已经存在关联的标识符的那些Resource块,Terraform会比较真实基础设施对象和Resource参数的区别,并在必要的时候更新对象。
概括起来说,当Terraform配置被应用时:
- 创建存在于配置文件中,但是在State中没有关联真实基础设施对象的资源
- 销毁存在于State中,但是不存在于配置文件的资源
- 更新参数发生变化的资源
- 删除、重新创建参数发生变化,但是不能原地(in-palce)更新的资源。不能更新通常是因为云API的限制,例如腾讯云VPC的CIDR不支持修改
以上4点,适用于所有资源类型。但是需要注意,底层发生的事情,取决于Provider,Terraform只是去调用Provider的相应接口。
在相同模块中,你可以在表达式中访问某个资源的属性,语法 <RESOURCE TYPE>.<NAME>.<ATTRIBUTE>
除了配置文件中列出的资源参数之外,资源还提供一些只读的属性。属性表示了一些提供云API拉取到的信息。这些信息通畅需要在资源创建后才可获知,例如随机生成的资源ID vpc-d8o3c8vq
很多Provider还提供特殊的数据源(data)资源,这种特殊的资源仅仅用来查询信息。
某些资源必须在另外一些资源之后创建,例如CVM必须在其所属Subnet创建后才能创建,这意味着某些资源存在依赖关系。
Terraform能够自动分析资源依赖关系,其分析依据就是资源的参数的值表达式。如果表达式中引用了另一个资源,则当前资源依赖于该资源。
对于无法通过配置文件分析的隐含依赖,需要你手工配置 depends_on元参数。
Terraform会自动并行处理没有依赖关系的资源。
这类特殊的资源不会对应某个云上基础设施对象,而是仅仅存在于Terraform本地State中。Local-Only资源用于一些中间计算过程,包括生成随机ID、创建私钥等。
Local-Only资源的行为和普通资源一致,只是其结果数据仅仅存在于State中,删除时也仅仅是从State中移除对应数据。
1 2 3 |
resource "resource_type" "local_name" { # arguments... } |
两个标签分别代表资源的类型和本地名称。
资源类型提示正在描述的是那种云上基础设施,资源类型决定可用的参数集。本地名称用于在模块的其它地方饮用该资源,此外没有意义。资源类型+本地名称是资源的唯一标识,必须在模块范围内唯一。
每一种资源都由一个Provider来实现。Provider是Terraform的插件,它提供若干资源类型。通常一个云服务商提供提供一个Provider。初始化工作目录时Terraform能够自动从Terraform仓库下载大部分所需的Provider。
模块需要知道,利用哪些Provider才能管理所有的资源。此外Provider还需要经过配置才能工作,例如设置访问云API的凭证。这些配置由根模块负责。
元参数可以用于任何资源类型。
该元参数用于处理隐含的资源/模块依赖,这些依赖无法通过分析Terraform配置文件得到。从0.13版本开始,该元参数可用于模块。之前的版本仅仅用于资源。
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 |
resource "aws_iam_role" "example" { name = "example" assume_role_policy = "..." } # 这个策略允许运行在EC2中的实例访问S3 API resource "aws_iam_role_policy" "example" { name = "example" role = aws_iam_role.example.name policy = jsonencode({ "Statement" = [{ "Action" = "s3:*", "Effect" = "Allow", }], }) } resource "aws_iam_instance_profile" "example" { # 这是可以自动分析出的依赖 role = aws_iam_role.example.name } resource "aws_instance" "example" { ami = "ami-a1b2c3d4" instance_type = "t2.micro" # 这是可以自动分析出的依赖,包括传递性依赖 iam_instance_profile = aws_iam_instance_profile.example # 如果这个实例中的程序需要访问S3接口,我们需要用元参数显式的声明依赖 # 从而分配策略 depends_on = [ aws_iam_role_policy.example, ] } |
默认情况下,一个resource块代表单个云上基础设施对象。如果你想用一个resource块生成多个类似的资源,可以用count或for_each参数。
设置了此元参数的上下文中,可以访问名为 count的变量,它具有属性 index,为从0开始计数的资源实例索引。 示例:
1 2 3 4 5 6 7 8 9 10 11 12 |
resource "aws_instance" "server" { # 创建4个类似的实例 count = 4 ami = "ami-a1b2c3d4" instance_type = "t2.micro" tags = { # 实例的索引作为tag的一部分 Name = "Server ${count.index}" } } |
在当前模块的其它地方,可以用这样的语法访问如上资源实例:
1 2 3 |
<RESOURCE_TYPE>.<NAME>[<INDEX>] aws_instance.server[0] |
如果资源的规格几乎完全一致,可以用count,否则,需要使用更加灵活的for_each元参数。
for_each的值必须是一个映射或set(string),你可以在上下文中访问 each对象, 它具有 key和 value两个属性,如果for_each的值是集合,则key和value相等。示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
resource "azurerm_resource_group" "rg" { for_each = { a_group = "eastus" another_group = "westus2" } # 对于每个键值对都会生成azurerm_resource_group资源 name = each.key location = each.value } resource "aws_iam_user" "the-accounts" { # 数组转换为集合 for_each = toset( ["Todd", "James", "Alice", "Dottie"] ) name = each.key } |
调用子模块时使用for_each的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# 父模块 my_buckets.tf module "bucket" { for_each = toset(["assets", "media"]) # 调用publish_bucket子模块 source = "./publish_bucket" # 将键赋值给子模块的name变量 name = "${each.key}_bucket" } # 子模块 文件名 # publish_bucket/bucket-and-cloudfront.tf # 声明模块的输入参数 variable "name" {} resource "aws_s3_bucket" "example" { # 访问变量 bucket = var.name # ... } |
关于for_each的值的元素,有如下限制:
- 键必须是确定的值,如果应用配置前值无法确定会报错。例如,你不能引用CVM的ID,因为这个ID必须在配置应用之后才可知
- 如果键是函数调用,则此函数不能是impure的(非幂等),impure函数包括uuid/bcrypt/timestamp等
- 敏感(Sensitive)值不能用做键值。需要注意,大部分函数,在接受敏感值参数后,返回值仍然是敏感的
当使用集合的时候,你必须明确的将值转换为集合。例如通过 toset函数将列表、元组转换为集合。使用 flatten函数可以将多层次的嵌套结构转换为列表。
for_each所在块定义的资源A,可以赋值给另一个资源B的for_each参数。这时,B那个for_each的键值对,值是资源的完整(创建或更新过的)实例。这种用法叫chaining:
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 |
variable "vpcs" { # 这里定义了map类型的变量,并且限定了map具有的键 type = map(object({ cidr_block = string })) } # 创建多个VPC资源 resource "aws_vpc" "example" { for_each = var.vpcs cidr_block = each.value.cidr_block } # 上述资源作为下面那个for_each的值 # 创建对应数量的网关资源 resource "aws_internet_gateway" "example" { # 为每个VPC创建一个网关 # 资源作为值 for_each = aws_vpc.example # 映射的值,在这里是完整的VPC对象 vpc_id = each.value.id } # 输出所有VPC ID output "vpc_ids" { value = { for k, v in aws_vpc.example : k => v.id } # 显式依赖网关资源,确保网关创建后,输出才可用 depends_on = [aws_internet_gateway.example] } |
引用for_each创建的资源的实例时,使用如下语法:
1 2 3 |
<TYPE>.<NAME>[<KEY>] azurerm_resource_group.rg["a_group"] |
这个参数用于指定使用的provider配置。可以覆盖Terraform的默认行为:将资源类型名的第一段(下划线分隔)作为provider的本地名称。同时使用provider的默认配置。例如资源类型google_compute_instance默认识别为google这个provider。
每个provider可以提供多个配置,配置常常用于管理多区域服务中的某个特定Region。每个provider可以有一个默认配置。
该参数的值必须是不被引号包围的 <PROVIDER>.<ALIAS>。使用该参数你可以显式的指定provider及其配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# 默认配置 provider "google" { region = "us-central1" } # 备选配置 provider "google" { alias = "europe" region = "europe-west1" } resource "google_compute_instance" "example" { # 通过使用该参数选择备选配置 provider = google.europe # ... } |
注意:资源总是隐含对它的provider的依赖,这确保创建资源前provider被配置好。
该参数可以对资源的生命周期进行控制。示例:
1 2 3 4 5 6 |
resource "azurerm_resource_group" "example" { # ... lifecycle { create_before_destroy = true } } |
lifecycle作为一个块,只能出现在resources块内。可用参数包括:
参数 | 说明 |
create_before_destroy |
默认情况下, 当Terraform无法进行in-place更新时,会删除并重新创建资源 该参数可以修改此行为:先创建新资源,然后删除旧资源 |
prevent_destroy | 如果设置为true,并且配置会导致基础设施中某个对象被删除,则报错 |
ignore_changes |
默认情况下,Terraform会对比真实基础设施中对象和当前配置文件中的所有字段,任何字段的不一致都会引发更新操作 该参数指定,在Terraform评估是否需要更新时,资源的哪些字段被忽略 如果设置为特殊值 all,则Terraform不会进行任何更新操作 |
某些资源类型提供了特殊的 timeouts内嵌块,用于指定多长时间后认定操作失败:
1 2 3 4 5 6 7 8 |
resource /* ... */ { # ... timeouts { create = "60m" update = "30s" delete = "2h" } } |
资源的绝大部分参数由资源类型决定。需要翻阅Provider的文档了解哪些参数可用。
对于大部分公共的、托管在Terraform仓库的Provider来说,其文档可以直接在仓库网站上获得。
对于一些无法使用Terraform声明式模型来表达的某些行为,可以使用Provisioner作为最后(总是不推荐的)手段。
使用Provisioner会引入复杂性和不确定性:
- Terraform无法将Provisioner执行的动作,作为计划的一部分。因为Provisioner理论上可以做任何事情
- Provisioner通常需要使用更多细节信息,例如直接访问服务器的网络、使用Terraform的登录凭证
Provisioner块不能用名字访问其所在的上下文资源,你必须使用 self对象。这个对象就指代对应的资源,你可以访问它的参数和属性。
该参数指定何时(create/update/destroy)运行Provisioner,下面是一个仅仅在删除资源时才执行的Provisioner:
1 2 3 4 5 6 7 |
resource "aws_instance" "web" { provisioner "local-exec" { # 在实际删除前调用 when = destroy command = "echo 'Destroy-time provisioner'" } } |
默认情况下,Provisioner在资源创建的时候调用,在更新/删除时不会调用。最常见的用法是使用Provisioner来初始化系统。如果Provisioner失败,则资源被标记为tainted,并且会在下一次 terraform apply时删除、重新创建。
定制Provisioner失败时的行为:
- continue 忽略错误
- fail 默认行为,导致配置应用立即失败,如果正在创建资源,则taint该资源
大部分Provisioner要求通过SSH或WinRM来访问远程资源。你可以在 connection块中声明如何连接。connection块可以内嵌在以下位置:
- resource,对资源的所有Provisioner生效
- provisioner,仅仅对当前Provisioner生效
在connection块中,你也可以使用 self来访问包含它的resource,这一点类似于provisioner
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
provisioner "file" { # Linux connection { type = "ssh" user = "root" password = "${var.root_password}" host = "${var.host}" } } provisioner "file" { # Windows connection { type = "winrm" user = "Administrator" password = "${var.admin_password}" host = "${var.host}" } } |
关于如何通过证书进行身份验证,如何通过堡垒机连接,参考官方文档。
如果你希望运行一个Provisioner,但是不想在任何真实的资源的上下文下运行,可以使用这种特殊的资源。
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 |
resource "aws_instance" "cluster" { count = 3 # ... } resource "null_resource" "cluster" { # Provisioner的触发时机 triggers = { # cluster中任何实例改变会触发Provisioner的重新执行 # 这种通配符语法会得到一个列表 cluster_instance_ids = "${join(",", aws_instance.cluster.*.id)}" } # 仅仅连接到第一个实例 connection { # 该函数访问列表的特定元素 host = "${element(aws_instance.cluster.*.public_ip, 0)}" } provisioner "remote-exec" { # 执行命令 inline = [ "bootstrap-cluster.sh ${join(" ", aws_instance.cluster.*.private_ip)}", ] } } |
Provisioner | 说明 | ||
file |
从运行Terraform的机器,复制文件或目录到新创建的资源(通常是虚拟机)。示例:
关于整个目录的拷贝,需要注意:
|
||
local-exec |
在资源创建之后,调用一个本地(运行Terraform的机器)可执行程序 注意:即使是在资源创建之后,但是不保证sshd这样的服务已经可用了。因此不要尝试在local-exec中调用ssh命令登录到资源
|
||
remote-exec |
在资源创建之后,登录到新创建的资源,执行命令
|
数据资源,由data块来描述,让Terraform从数据源读取信息。Data是一种特殊的Resource,也是由Provider来提供。Data只支持读取操作,相对的,普通的Resource支持增删改查操作,普通资源也叫受管(Manged)资源,一般简称Resource。
数据资源要求Terraform去特定数据源(第1标签)读取数据,并将结果导出为本地名称(第2标签):
1 2 3 4 5 6 7 8 9 10 11 |
# 读取匹配参数的aws_ami类型的数据源,并存放到example data "aws_ami" "example" { # 这些参数是查询条件(query constraints),具体哪些可用取决于数据源类型 most_recent = true owners = ["self"] tags = { Name = "app-server" Tested = "true" } } |
两个标签组合起来是data在模块中的唯一标识。
如果数据资源的查询约束(参数)都是常量值,或者是已知的变量值,那么数据资源的状态会在Terraform的Refresh阶段更新,这个阶段在创建执行计划之前。
查询约束可能引用了某些值,这些值在应用配置文件之前无法获知(例如资源ID)。这种情况下,读取数据源的操作将推迟到Apply之后。在Data读取完成之前,所有对它结果(导出名称)的引用都是不可用的。
大部分数据源都对应了某种云上基础设施,需要通过云API远程的读取。
某些特殊数据源仅仅供Terraform自己使用,例如Hashicorp的Provider template,它提供的template_file数据源,用于在本地渲染模板文件。这类数据源叫Local-Only数据源,其行为和一般数据源没有区别。
数据资源的依赖解析行为,和受管资源一致。
数据资源支持的元参数,和受管资源一致。
和传统编程语言对比,模块类似于函数,输入变量类似于函数参数,输出值类似于返回值,本地值则类似于函数内的局部变量。
输入变量作为Terraform模块的参数,从而实现模块的参数化、可跨多个配置复用。
对于定义在根模块中的变量,其值可以从Terraform CLI选项传入。对于子模块中定义的变量,则必须通过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 38 39 40 |
# 声明一个字符串类型的输入变量 # 变量名必须在模块范围内唯一 # 不得用做变量名:ource, version, providers, count, for_each, lifecycle, depends_on, locals variable "image_id" { # 类型 type = string # 描述 description = "" # 校验规则 validation { # 如果为true则校验成功 condition = bool-expr # 校验失败时的消息 error_message = "" } # 是否敏感,敏感信息不会现在Terraform UI上输出 sensitive = false } # 声明一个字符串的列表,并给出默认值 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" } ] } |
支持的简单类型: string、 number、 bool
支持的容器类型:
- 列表: list(<TYPE>)
- 集合: set(<TYPE>)
- 映射: map(<TYPE>)
- 对象: object({<ATTR NAME> = <TYPE>, ... })
- 元组: tuple([<TYPE>, ...])
关键字 any表示,任何类型都允许。
如果同时指定了类型和默认值,则提供的默认值必须可以转换为类型。
validation是一个块,其中condition是一个bool表达式:
1 2 3 4 5 6 7 8 9 10 11 |
variable "image_id" { type = string description = "The id of the machine image (AMI) to use for the server." validation { # can函数将错误转换为false # regex函数在找不到匹配的时候会失败 condition = can(regex("^ami-", var.image_id)) error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"." } } |
在声明输入变量的模块中,可以使用 var.<NAME>引用输入变量的值:
1 2 3 4 |
resource "aws_instance" "example" { instance_type = "t2.micro" ami = var.image_id } |
要给根模块中定义的变量赋值,有以下几种方式:
- 使用
-var命令行选项,可以多次使用,每次赋值一个变量,示例:
123terraform apply -var="image_id=ami-abc123"terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"terraform apply -var='image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' - 作为环境变量传入,示例:
12# 环境变量需要TF_VAR_前缀export TF_VAR_image_id=ami-abc123 - 使用
.tfvars文件,此文件可以自动载入或者通过命令行选项显式载入,示例:
12345image_id = "ami-abc123"availability_zone_names = ["us-east-1a","us-west-1c",]
1terraform apply -var-file="testing.tfvars"注意以下文件可以自动识别并载入:
- 名为 terraform.tfvars或 terraform.tfvars.json的文件
- 以 .auto.tfvars或 .auto.tfvars.json结尾的文件
如果通过多种方式给变量赋值,则优先级高的生效。优先级顺序从低到高:
- 环境变量文件
- terraform.tfvars
- terraform.tfvars.json
- *.auto.tfvars和*.auto.tfvars.json文件,多个文件,按字典序,后面的优先级高
- -var或-var-file命令行选项,多个选项,后面的优先级高
输出值是模块的“返回值”,具有以下用途:
- 子模块使用输出值将它创建的资源的属性的子集暴露给父模块
- 根模块可以利用输出值,将一些信息在terraform apply之后打印到控制台上
- 当使用远程状态(remote state)时,根模块的输出可以被其它配置通过 terraform_remote_state 数据源捕获到
1 2 3 4 5 6 7 8 9 10 11 |
# 输出的名字 output "instance_ip_addr" { # 值 value = aws_instance.server.private_ip # 描述 description = "" # 是否敏感 sensitive = "" # 依赖 depends_on = [] } |
子模块的输出值,通过这样的表达式访问:
1 2 3 4 |
module.<MODULE NAME>.<OUTPUT NAME> # 访问子模块web_server的输出值instance_ip_addr module.web_server.instance_ip_addr |
使用depends_on参数可以明确指定输出值依赖什么:
1 2 3 4 5 6 |
output "instance_ip_addr" { value = aws_instance.server.private_ip depends_on = [ aws_security_group_rule.local_access, ] } |
在一个模块内部,将表达式分配给一个名称。你可以同时声明多个本地值:
1 2 3 4 5 6 7 8 |
locals { service_name = "forum" instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id) common_tags = { Service = local.service_name Owner = local.owner } } |
引用本地值时,使用表达式: local.<NAME>
一套完整的配置文件(Configuration,简称配置),由1个根目录和若干文件/子目录组成。配置文件的扩展名为 .tf,此外HCL语言还提供了基于JSON的变体,这种配置文件的扩展名为 .tf.json。配置文件的编码方式为UTF-8,使用Unix风格的换行符。
所谓模块(Module)是存放在一个目录中的配置文件的集合。子目录中的文件不属于模块,不会自动包含到配置中。
将块存放在不同配置文件中仅仅是方便人类的阅读,和Terraform的行为无关。Terraform总是会评估模块中的所有文件,并将它们合并为单一的文档来看待。
Terraform命令总是在单个根模块的上下文中运行,根模块的目录通常作为命令的工作目录。此根模块会调用其它模块,这种调用关系会递归的发生,从而形成一个子模块树结构。
一个模块(通常是根模块)可以调用其它模块,从而将这些模块中定义的资源包含到配置中。当一个模块被其它模块调用时,它的角色是子模块。
子模块可以被同一个配置调用多次,不同模块也可以同时调用一个子模块。
引用子模块时,除了可以从本地文件系统获取,还可以从公共/私有仓库下载。
Terraform Registry托管了大量公共模块,用于管理各种各样的基础设施,这些模块可以被免费使用。Terraform能够自动下载这些模块的正确版本。Terraform Cloud / Enterprise版本都包含一个私服模块可以托管组织私有的模块。
使用名为 override.tf或 override.tf.json,或者后缀为 _override.tf或 _override.tf.json的文件,可以覆盖既有配置的某一部分。这些文件叫做覆盖文件。
加载配置时,Terraform最初会跳过覆盖文件。加载完成功文件后,会按照字典序处理覆盖文件。对于覆盖文件中的每个顶级块,Terraform会尝试寻找已经存在的,定义在常规文件中的对应块,并将块的内容进行合并。内容合并规则如下:
- 覆盖文件中的顶级块、普通配置文件中的顶级块,对应关系通过块头(块类型+标签)识别,相同块头的块被合并
- 顶级块中的参数被替换
- 顶级块中的内嵌块被替换,不会递归的进行合并
- 多个覆盖文件覆盖了同一个块的定义时,按覆盖文件名的字典序依次合并
此外,对于resource / data块,有如下特殊规则:
- 内嵌的lifecycle块不是简单的直接替换。假设覆盖文件仅仅设置了lifecycle的create_before_destroy属性,原始配置中任何ignore_changes参数保持原样
- 对于内嵌的provisioner块,原始配置中的(不管有几个)provisioner块直接被忽略
- 原始配置中的内嵌connection块被完全覆盖
- 元参数(meta-argument)depends_on不能出现在覆盖文件中
对于variable(变量)块,有如下特殊规则:
- 如果原始块定义了default参数(默认值)并且覆盖块修改了变量的type,则Terraform尝试将default转换为新的type,如果转换无法自动完成则报错
- 如果覆盖块修改了default,那么其值必须匹配原始块中的type
不建议过多的使用覆盖文件,这会降低配置的可读性。
对于output块,有如下特殊规则:
- 元参数depends_on不能出现在覆盖文件中
对于local块,有如下特殊规则:
- 每个local块定义(或修改)了若干具有名字的值,覆盖时使用value-by-value的方式,至于值在何处定义不影响
对于terraform块,有如下特殊规则:
- required_providers的值,按element-by-element的方式进行覆盖。这样,覆盖块可以仅仅调整单个provider的配置,而不影响其他providers
- 覆盖required_version、required_providers时,被覆盖的元素被整个替换
下面是原始文件+覆盖文件的示例:
1 2 3 4 5 6 7 8 9 10 |
# example.tf resource "aws_instance" "web" { instance_type = "t2.micro" ami = "ami-408c7f28" } # override.tf resource "aws_instance" "web" { ami = "foo" } |
所谓调用,就是将特定的值传递给子模块作为输入变量,从而将子模块中的配置包含进来。
1 2 3 4 5 6 7 8 9 10 11 |
module "servers" { # 源,必须,指定子模块的位置 source = "./app-cluster" # 版本,如果模块位于仓库中,建议制定 version = "0.0.5" # 支持一些元参数 # 大部分其它参数,都是为子模块提供输入变量 servers = 5 } |
元参数 | 说明 | ||
count | 用于创建多个模块实例,参考资源(resources)的元参数 | ||
for_each | 用于创建多个模块实例,参考资源(resources)的元参数 | ||
providers |
将Provider配置传递给子模块:
如果子模块没有定义任何Provider alias,则该元参数是可选的。不指定该元参数时,子模块会从父模块继承所有默认Provider配置,所谓默认配置就是没有alias的provider块所定义的配置 |
||
depends_on |
显式指定整个模块对特定目标的依赖,参考资源(resources)的元参数 |
module块的source参数指定了从何处下载子模块的代码。
terraform init中有一个步骤,专门负责模块的安装。它支持从本地路径、Terraform Registry、GitHub、一般性Git仓库、HTTP URL、S3桶等多种来源下载模块。
对于紧密相关的模块,例如为了减少代码重复,从单一模块拆分得到的,建议使用本地路径方式存储。
1 2 3 4 |
module "consul" { # 必须以./或../开头,提示这是一个本地模块 source = "./consul" } |
本地路径的源具有一个特殊的地方,它没有“安装”这个步骤。
对于希望公开分享的模块,可以存放在这个Terraform仓库中。这种仓库的源地址格式为 <HOSTNAME>/<NAMESPACE>/<NAME>/<PROVIDER>。示例:
1 2 3 4 5 6 |
# 这个模块创建一个Consul服务 module "consul" { # 为AWS提供的consule模块,使用Terraform公共仓库时HOSTNAME可以省略 source = "hashicorp/consul/aws" version = "0.1.0" } |
为了访问私有仓库,你可能需要在CLI配置中添加访问令牌。
如果source以github.com开头,则Terraform会尝试到GitHub拉取模块源码:
1 2 3 4 5 6 7 8 9 |
module "consul" { # 通过HTTPS source = "github.com/hashicorp/example" } module "consul" { # 通过SSH source = "git@github.com:hashicorp/example.git" } |
如果source以 git::开头,则Terraform认为模块托管在一般性的Git服务器中:
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命令下载模块,因此本地机器的任何Git配置都可用,包括凭证信息。
默认情况下,使用Git仓库的HEAD,要选择其它修订版,使用ref参数:
1 2 3 |
module "vpc" { source = "git::https://example.com/vpc.git?ref=v1.2.0" } |
任何可以作为git checkout参数的值,都可以作为ref参数。
如果source指定为一个普通的URL,那么Terraform会:
- 附加GET参数 terraform-get=1,请求那个URL
- 如果得到2xx应答,那么尝试从以下位置读取模块实际地址:
- 响应头 X-Terraform-Get
- HTML元素:
1<meta name="terraform-get" content="github.com/hashicorp/example" />
如果URL的结尾是可识别的压缩格式扩展名(zip tar.bz2 tbz2 tar.gz tgz tar.xz txz)则Terraform会跳过上面处理过程,直接下载压缩包:
1 2 3 |
module "vpc" { source = "https://example.com/vpc-module?archive=zip" } |
如果需要的模块位于源的子目录中,可以使用特殊的 //语法来引用:
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
git::https://example.com/network.git//modules/vpc?ref=v1.2.0
开发模块就是构思和编写TF文件的过程,你需要考虑模块的输入(变量)、输出(值),模块读取和创建哪些资源。你只需要将这些TF文件放在一个目录中就可以了。
如果开发的模块可能被很多配置复用,建议在独立的版本库中管理。
尽管内嵌多个子目录(作为子模块)是允许的,但是建议尽可能的让目录扁平化,可以使用模块组合,而避免深层次的目录树,这可以增强可复用性。
过度的模块化容易让配置难以理解。
一个好的模块应该通过描述架构中新概念来提升抽象层次,这个概念由一些Provider中的基本元素组成。例如,我们想基于一些CVM、一个CLB构建一个Redis集群,这样的集群就适合封装在一个模块中。
永远不要开发那种仅仅为了包装单个其它资源的模块。
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/ │ ├── .../ |
一个简单的Terraform配置,仅仅包含一个根模块,我们在这个扁平的结构中创建所有资源:
1 2 3 4 5 6 7 8 9 10 |
resource "aws_vpc" "example" { cidr_block = "10.1.0.0/16" } resource "aws_subnet" "example" { vpc_id = aws_vpc.example.id availability_zone = "us-west-2b" cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 4, 1) } |
当引入 modules块后,配置变成层次化的,每个模块可以创建自己的资源,甚至有自己的下级子模块。 无节制的使用子模块会导致很深的树状配置结构,这是反模式。
Terraform建议保持配置的扁平,仅仅引入一层子模块。假设我们需要在AWS上创建一个Consul集群,它依赖一个子网。我们可以将子网的创建封装到模块:
1 2 3 4 5 |
module "network" { source = "./modules/aws-network" base_cidr_block = "10.0.0.0/8" } |
将Consul集群的创建封装到另外一个模块:
1 2 3 4 5 6 |
module "consul_cluster" { source = "./modules/aws-consul-cluster" vpc_id = module.network.vpc_id subnet_ids = module.network.subnet_ids } |
根模块通过上面两个module块使用这两个子模块,这种简单的单层结构叫模块组合( module composition)
上面这个例子也体现了依赖反转的原则: consul_cluster需要一个网络,但是不是它(这个模块)自己去创建网络,而是由外部创建并注入给它。
在未来进一步的重构中,可能由另外一个配置负责创建网络,而仅仅通过数据资源将读取网络信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
data "aws_vpc" "main" { tags = { Environment = "production" } } data "aws_subnet_ids" "main" { vpc_id = data.aws_vpc.main.id } module "consul_cluster" { source = "./modules/aws-consul-cluster" vpc_id = data.aws_vpc.main.id subnet_ids = data.aws_subnet_ids.main.ids } |
开发可复用模块时,一个很常见的情况是,某个依赖的资源在某些条件下不需要创建 —— 例如在某些环境下,资源预先已经存在。
这种情况下同样要应用依赖反转原则:将这些可能需要创建的依赖资源作为模块的输入参数:
1 2 3 4 5 6 7 8 |
# 下面这个变量代表依赖的资源 variable "ami" { type = object({ # 仅仅需要定义对于本模块有意义的属性 id = string architecture = string }) } |
由模块的调用者决定创建依赖资源:
1 2 3 4 5 6 7 8 9 10 |
resource "aws_ami" "example" { name = "local-copy-of-ami" source_ami_id = "ami-abc123" source_ami_region = "eu-west-1" } module "example" { source = "./modules/example" ami = aws_ami.example } |
还是直接使用已经存在的依赖资源:
1 2 3 4 5 6 7 8 9 10 11 12 |
data "aws_ami" "example" { owner = "10000" tags = { application = "example-app" environment = "dev" } } module "example" { source = "./modules/example" ami = data.aws_ami.example } |
因为,只有调用者才清楚实际环境是什么样的。
Terraform本身没有提供适配多个云服务商的相似资源的抽象,这会屏蔽云服务商的差异性。尽管如此,作为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 |
module "webserver" { source = "./modules/webserver" } locals { fixed_recordsets = [ { name = "www" type = "CNAME" ttl = 3600 records = [ "webserver01", "webserver02", ] }, ] server_recordsets = [ for i, addr in module.webserver.public_ip_addrs : { name = format("webserver%02d", i) type = "A" records = [addr] } ] } |
上面的recordset,抽象了所有域名系统都应该支持的DNS记录集资源。
当针对某个云服务商实现此资源时,我们可以开发一个模块。
1 2 3 4 5 6 |
# 此模块在AWS Route53上实现了记录集资源 module "dns_records" { source = "./modules/route53-dns-records" route53_zone_id = var.route53_zone_id recordsets = concat(local.fixed_recordsets, local.server_recordsets) } |
需要切换云服务商时,只需要替换上述dns_records模块的source即可,指向对应的模块即可。所有这些模块都需要定义输入参数:
1 2 3 4 5 6 7 8 |
variable "recordsets" { type = list(object({ name = string type = string ttl = number records = list(string) })) } |
大部分模块都会描述需要被创建和管理的基础设施对象,偶尔的情况下,模块仅仅去抓取需要的信息。
其中一种情况是,一套系统被划分为多个子系统,这些子系统都需要获取某种信息,这些信息可以用由一个data-only模块抓取。
出于复用目的的模块可以发布到Terraform Registry, 这样模块可以很容易被所有人使用。如果仅仅在组织内部共享,可以发布到私有仓库。
通过Git、S3、HTTP等方式发布模块也是可以的,模块支持多种源。
所谓提供者,就是Terraform的插件/驱动,负责和特定云厂商的API或者其它任何API打交道。
每个Provider都可以提供若干受管资源、数据资源,供Terraform使用。 反过来说,任何资源都是由Provider提供,没有Provider,Terraform什么都做不了。
Provider和Terraform完全独立的分发,公共的Provider托管在Terraform仓库(Registry)。
在每次Terraform运行过程中,需要的Provider会自动被安装。
Terraform CLI会在初始化工作目录的时候安装Provider,它能够自动从Terraform Registry下载Provider,或者从本地镜像/缓存加载。要指定缓存位置,在CLI配置文件中设置 plugin_cache_dir。或者设置环境变量 TF_PLUGIN_CACHE_DIR
为了保证,针对一套配置,总是安装相同版本的Provider,可以使用CLI创建一个依赖锁文件,并将此文件和配置一起纳入版本管理。
每个模块都必须声明它需要哪些Provider,这样Terraform才能够安装和使用它们。声明在 required_providers块中进行:
1 2 3 4 5 6 7 8 9 10 11 12 |
terraform { required_providers { # 该块的每个参数,启用一个Provider # key是Provider的本地名称,必须是模块范围内的唯一标识符 mycloud = { # 源地址 source = "mycorp/mycloud" # 版本约束 version = "~> 1.0" } } } |
在required_providers块之外,Terraform总是使用本地名称来引用Provider:
1 2 3 |
provider "mycloud" { # 配置mycloud这个Provider } |
Provider的源地址,是它的全局标识符,这个地址当然也指明了应该从何处下载Provider。
源地址的格式为: [<HOSTNAME>/]<NAMESPACE>/<TYPE>,各字段说明如下:
- 可选的主机名,默认registry.terraform.io,即Terraform Registry的主机名
- 命名空间,通常是发布Provider的组织
- 类型,通常是Provider管理的平台/系统的简短名称
>= 1.0表示要求1.0或更高版本。 ~> 1.0.4表示仅仅允许1.0.x版本。
目前仅有一个内置于Terraform的Provider,名为terraform_remote_state。你不需要再配置文件中引入它,尽管如此它还是有自己的源地址terraform.io/builtin/terraform。
某些组织可能会开发自己的Provider,以管理特殊的基础设施。他们可能希望在Terraform中使用这些Provider,但是却不将其发布到公共仓库中。这种情况下,,构建自己的私有仓库。更简单的,可以使用更简单的Provider安装方法,例如自己将其存放在本地文件系统的特定目录。
任何Provider都必须有源地址,源地址必须包含(或者隐含默认值)一个主机名。如果通过本地文件系统分发Provider,则这个主机名只是个占位符,甚至不需要可解析。你通常可以使用terraform.yourcompany.com作为主机名。你可以这样引入私有Provider:
1 2 3 4 5 6 7 8 |
terraform { required_providers { mycloud = { source = "terraform.example.com/examplecorp/ourcloud" version = ">= 1.0" } } } |
选择一个隐式本地镜像目录(implied local mirror directories ),并创建目录terraform.example.com/examplecorp/ourcloud/1.0.0,在此目录中创建一个代表运行平台的子目录,例如linux_amd64,并将Provider的可执行文件、以及任何其它需要的文件存放到其中即可。对于Windows,可执行文件的路径可能是terraform.example.com/examplecorp/ourcloud/1.0.0/windows_amd64/terraform-provider-ourcloud.exe
除了引入Provider,你可能还需要对其进行配置才能使用。
配置时,使用provider块。只有根模块才可以配置一个Provider。子模块会自动从根模块继承Provider配置。示例:
1 2 3 4 5 |
# 本地名称,引入Provider时指定 provider "google" { project = "acme-app" region = "us-central1" } |
具体哪些配置参数可用,取决于Provider。配置时可以使用表达式,但是只能引用那些应用配置之前即可知的值 —— 可以安全的引用输入变量,但是不能使用那些由资源导出的属性。
Provider支持两个元参数,其中一个是alias,用于定义备选的Provider配置:
1 2 3 4 |
provider "aws" { alias = "west" region = "us-west-2" } |
声明资源时,可以指定使用备选的Provider配置:
1 2 3 |
resource "aws_instance" "foo" { provider = aws.west } |
这个元参数已经弃用,是旧的管理Provider版本的方式。
Terraform配置文件可以引用两类外部依赖:
- Providers,如上个章节所述,用于和外部系统交互的插件
- Modules,可复用的配置文件集合
这两类依赖都可以独立发布,并进行版本管理。引用这些依赖时,Terraform需要知道使用什么版本。
配置文件中的版本约束,指定了潜在的兼容性版本范围。但是到底选择(并锁定使用)依赖的哪个版本,由名为.terraform.lock.hcl的依赖锁文件决定。注意,当前依赖锁文件仅仅管理Provider的版本,对于Module,仍然总是拉取匹配版本约束的最新版本。
每当运行 terraform init命令时,依赖锁文件会自动创建/更新。此文件应该纳入版本管理。依赖锁文件使用和TF类似的语法。
运行terraform init时,如果:
- 依赖没有记录在依赖锁文件中,则尝试拉取匹配版本约束的最新版本。并将获取到的版本信息记录到依赖锁文件
- 依赖已经记录,则使用记录的版本
运行 terraform init -upgrade会强制拉取最新的、匹配约束的版本并更新依赖锁文件。
Terraform逻辑上划分为两个部分:核心和插件。Terraform核心通过RPC来调用插件。Terraform支持多种发现和加载插件的方式。Terraform插件有两类
- Provider:通常用于对接到某特定云服务商,在其上创建基础设施对象
- Provisioner:对接到某种provisioner,例如Bash
核心的职责包括:
- 基础设施即代码:读取和解释配置文件和模块
- 资源状态管理
- 构造资源依赖图
- 执行计划
- 通过RPC和插件交互
插件和核心一样,基于Go语言编写。Terraform使用的所有Provider和Provisioner都是插件,它们在独立进程中运行。
Provider插件的职责是:
- 初始化任何必要的库,用于进行API调用
- 与基础设施提供者进行交互
- 定义映射到特定服务的资源
Provisioner插件的职责是:
- 在特定资源创建后、销毁前执行命令或脚本
当 terraform init运行后,Terraform会读取工作目录中的配置文件,确定需要哪些插件。并在多个位置搜索以及安装的插件,下载缺失的插件,确定使用插件的什么版本,并且更新依赖锁文件,锁定插件版本。
关于插件发现,有以下规则:
- 如果已经安装了满足版本约束的插件,Terraform会使用其中最新的。即使Terraform Registry有更新的满足版本约束的插件,默认也不会主动下载。使用 terraform init -upgrade可以强制下载最新版本
- 如果没有安装满足版本约束的插件,且插件托管在Registry,则下载并存放到 .terraform/providers/目录下
Provider应该基于单一API集合,或者SDK,例如仅仅针对腾讯云API,实现对腾讯云基础实施对象CRUD的封装。
Terraform插件定义的资源,应该对应单一的云资源,作为该云资源的声明式表示。资源通常应该提供创建/读取/删除,以及可选的更新方法。
对多个云资源的组合,或者其它高级特性,应该通过模块来实现。
名称、数据类型、结构,应当尽可能匹配,除非这样做会影响Provider用户的体验。
Terraform资源应该支持 terraform import操作。
一旦Provider发布,后续就面临向后兼容性问题。
开发Provider时,有两个SDK可供选择:
- SDKv2:当前大部分现有的Provider基于此SDK开发,提供稳定的开发体验
- Terraform Plugin Framework:新的SDK,还在积极的开发中。目标是提升开发体验,对齐Terraform新的架构
如何选择:
- 如果维护既有Provider,沿用它当前使用的SDK。如果开发全新的Provider,可以考虑使用Framework
- 如果使用的Terraform版本小于v1.0.3,则只能基于SDKv2开发。Framework基于Terraform Protocol Version 6构建,旧版本的Terraform不能下载基于Version 6的Provider
- Framework的接口可能发生改变,考虑成本
- 是否需要Framework提供的新特性:
- 支持获知一个值是否在配置、状态或计划中设置
- 支持获知一个值是否null、unknown,或者是空白值
- 支持object这样的结构化类型
- 支持嵌套属性
- 支持以any类型为元素的map
- 支持获知何时一个optional或计算出的字段从配置中移除了
- 是否需要Framework尚未实现的,SDKv2已经支持的特性:
- 超时支持
- 定义资源状态upgraders
构建Provider的时候,使用go build命令,按照构建二进制文件的常规方式即可。你也可以使用GoReleaser来自动化构建多平台的、包含checksum的、自动签名的Provider。
每个发布包含以下文件:
- 一个或多个zip文件,其命名格式为
terraform-provider-{NAME}_{VERSION}_{OS}_{ARCH}.zip
- zip中包含Provider的二进制文件,命名为 terraform-provider-{NAME}_v{VERSION}
- 一个摘要文件
terraform-provider-{NAME}_{VERSION}_SHA256SUMS,包含每个zip的sha256:
1shasum -a 256 *.zip > terraform-provider-{NAME}_{VERSION}_SHA256SUMS - 一个GPG二进制文件
terraform-provider-{NAME}_{VERSION}_SHA256SUMS.sig,使用密钥对上述摘要文件进行前面:
1gpg --detach-sign terraform-provider-{NAME}_{VERSION}_SHA256SUMS - 发布必须是finalized的(不是一个私有的草稿)
本章开发和使用一个Provider,它和虚构的咖啡店应用Hashicups进行交互。此咖啡店应用支持查看和订购咖啡,它开放公共API端点:
- 列出咖啡品种
- 列出特定咖啡的成分
以及需要身份认证的API端点:
- CRUD咖啡订单
HashiCups Provider基于一个Golang客户端库,利用REST API访问以上API端点,管理咖啡订单。
首先我们从用户角度来感受一下,如何使用HashiCups Provider管理咖啡订单。本章后续会分析和重构该Provider的源码。
执行下面的命令下载使用HashiCups Provider的Terraform配置的空白项目。此项目没有Terraform配置,但是提供了在本地运行HashiCup咖啡店应用的脚本。
1 2 |
git clone https://github.com/hashicorp/learn-terraform-hashicups-provider cd learn-terraform-hashicups-provider |
执行下面的命令,在本地启动HashiCup咖啡店应用:
1 |
cd docker_compose && docker-compose up |
API监听端口是19090。检查并确认服务器正常工作:
1 |
curl localhost:19090/health |
从0.13+开始,必须在Terraform配置中声明所有依赖的Provider及其源(从哪里下载)。源的格式为[hostname]/[namespace]/[name],对于Terraform Registry中的hashicorp命名空间,hostname和namespace都是可选的。Terraform Registry对应的hostname为registry.terraform.io
这里用到的Provider没有托管在Registry,需要手工下载:
1 |
curl -LO https://github.com/hashicorp/terraform-provider-hashicups/releases/download/v0.3.1/terraform-provider-hashicups_0.3.1_linux_amd64.zip |
或者从源码编译:
1 2 3 4 |
git clone https://github.com/hashicorp/terraform-provider-hashicups go mod vendor go build -o terraform-provider-hashicups mv terraform-provider-hashicups ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64 |
并且存放到:
~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target}
~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1
1 |
curl -X POST localhost:19090/signup -d '{"username":"education", "password":"test123"}' |
登录以获得Token:
1 2 |
curl -X POST localhost:19090/signin -d '{"username":"education", "password":"test123"}' {"UserID":1,"Username":"education","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw"} |
将Token设置到环境变量:
1 |
export HASHICUPS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw |
添加下面的代码到main.tf:
1 2 3 4 5 6 7 8 |
terraform { required_providers { hashicups = { version = "~> 0.3.1" source = "hashicorp.com/edu/hashicups" } } } |
并进行初始化: terraform init
将以下内容添加到main.tf中:
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 |
# 配置Provider provider "hashicups" { username = "education" password = "test123" } # 定义一个名为edu的订单资源 resource "hashicups_order" "edu" { # 订单包含2个品种3的咖啡 items { coffee { id = 3 } quantity = 2 } # 订单包含2个品种2的咖啡 items { coffee { id = 2 } quantity = 2 } } # 输出edu资源,这个输出在资源创建后可用 output "edu_order" { value = hashicups_order.edu } |
执行下面的命令获取执行计划:
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 |
terraform plan 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: # 这里会列出计划中包含的操作 # 这是一个创建操作 # hashicups_order.edu will be created + resource "hashicups_order" "edu" { # 每个属性的值 这个表示未知值 + id = (known after apply) + last_updated = (known after apply) + items { + quantity = 2 + coffee { + description = (known after apply) + id = 3 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) } } + items { + quantity = 2 + coffee { + description = (known after apply) + id = 2 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) } } } Plan: 1 to add, 0 to change, 0 to destroy. # 这里显示输出值会发生的变更 Changes to Outputs: + edu_order = { + id = (known after apply) + items = [ + { + coffee = [ + { + description = (known after apply) + id = 3 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) }, ] + quantity = 2 }, + { + coffee = [ + { + description = (known after apply) + id = 2 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) }, ] + quantity = 2 }, ] + last_updated = (known after apply) } |
执行 terraform apply即可应用变更。 利用 terraform state show命令可以显示资源状态:
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 |
terraform state show hashicups_order.edu # hashicups_order.edu: resource "hashicups_order" "edu" { id = "1" items { quantity = 2 coffee { id = 3 image = "/nomad.png" name = "Nomadicano" price = 150 teaser = "Drink one today and you will want to schedule another" } } items { quantity = 2 coffee { id = 2 image = "/vault.png" name = "Vaulatte" price = 200 teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" } } } |
可以看到Schema中所有属性均被填充。
我们修改一下订单配置,将items[*].quantity改一下,然后看看执行计划:
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 |
terraform plan hashicups_order.edu: Refreshing state... [id=1] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: ~ update in-place # ~表示原地更新操作 Terraform will perform the following actions: # 这里会显示diff # hashicups_order.edu will be updated in-place ~ resource "hashicups_order" "edu" { id = "1" ~ items { ~ quantity = 2 -> 3 # (1 unchanged block hidden) } ~ items { ~ quantity = 2 -> 1 # (1 unchanged block hidden) } } Plan: 0 to add, 1 to change, 0 to destroy. Changes to Outputs: # 这里会显示diff ~ edu_order = { ~ items = [ ~ { ~ quantity = 2 -> 3 # (1 unchanged element hidden) }, ~ { ~ quantity = 2 -> 1 # (1 unchanged element hidden) }, ] # (2 unchanged elements hidden) } |
通过apply命令应用上述执行计划。
本节我们来演示如何读取已经创建的订单的咖啡的配料表:
1 2 3 4 5 6 7 8 |
data "hashicups_ingredients" "first_coffee" { # 声明多次的内嵌块,自动成为数组 coffee_id = hashicups_order.edu.items[0].coffee[0].id } output "first_coffee_ingredients" { value = data.hashicups_ingredients.first_coffee } |
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 |
terraform destroy hashicups_order.edu: Refreshing state... [id=1] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: - destroy # -表示删除操作 Terraform will perform the following actions: # hashicups_order.edu will be destroyed - resource "hashicups_order" "edu" { - id = "1" -> null - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST" -> null - items { - quantity = 3 -> null - coffee { - id = 3 -> null - image = "/nomad.png" -> null - name = "Nomadicano" -> null - price = 150 -> null - teaser = "Drink one today and you will want to schedule another" -> null } } - items { - quantity = 1 -> null - coffee { - id = 2 -> null - image = "/vault.png" -> null - name = "Vaulatte" -> null - price = 200 -> null - teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" -> null } } } Plan: 0 to add, 0 to change, 1 to destroy. Changes to Outputs: - edu_order = { - id = "1" - items = [ - { - coffee = [ - { - description = "" - id = 3 - image = "/nomad.png" - name = "Nomadicano" - price = 150 - teaser = "Drink one today and you will want to schedule another" }, ] - quantity = 3 }, - { - coffee = [ - { - description = "" - id = 2 - image = "/vault.png" - name = "Vaulatte" - price = 200 - teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" }, ] - quantity = 1 }, ] - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST" } -> null - first_coffee_ingredients = { - coffee_id = 3 - id = "3" - ingredients = [ - { - id = 1 - name = "ingredient - Espresso" - quantity = 20 - unit = "ml" }, - { - id = 3 - name = "ingredient - Hot Water" - quantity = 100 - unit = "ml" }, ] } -> null |
签出Provider源码:
1 |
git clone --branch boilerplate https://github.com/hashicorp/terraform-provider-hashicups |
注意SDKv2的依赖:
1 |
github.com/hashicorp/terraform-plugin-sdk/v2 v2.8.0 |
分支boilerplate包含一些样板文件。 程序的入口点如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package main import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" "terraform-provider-hashicups/hashicups" ) func main() { // 启动插件的RPC服务器端 plugin.Serve(&plugin.ServeOpts{ ProviderFunc: func() *schema.Provider { return hashicups.Provider() }, }) } |
Provider函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
package hashicups import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) // 定义了一个Provider func Provider() *schema.Provider { return &schema.Provider{ // 资源映射,从资源类型名到Schema ResourcesMap: map[string]*schema.Resource{}, // 数据资源映射,从资源类型名到Schema。注意资源和数据资源本质上是同一种结构 DataSourcesMap: map[string]*schema.Resource{}, } } |
上面已经定义了Provider的骨架,这里我们实现一个咖啡数据源,此数据源能够从HasiCups服务拉取所有售卖的咖啡信息。
建议每个数据源都在独立的源文件中编写,并且文件名以 data_source_开头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package hashicups import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) // 定义了一个Resource,其中包含Schema以及CRUD操作 func dataSourceCoffees() *schema.Resource { return &schema.Resource{ // 由于数据资源仅仅支持读操作,因此仅声明ReadContext ReadContext: dataSourceCoffeesRead, Schema: map[string]*schema.Schema{}, } } |
下面我们完善此数据资源的Schema,根据Terraform的最佳实践,Schema应该尽量和基础实施匹配:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// curl localhost:19090/coffees [ { "id": 1, "name": "Packer Spiced Latte", "teaser": "Packed with goodness to spice up your images", "description": "", "price": 350, "image": "/packer.png", "ingredients": [ { "ingredient_id": 1 }, { "ingredient_id": 2 }, { "ingredient_id": 4 } ] } ] |
根据服务器返回的咖啡数据结构,编写对应的Schema并且添加到上面的dataSourceCoffees方法中:
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 |
Schema: map[string]*schema.Schema{ "coffees": { // 注意这里是列表 Type: schema.TypeList, Computed: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeInt, // 此值是"计算得到的",也就是在创建资源时(除非手工配置)会得到此值的结果 Computed: true, }, "name": { Type: schema.TypeString, Computed: true, }, "teaser": { Type: schema.TypeString, Computed: true, }, "description": { Type: schema.TypeString, Computed: true, }, "price": { Type: schema.TypeInt, Computed: true, }, "image": { Type: schema.TypeString, Computed: true, }, // 复杂类型,一个列表 "ingredients": { Type: schema.TypeList, Computed: true, // 列表元素的Schema Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "ingredient_id": { Type: schema.TypeInt, Computed: true, }, }, }, // 如果是list(string),可以这样声明Elem Elem: schema.Schema{ Type: schema.TypeString, }, }, }, }, }, }, } } |
定义好Schema后,我们需要实现读操作:
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 |
// 读结果存放在这里 这个m是meta,元参数, // 是配置Provider的返回值,下文有说明 func dataSourceCoffeesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { client := &http.Client{Timeout: 10 * time.Second} // 这是一个切片,用于收集警告或者错误信息 var diags diag.Diagnostics // 像云API发起请求 req, err := http.NewRequest("GET", fmt.Sprintf("%s/coffees", "http://localhost:19090"), nil) if err != nil { // 收集错误 return diag.FromErr(err) } r, err := client.Do(req) if err != nil { return diag.FromErr(err) } defer r.Body.Close() // 将响应反串行化到临时对象中 coffees := make([]map[string]interface{}, 0) err = json.NewDecoder(r.Body).Decode(&coffees) if err != nil { return diag.FromErr(err) } // schema.ResourceData用于查询和设置资源属性 // 此方法将响应设置到Terraform数据源,并且保证字段设置到Schema对应位置 if err := d.Set("coffees", coffees); err != nil { return diag.FromErr(err) } // 设置数据资源的ID d.SetId(strconv.FormatInt(time.Now().Unix(), 10)) return diags } |
数据资源的ID被设置为非空值,这提示Terraform,目标资源已经被创建。 作为一个列表,此数据资源没有真实的ID,因此我们这里设置为时间戳。
如果数据资源被从Terraform外部删除了,这里应该设置空ID:
1 2 3 4 |
if resourceDoesntExist { d.SetID("") return } |
这样Terraform会自动将state中的数据资源清除掉。
将上面的数据资源配置到Provider中:
1 2 3 4 5 6 7 8 |
func Provider() *schema.Provider { return &schema.Provider{ ResourcesMap: map[string]*schema.Resource{}, DataSourcesMap: map[string]*schema.Resource{ "hashicups_coffees": dataSourceCoffees(), }, } } |
现在我们开发一个模块,使用上面定义的咖啡数据源。首先需要编译好Provider: make install。
根模块配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
terraform { required_providers { hashicups = { // 当前构建的版本是0.2 version = "0.2" source = "hashicorp.com/edu/hashicups" } } } // 目前Provider不支持配置 provider "hashicups" {} // 调用coffee子模块 module "psl" { source = "./coffee" // 传递输入参数 coffee_name = "Packer Spiced Latte" } // 打印coffee模块的coffee输出值 output "psl" { value = module.psl.coffee } |
coffee子模块配置:
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 |
// 子模块需要声明自己的依赖 terraform { required_providers { hashicups = { version = "0.2" source = "hashicorp.com/edu/hashicups" } } } // 输入变量,咖啡品类 variable "coffee_name" { type = string default = "Vagrante espresso" } // 调用上面开发的数据源,拉取所有咖啡品类 data "hashicups_coffees" "all" {} # 输出所有咖啡 output "all_coffees" { value = data.hashicups_coffees.all.coffees } output "coffee" { value = { // 遍历所有咖啡 for coffee in data.hashicups_coffees.all.coffees : // 返回咖啡ID到咖啡资源的映射 coffee.id => coffee // 过滤,要求名称匹配输入参数 if coffee.name == var.coffee_name } } |
应用根模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# terraform init && terraform apply --auto-approve psl = { "1" = { "description" = "" "id" = 1 "image" = "/packer.png" "ingredients" = tolist([ { "ingredient_id" = 1 }, { "ingredient_id" = 2 }, { "ingredient_id" = 4 }, ]) "name" = "Packer Spiced Latte" "price" = 350 "teaser" = "Packed with goodness to spice up your images" } } |
本节我们来演示如何为Provider增加参数,Provider的实现又是如何读取这些参数的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
func Provider() *schema.Provider { return &schema.Provider{ // ... // 定义Provider的配置参数 Schema: map[string]*schema.Schema{ "username": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_USERNAME", nil), }, "password": { Type: schema.TypeString, Optional: true, // 敏感数据,在输出时会处理 Sensitive: true, // 默认值函数 从环境变量读取默认值 DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_PASSWORD", nil), }, }, ConfigureContextFunc: providerConfigure, } } |
用户提供的username/password,如何被Provider的ReadContext函数访问呢?这需要配置Provider。配置过程是由 schema.Provider的 ConfigureContextFunc函数负责的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { // 读取Provider的配置参数 username := d.Get("username").(string) password := d.Get("password").(string) var diags diag.Diagnostics if (username != "") && (password != "") { c, err := hashicups.NewClient(nil, &username, &password) if err != nil { return nil, diag.FromErr(err) } return c, diags } c, err := hashicups.NewClient(nil, nil, nil) if err != nil { return nil, diag.FromErr(err) } return c, diags } |
可以看到配置Provider后,会返回一个interface{},这个对象会传递给CRUD操作的最后一个参数:
1 2 3 4 5 6 7 8 9 10 11 |
// See Resource documentation. type CreateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics // See Resource documentation. type ReadContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics // See Resource documentation. type UpdateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics // See Resource documentation. type DeleteContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics |
一般情况下,这个interface{}是配置好的云API客户端,或者是云API配置结构。
本节我们演示如何在读操作中使用Provider参数,更精确的说是基于这些参数配置Provider后的结果。
首先我们创建几个咖啡订单(用于后续查询):
1 |
curl -X POST -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders -d '[{"coffee": { "id":1 }, "quantity":4}, {"coffee": { "id":3 }, "quantity":3}]' |
看一下订单的数据结构:
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 |
// curl -X GET -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders/2 { "id": 2, "items": [ { "coffee": { "id": 1, "name": "Packer Spiced Latte", "teaser": "Packed with goodness to spice up your images", "description": "", "price": 350, "image": "/packer.png", "ingredients": null }, "quantity": 4 }, { "coffee": { "id": 3, "name": "Nomadicano", "teaser": "Drink one today and you will want to schedule another", "description": "", "price": 150, "image": "/nomad.png", "ingredients": null }, "quantity": 3 } ] } |
下面我们定义对应的数据源:
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 |
func dataSourceOrder() *schema.Resource { return &schema.Resource{ // 读取操作,见下文 ReadContext: dataSourceOrderRead, Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeInt, Required: true, }, "items": { Type: schema.TypeList, Computed: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "coffee_id": { Type: schema.TypeInt, Computed: true, }, "coffee_name": { Type: schema.TypeString, Computed: true, }, "coffee_teaser": { Type: schema.TypeString, Computed: true, }, "coffee_description": { Type: schema.TypeString, Computed: true, }, "coffee_price": { Type: schema.TypeInt, Computed: true, }, "coffee_image": { Type: schema.TypeString, Computed: true, }, "quantity": { Type: schema.TypeInt, Computed: true, }, }, }, }, }, } } |
注意这个数据源的Schema和API的结构没有做对应,进行了扁平化处理。
读取订单的操作如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
func dataSourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // 配置Provider后,得到的是一个客户端 c := m.(*hc.Client) var diags diag.Diagnostics orderID := strconv.Itoa(d.Get("id").(int)) // 基于此客户端进行订单查询 order, err := c.GetOrder(orderID) if err != nil { return diag.FromErr(err) } // 订单结构和我们数据源的结构不一致,这里做转换 orderItems := flattenOrderItemsData(&order.Items) if err := d.Set("items", orderItems); err != nil { return diag.FromErr(err) } d.SetId(orderID) return diags } |
将此资源注册到Provider:
1 2 3 4 5 6 7 8 9 10 |
func Provider() *schema.Provider { return &schema.Provider{ // ... DataSourcesMap: map[string]*schema.Resource{ // ... "hashicups_order": dataSourceOrder(), }, ConfigureContextFunc: providerConfigure, } } |
在配置文件中使用该数据源:
1 2 3 4 5 6 7 |
data "hashicups_order" "order" { id = 1 } output "order" { value = data.hashicups_order.order } |
本节演示如何基于日志来调试Provider,我们会添加定制的错误消息,并且显示详细的Terraform Provider日志。
开发Provider时需要实现很多函数,这些函数常常具有一个返回值 diag.Diagnostics,例如上面的CRUD操作,以及配置Provider的函数:
1 |
type ConfigureContextFunc func(context.Context, *ResourceData) (interface{}, diag.Diagnostics) |
警告/错误级别的调试信息,都要放到diag.Diagnostics中,执行Terraform CLI命令时,这些信息会自动打印。
当创建HashiCups客户端失败时,我们可以添加一条针对信息:
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 |
func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { // ... if (username != "") && (password != "") { c, err := hashicups.NewClient(nil, &username, &password) if err != nil { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "Unable to create HashiCups client", Detail: "Unable to auth user for authenticated HashiCups client", }) return nil, diags } return c, diags } c, err := hashicups.NewClient(nil, nil, nil) if err != nil { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "Unable to create HashiCups client", Detail: "Unable to auth user for authenticated HashiCups client", }) return nil, diags } return c, diags } |
执行命令时,错误信息会打印出来:
1 2 3 4 5 6 7 8 |
terraform init && terraform apply --auto-approve ## ... module.psl.data.hashicups_coffees.all: Refreshing state... # 摘要 Error: Unable to create HashiCups client # 详情 Unable to auth user for authenticated HashiCups client |
资源支持创建、修改、删除等写操作,上面编写的数据源则仅支持读。尽管资源、数据源可能指向同一类实体,但是Schema不能共用。
资源的文件名前缀通常使用 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 |
package hashicups import ( "context" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceOrder() *schema.Resource { return &schema.Resource{ CreateContext: resourceOrderCreate, ReadContext: resourceOrderRead, UpdateContext: resourceOrderUpdate, DeleteContext: resourceOrderDelete, Schema: map[string]*schema.Schema{}, } } func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // Warning or errors can be collected in a slice type var diags diag.Diagnostics return diags } func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // Warning or errors can be collected in a slice type var diags diag.Diagnostics return diags } func resourceOrderUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { return resourceOrderRead(ctx, d, m) } func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // Warning or errors can be collected in a slice type var diags diag.Diagnostics return diags } |
我们需要将其注册到Provider:
1 2 3 4 5 6 7 8 9 |
func Provider() *schema.Provider { return &schema.Provider{ // ... ResourcesMap: map[string]*schema.Resource{ "hashicups_order": resourceOrder(), }, // ... } } |
不同于上问的订单数据源,这里我们设计了和API结构更加匹配的Schema:
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 |
Schema: map[string]*schema.Schema{ "items": { Type: schema.TypeList, Required: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "coffee": { Type: schema.TypeList, // coffee明明是一个,还非得声明为列表 MaxItems: 1, Required: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeInt, Required: true, }, "name": { Type: schema.TypeString, Computed: true, }, "teaser": { Type: schema.TypeString, Computed: true, }, "description": { Type: schema.TypeString, Computed: true, }, "price": { Type: schema.TypeInt, Computed: true, }, "image": { Type: schema.TypeString, Computed: true, }, }, }, }, "quantity": { Type: schema.TypeInt, Required: true, }, }, }, }, } |
注意和订单数据源Schema的其它几个重要区别:
- 在资源Schema中,顶级的id属性不存在。这是因为资源中你无法提前知道id,也不能将id作为输入参数。id是在资源创建过程中自动生成的
- 在资源Schema中,items是必须字段,而不是计算出的字段。这是因为我们在配置中声明订单资源时必须提供订单项信息
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 |
func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // 元参数的真实类型是HashCups客户端 c := m.(*hc.Client) var diags diag.Diagnostics // 从ResourceData中获得订单条目(的参数) items := d.Get("items").([]interface{}) ois := []hc.OrderItem{} // 遍历订单条目,构造为HashCups客户端所需的OrderItem for _, item := range items { i := item.(map[string]interface{}) co := i["coffee"].([]interface{})[0] // 只取第一个coffee,为何非要定义为列表 coffee := co.(map[string]interface{}) oi := hc.OrderItem{ Coffee: hc.Coffee{ ID: coffee["id"].(int), }, |