自建 Unbound 递归 DNS 解析器实战:DNSSEC 强验证、access-control 收紧与 systemd-resolved 冲突

为什么个人站长值得自建一个递归 DNS 解析器

先说一个很多人不知道的事实:你 VPS 上默认那个 resolv.conf,写的往往是云厂商的内网 DNS,比如 100.100.100.100 或者 183.60.83.19 之类。它是能用,但你完全不知道它在背后做了什么——有没有记录你的查询、有没有过滤某些域名、有没有在解析失败时返回一个广告导航页(DNS 劫持)。对个人站长来说,服务器上跑的每一个 curl、每一次 apt update、每一个 API 调用,都要走这个解析器。

这篇文章讲的是自建递归解析器 Unbound 的完整落地:它和 BIND 的区别在哪、为什么个人站更适合 Unbound、完整配置怎么写、DNSSEC 验证怎么开、和 systemd-resolved 打架怎么解决,以及我自己踩过的几个坑。

一、递归解析器和权威 DNS 是两码事

这是最容易搞混的地方。很多人看到「自建 DNS」就以为要像 BIND 那样配 zone 文件、写 A 记录——那是权威服务器(authoritative),作用是「回答别人关于我域名的查询」。而 递归解析器(recursive resolver) 干的是完全相反的事:它代替你的服务器去问别人。

完整的一次递归查询是这样的:

  • 你的机器问 Unbound:「www.example.com 的 IP 是多少?」
  • Unbound 先问根服务器(.),根服务器回答:「.com 的权威服务器在 a.gtld-servers.net」
  • Unbound 再问 .com 的服务器,回答:「example.com 的 NS 在 ns1.example.com」
  • Unbound 再问 ns1.example.com,终于拿到真正的 A 记录
  • Unbound 把结果缓存起来(按 TTL),返回给你的机器

云厂商的 DNS 就是这样一个解析器,只不过它是公共的、你不知道它怎么配置。自建之后,好处是:

项目云厂商 DNS自建 Unbound
查询日志不透明,可能被记录完全自己掌控,可关可开
DNS 劫持/广告跳转部分厂商有无,返回真实 NXDOMAIN
DNSSEC 验证通常不验证可强验证,防投毒
缓存命中率共享缓存,可能被污染私有缓存,干净
查询延迟看厂商节点分布首次稍慢,命中后极快
可用性厂商偶发故障就全站解析失败自己可控,可配多个上游兜底

二、Unbound 和 BIND9 怎么选

个人站长场景下,我的建议很明确:权威用 BIND 或 PowerDNS,递归用 Unbound。原因是 Unbound 在设计上就是「只做递归解析器」这一件事,代码量小、默认权限模型严格(可以 chroot、可以降权到 nobody)、内置 DNSSEC 验证,而且配置文件比 BIND 的 zone 语法清爽太多。

反过来说,如果你想用一台 Unbound 既做递归又做权威,那是在给自己找麻烦——Unbound 虽然支持 local-zone,但不适合当生产权威服务器。

安装(Debian 12 / Ubuntu 22.04)

apt update
apt install -y unbound unbound-host dnsutils

# Debian 12 上装完是自动启动的,先停掉改配置
systemctl stop unbound

Ubuntu 上装 Unbound 有个坑:它会顺手帮你改 /etc/resolv.conf 指向 127.0.0.1,但如果 resolv.conf 是 systemd-resolved 托管的软链接,你这个改动会被覆盖回去。后面第三节专门讲这个。

三、最小可用配置:监听本地就行

99% 的个人站长只需要本机使用这个解析器,不需要给公网提供递归服务。这非常重要——给公网开放递归解析器,会被当成 DNS 放大攻击的跳板,几小时内就会收到云厂商的滥用告警甚至停机。

配置文件在 /etc/unbound/unbound.conf.d/local.conf(Debian 系把主配置拆成了多个片段,别去改 /etc/unbound/unbound.conf 主文件,升级时容易被覆盖):

server:
    # 只监听本机,绝不监听 0.0.0.0
    interface: 127.0.0.1
    interface: ::1
    port: 53

    # 只允许本机查询,这是关键的安全边界
    access-control: 127.0.0.0/8 allow
    access-control: ::1 allow
    # 其余全部拒绝(默认就是拒绝,但显式写出来更清楚)
    access-control: 0.0.0.0/0 refuse
    access-control: ::/0 refuse

    # 降权运行,别用 root
    username: "unbound"
    directory: "/etc/unbound"
    chroot: "/etc/unbound"

    # 记录日志(排查完可以关掉,减少磁盘写入)
    logfile: "/var/log/unbound/unbound.log"
    verbosity: 1
    log-queries: no
    log-replies: no

    # 隐藏版本号,别给人指纹
    hide-identity: yes
    hide-version: yes

    # 关键:强制 DNSSEC 验证
    auto-trust-anchor-file: "/var/lib/unbound/root.key"
    val-log-level: 2

    # 缓存大小:个人站 64MB 足够,换来极高命中率
    msg-cache-size: 64m
    rrset-cache-size: 128m
    cache-min-ttl: 60
    cache-max-ttl: 86400

    # 隐私:不发送客户端子网、最小化查询
    qname-minimisation: yes
    qname-minimisation-strict: yes
    do-not-query-localhost: yes
    private-address: 10.0.0.0/8
    private-address: 172.16.0.0/12
    private-address: 192.168.0.0/16

    # 防投毒:固化 nameserver 端口和目标
    harden-glue: yes
    harden-dnssec-stripped: yes
    harden-below-nxdomain: yes
    harden-referral-path: yes
    use-caps-for-id: yes

    # 预取热门域名,压缩首次查询延迟
    prefetch: yes
    prefetch-key: yes

配置里几个参数值得单独解释,因为它们经常被抄错:

  • access-control:这是最核心的安全设置。写 allow 的范围才能查询,其他一律拒绝。有人图省事写 access-control: 0.0.0.0/0 allow,那等于开了一个公开递归解析器,是事故级别的错误。
  • auto-trust-anchor-file:指向根信任锚(root.key),DNSSEC 验证的起点。Unbound 会自动通过 RFC 5011 机制更新它,前提是这个文件所在目录可写。Debian 包会自动装 unbound-anchor,如果看到 root.key 为空或报错,手动执行一次 unbound-anchor -a /var/lib/unbound/root.key。
  • harden-dnssec-stripped:如果上游把 DNSSEC 记录剥掉了却声称验证通过,直接拒绝。这是防降级攻击的关键。
  • qname-minimisation:查询逐步缩减域名前缀,减少向根服务器泄露完整域名,是 RFC 7816 的隐私增强。

改完检查语法并启动:

unbound-checkconf
# 输出 "unbound-checkconf: no errors in /etc/unbound/unbound.conf" 才算过

systemctl enable --now unbound
systemctl status unbound --no-pager

四、让系统真的用上它:systemd-resolved 的冲突

这是自建解析器最常翻车的地方。你配置好了 Unbound,结果发现 dig 的结果还是走的老 DNS——因为 Ubuntu/Debian 新版都用 systemd-resolved 在 127.0.0.53:53 上占着 53 端口,你的 Unbound 报「address already in use」或者干脆被忽略。

先确认状态:

# 看 53 端口被谁占了
ss -lunp | grep ':53'
# 如果看到 127.0.0.53:53 是 systemd-resolve,就是它

# 看当前实际使用的上游
resolvectl status | head -30

有两种处理方式,我推荐第一种。

方案一:停掉 resolved,直接让 Unbound 独占 53(推荐)

systemctl disable --now systemd-resolved

# 删掉托管软链接,换成真实文件
rm -f /etc/resolv.conf
cat > /etc/resolv.conf <<'EOF'
nameserver 127.0.0.1
nameserver ::1
options edns0 trust-ad
search .
EOF

# 防止被 NetworkManager / cloud-init 再次覆盖
chattr +i /etc/resolv.conf

systemctl restart unbound

注意 options trust-ad 这一行——没有它,glibc 在 getaddrinfo 时不会向应用传递 AD(Authenticated Data)标志,你就「验证了 DNSSEC 但应用不知道」。这是一个非常隐蔽的细节。

chattr +i 上锁很有效,但记住以后要改 resolv.conf 必须先 chattr -i,否则会报「Operation not permitted」,很多人在这卡半天。

方案二:保留 resolved,让它转发给 Unbound

如果你不想停 resolved(比如还在用它的 mDNS 功能),就改它的上游指向 Unbound 的另一个端口:

# Unbound 监听 5335(避开 resolved 占用的 53)
# /etc/unbound/unbound.conf.d/local.conf 里改 port: 5335

# 然后告诉 resolved 转发给它
mkdir -p /etc/systemd/resolved.conf.d
cat > /etc/systemd/resolved.conf.d/unbound.conf <<'EOF'
[Resolve]
DNS=127.0.0.1:5335
DNSStubListener=no
DNSSEC=allow-downgrade
EOF

systemctl restart systemd-resolved unbound

两种方案我实际都用过,方案一更干净、少一层转发、延迟更低,个人 VPS 上没有任何理由留着 resolved。

五、验证:别只看「服务起来了」

服务 active 不代表解析真的走了你的 Unbound。按这个顺序实测:

# 1. 直接指定用 Unbound 查询,确认能解析
dig @127.0.0.1 example.com +short

# 2. 确认响应来自本地 Unbound(看 SERVER 行应该是 127.0.0.1#53)
dig example.com | grep -A1 'SERVER:'

# 3. DNSSEC 验证:这个域名有 DNSSEC,应该看到 ad 标志
dig @127.0.0.1 cloudflare.com +dnssec | grep flags
# 期望:flags: qr rd ra ad;   <-- 有 ad 就说明验证通过

# 4. 验证一个「DNSSEC 故意配错」的域名,必须返回 SERVFAIL
dig @127.0.0.1 dnssec-failed.org
# 期望:status: SERVFAIL

# 5. 缓存是否生效:第二次查询的 Query time 应该降到 1ms 以内
dig @127.0.0.1 www.qq.com | grep 'Query time'

第 4 步是判断 DNSSEC 到底有没有真正生效的唯一可靠方法。如果 dnssec-failed.org 返回正常解析结果,说明你的验证被绕过了——很可能是 auto-trust-anchor-file 路径写错或 root.key 是空的。

查 Unbound 的运行统计

unbound-control stats_noreset | grep -E 'total.num|cache.hits|cache.miss|num.query.type.A|num.answer.secure|num.answer.bogus'

几个关键指标:

  • total.num.cachehits / total.num.queries → 缓存命中率。稳定运行一天后,个人站应该看到 80% 以上。低于 50% 说明 TTL 设置有问题或缓存被打爆。
  • num.answer.secure → 通过 DNSSEC 验证的响应数,应该持续增长。
  • num.answer.bogus → 验证失败的响应数。偶尔有是正常的(遇到真的配错 DNSSEC 的站点),但如果持续高速增长,说明你的根信任锚坏了,所有验证都失败。

六、我踩过的四个坑

坑一:日志把磁盘写满

开了 verbosity: 2 加 log-queries: yes 之后,一个访问量稍大的站一天能写出几个 G 的日志。修法是 verbosity 保持 1、关掉 log-queries,并加一条 logrotate:

cat > /etc/logrotate.d/unbound <<'EOF'
/var/log/unbound/unbound.log {
    weekly
    rotate 4
    compress
    delaycompress
    missingok
    notifempty
    create 0640 unbound unbound
    postrotate
        /usr/sbin/unbound-control log_reopen 2>/dev/null || true
    endscript
}
EOF

注意 postrotate 里的 unbound-control log_reopen——只是 rotate 文件不通知进程的话,Unbound 会继续往已经改名(甚至已删除)的旧 inode 写,新文件永远是空的。这个坑和 Nginx 的 nginx -s reopen 是同一类。

坑二:chroot 之后 unbound 起不来

Debian 包默认在 systemd unit 里带了 chroot 和 ProtectSystem=strict 之类的硬化参数。如果你把日志目录或 root.key 放在 chroot 之外,进程会静默失败。排查方法:

journalctl -u unbound -n 50 --no-pager
# 找 "failed to open" / "permission denied" 之类的行

要么把所有路径都放进 chroot 目录(/etc/unbound/)之下,要么在 /etc/systemd/system/unbound.service.d/override.conf 里覆盖掉硬化参数。我选前者,更安全。

坑三:私有地址被拒绝,内网域名解析失败

private-address 列表里的网段,Unbound 会认为「公网 DNS 不该返回这些地址」而拒绝。这本来是防投毒的,但如果你在服务器上要解析内网的域名(比如数据库走内网域名 db.internal),转发给上游后会被这条规则拦掉。修法是给内网域名单独配 domain-insecure 和 local-zone,或者用 forward-zone 指定内网 DNS。

坑四:DNS 解析慢导致 SSH 登录卡 5 秒

配完之后发现 SSH 登录要卡好几秒才出密码提示。原因是 sshd 默认开了 UseDNS yes,登录时反查客户端 IP 的 PTR 记录,而这个反查走递归要好几跳。修法:

# /etc/ssh/sshd_config
UseDNS no
GSSAPIAuthentication no

这不是 Unbound 的 bug,但自建递归后「首次查询慢」的特性会把这类问题的症状放大,值得顺手一起改掉。

七、什么时候不该自建递归解析器

说点劝退的。以下情况别折腾:

  • 只有一两台机器、业务不敏感:云厂商 DNS 省事,收益抵不过维护成本。
  • 服务器到根服务器的网络质量差:有些机房对 53 端口 UDP 有 QoS 或丢包,递归查询会非常慢,这种情况用 DNS over TLS 的公共解析器(1.1.1.1、9.9.9.9)更好。
  • 想做的是「给公网提供 DNS 服务」:那你要的是权威服务器,别用 Unbound。
  • 服务器内存极小(不足 256MB):Unbound 基础占用约 40-60MB,加上缓存,低配机上要慎重。可以把 rrset-cache-size 降到 16m 试试。

八、小结

自建 Unbound 递归解析器的核心收益有三点:解析链路透明可控(知道每个域名去哪问的)、DNSSEC 真正生效(防缓存投毒)、缓存私有化(不受公共 DNS 污染影响)。代价是要处理 systemd-resolved 冲突、chroot 权限、日志轮转这几件琐事,一次性配好之后基本不用管。

如果你只做一件事,那就做把 access-control 收紧到 127.0.0.0/8——开放递归解析器被滥用导致云厂商停机,是这类部署里最常见、后果最严重的失误。

Last modification:September 29th, 2026 at 01:25 pm

Leave a Comment