起因
家里的网络设备越来越多,DNS 请求全都发往运营商或者公共 DNS。这样有几个问题:
- 广告域名解析出来了,浏览器该加载的广告一个不少;
- 国内域名和国外域名混在一起解析,有时候国外网站慢得离谱;
- 运营商能看到你访问了哪些域名,隐私上不太舒服。
于是想着自己搭一套 DNS 服务,放在内网做统一入口。试了几个方案之后,最后选了 mosdns。
这篇文章记录一下完整的搭建过程和踩过的坑,方便以后重装时参考。
为什么选 mosdns
mosdns 是一个用 Go 写的 DNS 转发器,特点是插件化,配置文件写起来像搭积木。相比其他方案:
- 分流规则灵活,可以按域名列表走不同的上游;
- 自带缓存,还支持"过期缓存先用着、后台悄悄刷新";
- 支持 DoT / DoH / DoQ 等加密协议,既能当服务端也能当客户端;
- 单文件二进制,资源占用低,一台小机器就跑得动。
对我来说最关键的是第三点——对外能提供加密 DNS,手机在外面也能安全查询。
整体架构
先说一下最终的架构:
家里的设备(手机、电脑)
│
│ 普通 DNS(UDP/TCP 53)
▼
mosdns(内网一台机器)
│
├── 广告域名 → 直接拦掉
├── 国内域名 → 阿里/腾讯/百度 DNS
└── 其他域名 → 自建的加密 DNS 上游
│
│ 加密 DNS(DoT 853,带证书)
▼
外部设备(手机流量下)也能用
缓存分两级:国内域名缓存时间长一点(一天),其他域名短一点(三小时),因为国外的 CDN 地址经常变。
部署步骤
1. 准备 Docker 环境
mosdns 官方提供了 Docker 镜像,直接用就行。新建一个目录,放两个文件:config.yaml(主配置)和 docker-compose.yml。
2. 编写配置文件
配置分几块:缓存、上游、域名列表、处理流程、监听端口。
缓存部分:
plugins:
# 国内域名缓存,lazy_cache_ttl 设成一天
- tag: cache_domestic
type: cache
args:
size: 200000
lazy_cache_ttl: 86400
dump_file: /etc/mosdns/data/cache-domestic.dump
dump_interval: 600
# 其他域名缓存,时间短一些
- tag: cache_short
type: cache
args:
size: 100000
lazy_cache_ttl: 10800
dump_file: /etc/mosdns/data/cache-short.dump
dump_interval: 600
这里有个概念叫 lazy cache,值得说一下。普通的 DNS 缓存,一旦记录过期就必须重新去问上游,用户得等着。lazy cache 的做法是:记录过期后,先拿旧的答案返回给你,同时在后台悄悄去上游拿最新的。这样用户几乎感觉不到等待,数据又不会太旧。
上游部分:
# 国内 DNS,三个并发,取最快返回的
- tag: forward_domestic
type: forward
args:
concurrent: 3
upstreams:
- tag: "ali"
addr: "udp://223.5.5.5"
- tag: "tencent"
addr: "udp://119.29.29.29"
- tag: "baidu"
addr: "udp://180.76.76.76"
# 自建的加密 DNS 上游
- tag: forward_upstream
type: forward
args:
concurrent: 2
upstreams:
- tag: "self1"
addr: "https://你的DoH地址/dns-query"
bootstrap: "223.5.5.5"
idle_timeout: 600
max_conns: 4
concurrent 表示同时向多个上游发请求,谁先回就用谁,相当于自动选最快的。
bootstrap 这个参数解决了一个实际问题:如果上游用的是域名而不是 IP,mosdns 需要先把域名解析成 IP 才能连接。但解析这个域名本身又需要 DNS,会形成循环。所以指定一个固定的 DNS 服务器专门用来解析上游域名。
处理流程:
- tag: main_sequence
type: sequence
args:
# 1. 本地域名覆写,优先级最高
- exec: $hosts
- matches: has_resp
exec: accept
# 2. 白名单域名直接放行,不走广告拦截
- matches:
- qname $whitelist_domain
exec: $forward_upstream
- matches: has_resp
exec: accept
# 3. 广告域名拦截
- matches:
- qname $ad_domain
exec: reject 3
# 4. 国内域名走国内 DNS
- matches:
- qname $cn_domain
exec: $cache_domestic
- matches: has_resp
exec: accept
- matches:
- qname $cn_domain
exec: $forward_domestic
- matches: has_resp
exec: accept
# 5. 剩下的走加密上游
- exec: $cache_short
- matches: has_resp
exec: accept
- exec: $forward_upstream
这个流程读起来就是"先做 A,如果已经有结果就直接返回;否则做 B……",很直观。
监听端口:
- type: udp_server
args:
entry: main_sequence
listen: ":53"
- type: tcp_server
args:
entry: main_sequence
listen: ":53"
# 对外加密 DNS,需要证书
- type: tcp_server
args:
entry: main_sequence
listen: ":853"
cert: "/etc/mosdns/certs/dot.pem"
key: "/etc/mosdns/certs/dot.key"
3. 准备证书
对外提供 DoT 服务需要一张证书,证书里的域名必须和客户端连接的域名一致,否则会报证书错误。
如果有正式域名,可以用 Let's Encrypt 签一张。如果只是内网用,自签名也可以,但客户端可能要手动信任。
4. 启动
docker compose up -d
启动后看日志,出现 all plugins are loaded 就说明配置没问题。
广告拦截规则
拦截规则用的是几个公开的域名列表:
- Loyalsoldier 的 reject-list:GitHub 上的经典列表;
- anti-AD:国内维护得比较勤的列表;
- hagezi 的 pro 和 native.xiaomi:后者专门针对小米设备的广告。
加起来大概六十万条域名。这些列表放在容器里定期更新。
下载源的选择这里有个小坑。一开始用了一个自建的 GitHub 代理来下载,结果大文件只有 89 KB/s,一个 4MB 的列表要等好久。后来换成 jsDelivr 的 CDN,快了几十倍。不过 jsDelivr 有好几个入口,国内网络下 cdn.jsdelivr.net 有时候连不上,换成 gcore.jsdelivr.net 就稳定了。
自动更新列表
规则列表会变,需要定期更新。做法是在容器启动脚本里放一个后台循环,每 6 小时检查一次列表的更新时间,超过 24 小时就重新下载,下载完重启一下 mosdns 进程加载新规则。
这里踩了两个坑:
第一个坑是失败判断失效。原来的写法是:
echo "$urls" | while read -r url file; do
wget -q -O "$file" "$url" || ok=false
done
touch "$TIMESTAMP_FILE"
管道后面的 while 是在子 shell 里执行的,里面设置的 ok=false 传不出来。结果就是不管下载成功还是失败,时间戳都会被更新,导致 24 小时内不再重试。改成用文件重定向就好了:
while read -r url file; do
wget -q -O "$file.tmp" "$url" && mv "$file.tmp" "$file" || fail=$((fail+1))
done < "$URL_LIST"
第二个坑是下载中断会破坏原有文件。直接 wget -O 目标文件,如果下载到一半网络断了,文件就只剩一半。改成先下到临时文件,成功了再覆盖。
监控
mosdns 自带 Prometheus 指标接口,在配置里开一个 API 端口就能访问:
api:
http: "0.0.0.0:5380"
访问 /metrics 能看到缓存命中率、查询次数这些数据。如果上游服务器也配了 tag,还能看到每个上游的响应延迟和错误次数:
- tag: forward_domestic
args:
upstreams:
- tag: "ali" # 加上这行才有指标
addr: "udp://223.5.5.5"
这个功能挺实用。搭好之后一看延迟数据,发现某个国内 DNS 的 P95 延迟居然有 4000 多毫秒,还经常超时,果断换掉了。
配合 Grafana 可以做个面板,把缓存命中率、上游延迟、错误率都画出来,一眼就能看出哪里有问题。
查询日志
如果想看具体某个域名是怎么解析的,可以在处理流程里加一行:
- exec: query_summary mosdns_query
它会打印每个查询的域名、客户端 IP、返回码和耗时。
注意位置:这行必须放在流程的最前面。如果放在 accept 之后,请求已经被处理完返回了,根本走不到那行,什么都不会输出。
关于 DNS 泄露
搭好之后建议测一下,确认广告拦截和分流都正常工作。另外提一句,如果家里设备比较多,最好把路由器的 DHCP 下发的 DNS 也改成这台机器的地址,否则有些设备还是走原来的 DNS。
小结
整套东西搭下来,用了一台内网机器,跑一个 Docker 容器,内存占用 140MB 左右(主要是那六十万条规则)。
用下来感受:
- 广告域名基本都能拦掉,浏览网页清爽不少;
- 国内域名解析很快,因为有缓存加持;
- 手机在外面也能用加密 DNS,不用担心被运营商看光;
- 监控面板能直观看到各个上游的健康状况,出问题好排查。
如果家里有闲置的小主机或者 NAS,这套方案挺值得试一下。
本文记录的是个人部署经验,配置中的域名、IP 等均已隐去,实际使用时请替换成自己的。