拯救Terraform,就靠它了

1,030 阅读4分钟

背景

随着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的封装

image.png

为了提高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