用 OpenVPN 访问 ChatGPT 后,国内网站怎么反而慢了?
本机使用 Tunnelblick / OpenVPN 访问 ChatGPT、Claude,同时保留国内 IP 直连。DNS 也需要分流,否则国内网站可能解析到海外节点,后面的连接跟着绕路。
以前按域名手动指定 DNS,这次改用 KixDNS 和 GeoSite 自动分流,再加上本地缓存。环境是 Apple Silicon Mac,记录下配置和维护过程。
国内网站为什么会绕到国外
应用先解析域名,再按目标 IP 路由。OpenVPN 按 IP 分流,DNS 返回的地址会影响后面走哪条路。
比如国内网站的查询跟着 VPN 发到海外 DNS。部分使用 CDN 的网站会根据解析器位置或查询携带的客户端子网信息选择节点,可能返回靠近海外解析器或代理出口的国外节点 IP。本机拿到这个 IP 后,又按海外路由走 VPN,访问国内网站也绕到了国外,速度就慢了。
国内域名使用国内 DNS,可以减少这种绕路。CDN 调度仍由网站决定,配置后需要检查实际目标路由。VPN 出口拥塞和带宽不足等原因,要另外排查。
flowchart TD
A[访问国内网站] --> B[查询经 VPN 发往海外 DNS]
B --> C[CDN 可能返回海外节点 IP]
C --> D[目标 IP 命中海外路由]
D --> E[网站连接也经过 VPN]
E --> F[国内访问绕路变慢]
以前通过域名手动指定 DNS
以前 DNS 分流是在 macOS 的 /etc/resolver/ 里手动配的,按域名指定 nameserver。比如阿里云、腾讯云使用国内 DNS,需要海外解析的域名单独指定海外 DNS。
几个固定网站这样配就够用。但新增网站要逐个补规则,登录、接口和静态资源还可能使用不同域名,漏掉的查询仍走默认 DNS。
这次保留原来的上游选择,把规则迁入 KixDNS。GeoSite 负责覆盖更多域名,少量例外继续手动指定。
改用 KixDNS 后自动处理什么
系统统一查询本地 KixDNS。规则按自定义、GeoSite cn、默认海外的顺序匹配。旧的域名专用配置也已调整为查询本地服务,避免绕过它。
国内使用阿里 DoH,海外按 Cloudflare 1.1.1.1、Quad9 9.9.9.9、Google 8.8.8.8 的顺序回退。规则库已收录的域名自动分类,有遗漏或特殊需求时再加例外。
| 项目 | 以前手动配置 | 本地 KixDNS |
|---|---|---|
| 域名分流 | 每个域名单独指定 DNS | GeoSite 自动分类,自定义规则覆盖 |
| 规则维护 | 新网站逐个补配置 | 更新分类库,少量例外单独维护 |
| 重复查询 | 主要依赖系统和上游缓存 | 增加统一的本地缓存和后台刷新 |
| 检查问题 | 检查系统 resolver 和目标路由 | 日志直接显示管道、上游和缓存命中 |
缓存和后台刷新减少重复查询的等待,上游短暂失败时可在限定时间内返回旧缓存。解析速度的实测放在后面。
安全方面,应用到 KixDNS 的查询留在本机回环地址,KixDNS 到上游使用 DoH,开启 TLS 加密和证书校验,降低这段传输中查询被读取或响应被篡改的风险。转发时清除 ECS,不主动附带客户端子网信息。上游仍能看到收到的域名查询,所以要选择信任的解析服务。
服务只监听 127.0.0.1。浏览器自带 DNS、应用自行解析或 VPN 接管 DNS,都可能绕过它,需要单独检查。
整体访问流程
DNS 和访问流量分别处理。
flowchart TD
A[应用发起 DNS 查询] --> B["本地 KixDNS<br/>127.0.0.1:53"]
B --> C{缓存是否命中}
C -->|命中| G[返回解析结果]
C -->|未命中| D{域名分流}
D -->|国内域名| E[阿里 DoH]
D -->|海外及其他域名| F[Cloudflare DoH 1.1.1.1]
E --> G
F -->|有效响应| G
F -->|失败| Q[Quad9 DoH 9.9.9.9]
Q -->|有效响应| G
Q -->|失败| R[Google DoH 8.8.8.8]
R -->|有效响应| G
R -->|仍失败| S[按缓存策略兜底或返回失败]
G --> H[应用向目标 IP 发起 HTTPS 连接]
H --> I{macOS 系统 IP 路由}
I -->|国内目标 IP| J[Wi-Fi 网关直连]
I -->|海外目标 IP| K[OpenVPN 隧道]
J --> L[国内网站]
K --> M[海外网站]
这里沿用已有 OpenVPN 路由,下面只部署 DNS。网站是否走 VPN,最后通过目标路由和实际 HTTPS 连接验证。
编译 KixDNS
源码在 olicesx/kixdns。本次使用 Rust 1.98.1,编译的源码 commit 为 42981abe52ce53271be50b430594e02601e8b615。
1 | mkdir -p "$HOME/localdns" |
需要先安装 Rust 1.98.1。本机 rsproxy 连接异常,实际从 /tmp 使用独立 CARGO_HOME 和官方 crates.io 构建,没有修改全局配置。
1 | mkdir -p "$HOME/localdns/cargo-home" |
这里切到 /tmp 也有作用。从用户目录下构建时,Cargo 会沿父目录读取 .cargo/config.toml,仅换 CARGO_HOME 仍可能读到原来的镜像配置。
准备国内域名规则
GeoSite 使用 Loyalsoldier/v2ray-rules-dat 的数据。下载后检查 SHA-256,再安装到系统目录。
1 | set -eu |
后续更新方法放在维护部分。
配置分流和缓存
flowchart TD
A[收到域名查询] --> B{命中自定义规则}
B -->|是| C[采用指定管道]
B -->|否| D{命中 GeoSite cn}
D -->|是| E[国内管道 cn]
D -->|否| F["海外入口 global<br/>Cloudflare → Quad9 → Google"]
配置保存在 /usr/local/etc/kixdns/pipeline.json。
下面是按本次部署整理的完整配置示例。其中 aliyun.com、cloud.tencent.com 固定走国内,manus.im 固定走海外。自定义规则之后匹配 GeoSite 的 cn,其余域名默认走海外。
1 | { |
国内两条阿里 DoH 并发查询。海外采用三个独立管道串联,先查 global 中的 Cloudflare,失败后跳到 global-quad9,最后才是 global-google。不要把三个地址写成同一个 upstream 数组,那会变成并发取最快响应。
NOERROR 和 NXDOMAIN 都作为有效结果停止回退;超时、SERVFAIL、REFUSED 等错误才进入下一家。这是 DNS 查询失败后的回退,网站连接到某个 IP 超时,不会自动换 DNS。上游使用 IP 地址,避免启动时依赖自身解析。
flowchart TD
A[海外域名未命中缓存] --> B["global<br/>Cloudflare 1.1.1.1"]
B -->|有效响应| E[返回结果]
B -->|查询失败| C["global-quad9<br/>Quad9 9.9.9.9"]
C -->|有效响应| E
C -->|查询失败| D["global-google<br/>Google 8.8.8.8"]
D -->|有效响应| E
D -->|仍失败| F[按缓存策略兜底或返回失败]
原始测速使用 3 秒上游超时。复核时 VPN 下冷查询约需 4 秒,3 秒会误报 SERVFAIL,所以文中示例放宽到 9 秒,整体请求上限改为 30 秒,覆盖三家顺序尝试。加长超时只避免过早失败,不会让正常查询变快,仍需检查 VPN 和上游连通性。
缓存容量是 10000 条,尊重上游原始 TTL,最长保留 1 小时。TTL 剩余 15% 时触发后台刷新。上游暂时失败时允许返回旧缓存,最多过期 5 分钟,返回的 TTL 为 30 秒,并且关闭过期窗口重置,避免旧记录一直续下去。服务重启后内存缓存清空。
国内上游选阿里的两个已验证地址。DNSPod 的 IP HTTPS 地址在当时网络下连接超时,未加入配置。
这里还踩了一个配置字段的问题。文档里出现过 geosite,这次编译出的程序要求写成 geo_site,否则启动时会报 unknown variant。配置文件要用实际运行的版本验证一下。
用 launchd 管理服务
创建 /Library/LaunchDaemons/org.localdns.kixdns.plist。
1 |
|
修正权限,检查语法,再启动。已安装过的服务先执行 sudo launchctl bootout system/org.localdns.kixdns,再执行 bootstrap。
1 | sudo chown root:wheel /Library/LaunchDaemons/org.localdns.kixdns.plist |
正式部署前先用 1053 端口验证,UDP、TCP、IPv6 记录和并发查询均正常。模拟上游故障时,旧缓存兜底和过期限制也通过了测试。
服务重启和日志维护命令放在后面。
切换 macOS 系统 DNS
先确认服务能解析,再保存原来的设置。
1 | networksetup -listnetworkserviceorder |
本机使用 Wi-Fi,原来没有手动指定 DNS,使用的是网络下发的地址。切换后只指定本地 DNS。
1 | sudo networksetup -setdnsservers "Wi-Fi" 127.0.0.1 |
切换后刷新系统 DNS 缓存,再检查默认 nameserver。
1 | sudo dscacheutil -flushcache |
默认 resolver 应指向 127.0.0.1,.local 继续使用 mDNS。
验证 DNS 分流
直接查询可以确认 KixDNS 有回应,再用系统解析确认 macOS 真正在使用它。
1 | dig @127.0.0.1 api.anthropic.com |
对照日志,本次百度、阿里云和腾讯云使用 cn,Claude、Anthropic API、ChatGPT 和 OpenAI API 使用 global。
系统自身有缓存,重复解析可能不产生新的 KixDNS 日志。
验证访问流量
解析出来的 IP 还要检查路由。选择查询结果中的实际目标地址,执行下面的命令。
1 | route -n get 160.79.104.10 |
对其它网站同样检查。下面是当时全部解析地址的路由检查和实际 HTTPS 连接结果。
| 测试网站 | DNS 管道 | 目标 IP 路由 | HTTPS 连接源地址 |
|---|---|---|---|
| 百度 | cn | Wi-Fi en0 | Wi-Fi 地址 |
| 阿里云及官网 | cn | Wi-Fi en0 | Wi-Fi 地址 |
| 腾讯云及控制台 | cn | Wi-Fi en0 | Wi-Fi 地址 |
| Claude、Anthropic API | global | VPN utun4 | VPN 地址 |
| ChatGPT、OpenAI API | global | VPN utun4 | VPN 地址 |
本机系统 HTTP/SOCKS 代理没有开启,海外连接由 Tunnelblick / OpenVPN 的系统路由接管。同一隧道访问 Cloudflare trace,出口检测为 SG。
部分 HEAD 请求返回 403、404 等状态,TCP/TLS 已建立。这只验证连接路径,不代表登录或认证 API 可用。VPN 重连或 CDN 地址变化后需要重新检查。
解析速度和缓存效果
下面保留 2026 年 10 月 13 日的原始测速。当时海外上游为 Cloudflare 和 Google 并发,尚未切换为本文现在的三家顺序回退,数据不代表新顺序的首次查询耗时。测试选了 12 个国内外域名。每轮启动一个临时 KixDNS 实例,做 3 轮冷缓存查询。随后在正式服务上预热,再对每个域名做 10 次缓存查询,不清空正在使用的服务缓存。
查询 A 记录,使用 UDP,超时 4 秒,不重试。计时直接从 socket 发出查询到收到响应,不包含进程启动,也不经过系统 DNS 缓存。
下面是耗时中位数,单位毫秒。
| 查询方式 | 国内域名 | 海外域名 | 整体 |
|---|---|---|---|
| 本地 DNS,未命中缓存 | 27.70 | 168.80 | 109.53 |
| 本地 DNS,命中缓存 | 0.143 | 0.142 | 0.142 |
| 直接查询 1.1.1.1 | 165.39 | 136.19 | 151.60 |
| 直接查询 8.8.8.8 | 173.85 | 165.97 | 166.36 |
120 次重复查询全部命中缓存,整体 P95 为 0.256ms。这次国内首次解析比两个海外公共 DNS 快约 6 倍。海外首次解析和直接查询 Google 接近,没测出明显加速,缓存命中后的收益更大。
本地上游走 DoH,对比公共 DNS 走 UDP,公共 DNS 自身的缓存无法清空。这是当前网络下的小样本结果,首次查询仍出现过约 1.2 秒的延迟尖峰,不能拿它当所有网络环境下的性能保证。
Wi-Fi 和有线 DNS 自动恢复
2026 年 11 月补充。设置好本地 DNS 后,还要处理网络重连、唤醒后 DNS 被改写的情况。先检查 VPN 或网络管理软件是否接管 DNS,自动恢复作为兜底。
本机加了一个独立的 launchd 守护,管理当前网络位置中启用、已连接且已有 IP 的 Wi-Fi 和物理以太网。iPhone USB、VPN、网桥和未连接的网卡会跳过。
守护怎么判断和恢复
守护通过 SystemConfiguration 监听网络变化,通过 IOKit 接收唤醒通知,启动时也检查一次。事件合并处理,正常状态不轮询。
flowchart TD
A[启动、网络变化或唤醒] --> B[筛选已连接的 Wi-Fi 和有线服务]
B --> C{本地 KixDNS 健康检查通过}
C -->|否| D[保持 DNS 不变,延迟重试]
D --> C
C -->|是| E{已保存 DNS 是否仅为 127.0.0.1}
E -->|是| F[不改写,必要时记录生效配置差异]
E -->|否| G[记录原配置,恢复本地 DNS]
G --> H[回读验证并记录修改前后值]
健康检查向 127.0.0.1:53 查询 _kixdns-health.invalid 的 TXT,必须收到 localdns-guardian-ok。KixDNS 静态应答,不依赖外网。
保存值已为 127.0.0.1 时,守护不与 VPN 争抢解析器。日志保留 macOS 报告的 DHCP DNS,判断服务配置时优先使用手动值,避免误报。
编译和安装
源码包包含代码、测试和安装验证脚本,附 SHA-256 校验文件,不含本机网络配置或账号信息。
需要前文的 KixDNS 服务和带 Swift 的 Xcode Command Line Tools。检查源码和校验结果后编译。
1 | set -eu |
入口脚本先备份 KixDNS 配置,再添加本地健康检查记录,确认应答正确后安装守护。原有国内外解析上游不变。程序安装到 /Library/PrivilegedHelperTools/org.localdns.dns-guardian,以 root 运行,开机启动、异常退出后由 launchd 重启。
plist 安装到 /Library/LaunchDaemons/org.localdns.dns-guardian.plist。RunAtLoad 和 KeepAlive 均启用,异常重启间隔由 ThrottleInterval 限制为 10 秒。完整文件在源码包中,不需要手动重复创建。
验证恢复是否生效
确认 Wi-Fi 已使用本地 DNS 后,在源码包根目录复测恢复和健康检查门禁。
1 | cd "$HOME/localdns-guardian-download/localdns-guardian" |
脚本保留 127.0.0.1 为首选,临时增加备用回环地址来制造漂移,再更换健康应答验证拒绝写入和恢复重试。结束后恢复配置并保存记录。
本机的验证结果如下。
| 检查项 | 结果 |
|---|---|
| 自动测试 | 52 项通过,含 Wi-Fi、有线恢复、错误健康应答、重试及日志轮转 |
| 独立定向复查 | 健康门禁、接口筛选、DNS 写入和唤醒处理通过 |
| Wi-Fi 真实配置漂移 | 自动恢复为仅 127.0.0.1 |
| 健康检查不通过 | DNS 保持不变 |
| 健康恢复后的重试 | 自动完成修复 |
| 修改前后日志 | 值与实际配置一致,没有重复写入循环 |
| 有线实网 | 当时未接线,接口识别和隔离测试通过,未做实网恢复测试 |
| 实际睡眠唤醒 | 通知注册测试通过,未主动休眠电脑验证 |
后续维护
更新 GeoSite 域名规则
GeoSite 规则可以每周检查一次。遇到分流异常时,先更新分类库,再判断是否需要加自定义规则。
flowchart TD
A[下载到临时目录] --> B{下载及 SHA-256 校验成功}
B -->|否| C[结束并保留原规则]
B -->|是| D[备份现有规则]
D --> E[安装新文件并替换]
E --> F[重启服务并清空缓存]
F --> G{解析与路由是否正常}
G -->|是| H[完成更新]
G -->|否| I[恢复备份并重启]
下面的脚本保存为 $HOME/localdns/update-geosite.sh。它先在临时目录下载并校验,成功后备份现有规则,再替换生效文件。下载或校验失败时,原规则不变。
1 |
|
执行更新,然后检查服务和解析。
1 | chmod +x "$HOME/localdns/update-geosite.sh" |
GeoSite 支持热加载。脚本主动重启,清掉旧缓存后重新解析。它只更新 DNS 分类,OpenVPN 的 IP 路由仍需独立维护。
如果更新后有异常,先恢复上一份规则。
1 | sudo cp -p /usr/local/etc/kixdns/geosite.dat.previous \ |
添加自定义域名规则
日志确认分类不合适后,在 pipeline_select 里添加例外,放到 geo_site 和最后的 any 之前。第一个命中的规则生效。
比如要让某个域名及其子域名走国内上游,可以加入下面这个条目,example.com 替换成自己的域名。指定海外上游时把 pipeline 改成 global。
1 | { |
修改生效配置之前先备份,并检查 JSON 语法。
1 | sudo cp -p /usr/local/etc/kixdns/pipeline.json \ |
管道规则支持热加载,无效配置会保留上一份有效配置。JSON 语法检查只能确认文件格式,最终还要检查服务日志和目标域名的实际解析。监听端口、缓存容量等初始化参数修改后需要重启服务。
检查服务与日志
先看系统当前使用的 DNS,再检查两个服务。
1 | networksetup -getdnsservers "Wi-Fi" |
健康检查应显示 PASS。--status 列出各网络服务的识别结果、保存值和报告值,未连接的有线网卡应为 disconnected。保存值由 networksetup 检查,最终解析器再看 scutil --dns,VPN 重连后也这样核对。
两个服务的日志分别查看。
1 | tail -n 30 /usr/local/var/log/kixdns/service.log |
KixDNS 日志看 pipeline、upstream 和 cache_hit;守护日志看触发原因、健康状态和修改前后值。
| 守护日志事件 | 含义 |
|---|---|
| dns_applied | 已恢复 DNS,savedDNS 为修改前的值,savedDNSAfter 应为 127.0.0.1 |
| health_skip | 本地健康检查失败,没有写入 DNS |
| drift_deferred | 接口未连接等原因,暂不处理 |
| effective_conflict | 保存值已正确,但报告的生效配置存在差异,只记录、不争抢 |
守护日志达到 1 MiB 时轮转,保留 3 份备份;日志和原始配置快照仅 root 可读。KixDNS 查询日志需要另行轮转,长期使用也可以把它的 plist 中 RUST_LOG 改为 warn,排查时再打开。
重启和更新
只重启 KixDNS 进程使用下面的命令,内存缓存会清空。
1 | sudo launchctl kickstart -k system/org.localdns.kixdns |
修改 plist 后要用 bootout 和 bootstrap 重新加载,单独 kickstart 不会读取新 plist。下面以守护为例,也可用于临时暂停守护、手动排查 DNS。
1 | sudo launchctl bootout system/org.localdns.dns-guardian |
重新加载 KixDNS 时,将上述命令中的服务名同时替换为 org.localdns.kixdns。
更新守护源码后,在 guardian/ 目录重新执行 swift test、swift build -c release,再回到源码包根目录运行 sudo bash scripts/install-dns-guardian.sh。GeoSite 独立更新,不需要重装守护。
卸载守护或停用本地 DNS
只移除守护、继续使用 KixDNS 时,从源码包根目录执行下面的命令。脚本会先停守护,默认保留当前 DNS、日志和快照,不会卸载 KixDNS。
1 | cd "$HOME/localdns-guardian-download/localdns-guardian" |
需要卸载时一并恢复原始 DNS,改用 sudo bash guardian/uninstall.sh --restore-original。它只恢复仍为守护目标值的接口,避免覆盖后来的手动修改。原始记录在 /Library/Application Support/LocalDNSGuardian/state.json。
完全停用本地 DNS 时,先停守护,再恢复网络 DNS。本机 Wi-Fi 原先使用网络下发的地址,可以这样恢复。
1 | sudo launchctl bootout system/org.localdns.dns-guardian |
有线网卡按实际服务名恢复,原先手动配置过 DNS 的机器要填回旧地址。域名专用规则也要恢复或移除。确认解析不再依赖本地服务后,最后停 KixDNS,日志和快照继续保留。
1 | sudo launchctl bootout system/org.localdns.kixdns |
参考








