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 文件,用于忽略掉本地存储的基本配置,以避免泄露私钥,以及引入不必要的内容。
1 | .terraform/ |
其中, .terraform/
目录里将会存储 Terraform State 相关的配置信息,而 .terraform.lock.hcl
是一个本地的配置锁。
除此以外的代码主要分为两部分内容: GitLab CI 流水线配置文件 和 Terraform 代码。
GitLab CI 流水线配置
GitLab 提供了一个使用 Terraform 管理的流水线模板: Terraform.gitlab-ci.yml
我们可以直接使用这个模板创建我们的 .gitlab-ci.yml 流水线配置文件:
1 | # This specific template is located at: |
Terraform 代码
Terraform 代码的格式比较简单:它会读取所有根目录下的 .tf
文件,并依此生成需要的结构。为了更为清晰地管理,我将基础配置内容放在 terraform.tf
文件中,将区域信息配置在 zones.tf
文件中,将一些常量配置在 locals.tf
文件中,将对应的域名(例如 candinya.com )配置在 dns_candinya_com.tf
中。就像这样:
1 | terraform { |
1 | resource "cloudflare_zone" "candinya_com" { |
1 | locals { |
1 | resource "cloudflare_record" "candinya_com_root" { |
依此创建好上面所有的内容之后,我们就可以将代码推送给 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 |
我们会得到一系列形如这样的结果:
1 | resource "cloudflare_zone" "terraform_managed_resource_区域id" { |
然后,我们可以再使用这样的命令,获得对应 区域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 | resource "cloudflare_zone" "candinya_com" { |
1 | terraform import cloudflare_zone.candinya_com 区域id |
然后再运行这行命令,我们就能得到这样的结果:
1 | $ terraform import cloudflare_zone.candinya_com 区域id |
此时,我们再使用 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 | resource "cloudflare_record" "terraform_managed_resource_资源id" { |
其中, ttl
是不必要的字段, name
可以被修剪, zone_id
可以被使用变量替代。因而,我们可以将这条记录优化成这样:
1 | resource "cloudflare_record" "candinya_com_root" { |
也就是我们在一开始手动定义的样式。
别忘了对应的导入解析命令:
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
是这样的:
1 | # This specific template is located at: |
存在的问题
还记得上面的 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 来运行反而会成为一种更为流畅的解决思路:毕竟至少它们提供了拉取型的镜像仓库功能。