技术向约 3.5 千字

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

用代码和 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 日志

dns-as-code-via-dnscontrol/ci-pr.png

Pull Request 下的预览评论

dns-as-code-via-dnscontrol/creating-pr.png

合并 Pull Request 后默认分支的 CI 日志

dns-as-code-via-dnscontrol/ci-commit.png

默认分支下 Commit 的部署评论

dns-as-code-via-dnscontrol/commit-comment.png

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 更新时 删除旧的预览评论 等特性。

评论加载中...