背景
随着IAC(Infrastruture as code)理念的普及,Terraform已被广泛的用来管理云资源基础设施。一般而言,我们的基础设施往往存在多套环境如dev、test、production等;为了适配这种情况,Terrform代码结构往往会呈现多层嵌套的布局:
├── dev
│ ├── web
│ │ ├── main.tf
│ │ └── outputs.tf
│ │ └── vars.tf
│ ├── mysql
│ │ ├── main.tf
│ │ └── outputs.tf
│ │ └── vars.tf
│ └── vpc
│ ├── main.tf
│ └── outputs.tf
│ └── vars.tf
├── test
│ ├── web
│ │ ├── main.tf
│ │ └── outputs.tf
│ └── vars.tf
│ ├── mysql
│ │ ├── main.tf
│ │ └── outputs.tf
│ └── vars.tf
│ └── vpc
│ ├── main.tf
│ └── outputs.tf
│ └── vars.tf
└── prod
├── web
│ ├── main.tf
│ └── outputs.tf
│ |__ vars.tf
├── mysql
│ ├── main.tf
│ |__ outputs.tf
| |__ vars.tf
└── vpc
├── main.tf
└── outputs.tf
└── vars.tf
如上所示,部署一个应用我们需要分别定义vpc、mysql、web模块;同时,我们还得在每套环境中都重复的定义一遍所有模块,尽管各个环境之间只是存在配置参数上的细微差别,例如不同的vpc或者instance type。可以想象,在面对更为庞大的基础设施时,这样的重复性做法无疑会导致代码结构变成一坨xx,给维护带来巨大的困难。
至今仍记得当年被AWS CloudFormation支配的恐惧:用了整整1000余行代码才堪堪实现了创建10余台EC2外加几个Cloudwatch Metrics的简单逻辑
发现问题没有?
没错,IAC工具,无论是Terraform还是CloudFormation本质上只是一个配置文件管理工具
,没有函数复用的功能,导致了不得不用重复的方式去解决扩展性问题
Terragrunt是什么
为了解决Terrafrom的重复性弊端,Terragrunt应运而生。
即使只看名字也猜得出来跟Terraform有着千丝万缕的联系。没错,Terragrunt官方将自身定义为Terrafom Wrapper
,意即对Terraform的封装
为了提高Terraform的可重用性,Terragrunt的做法就是将模块和配置分离:
使用Terrafrom 定义通用模版或者模块,存放在远端仓库中
使用Terragrunt管理具体参数配置,如instance_type 或者 vpc id等
Terragrunt代码引用Terraform模块并使用terragrunt apply 生成最终的配置
简而言之,就是将terraform代码作为函数,在terragrunt代码中定义入参并调用terraform。
在Terragrunt中,使用terragrunt.hcl文件管理具体配置参数;terragrunt的命令与terraform相差无几,很容易上手
如何使用Terragrunt
以开头提到的terraform例子说明
定义Terraform模块
新建目录infrastructue-module,在其中分别定义web、mysql、vpc模块
infrastructue-module
├── web
│ ├── main.tf
│ └── outputs.tf
│ └── vars.tf
├── mysql
│ ├── main.tf
│ └── outputs.tf
│ └── vars.tf
└── vpc
├── main.tf
└── outputs.tf
└── vars.tf
以vpc模块为例,其中vpc/main.tf内容:
terraform {
required_version = "= 0.14.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.69.0"
}
}
}
provider "aws" {
profile = var.aws_profile
region = var.aws_region
}
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr_block
}
在vars.tf文件中定义所需参数,vpc/vars.tf内容:
# Resource AWS
variable "aws_profile" {
description = "AWS Profile in ~/.aws/credentials"
type = string
}
variable "aws_region" {
description = "AWS Region"
type = string
}
# Resource AWS VPC
variable "vpc_cidr_block" {
description = "VPC CIDR block"
type = string
}
将以上代码并保存在git repo中: gitlab.com/infrastructue-module.git
创建Terragrunt文件
新建目录infrastructue,在其中新建terragrunt.hcl文件,引用远端的Terraform模块。
以创建vpc为例:
terraform {
# 通过source 关键词引用远端的terraform vpc模块 可以使用tag的方式引用特定版本
source = "git::git@gitlab.com:/infrastructue-module.git//vpc?ref=v0.0.1"
# 在input部分输入具体参数配置,与terrafrom中的vars.tf文件相对应
inputs = {
vpc_cidr_block = "10.0.0.0/16"
aws_profile = "dev"
aws_region = "us-west-2"
}
由于每个环境下的参数配置都不同,因此,针对每个环境下的每个模块,我们都需要建立相对应的hcl文件。如此一来,terragrunt最终的目录结构可以简化成如下布局:
infrastructue
├── dev
│ ├── web
│ │ |__ terragrunt.hcl
│ ├── mysql
│ │ |__ terragrunt.hcl
│ └── vpc
│ |__ terragrunt.hcl
├── test
│ ├── web
│ │ |__ terragrunt.hcl
│ ├── mysql
│ │ |__ terragrunt.hcl
│ └── vpc
│ |__ terragrunt.hcl
└── prod
├── web
│ |__ terragrunt.hcl
├── mysql
│ |__ terragrunt.hcl
└── vpc
|__ terragrunt.hcl
与原来的Terrform代码比较,每个环境的每个子模块只需要建立一个terragrunt.hcl文件。
最后我们将terragrun模块保存为gitlab.com:/infrastructue.git
。
运行terrgrunt脚本
万事具备,只欠执行。如果要创建vpc,那么需要进入terragrunt.hcl所在的目录
$ cd dev/vpc
# 等同于 terraform init,不过它多了一步,会下载terraform代码
$ terragrunt init
# 等同于 terrform plan
$ terragrunt plan
# 等同于 terraform apply
$ terragrunt apply
# 等同于 terraform destroy
$ terragrunt destroy
最后,执行terragrunt apply就能成功的创建VPC。
这种将模块和参数分离出来的做法,保持了terraform代码的整洁性和可重用性
include的使用
include类似编程语言中的import,可以帮助我们实现terragrunt文件之间的引用
一些全局或基础参数可以通过include引入。比如,我们使用了AWS Provider,那么每个hcl文件的input部分都必须重复配置provider的基本信息,如下所示
...
inputs = {
...
aws_profile = "dev"
aws_region = "us-west-2"
...
}
为了实现复用,我们将以上配置单独保存为dev/terragrunt.hcl
,
cat dev/terragrunt.hcl
inputs = {
# Global
aws_profile = "aws-staging"
aws_region = "us-west-2"
}
再由子文件dev/vpc/terragrunt.hcl对其进行引用:
cat dev/vpc/terragrunt.hcl
terraform {
source = "git::git@gitlab.com:/infrastructue-module.git//vpc?ref=v0.0.1"
}
# 通过include引用上层根文件 dev/terragrunt.hcl
include {
path = find_in_parent_folders()
}
# inputs 部分只需输入参数vpc_cidr_block
inputs = {
vpc_cidr_block = "10.0.0.0/16"
}
建议将各自环境下的通用部分提炼出来,形成一个root terragrunt.hcl文件,放置在各自环境的根目录下
infrastructue
├── dev
| |__terragrunt.hcl
│ ├── web
│ │ |__ terragrunt.hcl
│ ├── mysql
│ │ |__ terragrunt.hcl
│ └── vpc
│ |__ terragrunt.hcl
├── test
| |__terragrunt.hcl
│ ├── web
│ │ |__ terragrunt.hcl
│ ├── mysql
│ │ |__ terragrunt.hcl
│ └── vpc
│ |__ terragrunt.hcl
└── prod
|__terragrunt.hcl
├── web
│ |__ terragrunt.hcl
├── mysql
│ |__ terragrunt.hcl
└── vpc
|__ terragrunt.hcl
状态管理
众所周知,在terraform中为了实现幂等性,即执行多次terraform apply的结果是一样的,我们往往会使用remote state做持久化,常见的做法是使用s3作为remote state的存储地址。因此,我们就需要为每个创建的资源加一段backend配置,那么terraform代码应该是这样的:
cat infrastructue-module/vpc/main.tf
terraform {
required_version = "= 0.14.11"
# 在backend中定义remote state的s3地址
backend "s3" {
bucket = "infrastructue"
key = "vpc/terraform.tfstate"
region = "us-west-2"
encrypt = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.69.0"
}
}
}
provider "aws" {
profile = var.aws_profile
region = var.aws_region
}
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr_block
}
同样的对于其他模块,比如mysql,我们也需要加入backend这段代码
cat infrastructue-module/mysql/main.tf
terraform {
required_version = "= 0.14.11"
# 在backend中定义remote state的s3地址,
backend "s3" {
bucket = "infrastructue"
key = "mysql/terraform.tfstate"
region = "us-west-2"
encrypt = true
}
}
...
仔细对比可以看出来,与vpc模块不同的地方仅仅在于key指定不同的路径,这是为了减少爆炸路径,所以最佳实践是每个资源对应单独的一个tfstate文件。那么为了遵守这种最佳实践,我们就需要为所有的资源都配置backend,并保证key指定的路径不能重复。
显然,这样的做法会导致带来重复工作,并且还容易出错。为此,Terragrunt提供了path_relative_to_include
函数来简化remote state的配置。
那么该如何使用呢?
我们将backend部分的代码保存到 root terragrunt.hcl文件中,作为通用配置
cat infrastructue/dev/terragrunt.hcl
remote_state {
backend = "s3"
config = {
encrypt = true
bucket = "infrastructue"
key = "dev/${path_relative_to_include()}/terraform.tfstate"
region = "us-west-2"
}
}
inputs = {
...
}
同时,在子文件中只需通过include引用,无需再为每个资源都重复定义backend,
cat dev/vpc/terragrunt.hcl
terraform {
source = "git::git@gitlab.com:/infrastructue-module.git//vpc?ref=v0.0.1"
}
include {
path = find_in_parent_folders()
}
...
关键之处在于 root terragrunt.hcl文件中的函数path_relative_to_include
会动态获取子文件的相对路径,不需要我们再去费劲苦心的一一写死。
因此,最终生成的对应于子文件dev/vpc/terragrunt.hcl的状态文件地址是s3://infrastructue/dev/vpc/terraform.tfstate
同样的,对于dev/mysql/terragrunt.hcl的状态文件就是s3://infrastructue/dev/mysql/terraform.tfstate
对于prod/mysql/terragrunt.hcl的状态文件就是s3://infrastructue/prod/mysql/terraform.tfstate
通过仅仅使用path_relative_to_include我们就能减少大量重复代码,并保证remote state file隔离性和唯一性。
本地调试
Terragrunt的引入不可避免的带来了本地调试的不便:由于我们的terragrunt代码依赖于远程terraform代码,因此,为了调试本地terrargrunt代码,我们就必须首先将尚未自测通过的terraform代码推送到repo;显然,这样的做法不太优雅。
Terragrunt显然认识到了这一点,为此,提供了参数 --terragrunt-source 来指定本地的terraform模块
# --terragrunt-source 覆盖hcl中source
terragrunt plan --terragrunt-source ./terraform-module/vpc