使用 Terraform 管理 CloudFlare 上的 DNS 解析记录

  1. 1. Terraform 是什么
  2. 2. 准备工作
    1. 2.1. 创建一个仓库
    2. 2.2. 准备 terraform 可执行文件
    3. 2.3. 准备 API Token
  3. 3. 创建 Terraform 代码仓库
    1. 3.1. 需要忽略的内容
    2. 3.2. GitLab CI 流水线配置
    3. 3.3. Terraform 代码
  4. 4. 初始化 Terraform 配置
    1. 4.1. 本地初始化 Terraform 存储状态后端
    2. 4.2. 导入已有的记录
      1. 4.2.1. 准备工作
      2. 4.2.2. 导入区域(域名)
      3. 4.2.3. 导入解析记录
    3. 4.3. 提交确认
  5. 5. 之后的改动
    1. 5.1. 自动应用变更
    2. 5.2. 存在的问题

CloudFlare 提供了一个方便的手动 DNS 解析记录管理界面,但当所需的解析规模逐渐增大,或是涉及到需要团队合作的场合时,再使用基于人力的管理方案的话,就更有可能引入一些奇怪的问题。本文以在 GitLab 上托管的仓库与流水线的实现为例,简单叙述使用 Terraform 管理 CloudFlare DNS 的操作流程。

在 GitLab 实现有两个原因:其一是其有官方的 Terraform State 集成,可以省去手动配置状态存储的麻烦;其二则是因为我自己主要使用的是自建的 GitLab ,管理自己的项目比较方便。工作上使用的则是基于 GitHub Actions + Google Cloud Storage 的方案,它也能稳定流畅地工作。

Terraform 是什么

Terraform 是一种用于使用代码来管理资源的实现方案,它可以支持管理包括但不仅限于服务器、DNS、集群等等基础设施资源。使用它是因为它很方便,并且有较为活跃的维护和较为丰富的组件支持,相对来说较为成熟。

它需要基于现有的状态( State )结合代码来计算变更,所以需要使用一个稳定安全的解决方案来存储它的状态。 GitLab 恰好有 Terraform 的状态存储集成,就能更加方便地管理它。

准备工作

创建一个仓库

为了避免和其他项目混淆或冲突,一般比较推荐单独开一个仓库来存储与某一个平台特定相关的 Terraform 代码。幸运的是, GitLab 提供了方便使用的子群组功能,因而能更加优雅地归类项目。例如,我创建了一个名为 DNS 的仓库,来创建与 CloudFlare 的 DNS 解析配置相关的 terraform 脚本。

准备 terraform 可执行文件

有一部分命令需要在本地运行,因而我们需要在本地安装 terraform 可执行文件。

不同平台的有不同的可执行文件提供方式,具体可以参考 Install Terraform 。 macOS 和 Linux 平台可以选择使用相对应的包管理工具, Windows 平台可以手动下载可执行文件,或是也可以使用一些包管理工具来安装。

我使用的是 Chocolatey 来管理,也可以选择使用例如 scoop 这样的简单好用的管理工具。

准备 API Token

由于我们将要使用 API 来管理 CloudFlare 上的资源,因而我们需要创建一个 API Token 来调用它。使用 编辑区域 DNS 这个模板,在 区域资源 里选择 账户的所有区域 ,并选择自己的账户,继续就能获得一个可以用于管理 DNS 的 Token 。如果有管理其他资源(例如增删域名)的需要,也可以添加相应的权限。

从安全的角度讲,这一步最好创建两个权限相同的 Token ,其中一个存储进入 CI 的环境变量中以供自动化使用,另一个则在初始化工作完成后及时销毁,以避免因使用广泛造成可能出现的意外泄露等安全隐患。

将我们准备好的 API Token 放置在项目仓库的 设置 - CI/CD - 变量 中,变量名为 CLOUDFLARE_API_TOKEN ,再将其标记为「隐藏」,这样我们的 Token 就准备好了。

创建 Terraform 代码仓库

需要忽略的内容

我们首先创建一个 .gitignore 文件,用于忽略掉本地存储的基本配置,以避免泄露私钥,以及引入不必要的内容。

.gitignore
1
2
.terraform/
.terraform.lock.hcl

其中, .terraform/ 目录里将会存储 Terraform State 相关的配置信息,而 .terraform.lock.hcl 是一个本地的配置锁。

除此以外的代码主要分为两部分内容: GitLab CI 流水线配置文件 和 Terraform 代码。

GitLab CI 流水线配置

GitLab 提供了一个使用 Terraform 管理的流水线模板: Terraform.gitlab-ci.yml

我们可以直接使用这个模板创建我们的 .gitlab-ci.yml 流水线配置文件:

.gitlab-ci.yml
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
# This specific template is located at:
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Terraform.gitlab-ci.yml

include:
- template: Terraform/Base.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Terraform/Base.gitlab-ci.yml
- template: Jobs/SAST-IaC.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/SAST-IaC.gitlab-ci.yml

stages:
- validate
- test
- build
- deploy
- cleanup

fmt:
extends: .terraform:fmt
needs: []

validate:
extends: .terraform:validate
needs: []

build:
extends: .terraform:build
environment:
name: $TF_STATE_NAME
action: prepare

deploy:
extends: .terraform:deploy
dependencies:
- build
environment:
name: $TF_STATE_NAME
action: start

Terraform 代码

Terraform 代码的格式比较简单:它会读取所有根目录下的 .tf 文件,并依此生成需要的结构。为了更为清晰地管理,我将基础配置内容放在 terraform.tf 文件中,将区域信息配置在 zones.tf 文件中,将一些常量配置在 locals.tf 文件中,将对应的域名(例如 candinya.com )配置在 dns_candinya_com.tf 中。就像这样:

terraform.tf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
terraform {
backend "http" {
}

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.2.0"
}
}
}

provider "cloudflare" {
# Configuration options
}
zones.tf
1
2
3
4
resource "cloudflare_zone" "candinya_com" {
account_id = local.account_id
zone = "candinya.com"
}
locals.tf
1
2
3
locals {
account_id = "您的 CloudFlare Account ID"
}
dns_candinya_com.tf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
resource "cloudflare_record" "candinya_com_root" {
zone_id = cloudflare_zone.candinya_com.id
name = "@"
value = "candiblog.pages.dev"
type = "CNAME"
proxied = true
}

resource "cloudflare_record" "candinya_com_kr_demo" {
zone_id = cloudflare_zone.candinya_com.id
name = "kr-demo"
value = "candinya.github.io"
type = "CNAME"
proxied = true
}

依此创建好上面所有的内容之后,我们就可以将代码推送给 GitLab CI 来检查 Terraform 状态了。不出意外的话,我们会得到这样的流水线运行状态:

初始化 Terraform 配置

进入项目仓库的 运维 - Terraform 状态 菜单中,我们就可以看到仓库对应的 Terraform 现有的状态。由于是新创建的项目,所以这里什么都没有,并且给出了提示信息:浏览文档,或是 复制 Terraform init 命令

deprecated-and-will-be-removed-in-18.0 这个问题我们暂时先不管它。我们进入 build 这个步骤的日志浏览界面(不知道为什么这里的 GitLab 没法打开日志,奇怪了),可以看到类似这样的新状态提示信息:

您一定会感到非常奇怪:这些记录都应该是已经存在了的,为什么会要提示创建它们呢?

原来, Terraform 在 plan 的时候,它只关注新的状态和当前存储的状态的差异。对于这个仓库来说, Terraform 此时的状态是空的,即没有被创建;因而在 Terraform 看来,我们创建的这些记录都是新的东西,全都需要新创建。也因此,如果此时贸然点击继续执行,那么轻则出现冲突提示执行失败,重则可能会引入错误的记录,造成生产环境的状态异常。

因而,我们需要手动修改 Terraform 存储的状态,导入已有的记录,以让 Terraform 知道它们的存在。

本地初始化 Terraform 存储状态后端

此时再访问项目仓库的 Terraform 状态菜单,会发现原来的提示信息已经消失了,取而代之的是一个名称为 default 的状态存储。这就是 CI 默认使用的状态,为了手动编辑它,我们可以点击它「操作」菜单处的三个小点,选择 复制 Terraform init 命令 来复制本地环境的初始化命令。

我们需要用到一个访问令牌来操作存储着的 Terraform 状态。我们可以跟随指引,在 用户设置 - 访问令牌 里创建,选择范围为 api ,到期时间可以设置得短一点(因为在手动导入完成之后这个访问令牌就可以销毁了)。

创建完成之后,我们依照初始化命令,将刚刚生成的访问令牌设置进入环境变量之后,执行后续的 terraform init 命令就可以在本地初始化存储状态了。

此时,您会观察到 Terraform 生成了一个 .terraform 目录和一个 .terraform.lock.hcl 文件,它们就是刚才在 .gitignore 里忽略掉的本地存储了。使用 terraform show 显示已有的状态,可以得到这样的提示:

1
The state file is empty. No resources are represented.

确实,现在的状态文件里什么都没有。

导入已有的记录

准备工作

CloudFlare 提供了一个便于管理 Terraform 记录的工具: cf-terraforming 。我们可以直接从 最新的发布 处下载预先构建的可执行文件;或者由于它是使用 go 编写的,如果我们的设备上有 go 环境的话,也可以使用这个命令来安装它:

1
go install github.com/cloudflare/cf-terraforming/cmd/cf-terraforming@latest

设置之前得到的 CloudFlare API Token 为环境变量 CLOUDFLARE_API_TOKEN ,它就准备就绪了。

导入区域(域名)

我们可以使用这样的命令,把所有的 zone 都导出到 zones.tf 文件中:

1
cf-terraforming generate --resource-type "cloudflare_zone" > zones.tf

我们会得到一系列形如这样的结果:

zones.tf
1
2
3
4
5
6
7
resource "cloudflare_zone" "terraform_managed_resource_区域id" {
account_id = "账户id"
paused = false
plan = "free"
type = "full"
zone = "candinya.com"
}

然后,我们可以再使用这样的命令,获得对应 区域id 的 terraform 导入命令:

1
cf-terraforming import --resource-type "cloudflare_zone"

我们会得到一系列形如这样的结果:

1
terraform import cloudflare_zone.terraform_managed_resource_区域id 区域id

但这样的结果非常不美观。有没有什么办法来优化一下呢?

既然这么说了,那肯定是有的—— Terraform 里 resource 的具体名字叫什么其实并不重要,也就是它也可以不叫 terraform_managed_resource_区域id 而是 candinya_com 这样的字段——只要保证没有重复就好。因而,同时修改 zones.tf 中的记录为我们一开始创建的记录格式,并修改刚刚生成的导入命令,就像这样:

1
2
3
4
resource "cloudflare_zone" "candinya_com" {
account_id = local.account_id
zone = "candinya.com"
}
1
terraform import cloudflare_zone.candinya_com 区域id

然后再运行这行命令,我们就能得到这样的结果:

1
2
3
4
5
6
7
8
9
10
11
$ terraform import cloudflare_zone.candinya_com 区域id
Acquiring state lock. This may take a few moments...
cloudflare_zone.candinya_com: Importing from ID "区域id"...
cloudflare_zone.candinya_com: Import prepared!
Prepared cloudflare_zone for import
cloudflare_zone.candinya_com: Refreshing state... [id=区域id]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

此时,我们再使用 terraform show 显示已有的状态,它就不再是空的了。

以此类推,我们可以继续导入剩下的所有域名。

导入解析记录

在区域导入完成后,我们就可以开始导入解析记录了。与导入区域的命令类似,我们可以使用这样的命令生成某个特定区域内的所有 DNS 解析记录:

1
cf-terraforming generate --resource-type "cloudflare_record" --zone 区域id > dns_candinya_com.tf

和对应的导入命令:

1
cf-terraforming import --resource-type "cloudflare_record" --zone 区域id

我们会得到形如这样的解析记录:

1
2
3
4
5
6
7
8
resource "cloudflare_record" "terraform_managed_resource_资源id" {
name = "candinya.com"
proxied = true
ttl = 1
type = "CNAME"
value = "candiblog.pages.dev"
zone_id = "区域id"
}

其中, ttl 是不必要的字段, name 可以被修剪, zone_id 可以被使用变量替代。因而,我们可以将这条记录优化成这样:

1
2
3
4
5
6
7
resource "cloudflare_record" "candinya_com_root" {
zone_id = cloudflare_zone.candinya_com.id
name = "@"
value = "candiblog.pages.dev"
type = "CNAME"
proxied = true
}

也就是我们在一开始手动定义的样式。

别忘了对应的导入解析命令:

1
terraform import cloudflare_record.terraform_managed_resource_资源id 区域id/资源id

我们同样可以将 terraform_managed_resource_资源id 重命名成我们喜欢的格式,来更优雅地管理它们。就像这样:

1
terraform import cloudflare_record.candinya_com_root 区域id/资源id

还是一样,执行这些对应的导入命令,我们就可以将这个资源纳入 Terraform 的管理啦。

提交确认

当所有的区域和记录都导入完成后,我们就可以提交我们的代码交给 CI 去构建了。不出意外的话,我们会得到这样的结果:

Terraform 会提示需要替换掉使用 @ 来替代完整字符的记录,这是正常的。而之前提示需要创建的资源,则已经不再提示需要创建,这说明我们成功地将 CloudFlare 上解析记录的状态更新到了 Terraform 里,以后就可以使用这个仓库来管理对应的记录了。手动继续之后,就能看到执行成功的记录变更:

之后的改动

自此,我们就可以使用这个仓库来管理 CloudFlare 上的区域(域名)和 DNS 解析记录了。需要新的解析时可以直接在这里创建,而需要销毁旧的记录时直接删除再应用就可以自动执行。

与人力管理相比,这样更不容易出错,并且也能借助版本管理工具更好地回溯历史变更;可能唯一需要注意的点就是对于一些新推出的 CloudFlare 产品来说,如果还没能纳入 Terraform 管理而它又会在控制台创建时自动绑定到相关的服务上的话,有可能会出现一些意料之外的记录冲突问题。不过一般来说都问题不大,如果实在冲突严重的话也可以使用 terraform state rm 来清理掉对对应资源的跟踪,然后完全交由控制台管理就可以。

自动应用变更

GitLab 这个默认流水线需要人工确认就比较麻烦,如果能确保提交的变更即使出错也在可控范围内的话,能否让它自动执行呢?

答案是:非常简单, GitLab 的 Terraform 模板开发者已经想到了这个问题,并提供了一个配置方案:只需要设置一个名为 TF_AUTO_DEPLOY 值为 true 的 variable ,就可以轻松实现自动应用。就像这样:

此时我们的 .gitlab-ci.yml 是这样的:

.gitlab-ci.yml
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
# This specific template is located at:
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Terraform.gitlab-ci.yml

include:
- template: Terraform/Base.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Terraform/Base.gitlab-ci.yml
- template: Jobs/SAST-IaC.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/SAST-IaC.gitlab-ci.yml

variables:
TF_AUTO_DEPLOY: "true"

stages:
- validate
- test
- build
- deploy
- cleanup

fmt:
extends: .terraform:fmt
needs: []

validate:
extends: .terraform:validate
needs: []

build:
extends: .terraform:build
environment:
name: $TF_STATE_NAME
action: prepare

deploy:
extends: .terraform:deploy
dependencies:
- build
environment:
name: $TF_STATE_NAME
action: start

存在的问题

还记得上面的 deprecated-and-will-be-removed-in-18.0 这个问题吗?

GitLab 预定将在 v18 大版本中丢弃对 Terraform 的 CI 支持,转而使用 OpenTofu 来提供 CI/CD 流水线。但是这个流水线组件还没有被集成进入自建版本的 GitLab 实例,所以它暂时不能被直接使用,而需要使用制作镜像仓库的方法来调用它;但拉取型的镜像仓库仅在商业版本的自建实例中提供,免费版本的自建实例没有这个功能。

因此,如果等到 v18 大版本更新的时候 CI 依然没有被集成的话, Terraform 流水线将变得不再可用,或需要手动实现完整复杂的流水线构建路径。短期来说这并不是个问题,因为 v18 大版本预计会到 2025 年才发布;但从长期来讲,现在才开始玩 Terraform 基本就等于是赶了个晚集,可能如果他们继续这样混乱下去的话,自建 Forgejo / Gitea 实例配合 GitHub Actions 来运行反而会成为一种更为流畅的解决思路:毕竟至少它们提供了拉取型的镜像仓库功能。