用代码和 Git 管理 DNS 记录 —— DNSControl 和 GitHub Actions CI/CD 实践

作为互联网基础设施的基石之一,DNS 也是最脆弱的环节之一。在项目从上线、运营维护的整个生命周期中,DNS 记录的变更和管理是不可避免的。传统上,DNS 记录的管理往往依赖于域名注册商或 DNS 服务商提供的 控制平面,操作不直观、不可复现、容易出错、难以追溯、没有自动化。基础设施即代码(Infrastructure as Code, IaC)无疑为脆弱的 DNS 记录管理给出了一个方向。
楔子
skk.moe 作为我的个人域名,总共两位数的子域名上运行的业务 每天都承载了来自十万余名用户的上千万次请求。虽然这些业务全部没有 SLA 保障,我也从中没有获得任何收益,但是我依然希望至少尽力保证它们的稳定运行。
承载 skk.moe 的核心基础设施包括通过 CNAME 接入(Partial Setup)的 Cloudflare CDN,以及由 Vercel 提供的权威 DNS 服务(Vercel 自己没有任何 DNS 基础设施,其权威 DNS 服务先后由 Constellix 和 NS1 提供)。其中,Cloudflare CDN 即使使用了 Partial Setup 的 CNAME 方式接入,CDN 的源站配置(Pull Zone)依然是通过 DNS 进行的;在 Cloudflare 添加 DNS 记录的同时,我还需要在 Vercel 的权威 DNS 中添加指向 Cloudflare 的 CNAME 记录。
而 Vercel 作为一家核心是 Serverless 的 PaaS 提供商,自家的 DNS 控制平面可谓是惨不忍睹:
- 没有分页,只有「Load More」按钮
- 没有搜索、没有筛选
- 极其有限的批量操作
- 没有变更日志、没有审计和追溯
- 没有回滚
- 没法快速导出、备份现有 DNS 记录,难以在未来迁移到其他 DNS 供应商
好在,Vercel 还是提供了相对完善的 RESTful API,因此四年前,为了更方便地管理 skk.moe 的 DNS 记录,我开发了 vercel-dns.skk.moe(开源在 GitHub 上)。这是一个基于 Vercel API 的 React、Next.js Pages Router、SWR 和 foxact 开发的 Web App,相比 Vercel 自家的 DNS 控制平面,提供了更友好的 UI 和 UX,支持了分页和搜索筛选等功能。vercel-dns.skk.moe 极大地提升了我管理 DNS 记录的效率,但是它并没有完全解决上面提到的问题。除此以外,我依然没有解决需要手动在 Vercel 和 Cloudflare 两个不同的 DNS 控制平面中重复添加、修改、删除 DNS 记录的问题。
DNS as Code
设想一下,如果能通过某种 DSL 来直观地描述 skk.moe 的 DNS 记录,使用 Git 进行版本控制和历史追溯,通过 Git Pull/Merge Request 发起变更申请,通过 CI 持续进行自动化 Lint 和测试,合并 Pull/Merge Request 后可以通过 CD 进行自动化部署、将这些 DNS 记录同步到 Vercel 和 Cloudflare 上,就能解决上述所有问题、大大提升 DNS 记录管理的效率和可靠性了。
幸运的是,和 Terraform 等 基础设施即代码(Infrastructure as Code, IaC)工具被创造出来的同一时期,不少互联网公司都曾在 DNS 记录管理方面遇到过类似的困难,并且都开发了相对应的解决方案。其中,GitHub 为了在 NS1 和 AWS Route53 等多家权威 DNS 服务商上管理 DNS 记录、并将配置同步到 Fastly 在内的 CDN 上,于 2017 年用 Python 开发了 octoDNS 工具,使用 YAML 作为 DSL;同年 Stack Overflow 同样为了在多家权威 DNS 服务商之间管理上百个域名、上千条 DNS 记录,用 Go 开发了 DNSControl 工具,使用 ES5 JavaScript 作为 DSL。而在这两者之间,我最终选择了 DNSControl:
- 两者同时支持 Cloudflare,但是两者都不支持 Vercel。因此不论我选择哪一个工具,我都需要自行开发一个 provider 来支持 Vercel。考虑到我对 Python 和 Go 这两门语言虽然比较熟悉、但是远远达不到精通的程度,至少 Go 的静态类型特性和编译期检查至少能尽可能避免我犯错。
- DNSControl 使用 JavaScript 作为 DSL,对于精通 JavaScript 的我来说,无疑是一大优势,而且使用 JavaScript 变量、函数、数组以及数组上的
map、filter、reduce等高阶函数、能非常方便地描述复杂的 DNS 记录并进行复用;而 octoDNS 使用 YAML 作为 DSL,先不论 YAML footgun 的那些经典笑话(挪威NO),从表现力和可编程性上来说,YAML 远远不如 JavaScript。 - DNSControl 的 DX 更为友好,需要实现的接口更少(只需要实现
GetNameservers、GetZoneRecords和GetZoneRecordsCorrections三个方法),而且相比 octoDNS 含糊其辞的文档,DNSControl 有一篇 Step by step 的 16 步教程 手把手教学如何编写 provider 并合并到 DNSControl 上游。
最终,即使是并不精通 Golang 的我,也只花费了不到两天的时间,就完成了 Vercel provider 的开发,现在已被合并进 DNSControl 主线。
使用 DNSControl
DNSControl 所需要的文件结构非常简单,只需要两个文件即可:
creds.json:记录 DNS 供应商 API Token 等认证信息dnsconfig.js:DNS 记录配置文件,使用 JavaScript 作为 DSL 描述 DNS 记录
其中,creds.json 中可以直接引用环境变量名称:
{
"cloudflare": {
"TYPE": "CLOUDFLAREAPI",
"accountid": "$CLOUDFLARE_ACCOUNT_ID",
"apitoken": "$CLOUDFLARE_API_TOKEN"
}
} 而 dnsconfig.js 则可以使用 JavaScript 来描述 DNS 记录:
var REG_NONE = NewRegistrar('none');
var DSP_CLOUDFLARE = NewDnsProvider('cloudflare');
D('skk.moe', REG_NONE, DnsProvider(DSP_CLOUDFLARE),
DefaultTTL(300),
A('@', '1.2.3.4', TTL(300)),
); 如果要启用 IDE 的代码补全和类型检查,DNSControl 内置了 TypeScript 类型声明文件,可以通过 CLI 生成:
dnscontrol write-types 然后就可以在 dnsconfig.js 文件的开头引用类型声明文件了:
// @ts-check
/// <reference path="types-dnscontrol.d.ts" /> 虽然 DNSControl 内置的 DNS 配置检查(dnscontrol check)也可以检查 JavaScript 语法错误,但是还是推荐在项目中安装和配置 ESLint,这里不做赘述。
迁移到 DNSControl
无需手动从 DNS 供应商的控制平面中逐条复制 DNS 记录到 dnsconfig.js 文件中,DNSControl 支持直接导出现有的 DNS 记录,并生成不同格式的文件:
# 从 DNS 供应商 cloudflare 导出 skk.moe 的 DNS 记录,生成 dnsconfig.js 格式的文件
dnscontrol get-zone --format=js -out=draft.js cloudflare - skk.moe
# 还可以生成 BIND zonefile 格式的文件
dnscontrol get-zone --format=zone -out=skk.moe.zone cloudflare - skk.moe 导出的 JavaScript 文件并不建议直接使用,但是可以作为一个起点,将记录复制到正式的 dnsconfig.js 中。
在 dnsconfig.js 中为同一个域名配置不同的记录
DNSControl 支持 Split Horizon DNS,可以为同一个域名配置不同的 namespace、每个 namespace 下可以有不同的 DNS 供应商和不同的 DNS 记录。namespace 通过 ! 符号来区分:
D(
'skk.moe', REG_NONE, DnsProvider(DSP_VERCEL),
DefaultTTL(300),
ALIAS('@', 'skk.moe.cdn.cloudflare.net.', TTL(300)),
ALIAS('www', 'www.skk.moe.cdn.cloudflare.net.', TTL(300)),
);
D(
'skk.moe!cloudflare', REG_NONE, DnsProvider(DSP_CLOUDFLARE),
DefaultTTL(300),
CNAME('@', 'homepage-server.atlas.skk.moe', TTL(300)),
CNAME('www', 'homepage-server.atlas.skk.moe.', TTL(300)),
); 通过这种方式,可以将 skk.moe 的权威 DNS 记录部署在 Vercel 上,而将 Cloudflare CDN 的源站相关配置 部署在 Cloudflare 上,互不干扰。
dnsconfig.js 编写技巧
DNSControl 使用了 JavaScript 作为 DSL,因此可以使用 JavaScript 的各种特性来提升配置文件的可读性和可维护性:
使用数组和 map 编写重复 DNS 记录
// CAA
[
'letsencrypt.org',
'pki.goog; cansignhttpexchanges=yes',
'ssl.com',
'sectigo.com',
'certainly.com',
].map(function (value) {
return /** @type {const} */ (['issue', 'issuewild'])
.map(function (tag) { return CAA('@', tag, value, TTL(600)); });
}), 使用变量和函数复用 CNAME 记录
var GITHUB_PAGES_CNAME = 'sukkaw.github.io.';
CNAME('doku', GITHUB_PAGES_CNAME),
CNAME('foxact', GITHUB_PAGES_CNAME), 通过变量,减少了出现 typo 的可能性、提升了可维护性。
使用函数为多个子域名复用多条 IP
/**
* @param {string} label
* @param {RecordModifier} [ttl]
*/
function RECORD_SET_ALPHA(label, ttl) {
ttl = ttl || END;
return [
A(label, '114.5.1.4', ttl),
A(label, '11.45.1.4', ttl),
A(label, '19.19.8.10', ttl),
A(label, '19.19.81.0', ttl),
AAAA(label, '::ffff:10e:3304', ttl),
AAAA(label, '::ffff:10e:50e', ttl),
];
}
D(
'skk.moe', REG_NONE,
DnsProvider(DSP_CLOUDFLARE),
DefaultTTL(300),
RECORD_SET_ALPHA('@'),
RECORD_SET_ALPHA('www', TTL(300)),
); 此时 DNSControl 会同时为 skk.moe 和 www.skk.moe 添加相同的 4 条 A 和 2 条 AAAA 记录。
其中,
DefaultTTL函数返回的是域名修饰符DomainModifier、指定整个域名的默认 TTL;TTL函数返回的是记录修饰符RecordModifier;END是一个空的 域名、记录修饰符,不更改任何行为。
在RECORD_SET_ALPHA函数中将ttl参数默认取为END,这样当不传入TTL()作为ttl参数时,DNSControl 能够回落到DefaultTTL指定的默认 TTL。
善用 DNSControl 内置的全局函数
前文中的 DefaultTTL、TTL、D、A、AAAA、CNAME、ALIAS 等函数都是 DNSControl 内置的全局函数。除此以外,DNSControl 还提供了很多有用的内置函数,可以大幅提升配置文件的可读性和可维护性,例如 TTL 函数不仅接受数字,还接受人类可读字符串格式:
TTL('10m') // 600
TTL('1h') // 3600
TTL('4h') // 这个可比 14400 好辨识多了
TTL('7d') // 604800又比如,DMARC_BUILDER 可以免去手写 _dmarc TXT 记录的繁琐:
DMARC_BUILDER({
policy: 'reject',
subdomainPolicy: 'reject',
percent: 100,
rua: [
'mailto:example.com'
],
alignmentDKIM: 'r',
alignmentSPF: 'r',
ttl: 600
}) 再比如,IGNORE、IGNORE_NAME、IGNORE_TARGET 等函数可以让 DNSControl 忽略某些 DNS 记录的变更,让 DNSControl 管理的 DNS 记录与 DNS 供应商控制的 DNS 记录共存、互相不干扰:
// 忽略所有 Cloudflare Zero Trust 自动创建和管理的 CNAME 记录
IGNORE_TARGET('*.cfargotunnel.com.', 'CNAME'),
// 忽略所有 Cloudflare Email Routing 自动创建和管理的只读 DKIM 记录
IGNORE_NAME('*._domainkey', 'TXT'), 使用 Git 和 GitHub Actions 实现 CI/CD
DNSControl 的所有项目文件均可以推送到 Git 仓库中(建议 creds.json 文件中只引用环境变量名称,因为将 token 推送到 Git 仓库是一件极其愚蠢的事情),因此可以轻松实现 版本管理、审计追溯、回退、变更申请与审核 等功能。
通过 GitHub Actions 等 CI/CD 工具,还可以实现自动化 检测、预览 和 最终部署。我们可以创建两个 GitHub Actions Workflow pr-preview.yml 和 commit.yml:
- 需要变更 DNS 记录时,需要新建分支并发起 Pull Request,
pr-preview.yml在 Pull Request 创建或更新时触发,执行dnscontrol preview,检查语法错误并预览变更内容,同时将结果作为评论添加到 Pull Request 中,供审核者查看 - Pull Request 审核通过并合并到主分支后,
commit.yml在主分支有新的提交时触发,执行dnscontrol push,将变更部署到各个 DNS 供应商上,并将结果作为评论添加到 Commit 中。
GitOps 工作流预览
以下是 skk.moe 的 DNS 记录变更的 GitOps 工作流效果截图:
创建 Pull Request 后的 CI 日志
Pull Request 下的预览评论
合并 Pull Request 后默认分支的 CI 日志
默认分支下 Commit 的部署评论
GitHub Actions
DNSControl 有一个第三方的 GitHub Action koenrh/dnscontrol-action,但是疏于维护,并不建议使用;DNSControl 官方的 GitHub Action StackExchange/dnscontrol-action 尚在紧锣密鼓地开发中。好在 DNSControl 逻辑非常简单,我们完全可以自己编写 GitHub Action Workflow:
# .github/workflows/pr-preview.yml
name: Pull Request Preview
on:
pull_request:
jobs:
preview:
permissions:
contents: read
pull-requests: write
issues: write
runs-on: ubuntu-slim
steps:
- uses: actions/checkout@v6
- name: Download DNSControl
run: |
wget https://github.com/StackExchange/dnscontrol/releases/download/v4.27.1/dnscontrol_4.27.1_linux_amd64.tar.gz -O dnscontrol.tar.gz
tar -xvf dnscontrol.tar.gz
mv dnscontrol_linux_amd64 dnscontrol
chmod +x ./dnscontrol
- name: Preview DNS changes
id: dnscontrol_preview
run: |
set +e # continue on error
OUTPUT=$(./dnscontrol preview 2>&1)
EXIT_CODE="$?"
echo "$OUTPUT"
FILTERED_OUTPUT=$(echo "$OUTPUT" | ./filter-preview-output.sh)
DELIMITER="DNSCONTROL-$RANDOM"
{
echo "output<<$DELIMITER"
echo "$OUTPUT"
echo "$DELIMITER"
echo "filtered_output<<$DELIMITER"
echo "$FILTERED_OUTPUT"
echo "$DELIMITER"
echo "exit_code<<$DELIMITER"
echo "$EXIT_CODE"
echo "$DELIMITER"
} >> "$GITHUB_OUTPUT"
# even dnscontrol exit code is non-zero, we want to continue commenting
# we will exit with proper code in the final step to fail the job if needed
exit 0
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VERCEL_API_TOKEN: ${{ secrets.VERCEL_API_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
- uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
## DNS Changes Preview
exit code: ${{ steps.dnscontrol_preview.outputs.exit_code }}
````diff
${{ steps.dnscontrol_preview.outputs.filtered_output }}
````
edit-mode: replace
- name: Exit with proper dnspreview exit code
run: exit ${{ steps.dnscontrol_preview.outputs.exit_code }} 其中,filter-preview-output.sh 是一个简单的 shell 脚本,用于过滤掉 DNSControl 输出中的 ANSI 颜色控制字符、并将 DNSControl 输出的变更记录逐行添加 +、-、! 等前缀,以便包括在 Markdown 代码块中、借助 diff 语法实现高亮:
#!/bin/bash
# source: https://github.com/koenrh/dnscontrol-action/pull/118
grep -v -e '^\.\.\.0 corrections$' |\
grep -v -e '^0 corrections' |\
grep -v -e '\.\.\. (skipping)' |\
# providers will be printed via corrections list, we don't need these lines
grep -v -e '^----- DNS Provider: ' |\
grep -v -e '^----- Registrar: ' |\
grep -v -e '^----- Getting nameservers from:' |\
# remove ANSI color codes
## https://stackoverflow.com/questions/17998978/removing-colors-from-output
sed -r "s/\x1B\[([0-9]{1,3}(;[0-9]{1,2};?)?)?[mGK]//g" |\
#
# License MIT | SukkaW (https://skk.moe) | 2024 | https://github.com/SukkaW/dnscontrol-gitops-template
# generate diff symbol for each line
#
# if contains "MODIFY", prefix this line with '! '
# if contains "CREATE", prefix this line with '+ '
# if contains "DELETE", prefix this line with '- '
sed -e 's/^\(.*MODIFY.*\)$/! \1/' -e 's/^\(.*CREATE.*\)$/+ \1/' -e 's/^\(.*DELETE.*\)$/- \1/' 而 commit.yml 则类似,只需要将 Workflow Trigger 的 on 改为 push、branches 限定为主分支、paths 限定为 dnsconfig.js,将 dnscontrol preview 改为 dnscontrol push,并限定这些步骤只在默认分支上运行即可。
完整的 GitHub 示例仓库在 SukkaW/dnscontrol-gitops-template,有更完整的 GitHub Actions Workflow 和 README 说明,还包含了当 Pull Request 更新时 删除旧的预览评论 等特性。