从一行 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 — 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.1、8.8.8.8、9.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.5 和 223.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=sg。sg 是新加坡。这说明处理这个请求的 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。