自建 BIND9 权威 DNS 实战:split-horizon 视图分离、zone 记录设计与 TTL/CNAME 陷阱

为什么你的个人站必须自建 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.confinclude 引入的,而 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-checkconfnamed-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 换过来的正确顺序

顺序错了会造成几分钟到几小时的解析中断,正确做法是:先在注册商后台新增 ns1ns2 的 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 是所有服务的前置依赖,它出问题的表现是「网站打不开」而监控图上什么都正常,属于最难排查的一类故障,值得多花半小时把冗余和审计做扎实。

Last modification:September 23rd, 2026 at 12:59 pm

Leave a Comment