混合云编排工具terraform

原文:https://int32bit.me/2019/10/09/%E6%B7%B7%E5%90%88%E4%BA%91%E7%BC%96%E6%8E%92%E5%B7%A5%E5%85%B7Terrafrom%E7%AE%80%E4%BB%8B/

1 Terraform背景

1.1 混合云编排

目前各大公有云以及云管理平台均提供了非常友好的交互界面,用户可以像超市买东西一样自助采购云资源。然而,当用户需要批量快速打包采购大量不同类型的云资源时,云管页面过多的交互反而降低了资源采购效率。

据统计,一个熟悉AWS页面操作的工程师,在AWS上初始化一个VPC包括创建VPC、子网、internet网关、NAT网关、路由等工作至少需要花费20分钟的时间,如果涉及跨多个云平台,则花费的时间势必会更长。这就像我们逛超市,不同的商品在不同的区域,甚至不同的售卖方式,有些东西还可能需要逛多个超市去买,很多重复的体力活,一件一件地买不仅工作量繁重还非常浪费时间。

如果我们只需要写一份完整货单然后直接下单效率就会提高很多,而且货单可以随时修改和复用。在云计算中这被称作资源编排(Orchestration),其实在很多云平台中都已经提供了资源编排的功能,比如AWS的CloudFormation、OpenStack的Heat等。

但是如上工具通常仅限用于自家的单一云平台上,而在混合云场景下,往往需要跨多个云平台,不仅有IaaS资源,还有PaaS资源,如果杂糅非常多的云编排工具,则不仅导致学习成本高、代码复用率低,还导致管理混乱,不利于多云的协同工作。解决如上问题的方法是引入一个统一的编排工具,能够通过相同的语法同时编排AWS、GCP、Kubernetes、Vmware、OpenStack、阿里云等云资源。

而这个混合云统一编排工具目前已经有很好的开源项目支持了,它就是下面将要介绍的Terraform项目。

1.2 Terraform简介

Terraform的设计目标为Infrastructure as Code,这里的Infrastructure是一个非常抽象的东西,可以认为是数据中心的一切抽象,如二层网络、交换机(子网)、路由器、虚拟机、负载均衡、防火墙、数据库等等。

Terraform是由Hashicorp公司推出的一个开源项目,这是一家牛逼的公司,除了Terraform项目,开源的项目还有我们熟知的Consul、Vault、Nomad等,涵盖了应用的Provision(资源供给)、Secure(密钥安全)、Connect(分布式通信)、Run(运行)4个阶段。

hashicorp products

Terraform和前面提到的CloudFormation、Heat相比除了支持混合云统一编排以外,还有个不同之处在于,Terraform在真正执行之前中间会有个plan计划阶段,这个阶段能够预览哪些资源会新创建、哪些资源会被删除以及更新,这有点像git在commit之前先执行diff人工review下代码,让开发者能够提前检查语法是否有错误以及资源是否为期望结果。

可能有人会说目前不是已经有一些配置管理工具(Configuration Management)如Puppet、Ansible、Chef等同样也可以创建云资源,比如Ansible就提供了模块支持创建AWS资源Ansible: Amazon Web Services,甚至可以通过--dry-run参数实现类似Terraform的plan功能。

但二者其实是有差别的,首先Orchestration如Terraform主要解决底层基础设施资源管理问题,而配置管理工具如Ansible主要面向操作系统的配置。其次Orchestration通常是声明式的(Declarative),声明式只关心最终的全局结果是否符合期望,如果和声明的不一致,则创建或者修改资源使其匹配最终状态。而配置管理工具通常是面向过程的(Procedural),需要告诉它哪个有,哪个没有,哪一步怎么做,执行时关心的是每个指令而不是最终的全局结果。

打个比方使用Ansible和Terraform都可以实现在AWS上同时创建5个虚拟机,并且都是通过count = 5变量指定实例数量。

Ansible:

1
- ec2: count: 5 image: ami-1 instance_type: t2.micro

Terraform:

1
2
3
4
5
resource "aws_instance" "example" { 
count = 5
ami = "ami-1"
instance_type = "t2.micro"
}

现在如果需要增加1个虚拟机,Terraform只需要把count值修改为6即可,因为count表示为全局的最终结果。但Ansible如果把count修改为6,则会再创建6个虚拟机,一共11个虚拟机,因此要实现创建6台虚拟机,只能再拷贝一份代码并指定count为1,非常不灵活。

如果修改AMI镜像,Terraform只需要修改ami参数为新镜像ID即可,Ansible则必须重写模块,并且需要手动删除没用的旧虚拟机。

2 Terraform入门

Terrafrom的安装可参考官方的文档Installing Terraform,安装完后建议先配置子命令自动补全功能:

1
terraform -install-autocomplete

2.1 Provider

Terraform其中一个最重要的概念为Provider,Provider为后端驱动,类似于Ansible的模块或者驱动,Provider为云平台的插件,换句话说,只需要实现Provider,就可以对接任一云平台。目前大多数云平台的Provider插件均已经实现了,AWS对应的Provider为aws,阿里云对应的Provider为alicloud。由于资源必然属于某个云平台,因此显然Terraform中所有的资源必须隶属于某个Provider。

Terraform目前支持超过160多种的providers,可以说只要人听过的云都能支持,主流的如AWS、GCP、OpenStack等,国内的阿里云、腾讯云、Ucloud以及OpenStack系的华为云、京东云等。

除了公有云,私有云如Oracle、Vmware的支持也都完全没有问题。同时也支持目前主流的PaaS平台,如Kubernetes、Helm、Rancher2等,基本不需要再造轮子直接用就完了。

terraform providers

Provider在Terraform中以插件的形式加载,在init阶段Terraform会自动下载所需要的所有Provider插件。

定义Provider实例的语法如下:

1
provider "aws" { profile = "default" region = "cn-northwest-1" }

语法和Puppet、Ansible非常类似。

其中aws为需要加载的Provider插件名称,大括号里面的内容为该Provider的配置,profiledefault表示AWS的认证信息为~/.aws/credentialsdefault配置信息。当然也可以直接把AKSK(access key、secret key)直接硬编码放到provider,不过这存在AKSK泄露的隐患,不建议这么做。

如上运行teraform init时会自动下载Provider aws插件。

2.2 Resource

2.2.1 Resource声明与创建

Resource是Terraform的主角,开发者大多数工作都是和Resource打交道,云平台中所有的资源都可以抽象为Terraform中的一个Resource实例。

定义一个Resource的语法非常简单,以官方的demo为例:

1
# cat example.tf /* 省略了Provider的定义 */ resource "aws_instance" "example" { ami = "ami-0829e595217a759b9" instance_type = "t2.micro" tags = { "Owner" = "int32bit" "Name" = "int32bit-test-ft" } }
  • 其中aws_instance资源类型(Resource Type),定义这个资源的类型,告诉Terraform这个Resource是AWS的虚拟机还是阿里云的VPC。
  • example资源名称(Resource Name),资源名称在同一个模块中必须唯一,主要用于供其他资源引用该资源。
  • 大括号里面的block块为配置参数(Configuration Arguments),定义资源的属性,比如虚拟机的规格、镜像、标签等。

显然这个Terraform脚本的功能为在AWS上创建一个EC2实例,镜像ID为ami-0829e595217a759b9,规格为t2.micro,自定义了OwnerName两个标签。

运行terraform init将根据脚本内容自动下载Provider插件:

terraform init

我们可以随时通过terraform plan预览查看这个脚本将要执行的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# terraform plan
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create

Terraform will perform the following actions:

# aws_instance.example will be created
+ resource "aws_instance" "example" {
+ ami = "ami-0829e595217a759b9"
+ arn = (known after apply)
+ tags = {
+ "Name" = "int32bit-test-ft"
+ "Owner" = "int32bit"
}
+ vpc_security_group_ids = (known after apply)
+ ...

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

如上输出可知,Terraform脚本将创建一个资源aws_instance.example,其中某些属性如ARN为known after apply,说明需要apply之后才能知道。

最后执行terrafrom apply执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# terraform apply
Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value: yes

aws_instance.example: Creating...
aws_instance.example: Still creating... [10s elapsed]
aws_instance.example: Still creating... [20s elapsed]
aws_instance.example: Creation complete after 20s [id=i-0bb96d24b6e6d37eb]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

apply会自动调用plan预览将要改变的资源,输入yes确认无误后真正执行,由输出可知创建的EC2 ID为i-0bb96d24b6e6d37eb

AWS查看虚拟机信息如下:

EC2 instance

2.2.2 Resource state文件

通过terraform show可查看创建的资源列表。

1
# terraform show # aws_instance.example: resource "aws_instance" "example" { ami = "ami-0829e595217a759b9" arn = "arn:aws-cn:ec2:cn-northwest-1:769527305874:instance/i-0bb96d24b6e6d37eb" availability_zone = "cn-northwest-1b" id = "i-0bb96d24b6e6d37eb" instance_state = "running" instance_type = "t2.micro" private_ip = "172.31.29.5" tags = { "Name" = "int32bit-test-ft" "Owner" = "int32bit" } root_block_device { delete_on_termination = true encrypted = false iops = 100 volume_id = "vol-033ff2804c08b927a" volume_size = 8 volume_type = "gp2" } }

注意terrafrom show读取的是Terraform自己的数据库而不是调用云平台API,所有Terraform的资源都会保存到自己的数据库上,默认会放在本地目录,文件名为terraform.tfstate,这个state文件非常重要,如果该文件损坏将导致已创建的资源被破坏或者重建,因此可以认为Terraform是一个有状态服务,涉及多人协作时不仅需要拷贝代码,还需要拷贝state文件,这会导致维护起来特别麻烦,可幸的是Terraform支持把state文件放到S3上或者consul,参考官方文档Remote State,建议把state文件从代码中分离放到S3上。

2.2.3 Resource更新

由Terraform的Infrastructure as Code的设计目标,资源是可以随时修改的,如下EC2增加一个标签Newkey:

1
resource "aws_instance" "example" { ami = "ami-0829e595217a759b9" instance_type = "t2.micro" tags = { "Owner" = "int32bit" "Name" = "int32bit-test-ft" "Newkey" = "test_new_key" } }

这里省略plan步骤直接apply:

1
# terraform apply aws_instance.example: Refreshing state... [id=i-0bb96d24b6e6d37eb] An execution plan has been generated and is shown below. Resource actions are indicated with the following symbols: ~ update in-place Terraform will perform the following actions: # aws_instance.example will be updated in-place ~ resource "aws_instance" "example" { /* ... */ ~ tags = { "Name" = "int32bit-test-ft" + "Newkey" = "test_new_key" "Owner" = "int32bit" } } Plan: 0 to add, 1 to change, 0 to destroy. aws_instance.example: Modifying... [id=i-0bb96d24b6e6d37eb] aws_instance.example: Modifications complete after 1s [id=i-0bb96d24b6e6d37eb] Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

如上update in-place表示不需要重建资源直接修改资源属性,由于本次修改只是添加一个标签,不需要重新创建虚拟机,因此可以通过update in-place实现

资源预览中+表示添加的内容,~表示更新的内容,-表示即将删除的内容。

如果修改AMI如下:

1
resource "aws_instance" "example" { ami = "ami-08af324f69cf03287" instance_type = "t2.micro" tags = { "Owner" = "int32bit" "Name" = "int32bit-test-ft" "Newkey" = "test_new_key" } }

执行apply结果如下:

1
# terraform apply aws_instance.example: Refreshing state... [id=i-0bb96d24b6e6d37eb] Resource actions are indicated with the following symbols: -/+ destroy and then create replacement Terraform will perform the following actions: # aws_instance.example must be replaced -/+ resource "aws_instance" "example" { ~ ami = "ami-0829e595217a759b9" -> "ami-08af324f69cf03287" ~ id = "i-0bb96d24b6e6d37eb" -> (known after apply) ~ instance_state = "running" -> (known after apply) ~ private_ip = "172.31.29.5" -> (known after apply) tags = { "Name" = "int32bit-test-ft" "Newkey" = "test_new_key" "Owner" = "int32bit" } ~ root_block_device { ~ delete_on_termination = true -> (known after apply) ~ encrypted = false -> (known after apply) ~ iops = 100 -> (known after apply) + kms_key_id = (known after apply) ~ volume_id = "vol-033ff2804c08b927a" -> (known after apply) ~ volume_size = 8 -> (known after apply) ~ volume_type = "gp2" -> (known after apply) } } Plan: 1 to add, 0 to change, 1 to destroy. aws_instance.example: Destroying... [id=i-0bb96d24b6e6d37eb] aws_instance.example: Destruction complete after 30s aws_instance.example: Creating... aws_instance.example: Creation complete after 41s [id=i-0f87444adc1c2b7b4] Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

此时由于AWS EC2实例不支持直接修改AMI,因此Terraform为了与我们的声明最终期望一致,先把之前的资源删除,然后创建一个新的EC2实例。

从AWS console上也可以看到原来的虚拟机terminate了,重新创建了一个新的虚拟机:

ec2 instance

删除的资源不可回退,因此建议在apply之前在plan中仔细查看哪些资源是原地修改的,哪些需要重建,哪些资源会被删除,避免资源被意外删除。

2.2.4 Resource之间的依赖

和Ansible、Puppet一样资源之间可能会有依赖,Terraform支持隐式依赖和显式依赖,隐式依赖由Terraform自动根据资源的引用关系分析资源的依赖关系,比如A引用了B,则A依赖于B,A资源创建之前必须先创建B。

如下我们为EC2实例绑定个弹性IP:

1
resource "aws_instance" "example" { ami = "ami-08af324f69cf03287" instance_type = "t2.micro" tags = { "Owner" = "int32bit" "Name" = "int32bit-test-ft" "Newkey" = "test_new_key" } } resource "aws_eip" "example_public_ip" { vpc = true instance = aws_instance.example.id }

此时由于example_public_ip引用了exampleid,因此example_public_ip依赖于EC2实例example,Terraform会先创建EC2实例,然后绑定弹性IP。

隐式依赖基本能解决90%的问题,大多数情况下我们不需要显式告诉Terraform哪些资源存在依赖。当然仍可能存在需要显式依赖的情况,Terraform通过depends_on指明资源所依赖的资源列表,比如EC2实例需要通过role访问S3,此时需要在虚拟机中关联角色,角色中的policy必须先就绪,这种情况下资源之间没有引用关系,Terraform无法推导资源的依赖关系,因此必须通过depends_on显示声明所依赖的资源。

1
depends_on = [ aws_iam_role_policy.example, ]

通过Terraform的graph可以导出资源的依赖图:

1
terraform graph | dot -Tsvg > graph.svg

3 Terraform其他功能

3.1 Input Variables

如上实例把AMI和instance type硬编码到脚本中非常不灵活,Terraform支持输入变量功能,建议把变量单独抽取出来,创建variables.tf文件,变量通过variable关键字声明:

1
variable "image_id" { type = string default = "ami-08af324f69cf03287" description = "The id of the machine image (AMI) to use for the server." } variable "instance_type" { default = "t2.micro" }

变量包括变量名称以及数据类型,数据类型默认为string,另外可以提供default默认值以及description。

此时可以在同一模块中的任意Resource通过var.变量名引用变量:

1
resource "aws_instance" "example" { ami = var.image_id instance_type = var.instance_type tags = { "Owner" = "int32bit" "Name" = "int32bit-test-ft" "Newkey" = "test_new_key" } }

此时plan以及apply均可以通过形如-var a=b的形式指定变量值:

1
2
3
terraform apply \
-var instance_type=t2.small \
-var image_id=ami-0fcb508ec48b146df

也可以通过后缀名为.tfvars的文件指定变量值:

1
2
3
4
# cat example.tfvars
image_id = "ami-0fcb508ec48b146df"
instance_type = "t2.small"
# terraform apply -var-file=example.tfvars

如果tfvars文件名为terraform.tfvars或者*.auto.tfvars,则Terraform会自动加载不需要通过-var-file指定。

另外还可以通过环境变量的形式指定变量值,环境变量名为TF_VAR_name,如TF_VAR_image_id

如果变量没有指定并且没有默认值,则在apply时会通过交互方式请求用户手动输入变量值。

3.2 Output Values

Output values用于Terraform执行完后输出结果,在多模块中子模块的output还可以被父模块引用。

如下输出EC2实例的ID以及私网IP:

1
2
3
4
5
6
7
output "instance_id" {
value = aws_instance.example.id
}

output "private_ip" {
value = aws_instance.example.private_ip
}

再次执行apply

1
2
3
4
5
6
7
8
9
# terraform apply aws_instance.example: Refreshing state... [id=i-0f87444adc1c2b7b4]
aws_eip.ip: Refreshing state... [id=eipalloc-0e2cec51cbf18b5d3]

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

instance_id = i-0f87444adc1c2b7b4
private_ip = 172.31.19.113

也可以通过terraform output命令输出output值:

1
2
3
# terraform output
instance_ip = i-0f87444adc1c2b7b4
private_ip = 172.31.19.113

3.3 Modules

如果把所有的资源都杂糅放到一块,则必然导致脚本很难维护,因此有必要按照不同的功能将代码分开,Terraform支持Module功能,通过source加载子模块。

如下是目录的结构:

1
2
3
4
5
6
7
8
9
10
11
# tree
.
├── example.tfvars
├── main.tf
├── sub_module
│ ├── main.tf
│ └── variables.tf
├── terraform.tfstate
└── terraform.tfstate.backup

1 directory, 6 files

在当前工作目录中有一个子目录sub_module,这个子目录也是一个基本完整的Terraform项目。然后通过module关键字加载模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# cat main.tf provider "aws" {
profile = "default"
region = "cn-northwest-1"
}

module "example" {
source = "./sub_module"
image_id = "ami-08af324f69cf03287"
instance_type = "t2.micro"
}

output "private_ip" {
value = module.example.private_ip
}

output "instance_id" {
value = module.example.instance_id
}

module中必须通过source参数指定子模块路径,如果子模块中有Input Variables,可以在module的body中指定。

另外由上面的例子可知,在父模块中可以引用子模块的output值。

3.4 Data Sources

前面我们使用的参数都是固定的静态变量,但有些情况下可能参数变量不确定或者参数可能随时变化。比如我们创建虚拟机通常需要指定我们自己的镜像模块,但我们的模板可能随时更新,如果在代码中指定AMI ID,则一旦我们更新镜像模板就需要重新修改代码。

Terraform中的Data Sources可以认为是动态变量,只有在运行时才能知道变量的值。

Data Sources通过data关键字声明,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
data "aws_ami" "my_image" {
most_recent = true
owners = ["self"]
tags = {
Name = "test-template-ami"
Tested = "True"
}
filter {
name = "state"
values = ["available"]
}
}

resource "aws_instance" "example" {
ami = data.aws_ami.my_image.id
instance_type = var.instance_type
tags = {
"Owner" = "int32bit"
"Name" = "int32bit-test-ft"
"Newkey" = "test_new_key"
}
}

如上例子中的EC2镜像没有指定AMI ID,而是通过data引用,Terraform运行时将首先根据标签选择镜像,然后选择状态为available的镜像,如果同时有多个镜像满足条件,则选择最新的镜像。

4 总结

Terraform是非常强大的混合云编排工具,语法简单明了,只需要通过配置文件声明需要的资源列表,Terraform就能够快速地完成多云资源的创建。

当然Terraform也有个问题就是前面提到的它是一个有状态服务,意味着被Terraform管理的资源,不能通过手动或者借助其他工具管理资源,因为外部修改资源后,Terraform会认为和期望结果不一致而触发一次更新操作。