为什么你的个人站必须自建 DNS 解析,以及 split-horizon 能解决什么
大多数个人站长对 DNS 的全部认知就是「在域名注册商后台改两条 A 记录」。这在一个纯静态、只有一台服务器的场景下确实够用,但只要出现下面任意一种情况,注册商自带的基础解析就会立刻变成瓶颈:内网测试环境需要访问同一个域名但不能走公网回源;主站挂了需要秒级切到备用 IP;邮件服务要从动态 IP 的家里服务器发出去;同一个域名对国内和海外用户要返回不同的节点。
这篇文章讲的是用 BIND9 在一台 2 核 2G 的廉价 VPS 上自建权威解析服务,重点落在 split-horizon(视图分离)这个几乎没人在个人站场景讲透的功能上。它能让「同一个域名,内网解析到内网地址、公网解析到公网地址」这件事变得极其干净,而成本是每月不到一杯咖啡的钱。
先想清楚:自建解析到底为了什么
自建 DNS 不是为了「显得专业」,它必须换来具体的收益,否则只是给自己增加一个会挂的组件。我自建的理由有三个,按重要性排序:
第一,视图分离。我的备份服务器与主服务器在同一个内网,走公网 IP 互传数据要绕一圈 CDN 还要算流量费,走内网地址则免费且快十倍。但应用配置里写的是域名,不能为了这个去改代码。
第二,故障切换的主动权。注册商的解析最小 TTL 往往不让你设得太小,而且修改后有几分钟到几十分钟的生效延迟,主站宕机时你只能干等。
第三,解析记录的可审计性。自己的 zone 文件是纯文本,进 Git 就能看到每一次改动是谁、什么时候、改了什么,这对一个长期有人评论「网站打不开了」的个人站很重要。
代价也很明确:你要自己保证这台 DNS 服务器不挂。所以自建的前提是至少两台机器做 allow-transfer 主从,或者接受「DNS 挂了全站都挂」的风险。下面的配置假定你有一主一从。
最小可用的 BIND9 配置
以 Debian 12 为例,安装并锁住监听范围:
apt install -y bind9 bind9utils bind9-doc
# /etc/bind/named.conf.options
options {
directory "/var/cache/bind";
recursion no; // 权威服务器不做递归,别当开放解析器
allow-query { any; };
allow-transfer { none; }; // 默认禁止, 在主从配置里按 zone 单独放开
dnssec-validation auto;
listen-on port 53 { any; };
listen-on-v6 { any; };
minimal-responses yes; // 响应更小, 减少放大攻击面
rate-limit {
responses-per-second 20;
window 5;
};
};recursion no 是这里最重要的一行。开放递归解析器会被攻击者当成 DNS 放大攻击的跳板,一旦你的 VPS 被利用去攻击别人,云厂商会直接封机器。个人站的权威服务器只回答自己 zone 里的问题,所以递归必须关掉。
然后是 zone 声明与视图分离。
// /etc/bind/named.conf.local
acl "internal" { 10.8.0.0/24; 127.0.0.1; }; // 内网 / VPN 网段
view "internal-view" {
match-clients { internal; };
recursion no;
zone "example.com" {
type master;
file "/etc/bind/zones/db.example.com.internal";
allow-transfer { none; };
};
};
view "external-view" {
match-clients { any; };
recursion no;
zone "example.com" {
type master;
file "/etc/bind/zones/db.example.com.external";
allow-transfer { 203.0.113.9; }; // 从服务器公网 IP
};
};视图的匹配是自上而下、第一个匹配者胜出,所以 internal 必须写在 external 前面。另外注意:named.conf.local 在默认配置里是由 named.conf 用 include 引入的,而 options {} 块已经写在 named.conf.options 里了——一个配置文件里只能有一个 options 块,别重复定义,否则 BIND 启动时会报 duplicate definition。
两份 zone 文件的差异就在几个记录上
公网视图保持正常:
$TTL 300
@ IN SOA ns1.example.com. admin.example.com. (
2026092301 ; serial 格式 YYYYMMDDnn
3600 ; refresh
900 ; retry
604800 ; expire
300 ) ; minimum
@ IN NS ns1.example.com.
@ IN NS ns2.example.com.
ns1 IN A 203.0.113.10
ns2 IN A 203.0.113.9
@ IN A 203.0.113.10
www IN CNAME example.com.
db IN A 203.0.113.11
@ IN MX 10 mail.example.com.
mail IN A 203.0.113.12
@ IN TXT "v=spf1 mx -all"内网视图几乎一样,只把需要走内网的名字换掉:
$TTL 300
@ IN SOA ns1.example.com. admin.example.com. (
2026092301 3600 900 604800 300 )
@ IN NS ns1.example.com.
ns1 IN A 10.8.0.2
@ IN A 10.8.0.2 ; 内网直连主库
www IN CNAME example.com.
db IN A 10.8.0.3 ; 备份机内网地址
mail IN A 10.8.0.4两个地方值得单独说。serial 必须是「每次改动都变大的数字」,用日期加序号是最省心的做法;如果两份文件的 serial 不一致且主从之间同步了,从服务器会不断尝试重传,日志里全是 zone transfer 报错。另外,SOA 里的 minimum 现在主要作为否定回答(NXDOMAIN)的 TTL 上限,把它设小一点(300)能让「记录不存在」这个结论更快过期,避免新增子域名后长时间被缓存成不存在。
验证:先查本机,再查公网
改完必须 named-checkconf 和 named-checkzone 双验证,这两个命令能拦住 90% 的语法错误:
named-checkconf -z /etc/bind/named.conf named-checkzone example.com /etc/bind/zones/db.example.com.external
然后在不同的来源上分别查询,亲自确认视图生效:
# 内网机器(或从匹配 internal ACL 的 IP)上 dig @10.8.0.2 www.example.com +short # 期望输出 10.8.0.2 # 公网机器上 dig @203.0.113.10 www.example.com +short # 期望输出 203.0.113.10 # 检查权威标志,不要出现 recursion available 的报错 dig @203.0.113.10 example.com SOA
如果内网查出来的还是公网地址,最常见的原因是 match-clients 里的网段写错了——注意 ACL 用的是「源 IP」,如果你的内网机器经过 NAT 出来,它看到的源 IP 是网关地址而不是客户端地址。用 rndc querylog 打开查询日志,在 /var/log/syslog 里能看到每个查询的真实源 IP,这是排查视图不生效最快的办法。
把域名注册商的 NS 换过来的正确顺序
顺序错了会造成几分钟到几小时的解析中断,正确做法是:先在注册商后台新增 ns1、ns2 的 glue record(粘合记录),等待它们能正常响应查询,然后再把域名的 NS 记录切过去。切之前务必在两台新 DNS 上把所有记录都建好——包括 MX、SPF、各种验证用的 TXT(Google、GitHub、各种站长工具都会让你加 TXT 验证),漏掉任何一条都可能让邮件进垃圾箱或某个服务失联。
切换后 48 小时内两套解析会并存,这时候要保证新服务器的 zone 内容不再改动,否则两份数据开始分叉,会出现「有的人解析到新地址、有的人解析到旧地址」的诡异现象。我自己的做法是在切换前把 zone 完整导出并冻结,切换完成后三天再开始正常维护。
别踩这几个坑:TTL、CNAME 和泛解析
自建解析之后,有些问题会从「注册商帮你处理好了」变成「你必须自己知道」,其中最容易吃亏的三个是 TTL、CNAME 的使用限制和泛解析。
TTL 决定了这条记录在别人缓存里能活多久。做故障切换的时候,你希望主站一挂就能立刻切到备用 IP,那就必须把 www 和 @ 的 TTL 设小(60 甚至 30)。但要明白一件事:TTL 是「上限」而不是「保证」,某些运营商的递归服务器会强行忽略你的 TTL 按自己的策略缓存,所以任何声称「秒级切换」的方案在真实网络里都做不到 100% 即时。稳妥的做法是把主备切换与 CDN 层的健康检查配合使用,DNS 只作为最后一道兜底。
CNAME 有个硬性限制:不能和其他记录共存于同一个名字下。也就是说 www CNAME example.com. 之后,你不能再给 www 加一条 TXT 记录,也不能有 MX——BIND 会直接拒绝加载这个 zone(named-checkzone 会报 CNAME and other data)。很多站长给自己的 www 加邮件验证用的 TXT 时踩的就是这个坑。解决办法是把 www 写成 A 记录,指向和主域名相同的 IP,宁可多维护一条记录也别硬上 CNAME。同样地,根域名(@)在标准的 DNS 语义里也不应使用 CNAME,虽然部分解析商做了兼容处理,但自建 BIND 时请老老实实用 A 记录。
泛解析 * IN A 203.0.113.10 看起来很方便——任何拼错或不存在的子域名都会被解析到一个地址上,可以做统一的落地页或者捕获错误输入。但它的副作用是让「记录不存在」这件事永远不会发生,所有子域名都会得到一个有效答案。这就意味着:任何针对你域名的子域名扫描都必然「有收获」,一些自动化工具会把你标记为「存在大量子域名」,增加被爬和被试探的面。更实际的问题是,一旦某个子域名被废弃,你会忘记删泛解析,于是这个本该失效的名字一直活着。我的建议是不要用泛解析,需要多少个名字就老老实实写多少条 A 记录,zone 文件的可读性和可审计性都远比省事重要。
一台 DNS 服务器就是单点故障
自建解析最容易被忽略的是冗余。BIND 从服务器的配置比主服务器还简单,核心就三行:
zone "example.com" {
type slave;
masters { 203.0.113.10; };
file "/var/cache/bind/db.example.com";
};不过有两个前提:主服务器的 allow-transfer 里必须有从服务器的地址(容易漏),以及防火墙要放开 53 端口的 TCP——很多人只放了 UDP,结果 UDP 查询正常、数据量稍大的响应(比如带很多记录的大 zone)会因截断而失败。DNS 的很多「偶发解析失败」根因就在这里。
最后一点经验:把 zone 文件放进 Git,每次改动提交一次,并在两台服务器上用 cron 每天比对一次 zone 内容的哈希,不一致就发告警。DNS 是所有服务的前置依赖,它出问题的表现是「网站打不开」而监控图上什么都正常,属于最难排查的一类故障,值得多花半小时把冗余和审计做扎实。