最近一段之间我开始较频繁地使用 Terraform,不仅是在公司环境中使用,也给自己的个人环境使用。跟友人说起这件事的时候,他们不以为然,甚至觉得现有的 Ansible 之类工具也一样可以完成 Terraform 的工作。本文是对 Terrform 及其必要性的介绍与说明。

一、Terraform 的必要性

越来越复杂的配置

Terraform 是 IaC (Infrastrucutre as Code) 工具。为什么 IaC 是必要的?因为现在的云服务(PaaS)的配置越来越复杂了。以一个普通的 AWS S3 bucket 为例,有哪些可配置项呢?光是访问控制(谁能/不能访问什么),就有这么好几层:

  • IAM policy 用于控制哪些 AWS 服务或 IAM 用户能访问这个 bucket 或里面的 objects;
  • ACL policy(分 bucket ACL 和 object ACL 两种)较为古老,用于设置简单的访问控制(public/private),已不推荐使用,但是 CloudFront 依然依赖它;
  • Bucket policy 较新,用于设置更细的访问控制(IP 地址白名单等),是 AWS 目前主推的访问控制方案;
  • Block public access(分 bucket 级和账户级两种)用于控制 bucket 是否允许创建新的 public ACL/policy,以及是否忽略已有的 public ACL/policy;
  • CORS configuration 用于设置从浏览器访问时返回哪些 CORS Headers;
  • Access points 还有自己的 policy 用于控制通过 access points 访问时哪些操作是被允许的。

还有更多常用设置:

  • Server access logging 用于设置访问日志记录到哪个 bucket 里;
  • Event notifications 用于设置新事件(如有文件被增加/删除)时通知哪些 SQS/SNS/Lambda;
  • Lifecycle rules 用于设置 objects 什么时候转存到廉价存储,什么时候删除;
  • Replication rules 用于设置 objects 如何复制到别的地区的 S3;
  • ……

当你建立你的第一个 bucket 的时候,在 AWS Console 网页上通过鼠标点击完成了这些设置,可能并不觉得如何,但当你有几十,甚至几百个 buckets 的时候,这些设置就变得要命了。尤其是当你需要对不同的 buckets 应用不同的设置的时候,光靠鼠标在那个难用的管理网页上点击,复制/粘贴/修改各种 JSON,绝对是个噩梦。

不光 S3,很多其他常用组件也有嵌套/嵌入的子项配置(如 IAM 和 ELB),纯粹靠鼠标点击去维护只能适用于小作坊,数量一大,管理出错的概率就直线上升,使用代码来管理各种资源势在必行。

配置飘移(drifting)

当然,稍微大一点的项目或公司,一般不会完全靠鼠标点击去管理 PaaS 上各种组件。对于重复性的任务,肯定是花点时间写点代码来干更快更爽。但并不是所有的 code 都能算作好的 IaC,一般的 code 是无法处理 drifting 问题的,用军事术语来就是射后不理

我之前在一家 20 人左右的创业公司工作的时候,曾经写过这么一个 Ansible Playbook,专门用来开 EC2 机器,其大致操作是:

  1. 执行一个本地 Play,根据传进来的参数先检查一遍合理性(比如机器命名是否符合规范);
  2. 查询 Route 53 Private Zone 查询该 hostname 是否已经被占用;
  3. 创建 EC2 实例(一个或多个);
  4. 在 Route 53 Private Zone 里注册 hostname 与实例地址的对应关系;
  5. 将新实例添加到 in-memory inventory 里的一个临时组里;
  6. 执行一个新的 Play,目标为这个临时组,对其 OS 安装必要的软件。

后来经过修改,这个 Playbook 也被用于在 AWS 以外的平台开机器。然而这套流程存在一个重要问题:这些的机器的后续状态无法被追踪,且难以替换。试想:机器用了一段时间之后由于业务需求,有些机器的配置发生了变更,修改了 security groups,增加了 EBS volumes 等。这时机器的状态和 Playbook 里定义的已经不一样了,当然这不影响正常使用,但是如果因为一些原因要批量修改甚至重建一部分机器呢?比如:

  • 开机器的时候指定了若干 security groups,但如果某天,一部分机器要新增/删除 security groups,如何找到这些机器确保一个不漏地改掉?
  • 由于上游(比如 AWS Marketplace)更新,某些机器必须要以新的 AMI 启动,如何才能不出差错地用新 AMI 重开一批,还要保证 ELB 和 Elastic IP 等配置不变,而且还要把存数据的 EBS 原样接回去?

这种实际资源(可能是 OS 里的某个文件,也可能是 AWS 里的某台机器)与定义中不一致的情况被称为 drifting。在管理文件时,Ansible 当然是可以妥善地处理 drifting 的——再运行一遍 Playbook,将指定路径的文件内容与定义中的文件进行对比,发现有不一致,覆盖掉,就解决了 drifting 的问题(这种同一 Playbook 跑多遍也不会有坏处的特性被称为幂等(idempotence)。但是在上述开 EC2 的例子中,机器一旦开完,就脱离了 Ansible 的掌控了,再运行一遍 Playbook,只会开出更多的机器,而不是去检查之前开的机器是否与定义一致(非幂等)。

IaC 中的 Code 并不是任意代码,而是要有能力管理基础资源整个生命周期的代码。理想状态下,这个代码应该是 declarative 的(「现在的机器数量为 10 台」),而不是 imperative 的(「创建 10 台机器」)。Ansible 在对于 OS 内的配置管理是可以 declarative 管理的,但是对于基础设施,则只能做到 imperative 管理。

只有像 Terraform 这样的 declarative IaC 才能妥善地处理 drifting 问题。

Single source of truth

当你完全皈依 IaC 之后,你就可以践行 single source of truth 的哲学了。吊销所有人员对 PaaS 的改动权限(可以留个只读权限),所有基础设施都由 declarative 代码定义,用 Git 等 VCS 进行版本管理。改动生产环境的配置(比如防火墙规则),不再是一个人操作,一堆人站在身后围观了,而是可以通过 PR / code review 的方式,甚至走一遍行政审批流程,最后再 merge PR,使配置生效。如果真的搞坏了啥,一个 git revert ,问题也就解决了。对于 DNS 记录等资源,能够通过版本历史来随时回滚,是一件多么幸福的事情啊!

IaC 不仅可以用于管理 PaaS 上的资源,这一套模型也可以用来管理其他虚拟资源,比如「某个用户组里有哪些用户」这样的概念。在使用 GitHub.com 进行代码托管的公司里,新员工入职时可能会用他个人的 GitHub.com 账号加入公司的 GitHub Orgnization,拥有一些 repo 的访问权限。员工离职时,也许需要吊销其 GitHub.com 账号对 private repo 的访问权限,但是允许其继续对公司的开源 public repo 继续做贡献。如果用 IaC 管理 GitHub Orgnization 的话,就可以清晰地定义哪些员工对哪些 repo 有访问权限,这些 IaC 代码就是 single source of truth,无论是开发团队还是 HR,都可以从中获取到自己想的信息。员工入职/离职时各种系统权限的授予/吊销,也是可以用 PR / code review / merge PR 来完成的。

二、Terraform 简介

基本使用

Terraform 是 HashiCorp 出品的开源 IaC 工具。它的本体是一个 Golang 写的单文件 CLI 工具,使用一种叫 HCL语言来定义资源的应有状态,然后根据这些定义,调用云平台的 API,对资源进行 CRUD 操作,使云平台上的资源符合 HCL 文件中定义的状态。

比如,以下是一个定义「在 AWS 的 us-west-2 区域上有 1 台 EC2」的例子。我增加了一些注释:

# 配置 AWS API 所需信息。区域是必须指定的,鉴权则是按 AWS SDK 标准的来。推荐用 `aws configure` 在本机配置 access key / secret key。
provider "aws" {
  region = "us-west-2"
}

# 定义一个 SSH 公钥。
resource "aws_key_pair" "main" {
  public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDB3eYUem12rVaP+2ijbGqFqTM4bfnYcYmHjDq7j6IjT"
}

# 定义一个 EC2 实例。
resource "aws_instance" "main" {
  # https://cloud-images.ubuntu.com/locator/ec2/
  ami           = "ami-0ee02425a4c7e78bb"
  instance_type = "t4g.nano"

  # 引用上面定义的 SSH 公钥。注意 HCL 不在意定义和引用的先后顺序,甚至不在意它们是否在同一个文件里。
  key_name = aws_key_pair.main.id

  # 引用下面定义的 security group。
  vpc_security_group_ids = [aws_security_group.open.id]

  # 指定 root EBS 类型为非默认的 gp3。如果不指定则是 AWS API 默认的 gp2。
  root_block_device {
    volume_type = "gp3"
  }

  tags = {
    Name = "my-frist-instance"
  }
}

# 定义一个双向全开的 security group。
resource "aws_security_group" "open" {
  ingress {
    from_port   = 0
    to_port     = 0
    protocol    = "all"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "all"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# 定义一个 Elastic IP 并 attach 到 EC2 实例上。
resource "aws_eip" "main" {
  instance = aws_instance.main.id
}

# 在屏幕上打印一些 EC2 实例的信息,方便查看。
output "instance" {
  value = {
    arn        = aws_instance.main.arn
    public_ip  = aws_eip.main.public_ip
    private_ip = aws_instance.main.private_ip
  }
}

在一个空目录(推荐用 Git 把这个目录管起来)中将以上内容保存成 main.tf,然后执行 terraform init 初始化 workspace。Terraform 会在目录里创建 .terraform 目录和 .terraform.lock.hcl 文件,用于存储一些内部数据。初始化完成之后,运行 terraform apply,Terraform 会比较文件中定义的资源和 AWS 中资源的差异,在屏幕上打印一份 CRUD 计划,告诉你它要干什么:

Terraform will perform the following actions:

  # aws_eip.main will be created
  + resource "aws_eip" "main" {
    [...]
  }

  # aws_instance.main will be created
  + resource "aws_instance" "main" { 
    [...]
  }

  # aws_key_pair.main will be created
  + resource "aws_key_pair" "main" { 
    [...]
  }

  # aws_security_group.open will be created 
  + resource "aws_security_group" "open" {  
    [...]
  }

[...]

Plan: 4 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

[...]

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

Outputs:

instance = {
  "arn" = "arn:aws:ec2:us-west-2:283712780869:instance/i-090478061ea9a8050"
  "private_ip" = "172.31.52.119"
  "public_ip" = "35.85.129.248"
}

这时候如果打开 AWS Console,便可看到这一实例,连同相关的资源(key + security group + Elastic IP)已经被创建:

ec2-instances_2022-06-04_13-56-25

由于我们没有指定 aws_key_pairaws_security_groupname 属性,Terraform 甚至还帮我们自动生成了名字。再也不用为想名字想到头秃了!

每次执行 terraform apply 的时候,Terraform 都会比较一遍文件中定义的资源和 AWS 中资源的差异,然后给出一份 CRUD 计划,执行这份计划即可让 AWS 中的资源与文件中定义的资源对齐。如上文所述,使用 Terraform 来管理资源的时候,Terraform 的文件定义应该作为 single source of truth。所有对资源的 CRUD 操作都应该通过修改 Terraform 定义来完成,而不应该绕过 Terraform 直接操作。但如果真的有人绕过 Terraform 直接修改了 AWS 里的资源,那在下次 terraform apply 的时候,这些改动也会被无情地覆盖掉。

状态文件

Terraform 使用状态文件来追踪资源。在执行 terraform apply 之后,当前目录里会产生一个 terraform.tfstate 文件,这就是 Terraform 的状态文件。虽然这个文件其实是个 JSON,但不应被直接编辑。如果你是唯一会修改这些资源的人,那这样的本地状态文件就够了,你甚至可以把这个状态文件也纳入 Git 的版本管理。但如果要团队协作的话,推荐使用共享存储来保存状态文件。

Terraform 支持很多存储后端,诸如 S3, etcd, PosgresSQL, Artifactory 等都可以用来存储状态文件。前段时间我无意中发现 GitLab 也通过 HTTP 提供了一个 Terraform 状态存储。这样倒是能把文件和状态都存在一起了。

Terraform Cloud / Enterprise

上述存储后端中的一部分支持锁定,防止多人同时操作出现问题。但对于稍大的团队来说,Terraform Cloud / Enterprise 才是正好的选择。比起单纯的状态文件存储,Terraform Cloud 提供了更多更丰富的功能:

  • 美观的网页 UI;
  • 与 VCS / CI 的整合,比如在有新的 PR 的时候自动做一个 CRUD 计划;
  • 团队成员可以对计划进行 review / comment,大家一起决定是否要 apply;
  • 方便地查阅状态文件中的内容及变化历史;
  • 记录每一次 terraform apply 的日志;
  • 通过配置策略,禁止某些 CRUD 操作,比如禁止创建全开放 security group(收费功能);
  • 根据 CRUD 计划,估算这一计划会让 AWS 等云平台的账单发生什么样的变化(收费功能)。

Terraform Cloud 长这样:

terraform-cloud_2022-06-04_14-25-13

当 Terraform Cloud 与 VCS 整合之后,编辑 HCL 的人甚至不再需要安装 Terraform CLI。直接用任意编辑器(甚至是 GitHub 的网页编辑器)修改文件,提交 PR,就会由 Terraform Cloud 去做 terraform apply这意味着 DevOps 可以「放权」,允许开发者通过提交 PR 的方式来对 infra 进行修改(而无需给他们开放 infra 权限)。

友人对 Terraform 默认在本地存储状态文件颇有微词,觉得管理起来麻烦。我对他说:Terraform 本身就像 Git 一样,是一个开源的工具,完全可以本地使用。但是为了团队协作和方便,人们通过会使用 GitHub / GitLab / BitBucket 等托管平台,并使用平台提供的一些附加功能。Terraform 本身也是可以本地使用的开源工具,而 Terraform Cloud / Enterprise 是 Terraform 的协作平台,在 Terraform 的基础上增加了一些附加功能。Terraform 的状态文件很小,可以很方便地迁移;如果哪天 HashiCorp / Terraform Cloud 倒闭,用户也可以轻易把状态文件迁移到别的存储后端上,并没有数据被锁死的风险。

三、Terraform 学习与实践

上面的例子只是一个简单的 EC2 实例,没有用到太复杂的东西,但 HCL 作为一门语言,该有的循环(for, for_each)、判断模块等概念自然也是有的。HashiCorp 提供了一整套交互式 Terraform 教程 可供学习。

合理利用编程语言的特性,我们可以用很少的代码管理很多的资源,比如把一套服务或组件封装成一个 module,通过传入不同的 variables,就可以方便地复制出多个环境(dev, staging, QA, UAT, beta, production),或是多区域部署。每个环境/区域的架构相同/相似,但是有不同的命名、权限、机器配置等。

我在与 Reorx 合作的 side project 中就实践了这一理念,编写 Terraform 代码的时候就想着模块化和多环境。不同的环境放在不同的 AWS 账号里(当然这些 AWS 账号也是用 Terraform 创建的),环境之间完全隔离。但由于这些环境是同一套 HCL 定义衍生的,所以有什么需要修改的地方,只要改一个地方就可以让多环境一起生效。一些可能经常需要修改的地方我甚至用 CSV 来存储数据,即使完全不熟悉 Terraform 的小伙伴也可以提交 PR 修改。

模块的代码可以直接放在同一 repo 里用相对路径引用,也可以发布到 GitHub 等代码托管网站或是 Terraform Registry 上。对于一些常见需求,很可能已经有别人写好的模块了。比如要在一个新的 AWS 账户的新区域里建立一个 non-default VPC 是一件比较麻烦的事情,因为涉及到 CIDR 的规范与计算(万一未来要做 peering 呢)、子网的路由表和 NAT 等。terraform-aws-modules/vpc 就帮你照料好了这一大堆东西,配合 cidrsubnets() 函数,只需要提供一个 CIDR,其他的就全自动建好了:

locals {
  name          = "main"
  cidr_block    = "10.18.0.0/16"
  subnets       = cidrsubnets(local.cidr_block, 4, 4, 4, 4, 4, 4)
  subnet_groups = chunklist(local.subnets, 3)
}

module "vpc" {
  source = "../../modules/vpc"

  name = local.name
  cidr = local.cidr_block

  azs             = ["${var.region}a", "${var.region}b", "${var.region}c"]
  private_subnets = local.subnet_groups[0]
  public_subnets  = local.subnet_groups[1]

  enable_nat_gateway = false
  single_nat_gateway = true
}

Terraform 也有助于实践最小权限原则。老实说,曾经的我,遇到哪个服务想读写某个 S3 bucket,我都是直接给 AmazonS3FullAccessAmazonS3ReadOnlyAccess 的,因为实在懒得去用那个网页 JSON 编辑器去设置到底允许哪个 bucket。有了 Terraform,则可以用 HCL 去定义这些 IAM 策略,精确控制权限,然后由 Terraform 将 HCL 转成 JSON(有语法检查)再提交给 AWS API。当然,这些东西都是模块化的,建立 100 个 buckets 配 200 个对应的只读/读写 IAM role,一点都不费劲。

四、Terraform 的朋友们

如上文所述,Terraform 和 Ansible 并不是互相替代的关系,甚至他们是可以合作的。HashiCorp 出品的工具,遵循着 immutable infrastructure 的理念。除非是数据库这种实在是没办法的东西,其他资源都应该视为是随时可以删除重建的。有什么东西坏了/被误删了,一个 terraform apply 即可满血复活。

那么 EC2 上运行的程序怎么办呢?简单的方法是用 user_data 让 EC2 启动的时候自动去安装并启动应用程序。但如果应用程序较大或是环境初始化时间较长,更好的方法是把应用程序做进 AMI 里,然后直接用这个 AMI 启动。

把应用程序直接塞到 AMI 里初听起来不是一个好主意,但这主要是因为制作(手搓) AMI 的过程比较痛苦。HashiCorp 还出了一套工具叫 Packer。Packer 和 Terraform 一样都是用 HCL,但是 Packer 定义的是如何生成一个 VM 镜像的 pipeline。这个 pipeline 的输入可能是一个 ISO,可能是现有的 VM 镜像,甚至可能是一台正在运行的 VM;Packer 将这些镜像用你定义的工具,比如 Ansible / Chef / Shell 去安装必要的软件,做安全加固等,最后再生成指定格式的 VM 镜像,存入指定的地方。当然同为 HCL 编写,Packer 的定义也是可以用各种循环、判断的。比如输入 N 个版本的 Ubuntu 和 M 个版本的 Java,输出 N × M 个最终镜像。

正如 Dockerfile 用来定义如何生成 Docker image 一样,Packer HCL 用来定义如何生成 VM image。有 Packer 的辅助,Terraform 能更加完善地做好 IaC。

本文地址: https://wzyboy.im/post/1476.html 。转载请注明出处。


欢迎留下评论。评论前,请先阅读《隐私声明》。