博客

从一行 Corefile 到自建分流 DNS:我的 CoreDNS 折腾记

Cover Image for 从一行 Corefile 到自建分流 DNS:我的 CoreDNS 折腾记

从一行 Corefile 到自建分流 DNS:我的 CoreDNS 折腾记

作者:bilxio,与 Claude (Anthropic) 共同完成

DNS 是互联网的电话簿,平时感知不到它的存在——直到它出了问题。

这篇文章记录了我从发现一个奇怪的网络问题,到最终自己写插件、打包分发的完整过程。如果你只是想解决家庭网络的 DNS 分流问题,文末有现成的安装方式;如果你对 Go 插件开发或 DNS 协议感兴趣,中间的部分应该对你有用。


前传

CoreDNS 在我机器上已经很久了,久到连我自己都忘了它最初为什么在这里。

当时的动机其实很朴素:DNS 私有化。所有的域名查询都在自己手里过一遍,日志落地,日后可以审计、分析——知道自己的设备到底在和谁说话。那时候甚至还没有 Loon。

后来种种原因,这套 DNS 慢慢腐化了:配置没人维护,从主 DNS 体系里摘掉,但进程还在跑。为了当时的稳定性,还弄了 supervisor 和 launchd 两套方案并行,于是系统里一直有两个 CoreDNS 在空转,谁也没去管它们。

直到最近,这台 arm64 的 Mac 开始无缘无故地发烫,CPU 降频,风扇转个不停。打开 htop 一看,CoreDNS 常年高居榜首。查了一下才想明白:那是一个很久以前下载的 amd64 二进制,Rosetta 一直在幕后帮它做指令翻译,日夜不停,不知道跑了多少个月。

干活吃饭的家伙,没有人不爱惜。

本来打算直接清掉它,但在动手之前,又想起了当初为什么要装上它。于是重新下载了 CoreDNS 最新的源码,编译成 arm64,让它跑得轻快一点。然后才发现,原来的域名分流方案是用十万个 zone block 堆出来的——内存和 CPU 双高,根本用不了。

后来写了一个 splitdns 插件解决了分流问题,又把它接进了 Loon。

顺手给 Chrome 重新设置了 DoH 指向本地 CoreDNS——这个功能因为之前系统腐坏关掉很久了。然后随手测了一下 aliyun.com。

居然没有跳国际站。

这个问题由来已久,之前一直以为是某种无解的玄学。这一刻突然好了,反而让我坐不住了:到底是哪里出了问题?它为什么以前会跳,现在又为什么不跳了?这个问题不是个案,背后一定有什么。

于是才有了后来的挖掘,才有了这篇文章。

再后来,你都知道了。


起点:一个让人摸不着头脑的问题

某天发现 Chrome 打开某些网站会报 ERR_NAME_NOT_RESOLVED,但同样的地址在 Safari 里完全正常。

第一反应是 DNS 污染,抓包看了一圈,发现更奇怪的事:Chrome 根本没有走系统 DNS,它自己配置了 DoH(DNS over HTTPS),直接向我自建的 CoreDNS 的 :5353 端口发查询——然后返回了真实的 AAAA 记录(IPv6 地址),Chrome 拿着这个 IPv6 地址去连,本机没有 IPv6 出口,于是失败。

而 Safari 走的是系统 DNS,系统 DNS 被 Loon 劫持,Loon 的 FakeIP 机制无论原始域名有没有 AAAA,都同时合成一条假 A 记录(落在 198.0.8.x 段)和一条假 AAAA 记录(落在 fd27:712::/32 段),两个假地址都指向 TUN,流量经过代理隧道正常出去了。

同一个域名,两个浏览器,两条完全不同的路径,一成一败。

这不是 Chrome 独有的问题——后来验证了 Edge 配置 DoH 之后同样会出现。问题的本质是:任何自己管理 DNS 的程序,都会绕过 Loon 的劫持,破坏 FakeIP 的前提

这让我开始认真梳理本机的网络架构。


摸清现状:一张图说清楚

我所在的网络环境不算简单:本机跑着 Loon(代理客户端),Loon 创建了 TUN 接口接管 IPv4 流量,同时我自建了 CoreDNS 提供本地 DNS 服务,CoreDNS 同时暴露了标准 53 端口、DoT(853)和 DoH(5353)三个入口。

流量路径大致是这样:

Safari / 系统应用
  → 系统 DNS (被 Loon 劫持到 198.19.0.3)
  → Loon FakeIP:合成假 A (198.0.8.x) + 假 AAAA (fd27:712::/32)
  → IPv4/IPv6 流量均进 TUN → 代理隧道出去  ✓

Chrome(自定义 DoH)
  → https://127.0.0.1:5353/dns-query → CoreDNS
  → Cloudflare → 返回真实 AAAA
  → Chrome 直连 IPv6 → 本机无 IPv6 出口 → 失败  ✗

DNS Architecture Diagram

DNS Architecture — macOS Loon + CoreDNS + Tailscale

问题根源很清晰:Chrome DoH 绕过了 Loon DNS,破坏了 FakeIP 的前提条件。

顺带搞清楚了一个细节:Loon 的 FakeIP 并不是"把 AAAA 压制成假 IPv4"这么简单。实测发现,对于一个纯 IPv6 域名(如 ipv6.ipchu.com,只有真实 AAAA 2a01:4f8:1c1e:6a32::1),经过 Loon 之后会同时返回假 A 198.0.8.91 和假 AAAA fd27:712::c600:85b——两条假记录,两个地址都指向 TUN。Loon 为自己的 FakeIP 池维护了两段地址:IPv4 用 198.0.8.0/22,IPv6 用 fd27:712::/32,双栈都走 TUN 隧道。这意味着在 FakeIP 环境下,物理网卡上几乎看不到任何"真实目标 IP"的流量——都被 Loon 托管了。


第一个解法:在 CoreDNS 里屏蔽 AAAA

既然 Chrome DoH 直连 CoreDNS,最直接的办法是在 CoreDNS 里把 AAAA 查询重写为空响应,强迫 Chrome 降级到 A 记录走 IPv4:

https://.:5353 {
  tls /path/to/cert.pem /path/to/key.pem
  rewrite type AAAA A
  forward . 127.0.0.1:53
}

加上这一行,Chrome DoH 拿到的就是 A 记录,后续走 FakeIP 机制正常代理。

治标,但有效。但这条路走到头会发现:真正的问题不在 AAAA 记录,而在于 DNS 分流本身没有打通。


真正想解决的问题:DNS 分流

解决了眼前的问题,我开始想解决一个更根本的需求:国内域名走国内 DNS,海外域名走海外 DNS

这件事本身不复杂,CoreDNS 的 forward 插件加上 zone 配置可以做到。但问题在于规模——常用的国内域名列表有 10 万条以上。把 10 万个 zone block 写进 Corefile 不是不行,但启动慢、内存占用大、配置文件更新麻烦。

我想要的是:一个插件,读一份域名列表文件,O(1) 查表决定上游

CoreDNS 的插件系统非常干净,每个插件就是一个实现了 plugin.Handler 接口的 Go 结构体:

type SplitDNS struct {
    Next    plugin.Handler
    domains map[string]struct{}  // hash set,O(1) 查找
    match   []string             // 命中时的上游地址
    def     []string             // 未命中时的上游地址
}

func (s SplitDNS) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
    name := r.Question[0].Name
    if s.inList(name) {
        return s.forward(ctx, w, r, s.match)
    }
    return s.forward(ctx, w, r, s.def)
}

域名列表加载到内存 hash set,查询时 O(1) 判断走哪个上游,比 zone block 方案在性能和灵活性上都好很多。

插件注册进 plugin.cfg,重新编译 CoreDNS,Corefile 里就可以用 splitdns 了:

.:53 {
  splitdns {
    list /path/to/china_domains.txt
    match tls://223.5.5.5 tls://223.6.6.6 {
      tls_servername dns.alidns.com
    }
    default tls://1.1.1.1 tls://1.0.0.1 {
      tls_servername one.one.one.one
    }
  }
  cache { success 9984 300 30 }
  log
}

国内域名走阿里 DNS,其他走 Cloudflare,全程加密传输(DoT)。


兜了一圈,回到 Loon 的 DNS 配置

splitdns 插件跑起来之后,分流逻辑在 CoreDNS 层面是对的,但实际体验并不理想。问题出在 Loon 本身。

Loon 有自己的 DNS 解析能力,也会做一定程度的分流,但效果不稳定。更严重的是一个实际案例:访问 aliyun.com 时被强制重定向到 alibabacloud.com 国际站——国内用户的登录态、控制台、账单全部对不上。症状隐蔽,不容易意识到是 DNS 出了问题(背后的根因后来做了专门的审计,放在下一节)。

解决方式出乎意料地简单:在 Loon 的 DNS 设置里,把上游 DNS 指向 CoreDNS(127.0.0.1

这一步打通了整个闭环:

macOS 系统 / 浏览器(不再单独配置 DoH)
  → DNS 查询
  → Loon 劫持
  → 委托给 CoreDNS 127.0.0.1:53
  → splitdns 分流:国内走阿里 DNS,海外走 Cloudflare
  → FakeIP 机制正常介入
  → 流量经代理隧道出去  ✓

系统和浏览器不再需要单独配置 DoH,因为它们的 DNS 请求会被 Loon 统一劫持,Loon 再委托给 CoreDNS 处理。CoreDNS 负责两件事:分流(国内/海外走不同上游)和加速(DoT 加密 + 缓存预取,减少重复查询延迟)。这两件事同样重要——解析延迟直接影响页面首字节时间,尤其是加载大量第三方资源的页面。


CoreDNS 与 Loon 的共生关系

上线之后发现海外域名的解析有将近 1 秒的延迟。原因并不复杂:1.1.1.18.8.8.89.9.9.9 这类支持 DoH/DoT 的知名公共 DNS,在国内网络环境下处于不可直连状态。CoreDNS 向这些上游发出的查询,必须经过 Loon 的代理隧道才能到达——这不是配置失误,是网络现实。隧道往返带来的延迟,就是这 ~1s 的来源。

这里有一个微妙的相互依存:

  • CoreDNS 依赖 Loon:海外域名的 DNS 查询需要走 Loon 的隧道才能到达 1.1.1.1
  • Loon 依赖 CoreDNS:Loon 把 DNS 上游委托给 CoreDNS,才能拿到正确的国内/海外 IP,路由决策才不会走偏

两者各取所长:Loon 负责流量代理和隧道,CoreDNS 负责 DNS 分流和缓存。拆开任何一方,另一方都会出问题。

这个依存关系还隐藏着一个先有鸡还是先有蛋的启动问题:CoreDNS 需要 Loon 的隧道才能访问 1.1.1.1,但 Loon 需要 CoreDNS 正确解析 DNS 才能建立隧道。系统冷启动时,两者都在等对方先就绪。

这正是 CoreDNS 的上游配置必须使用 IP 直连(而非域名)的根本原因:

# ✓ 正确:IP 直连,不依赖 DNS 解析就能建立连接
default tls://1.1.1.1 tls://1.0.0.1 {
  tls_servername one.one.one.one
}

# ✗ 危险:如果写成域名形式,CoreDNS 启动时需要先解析这个域名
# default tls://one.one.one.one  ← 死锁:解析域名需要 DNS,但 DNS 还没启动

国内主流的加密 DNS 中,腾讯的 doh.pub 等服务已不再支持 IP 直连,只能通过域名访问——这意味着它们无法用作 CoreDNS 的上游,因为 CoreDNS 在启动阶段还没有可用的 DNS 来解析这些域名。阿里的 223.5.5.5223.6.6.6 保留了 IP 直连的 DoT 支持,这是它们在这套架构里不可替代的原因。

实际上系统能正常启动,依赖的是一个微妙的时序:223.5.5.5 直连可用(国内,无需隧道),CoreDNS 先用它处理国内域名查询;Loon 借此完成隧道建立;隧道建立后 CoreDNS 才能访问 1.1.1.1 处理海外域名。serve_stale 缓存在这个过渡期间提供了额外的兜底。这套启动顺序是隐式的,没有任何一处文档说清楚过。

Cache 在这里发挥了关键作用。 隧道延迟是每次冷查询的固定成本,但大多数域名都是反复访问的。CoreDNS 的缓存配置:

cache {
  success  9984 300 30   # 成功响应最多缓存 5 分钟
  prefetch 5 10m 20%     # 高频记录提前后台刷新,命中时零延迟
  serve_stale 1h         # 上游不可达时用过期缓存兜底
}

prefetch 是其中最有价值的选项:当一条记录查询次数超过阈值、TTL 剩余不足 20% 时,CoreDNS 在后台提前刷新,用户下次查询直接命中缓存,感知不到隧道延迟。

为了直观了解缓存命中率和解析耗时分布,配套写了一个 dnslog 可视化工具,解析 CoreDNS 的访问日志,展示各域名的查询频率、上游耗时、缓存命中情况。"DNS 分流是否真的走了正确的上游"从此从猜测变成了可观测的事实。


追查到底:GeoDNS 与代理内部 DNS 的盲区

aliyun.com 被重定向到国际站这件事,一开始以为是 GeoIP 检测客户端 IP 导致的,但手动验证后发现两种 DNS 配置下 aliyun.com 解析到的 A 记录完全相同,而且 Loon 的请求日志也明确显示 www.aliyun.com 走的是 DIRECT 规则。如果客户端 IP 相同、目标 IP 相同、连接方式相同,为什么服务端会给出不同的响应?

用 Playwright 写了一个无头浏览器审计脚本,在两种 DNS 配置下分别加载 aliyun.com 首页,抓取完整的请求链和重定向记录。结果一目了然:

  • splitdns 正常:0 个 HTTP redirect,最终落在 www.aliyun.com
  • overseas-dns:第一个请求就触发了 document 级别的 302,直接跳到 alibabacloud.com/en?_p_lc=1

type: document 是关键——这不是某个子资源或 JS 触发的,是服务端收到主文档请求后直接返回了 302。

响应头里还藏着另一条线索:set-cookie: alicloud_deploy_r_s=sgsg 是新加坡。这说明处理这个请求的 CDN 节点在新加坡,而 splitdns 模式下响应头里有 via: cache3.cn6483——中国 CDN 节点。同一台机器发出的请求,落到了不同地区的 CDN 节点。

真正的答案在这条 DNS 查询里:

$ dig www.aliyun.com @1.1.1.1

www.aliyun.com.  CNAME  www-jp-de-intl-adns.aliyun.com.   ← "intl" 国际版路径
www-jp-de-intl-adns.aliyun.com.  CNAME  ...gds.alibabadns.com.
...
xjp-adns.aliyun.com.vipgds.alibabadns.com.  A  47.88.198.68
xjp-adns.aliyun.com.vipgds.alibabadns.com.  A  47.88.251.189
...
;; Query time: 1118 msec   ← 1秒延迟,DNS 查询经过了代理

对比 @223.5.5.5 的结果:直接返回 106.11.x.x 中国 IP,无 CNAME 跳转。

阿里使用了 GeoDNS:权威 DNS 根据查询来源的地理位置,返回不同的 CNAME 链。向 1.1.1.1 查询时,1.1.1.1 收到的请求来自新加坡出口(Loon 的代理),于是返回了 intl 国际版路径,最终 IP 是新加坡的 CDN 节点。Loon 拿到这个新加坡 IP 后做 DIRECT 连接——DIRECT 没有错,但目的地已经是新加坡了。

Loon 实际上有两层独立的决策:

第一层:路由决策(日志里可见)
  www.aliyun.com → 命中 DIRECT 规则 ✓

第二层:内部 DNS 解析(静默发生)
  FakeIP 机制下,Loon 需要一次内部 DNS 解析才能拿到真实 IP 建立连接
  → 这个查询走了代理隧道(Loon 的 DoH Traffic Mode 无论 Rule/Direct 均如此)
  → 1.1.1.1 收到新加坡来源 → 返回国际版 CNAME 链 → 新加坡 IP
  → DIRECT 连接到新加坡 IP → 新加坡 CDN → 302 到国际站

日志只记录了第一层,第二层完全不可见,造成了"明明 DIRECT 却跑到海外"的假象。

这个问题在 v2ray 生态里有对应的设计概念——domainStrategy:路由引擎在做规则匹配时,要不要把域名解析成 IP 再判断。AsIs 只看域名,IPIfNonMatch 在域名规则未命中时解析 IP,IPOnDemand 遇到 IP 规则立即解析。但这解决的是路由决策阶段的 DNS 问题。

更完整的解法是 sing-box 的做法——在 DNS 配置层面,给不同出站绑定不同的 DNS 服务器:

"dns": {
  "rules": [
    { "outbound": "direct", "server": "domestic" },  // DIRECT 出站用国内 DNS
    { "outbound": "proxy",  "server": "overseas"  }  // 代理出站用海外 DNS
  ]
}

这样 DNS 解析路径和流量路由路径对齐,不会出现"DIRECT 连接却拿到海外 IP"的情况。

把 Loon 的 DNS 上游改为 CoreDNS,本质上是在 Loon 外部做了这层对齐:CoreDNS 的 splitdns 保证 aliyun.com 永远用 223.5.5.5 解析,Loon 拿到中国 IP,DIRECT 连接落到中国 CDN。Loon 的内部 DNS 路径不再有机会走偏。


分发:GoReleaser + Homebrew Tap

自己用没问题,但想分享给别人用,就需要解决分发的问题。CoreDNS 是 Go 写的,编译产物是单一二进制,天然适合预编译分发。

用 GoReleaser 做构建和发布:

# .goreleaser.yml
builds:
  - goos: [darwin]
    goarch: [amd64, arm64]

universal_binaries:      # 合并为 macOS universal binary,一个文件跑 Intel 和 Apple Silicon
  - replace: true

release:
  github:
    owner: bilxio
    name: coredns

brews:
  - name: coredns-bilxio
    repository:
      owner: bilxio
      name: homebrew-tap  # 自动更新 Tap 仓库里的 Formula

git tag v1.12.1-bilxio.1 && goreleaser release --clean,几分钟后:

  • GitHub Releases 里出现 coredns_1.12.1-bilxio.1_darwin_universal.tar.gz
  • bilxio/homebrew-tap 里自动提交了新的 Formula 文件

用户安装只需要:

brew tap bilxio/tap
brew install coredns-bilxio

不需要 Go 环境,不需要手动编译,下载预编译二进制直接用。


几个值得记录的细节

GoReleaser 会从 origin remote 推断发布目标。 如果你的项目是 fork,origin 指向上游,GoReleaser 会尝试往上游发 Release 然后 403 报错。需要在配置里显式写明自己的仓库:

release:
  github:
    owner: your-username  # 不写这个就会出问题
    name: your-repo

Homebrew Tap 仓库名必须以 homebrew- 开头。 brew tap bilxio/tap 实际上对应的是 GitHub 上的 bilxio/homebrew-tap 仓库,Homebrew 自动去掉了前缀。一个 Tap 仓库可以放多个 Formula,适合长期维护多个工具。

CoreDNS 的 go generate ./... 在子包里找不到根目录的生成脚本。 plugin/chaos/setup.go 里有 //go:generate go run owners_generate.go,但这个脚本只在项目根目录,在子包里跑 generate 会找不到。发布流程里去掉 go generate 这个 before hook 即可,生成文件已经提交在仓库里。


这件事的终章还没写完

有一个变量在悄悄收紧:阿里的 223.5.5.5 DoT/DoH 服务正在限制匿名用量,并推行实名制——注册账号后通过 tls://<实名标识>.alidns.com 的方式访问,匿名的 IP 直连通道最终可能关闭。

这对当前架构是个直接冲击。223.5.5.5 在这套系统里不可替代的原因有两个:一是支持 IP 直连(解决启动依赖问题),二是作为国内权威 DNS 返回正确的 GeoDNS 结果。如果 IP 直连通道关闭,备选路径只有两条:

选项 A:接受实名,换用认证后的域名形式

注册账号,获取专属接入域名,Corefile 改写为:

match tls://<uid>.alidns.com {
  tls_servername <uid>.alidns.com
}

代价是实名绑定,收益是继续保有 DoT 加密和精准 GeoDNS 路由。

选项 B:国内部分退回 UDP 明文

match 223.5.5.5:53  # 普通 UDP,无加密

UDP 查询不需要 TLS 握手,没有 IP 直连的限制,启动依赖问题也消失了。代价是查询明文可见,在某些网络环境下有被污染的风险。但对于国内域名而言,223.5.5.5 的 UDP 服务稳定可靠,实际使用中未必是问题。

两条路都能走通,选哪条取决于你对实名的接受程度和对 DNS 明文的风险容忍度。这件事提醒我们:基础设施依赖的收紧往往不是一次性的冲击,而是慢慢收紧的绳子。架构设计时,上游依赖的可替换性值得认真考虑。


后记:加入 Tailscale

后来把 Tailscale 也接进了这套架构。Tailscale 在 macOS 上会创建自己的 utun 接口(100.64.0.0/10),并在 100.100.100.100 提供内网域名解析(MagicDNS)。

接入时有两处和 Loon 的冲突需要处理:Loon 的 bypass-tun 里有 100.100.100.100/32,会注入一条经物理网关的静态路由,优先级高于 Tailscale 自己的路由表条目,导致 MagicDNS 根本收不到查询;FakeIP 机制对 *.ts.net 同样返回假地址,Tailscale 主机名无法被正确使用。两处修复都很直接:从 bypass-tun 移除该条目,并在 Loon 的 [General] 里加上 real-ip = *.ts.net。CoreDNS 里加一个 zone block 将解析委托给 MagicDNS:

tail123.ts.net {
    forward . 100.100.100.100
    cache 30
}

在 tailscale 网页管理端,DNS 页面开启 “MagicDNS“(tailscale 会立刻在本机开启一个监听在 100.10.100.100 的 DNS服务),之后看 “Tailnet DNS name”, 假设你的是 "tail123.ts.net"

注意,在 mac 上的 Tailscale Setting 页面中,务必不要开启 "Use Tailscale DNS setings",否则 DNS 就会被 TS 直接接管

现在 CoreDNS 统管三条路径:国内域名走阿里 DNS、海外域名走 Cloudflare、Tailscale 内网域名走 MagicDNS,对外接口不变,还是 127.0.0.1:53


结语

这次折腾的起点是一个浏览器 DNS 异常,沿着问题一路挖下去:搞清楚了 FakeIP 机制、Loon TUN 的 IPv6 局限,又用无头浏览器审计工具找到了 aliyun.com 重定向的真正根因——GeoDNS 加上代理内部 DNS 路径不受控的叠加效应。最后顺手把整个工具链做成了可分发的状态。

每一个症状背后都有一个被隐藏的假设:FakeIP 假设所有 DNS 都经过 Loon;GeoDNS 假设 DNS 查询来源代表客户端位置;代理客户端假设内部 DNS 和路由决策是同一回事。这些假设在单一环境下成立,一旦几个系统叠在一起就会悄悄失效。

整件事最值得提炼的一句话是:专业的事给专业的人做

Loon 是代理客户端,它的核心能力是流量路由和隧道管理,DNS 只是顺带解决的问题——解决得够用,但不够深。它不理解 GeoDNS 的 CNAME 链,不知道内部 DNS 解析路径要和路由决策对齐,也没有可观测性让你看清楚发生了什么。

CoreDNS 是专门做 DNS 的,这些问题它天然就有答案:splitdns 处理分流,cache + prefetch 处理延迟,IP 直连处理启动依赖,log 插件提供可观测性。每一个设计决策背后都有明确的 DNS 语义。

最终的架构其实很简单:Loon 管流量,CoreDNS 管域名,两者通过一个干净的接口(127.0.0.1:53)连接。各自在最擅长的层做到极致,职责边界清晰,比任何一方单独承担所有职责都更稳定、更可维护、更可观测。

DNS 是个很有意思的层:它足够底层,藏着很多你平时意识不到的假设;但它又足够简单,一个几百行的 Go 插件、一次 Playwright 审计就能把问题摆到台面上。

不过解决完这些问题之后,很难真正高兴起来。费了这么大力气,解决的终究不是技术本身的复杂度,而是在一个不断收窄的空间里找到了一个暂时还能用的姿势。

如果你有类似的国内外分流需求,可以试试:

brew tap bilxio/tap
brew install coredns-bilxio

配置文件参考项目 README。有问题欢迎提 issue。

credits