[运维] - Terraform

Infrasturcture as Code

对于管理服务器等资源,Ansible什么的可能就不大好用了,我们可以使用Terraform进行弹性创建等等,并且可以将我们的配置等等信息文本化,从而通过git等版本管理系统来进行维护。

安装

官方网站

要素

语法

1
2
3
4
<BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" {
# Block body
<IDENTIFIER> = <EXPRESSION> # Argument
}

Terraform的语法目的在于定义资源

Terraform Block

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

terraform块的作用是让Terraform知道去Terraform Registry下载什么provider。上述代码作用:hashicorp/aws作为aws的提供源,hashicorp/awsregistry.terraform.io/hashicorp/aws的简写。我们同时可以指定version,如果不指定,会自动下载最新的。

Providers

1
2
3
4
provider "aws" {
profile = "default"
region = "us-west-2"
}

provider块用来设置provider。一个provider是一个插件,用来翻译Terraform用户和云服务之间的API。provider负责理解API和管理资源。profile属性用来选择储存在.aws文件里的credentials。我们不要在.tf文件里硬编码我们的密钥等信息,而且通过指定来引用。

Resources

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

tags = {
Name = "ExampleInstance"
}
}

resource块的用来定义资源,一个资源可以是物理的资源比如一台EC2实例,或者是一个逻辑上的资源,比如一个Docker容器。一个resource块包含2个字符串,这里是"aws_instance" "example",表示资源的类型aws_instance,且被命名为example

剩下的字段都是这个资源的参数。比如:服务器配置,镜像名称,或者VPC的ID

变换配置

服务器配置一直在变化,Terraform同样可以帮助我们管理这些变化,Terraform是声明型配置,当我们修改Terraform配置后,Terraform只会执行需要修改的部分来达到我们想要的状态。所以我们可以通过版本管理追溯到我们的架构的历史变化。

1
2
3
4
5
 resource "aws_instance" "example" {
- ami = "ami-830c94e3"
+ ami = "ami-08d70e59c07c61a3a"
instance_type = "t2.micro"
}

当上诉变化发生后,AWS provider知道当一个实例创建后就不能改变ami了,所以Terraform将会释放现有的实例,然后创建一个新的实例。

指令

1
2
3
4
5
6
7
8
9
10
11
12
# 初始化一个项目
treeaform init
# 格式化代码
terraform fmt
# 验证代码语法是否正确
terraform validate
# 先查看会生成的资源的列表
terraform plan
# 执行以下指令即可生成你的服务器等资源的配置清单,然后apply它就可以体现在真正的AWS上
terraform apply
# 销毁这些服务器,也只需要一个指令
terraform destory

可以把这个项目初始化为git库,这样可以通过版本管理,来管理你的配置

下面这个例子会启动一个ESC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.27"
}
}
}

provider "aws" {
profile = "default"
region = "us-west-2"
}

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

tags = {
Name = "ExampleInstance"
}
}

高级操作

变量

同Ansible,我们需要结构化我们的项目,也就是我们需要使用模板等复用我们的设置,或者调整一些参数。以下代码定义了一个instance_name变量,类型为string

1
2
3
4
5
variable "instance_name" {
description = "Value of the Name tag for the EC2 instance"
type = string
default = "ExampleInstance"
}

然后我们可以这样进行使用

1
2
3
4
5
6
7
8
9
 resource "aws_instance" "example" {
ami = "ami-08d70e59c07c61a3a"
instance_type = "t2.micro"

tags = {
- Name = "ExampleInstance"
+ Name = var.instance_name
}
}

Output

我们部署完后,可能会需要一些输出,比如服务器的IP,私钥啥的,就可以使用output

1
2
3
4
5
6
7
8
9
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.example.id
}

output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.example.public_ip
}

MAP

我们可以配置一个MAP类型的变量来对应不同情况

instance.tf

1
2
3
4
resource "aws_instance" "example" {
ami = "${lookup(var.AMIS, var.AWS_REGION)}"
instance_type = "t2.micro"
}

vars.tf

1
2
3
4
5
6
7
8
9
10
11
12
13
variable "AWS_ACCESS_KEY" {}
variable "AWS_SECRET_KEY" {}
variable "AWS_REGION" {
default = "us-east-1"
}
variable "AMIS" {
type = "map"
default = {
us-east-1 = "ami-13be557e"
us-west-2 = "ami-06b94666"
eu-west-1 = "ami-0d729a60"
}
}

ami-id会会变化,记住去官网查看ec2/locator

安装软件

我们只讨论Ansible的情况,我们可以先运行特仍然form,输出IP地址,然后运行ansible-playbook

  • 可以将他们通过脚本自动化
  • 也有第三方库来自动化他们

上传文件

1
2
3
4
provisioner "file" {
source = "app.conf"
destination = "/etc/myapp.conf"
}

在Linux上使用ssh进行上传

1
2
3
4
5
6
7
8
provisioner "file" {
source = "script.sh"
destination = "/opt/script.sh"
connection {
user = "${car.instance_username}"
password = "${var.instance_password}"
}
}

ec2-user是AWS的默认用户,ubuntu是Ubuntu Linux

SSH keypairs

instance.tf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
resource "aws_key_pair" "edward-key" {
key_name = "mykey"
public_key = "ssh-rsa my-public-key"
}

resource "aws_instance" "example" {
ami = "${lookup(var.AMIS, var.AWS_REGION)}"
instance_type = "t2.micro"
key_name = "${aws_key_pair.mykey.key_name}"
}

provisioner "file" {
source = "script.sh"
destination = "/opt/script.sh"
connection {
user = "${car.instance_username}"
password = "${file(var.path_to_private_key)}"
}
}

执行脚本

远程执行

1
2
3
4
5
6
provisioner "remote-exec" {
inline = [
"chmod +x /opt/script.sh"
"/opt/script.sh arguments"
]
}

输出

1
2
3
output "ip" {
value = "${aws_instance.example.public_ip}"
}
1
2
3
4
5
6
7
resource "aws_instance" "example" {
ami = "${lookup(var.AMIS, var.AWS_REGION)}"
instance_type = "t2.micro"
provisioner "local-exec" {
command = "echo ${aws_instance.example.public_ip} >> private_ips.txt"
}
}

模板

同理Ansile模板,创建一个模板文件

1
2
#!/bin/bash
echo "database-ip = ${myip}" >> /etc/myapp.conf

渲染

1
2
3
4
5
6
7
data "template_file" "my-temlpate" {
temlpate = "${file("temlpates/init.tpl")}"

vars {
myip = "${aws_instance.database1.private_ip}"
}
}

引用

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

user_data = "${data.temlpate_file.my-temlpate.rendered}"
}

State

Terraform会保存远程的状态在terraform.tfstate,这个文件包含一些ID和Terraform创建的资源的属性,以便可以对资源的追踪管理和销毁。我们必须安全保存terraform.tfstate文件,同时会有一个之前的状态叫做terraform.tfstate.backup。当我们输入apply后,就会生成上面2个文件,我们可以将这个2个文件加入版本控制系统,但是如果多人合作的情况下,这样会很容易产生git冲突。Terraform推荐将Store保存在远程服务器上。

手动管理State

我们也可以输入指令进行更高级的管理,我们可以输入以下指令来查看我们资源的列表

1
terraform state list

远程State

在真实的生成环境下,我们需要保持state安全且加密,同时负责此项目的人还可以管理,最好的方法就是远程管理state。Terraform的remote backends允许Terraform使用共享储存来保存数据。我们需要配置remote backend来启用远程state。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
terraform {
+ backend "remote" {
+ organization = "<ORG_NAME>"
+ workspaces {
+ name = "Example-Workspace"
+ }
+ }
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.27"
}
}
}

Datasoureces

有些数据是变化的,我们可以通过datasources来进行获取,比如AMIs的列表,可用地区等等

1
2
3
4
5
6
7
8
9
data "aws_ami" "example" {
most_recent = true

owners = ["self"]
tags = {
Name = "app-server"
Tested = "true"
}
}

模块

类似ansible的role,也就是可用重复利用,引入一个模块

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

本地方式

1
2
3
4
5
6
module "module-example" {
source = "./..."
region = "us-west-1"
ip-range = "10.0.0.0/8"
cluster-size = "3"
}

对一个module文件夹,我们可用有下面的结构

moddule/vars.tf

1
2
3
variable "region" {}
variable "ip-range" {}
variable "cluster-size" {}

moddule/cluster.tf

1
2
3
resource "aws_instance" "instance_1" {...}
resource "aws_instance" "instance_2" {...}
resource "aws_instance" "instance_3" {...}

moddule/output.tf

1
2
3
output "aws-cluster" {
value = "${aws_instance.instance-1.public},${aws_instance.instance-2.public},${aws_instance.instance-3.public}"
}

使用下面的指令来获取模块

1
terraform get

Terraform with AWS

VPC

也就是个人的虚拟网络。我们需要把我们的服务器或数据库放在里面,分默认的,和我们自定义的
VPC之间的机器不能通过私有IP进行交流,虽然可用公共IP,但不推荐,可用连接2个VPC,叫做peering

创建VPC

vpc.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
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
# Internet VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
instance_tenancy = "default"
enable_dns_support = "true"
enable_dns_hostnames = "true"
enable_classiclink = "false"
tags = {
Name = "main"
}
}

# Subnets
resource "aws_subnet" "main-public-1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = "true"
availability_zone = "eu-west-1a"

tags = {
Name = "main-public-1"
}
}

resource "aws_subnet" "main-public-2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
map_public_ip_on_launch = "true"
availability_zone = "eu-west-1b"

tags = {
Name = "main-public-2"
}
}

resource "aws_subnet" "main-public-3" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
map_public_ip_on_launch = "true"
availability_zone = "eu-west-1c"

tags = {
Name = "main-public-3"
}
}

resource "aws_subnet" "main-private-1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
map_public_ip_on_launch = "false"
availability_zone = "eu-west-1a"

tags = {
Name = "main-private-1"
}
}

resource "aws_subnet" "main-private-2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.5.0/24"
map_public_ip_on_launch = "false"
availability_zone = "eu-west-1b"

tags = {
Name = "main-private-2"
}
}

resource "aws_subnet" "main-private-3" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.6.0/24"
map_public_ip_on_launch = "false"
availability_zone = "eu-west-1c"

tags = {
Name = "main-private-3"
}
}

# Internet GW
resource "aws_internet_gateway" "main-gw" {
vpc_id = aws_vpc.main.id

tags = {
Name = "main"
}
}

# route tables
resource "aws_route_table" "main-public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main-gw.id
}

tags = {
Name = "main-public-1"
}
}

# route associations public
resource "aws_route_table_association" "main-public-1-a" {
subnet_id = aws_subnet.main-public-1.id
route_table_id = aws_route_table.main-public.id
}

resource "aws_route_table_association" "main-public-2-a" {
subnet_id = aws_subnet.main-public-2.id
route_table_id = aws_route_table.main-public.id
}

resource "aws_route_table_association" "main-public-3-a" {
subnet_id = aws_subnet.main-public-3.id
route_table_id = aws_route_table.main-public.id
}

默认情况下上面只有内网,所以我们需要一个gateway来访问外网,如果不需要外网访问则可不设置

nat.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
34
35
36
37
38
39
# nat gw
resource "aws_eip" "nat" {
vpc = true
}

resource "aws_nat_gateway" "nat-gw" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.main-public-1.id
depends_on = [aws_internet_gateway.main-gw]
}

# VPC setup for NAT
resource "aws_route_table" "main-private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat-gw.id
}

tags = {
Name = "main-private-1"
}
}

# route associations private
resource "aws_route_table_association" "main-private-1-a" {
subnet_id = aws_subnet.main-private-1.id
route_table_id = aws_route_table.main-private.id
}

resource "aws_route_table_association" "main-private-2-a" {
subnet_id = aws_subnet.main-private-2.id
route_table_id = aws_route_table.main-private.id
}

resource "aws_route_table_association" "main-private-3-a" {
subnet_id = aws_subnet.main-private-3.id
route_table_id = aws_route_table.main-private.id
}

安全组

安全组绑定一个VPC来定义进出口的端口和协议等等

securitygroup.tf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
resource "aws_security_group" "allow-ssh" {
vpc_id = aws_vpc.main.id
name = "allow-ssh"
description = "security group that allows ssh and all egress traffic"
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}

ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "allow-ssh"
}
}

EBS Volumes

创建额外的数据卷来保存数据,默认分配的磁盘会在服务器释放后释放

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
  
resource "aws_instance" "example" {
ami = var.AMIS[var.AWS_REGION]
instance_type = "t2.micro"

# the VPC subnet
subnet_id = aws_subnet.main-public-1.id

# the security group
vpc_security_group_ids = [aws_security_group.allow-ssh.id]

# the public SSH key
key_name = aws_key_pair.mykeypair.key_name

# user data
user_data = data.template_cloudinit_config.cloudinit-example.rendered
}

resource "aws_ebs_volume" "ebs-volume-1" {
availability_zone = "eu-west-1a"
size = 20
type = "gp2"
tags = {
Name = "extra volume data"
}
}

resource "aws_volume_attachment" "ebs-volume-1-attachment" {
device_name = var.INSTANCE_DEVICE_NAME
volume_id = aws_ebs_volume.ebs-volume-1.id
instance_id = aws_instance.example.id
skip_destroy = true # skip destroy to avoid issues with terraform destroy
}

User Data

User Data可以用来做一些初始化的事情,比如安装一些包,初始化数据卷等等

aws_instance

参考