用 Docker Compose 部署 OxiDNS:分流、去广告、可视化一次搞定

· 8 分钟阅读 · 3952 字 · 可乐虎

家里想上一套干净的 DNS,通常得拼两件套:mosdns 管国内外分流,AdGuard Home 管广告拦截。一个负责转发、一个负责过滤,端口要错开,改个上游还得开两个后台。

OxiDNS 把这两件事塞进了同一个进程:分流、去广告、缓存、规则自动更新、查询记录,一份 YAML 加一个 WebUI 全包。下面是我这台机器上的部署过程,配置放在文末,可以直接拿走改。

1. OxiDNS 是什么

项目在 svenshi/oxidns ,文档在 oxidns.org 。

Rust 写的插件化 DNS 引擎,路子跟 mosdns 接近,区别在于它把“为什么这么解析”也做成了功能:

  • 规则、缓存、上游是 provider,匹配条件是 matcher,动作是 executor,三者用 tag 串成一条 sequence,可以跳转、可以回退。逻辑再绕也不用写代码。
  • WebUI 的查询记录能看到一次查询命中了哪条规则、走了哪个上游、每一步的执行结果。调分流规则的时候,比反复 dig 再猜要快得多。
  • 广告规则直接吃 AdGuard 语法(||domain^),现成的列表拿来就能用,不用先转格式。
  • 上游支持 UDP、TCP、DoT、DoH、DoH3、DoQ,可以同时问几个、取最快的响应。
  • 解析结果还能写进 Linux 的 ipset / nftset、RouterOS 地址列表,或者触发 webhook、跑脚本。

短板也明显:项目 2026 年才起来,中文资料比 AdGuard Home 和 mosdns 少得多,遇到问题基本靠自己翻文档、看日志。

2. 三个方案怎么选

对比项OxiDNSAdGuard Homemosdns
开发语言RustGoGo
核心定位策略编排型 DNS 引擎家用广告拦截 + 加密 DNS分流型 DNS 转发器
国内外分流原生 geosite / geoip只能按域名逐个指定上游,没有数据集原生 geosite / geoip
广告拦截AdGuard 语法规则 + 黑洞应答最强,列表 / 家长控制 / 按客户端策略需外挂规则集,无过滤界面
WebUI内置,带查询记录与执行路径内置,最完善官方无,靠第三方面板或 LuCI
协议接入UDP/TCP/DoT/DoQ/DoH(H1/H2/H3)UDP/TCP/DoT/DoH/DoQ,另带 DHCPUDP/TCP/DoT/DoH/DoQ
系统联动ipset/nftset、RouterOS、webhook、脚本无ipset/nftset
配置方式YAML + WebUI以 WebUI 为主纯 YAML
上手难度中等,要理解 sequence低中偏高

这三个不是同一个赛道:AdGuard Home 把拦截做到极致,顺带能当加密 DNS 服务器;mosdns 把分流做到极致,但只给你一个配置文件;OxiDNS 想在中间找平衡,还补上了查询审计。

  • 只想全屋去广告,不折腾分流:AdGuard Home,生态最成熟,装上就能用。
  • 手上已经有一套 YAML 规则,只做纯转发、追求极致轻量:mosdns。
  • 想一个进程搞定分流和拦截,还想知道某次查询为什么走了这条上游:OxiDNS。

3. 部署前准备

Linux 就行,amd64 / arm64 都可以,1 核 512MB 足够跑。软路由、NAS、VPS、小主机都行。官方镜像 svenshi/oxidns 同时发布在 Docker Hub 和 ghcr.io。

端口这块要留意:OxiDNS 要独占宿主机的 53/udp 和 53/tcp,WebUI 走 9199/tcp。

53 端口是新手最容易卡住的地方,先把占用的服务让开:

bash
# Ubuntu / Debian:关掉 systemd-resolved 的存根监听
sudo mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' | sudo tee /etc/systemd/resolved.conf.d/oxidns.conf
sudo systemctl restart systemd-resolved
# 让宿主机自己继续用上游解析(别指向 127.0.0.53 那个存根)
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf

# 装了 dnsmasq / AdGuard Home 的话,一并停掉
sudo systemctl disable --now dnsmasq

确认端口真的空了:

bash
sudo ss -lnup | grep ':53 '
sudo ss -lntp | grep ':53 '

没有输出(或者只剩你自己的进程)就可以往下走。OpenWrt 上同理,先处理 dnsmasq 对 53 的占用——停掉它,或者把它挪到别的端口,让 OxiDNS 顶在 53 上。

建目录:

bash
mkdir -p /opt/oxidns/rules /opt/oxidns/data
cd /opt/oxidns

最终结构是这样:

text
/opt/oxidns
├── compose.yml
├── config.yaml
├── rules/      # geosite.dat / geoip.dat / 广告规则
└── data/       # 运行数据与日志

4. compose.yml:host 网络最省事

yaml
services:
  oxidns:
    image: svenshi/oxidns:latest
    container_name: oxidns
    restart: unless-stopped
    network_mode: host
    command: ["start", "-c", "/etc/oxidns/config.yaml", "-d", "/var/lib/oxidns"]
    volumes:
      - ./config.yaml:/etc/oxidns/config.yaml:ro
      - ./rules:/etc/oxidns/rules
      - ./data:/var/lib/oxidns
    environment:
      - TZ=Asia/Shanghai

几个地方说明一下。

用 host 网络:DNS 对端口和来源 IP 都敏感。host 模式直接共用宿主网络,53 端口天然可用,不用写 ports;客户端在查询记录里显示的也是真实内网 IP,而不是 Docker 网关那个 172.17.0.1。代价是宿主机 53 必须真空出来。

覆盖 command:镜像默认命令是 start -c /etc/oxidns/config.yaml -d /etc/oxidns,数据目录指向 /etc/oxidns。不覆盖的话,下面 ./data:/var/lib/oxidns 这个挂载其实没东西写进去,容器一重建,日志和运行数据就没了。加上 -d /var/lib/oxidns 挂载才算接上。

rules 不能挂只读:配置里的 download 插件要往 /etc/oxidns/rules 写新规则。挂成 :ro 的话,自动更新会在日志里报只读文件系统,规则永远停在第一次放进去的版本。

不写 ports:host 模式写了也不生效,还容易让人以为端口映射在起作用。

配置文件本身是只读挂载,这样做是对的,能防止容器自己改配置。代价是改完 config.yaml 必须 reload 或者重启容器才生效。

5. config.yaml:分段看

完整文件在文末附件,这里只挑几个关键段落讲,方便按自己的网络改。

5.1 管理接口与 WebUI

yaml
api:
  http:
    listen: "0.0.0.0:9199"
    auth:
      type: basic
      username: "admin"
      password: "ChangeMe_2026!"

这台机器只在家里局域网用,监听 0.0.0.0 正合适,手机、平板、电视都能打开后台看看解析记录。但 auth 里的账号密码得换成你自己的,别照抄示例。改完打开 http://内网IP:9199/ 就是 WebUI。

5.2 缓存

yaml
- tag: cache_main
  type: cache
  args:
    size: 16384
    short_circuit: true
    cache_negative: true
    cache_min_ttl: 60
    lazy_cache_ttl: 3600

short_circuit 让缓存命中直接应答、不再往下走匹配,省点 CPU;cache_negative 把 NXDOMAIN 也缓存住,上游抽风的时候客户端不至于疯狂重试;cache_min_ttl 给 TTL 很短的域名兜个底;lazy_cache_ttl 控制过期后还能拿旧记录顶多久,别设太大,否则 CDN 换了 IP,你会有一段时间连到旧节点。

5.3 上游:国内直连,国外加密

yaml
- tag: forward_cn
  type: forward
  args:
    concurrent: 2
    response_selection: fastest
    upstreams:
      - addr: "223.5.5.5:53"
        timeout: 5s
      - addr: "119.29.29.29:53"
        timeout: 5s

- tag: forward_oversea
  type: forward
  args:
    concurrent: 2
    response_selection: fastest
    bootstrap: "223.5.5.5:53"
    upstreams:
      - addr: "https://dns.cloudflare.com/dns-query"
        timeout: 8s
      - addr: "https://dns.google/dns-query"
        timeout: 8s

国内用阿里和 DNSPod 的明文上游,延迟最低;国外走 DoH,避免解析结果半路被改。concurrent: 2 表示同时问两个上游,fastest 取先回来的那个。

国外这段里的 bootstrap 别省。DoH 上游写的是域名(dns.cloudflare.com),OxiDNS 得先把这个域名解析成 IP 才能建立连接,可这时候它自己就是那个 DNS 服务,再查自己一圈就绕成环了。bootstrap 指定一个用纯 IP 访问的普通 DNS 专门干这件事:先把 DoH 服务器的地址解出来,之后就是缓存加连接复用,不会每次都查。

填一个国内能用的就行,比如 223.5.5.5:53 或 119.29.29.29:53,它只负责解析上游域名,不参与你的分流规则。如果你把上游写成 IP 形式,比如 https://1.1.1.1/dns-query,那就不需要 bootstrap 了,可以直接删掉这一行。

这里的两个 DoH 地址只是示例,换成你自己网络真能连通的:自建 DoH、Cloudflare、Google、NextDNS 都行。判断标准很简单,在宿主机上 curl -I 得通,OxiDNS 才能用。

5.4 主序列:先拦广告,再分流

yaml
- tag: main
  type: sequence
  args:
    - matches: "question $ad_rules"
      exec: goto ad_blocked
    - exec: $cache_main
    - matches: has_resp
      exec: accept
    - matches: "qname $geosite_cn"
      exec: goto local_seq
    - matches: "qname $geosite_no_cn"
      exec: goto remote_seq
    - exec: goto remote_seq

顺序就是执行顺序:命中广告规则就去黑洞拿 0.0.0.0;缓存里有就直接答;国内域名走国内序列;国外域名走海外上游;剩下没匹配上的,默认也当国外处理。

国内序列里还有一层校验:

yaml
- tag: local_seq
  type: sequence
  args:
    - exec: $cache_main
    - matches: has_resp
      exec: accept
    - exec: $forward_cn
    - matches: "resp_ip $geoip_cn"
      exec: accept
    - exec: drop_resp

国内上游返回的地址必须落在 geoip_cn 里才接受,否则直接丢掉这个响应。这是防污染的:有些域名在国内上游会返回一个莫名其妙的国内 IP,丢掉比让客户端傻乎乎连过去强。

5.5 规则自动更新

yaml
- tag: cron_update
  type: cron
  args:
    timezone: Asia/Shanghai
    jobs:
      - name: update_geo_rules
        interval: 1d
        executors:
          - $download_geo
          - $reload_geo
      - name: update_ad_rules
        interval: 7d
        executors:
          - $download_ad
          - $reload_ad

geosite / geoip 每天更新一次,广告列表每周一次。download 里写了 startup_if_missing: true,所以首次启动会自己去 jsDelivr 把规则拉下来,不用提前准备。

拉的是 Loyalsoldier 的规则集 和 anti-AD 的 AdGuard 语法列表。如果宿主机到 jsDelivr 不通(这个挺常见),第一次启动会卡在下载上,那就手动放进去:

bash
cd /opt/oxidns
curl -L -o rules/geosite.dat https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geosite.dat
curl -L -o rules/geoip.dat  https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/geoip-only-cn-private.dat
curl -L -o rules/adguard_ad.txt https://cdn.jsdelivr.net/gh/privacy-protection-tools/anti-AD@master/anti-ad-easylist.txt
ls -lh rules

6. 启动与验证

两个文件放进 /opt/oxidns 之后:

bash
cd /opt/oxidns
docker compose up -d
docker compose logs -f oxidns

首次启动会在日志里刷下载规则的进度,等它跑完,几十秒到两三分钟看网络情况。

然后一项一项验:

bash
# 1. 容器在跑
docker ps --filter name=oxidns

# 2. 管理接口就绪(开了 Basic Auth,所以要带账号密码)
curl -fsS -u admin:你的密码 http://127.0.0.1:9199/api/readyz

# 3. DNS 能应答,UDP 和 TCP 都要通
dig @127.0.0.1 example.com
dig @127.0.0.1 example.com +tcp

# 4. 分流有没有生效
dig @127.0.0.1 www.taobao.com    # 应该返回国内 IP
dig @127.0.0.1 www.google.com    # 应该返回海外 IP,且不是 0.0.0.0
dig @127.0.0.1 doubleclick.net   # 广告域名,应该返回 0.0.0.0

都对上,说明分流、拦截、上游都通了。想确认某次查询具体走了哪条规则、哪个上游,去 WebUI 的查询记录里点开看执行路径,比自己猜快。

改完配置想先验语法,免得重启把服务搞崩:

bash
docker compose exec oxidns oxidns check -c /etc/oxidns/config.yaml -d /var/lib/oxidns

最后让局域网设备用上它:路由器 DHCP 里把下发的 DNS 改成 OxiDNS 这台机器的 IP 就行。有个细节容易忽略——不少路由器(尤其开了“DNS 代理”“DNS 转发”的)会劫持 53 端口,强制走自己的解析。这种情况下客户端看着用的是你的 DNS,请求其实根本没到 OxiDNS。配置都对但拦截不生效,先查这个。

7. 几个坑

config.yaml 变成目录了:Docker 的 bind mount 在源文件不存在时会自动建一个同名目录,之后挂载到 /etc/oxidns/config.yaml 就报错。启动前确认它是个普通文件。

改了配置没反应:配置是只读挂载的,改完宿主机文件要重启容器(或者用管理 API reload)。docker compose restart oxidns 最稳。

海外域名解析超时:多半是 DoH 上游连不上,或者 bootstrap 没配。先在宿主机上 curl -I https://你的doh地址 验证,再换一个能通的。OxiDNS 命令行还有个 probe,可以批量测上游可用性。

容器起来又反复重启:先看 docker compose logs --tail 50 oxidns。最常见的是 rules/ 里的 .dat 没下载成功,配置引用的文件加载不到,进程直接退出,把 5.5 那三条 curl 补上就好。

广告误杀:在 ad_rules 的 rules 里加 @@||域名^ 白名单,优先级高于拦截规则,改完重启生效。

想给手机、外出的设备也提供加密 DNS:得在配置里额外加 doh_server 或 dot_server 插件(监听 443 / 853)。本文这套只开了 UDP/TCP 的 53,局域网够用,出门用还要再补一段。

8. 附:配置文件下载

下面两份是我这台机器上跑着的配置,做过脱敏:账号密码换成了占位值,我自己的 DoH 上游换成了公共示例。拿去用之前,记得改 5.1 的账号密码、5.3 的上游地址。

两份文件放同一个目录(compose.yml 里用的是相对路径),rules/ 和 data/ 按第 3 节建好,docker compose up -d 就能跑。