用 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 和 Google DoH。规则库已收录的域名自动分类,有遗漏或特殊需求时再加例外。

项目 以前手动配置 本地 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 / Google DoH]
    E --> G
    F --> G
    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
2
3
4
5
6
7
8
mkdir -p "$HOME/localdns"
cd "$HOME/localdns"
git clone --depth 1 https://github.com/olicesx/kixdns.git
cd kixdns
# 固定到本次验证过的版本
git fetch --depth 1 origin 42981abe52ce53271be50b430594e02601e8b615
git checkout --detach 42981abe52ce53271be50b430594e02601e8b615
cargo +1.98.1 build --release --locked

需要先安装 Rust 1.98.1。本机 rsproxy 连接异常,实际从 /tmp 使用独立 CARGO_HOME 和官方 crates.io 构建,没有修改全局配置。

1
2
3
4
5
mkdir -p "$HOME/localdns/cargo-home"
cd /tmp
CARGO_HOME="$HOME/localdns/cargo-home" cargo +1.98.1 build \
--release --locked \
--manifest-path "$HOME/localdns/kixdns/Cargo.toml"

这里切到 /tmp 也有作用。从用户目录下构建时,Cargo 会沿父目录读取 .cargo/config.toml,仅换 CARGO_HOME 仍可能读到原来的镜像配置。

准备国内域名规则

GeoSite 使用 Loyalsoldier/v2ray-rules-dat 的数据。下载后检查 SHA-256,再安装到系统目录。

1
2
3
4
5
6
7
8
9
10
11
12
set -eu
mkdir -p "$HOME/localdns/data"
cd "$HOME/localdns/data"
RULES_URL=https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download
curl -fL "$RULES_URL/geosite.dat" -o geosite.dat
curl -fL "$RULES_URL/geosite.dat.sha256sum" -o geosite.dat.sha256sum
shasum -a 256 -c geosite.dat.sha256sum

sudo mkdir -p /usr/local/libexec /usr/local/etc/kixdns /usr/local/var/log/kixdns
sudo install -o root -g wheel -m 755 \
"$HOME/localdns/kixdns/target/release/kixdns" /usr/local/libexec/kixdns
sudo install -o root -g wheel -m 644 geosite.dat /usr/local/etc/kixdns/geosite.dat

后续更新方法放在维护部分。

配置分流和缓存



flowchart TD
    A[收到域名查询] --> B{命中自定义规则}
    B -->|是| C[采用指定管道]
    B -->|否| D{命中 GeoSite cn}
    D -->|是| E[国内管道 cn]
    D -->|否| F[海外管道 global]

配置保存在 /usr/local/etc/kixdns/pipeline.json。

下面是按本次部署整理的完整配置示例。其中 aliyun.com、cloud.tencent.com 固定走国内,manus.im 固定走海外。自定义规则之后匹配 GeoSite 的 cn,其余域名默认走海外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
{
"version": "1.0",
"settings": {
"bind_udp": "127.0.0.1:53",
"bind_tcp": "127.0.0.1:53",
"min_ttl": 0,
"default_upstream": "https://1.1.1.1/dns-query",
"upstream_timeout_ms": 9000,
"request_timeout_ms": 22500,
"cache_capacity": 10000,
"cache_max_ttl": 3600,
"cache_background_refresh": true,
"cache_refresh_threshold_percent": 15,
"cache_refresh_min_ttl": 5,
"serve_stale": true,
"serve_stale_ttl": 30,
"serve_stale_expire_ttl": 300,
"serve_stale_ttl_reset": false,
"serve_stale_client_timeout_ms": 300,
"doh_pool_size": 8,
"geosite_data_paths": [
"/usr/local/etc/kixdns/geosite.dat"
]
},
"pipeline_select": [
{
"pipeline": "cn",
"matchers": [
{
"type": "domain_suffix",
"value": "aliyun.com"
}
]
},
{
"pipeline": "cn",
"matchers": [
{
"type": "domain_suffix",
"value": "cloud.tencent.com"
}
]
},
{
"pipeline": "global",
"matchers": [
{
"type": "domain_suffix",
"value": "manus.im"
}
]
},
{
"pipeline": "cn",
"matchers": [
{
"type": "geo_site",
"value": "cn"
}
]
},
{
"pipeline": "global",
"matchers": [
{
"type": "any"
}
]
}
],
"pipelines": [
{
"id": "global",
"rules": [
{
"name": "global-doh",
"matchers": [
{
"type": "any"
}
],
"actions": [
{
"type": "forward",
"upstream": [
"https://1.1.1.1/dns-query",
"https://8.8.8.8/dns-query"
],
"transport": "doh",
"ecs": {
"mode": "clear"
}
}
]
}
],
"ecs": {
"mode": "clear"
}
},
{
"id": "cn",
"rules": [
{
"name": "cn-doh",
"matchers": [
{
"type": "any"
}
],
"actions": [
{
"type": "forward",
"upstream": [
"https://223.5.5.5/dns-query",
"https://223.6.6.6/dns-query"
],
"transport": "doh",
"ecs": {
"mode": "clear"
}
}
]
}
],
"ecs": {
"mode": "clear"
}
}
]
}

同一组上游会并发查询,采用首个有效响应。上游地址都使用 IP,避免切换本地 DNS 后,还要先靠它解析自己的 DoH 上游域名。

原始测速使用 3 秒上游超时。复核时 VPN 下冷查询约需 4 秒,3 秒会误报 SERVFAIL,所以文中示例放宽到 9 秒,整体请求上限为 22.5 秒。加长超时只避免过早失败,不会让正常查询变快,仍需检查 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>org.localdns.kixdns</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/libexec/kixdns</string>
<string>run</string>
<string>-c</string><string>/usr/local/etc/kixdns/pipeline.json</string>
<string>--udp-workers</string><string>2</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>WorkingDirectory</key><string>/usr/local/etc/kixdns</string>
<key>EnvironmentVariables</key>
<dict><key>RUST_LOG</key><string>warn,kixdns::engine::phases=info</string></dict>
<key>StandardOutPath</key><string>/usr/local/var/log/kixdns/service.log</string>
<key>StandardErrorPath</key><string>/usr/local/var/log/kixdns/error.log</string>
<key>SoftResourceLimits</key>
<dict><key>NumberOfFiles</key><integer>4096</integer></dict>
<key>HardResourceLimits</key>
<dict><key>NumberOfFiles</key><integer>65536</integer></dict>
</dict>
</plist>

修正权限,检查语法,再启动。已安装过的服务先执行 sudo launchctl bootout system/org.localdns.kixdns,再执行 bootstrap。

1
2
3
4
5
6
7
8
sudo chown root:wheel /Library/LaunchDaemons/org.localdns.kixdns.plist
sudo chmod 644 /Library/LaunchDaemons/org.localdns.kixdns.plist
plutil -lint /Library/LaunchDaemons/org.localdns.kixdns.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/org.localdns.kixdns.plist
sudo launchctl print system/org.localdns.kixdns

dig @127.0.0.1 www.baidu.com
dig @127.0.0.1 www.google.com +tcp

正式部署前先用 1053 端口验证,UDP、TCP、IPv6 记录和并发查询均正常。模拟上游故障时,旧缓存兜底和过期限制也通过了测试。

服务重启和日志维护命令放在后面。

切换 macOS 系统 DNS

先确认服务能解析,再保存原来的设置。

1
2
3
networksetup -listnetworkserviceorder
networksetup -getdnsservers "Wi-Fi"
scutil --dns

本机使用 Wi-Fi,原来没有手动指定 DNS,使用的是网络下发的地址。切换后只指定本地 DNS。

1
sudo networksetup -setdnsservers "Wi-Fi" 127.0.0.1

切换后刷新系统 DNS 缓存,再检查默认 nameserver。

1
2
3
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
scutil --dns

默认 resolver 应指向 127.0.0.1,.local 继续使用 mDNS。

验证 DNS 分流

直接查询可以确认 KixDNS 有回应,再用系统解析确认 macOS 真正在使用它。

1
2
3
4
5
6
7
8
9
10
11
12
dig @127.0.0.1 api.anthropic.com
dig @127.0.0.1 api.openai.com
python3 - <<'PYCODE'
import socket
for domain in ["www.baidu.com", "aliyun.com", "cloud.tencent.com",
"claude.ai", "api.anthropic.com", "chatgpt.com", "api.openai.com"]:
addresses = {item[4][0] for item in socket.getaddrinfo(
domain, 443, socket.AF_INET, socket.SOCK_STREAM)}
print(domain, sorted(addresses))
PYCODE

tail -n 30 /usr/local/var/log/kixdns/service.log

日志里检查 pipeline、upstream 和 cache_hit。本次验证,百度、阿里云和腾讯云使用 cn 管道,Claude、Anthropic API、ChatGPT 和 OpenAI API 使用 global 管道。

系统自身有缓存,重复解析可能不产生新的 KixDNS 日志。

验证访问流量

解析出来的 IP 还要检查路由。选择查询结果中的实际目标地址,执行下面的命令。

1
2
3
4
5
6
7
route -n get 160.79.104.10
# 检查 gateway 和 interface,本机 Claude 目标走 utun4

curl --noproxy '*' -4 -I -sS --connect-timeout 8 --max-time 15 \
-o /dev/null \
-w 'remote=%{remote_ip} local=%{local_ip} http=%{http_code}\n' \
https://claude.ai/

对其它网站同样检查。下面是当时全部解析地址的路由检查和实际 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 日,选了 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 秒的延迟尖峰,不能拿它当所有网络环境下的性能保证。

后续维护

更新 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/sh
set -eu

if [ "$(id -u)" != 0 ]; then
echo "请通过 sudo 执行"
exit 1
fi

RULE_DIR=/usr/local/etc/kixdns
RULE_URL=https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT HUP INT TERM

curl -fLsS --connect-timeout 10 --max-time 180 \
"$RULE_URL/geosite.dat" -o "$WORK_DIR/geosite.dat"
curl -fLsS --connect-timeout 10 --max-time 30 \
"$RULE_URL/geosite.dat.sha256sum" -o "$WORK_DIR/geosite.dat.sha256sum"
(cd "$WORK_DIR" && shasum -a 256 -c geosite.dat.sha256sum)

cp -p "$RULE_DIR/geosite.dat" "$RULE_DIR/geosite.dat.previous"
install -o root -g wheel -m 644 \
"$WORK_DIR/geosite.dat" "$RULE_DIR/geosite.dat.new"
mv -f "$RULE_DIR/geosite.dat.new" "$RULE_DIR/geosite.dat"
launchctl kickstart -k system/org.localdns.kixdns

echo "规则已更新,服务已重启"

执行更新,然后检查服务和解析。

1
2
3
4
5
6
chmod +x "$HOME/localdns/update-geosite.sh"
sudo "$HOME/localdns/update-geosite.sh"
sudo launchctl print system/org.localdns.kixdns
dig @127.0.0.1 www.baidu.com
dig @127.0.0.1 api.openai.com
tail -n 20 /usr/local/var/log/kixdns/service.log

GeoSite 支持热加载。脚本主动重启,清掉旧缓存后重新解析。它只更新 DNS 分类,OpenVPN 的 IP 路由仍需独立维护。

如果更新后有异常,先恢复上一份规则。

1
2
3
sudo cp -p /usr/local/etc/kixdns/geosite.dat.previous \
/usr/local/etc/kixdns/geosite.dat
sudo launchctl kickstart -k system/org.localdns.kixdns

添加自定义域名规则

日志确认分类不合适后,在 pipeline_select 里添加例外,放到 geo_site 和最后的 any 之前。第一个命中的规则生效。

比如要让某个域名及其子域名走国内上游,可以加入下面这个条目,example.com 替换成自己的域名。指定海外上游时把 pipeline 改成 global。

1
2
3
4
5
6
7
8
9
{
"pipeline": "cn",
"matchers": [
{
"type": "domain_suffix",
"value": "example.com"
}
]
}

修改生效配置之前先备份,并检查 JSON 语法。

1
2
3
4
sudo cp -p /usr/local/etc/kixdns/pipeline.json \
/usr/local/etc/kixdns/pipeline.json.previous
sudo vi /usr/local/etc/kixdns/pipeline.json
python3 -m json.tool /usr/local/etc/kixdns/pipeline.json >/dev/null

管道规则支持热加载,无效配置会保留上一份有效配置。JSON 语法检查只能确认文件格式,最终还要检查服务日志和目标域名的实际解析。监听端口、缓存容量等初始化参数修改后需要重启服务。

检查日志和恢复系统 DNS

常用检查命令如下。

1
2
3
4
5
networksetup -getdnsservers "Wi-Fi"
scutil --dns
sudo launchctl print system/org.localdns.kixdns
tail -n 30 /usr/local/var/log/kixdns/service.log
cat /usr/local/var/log/kixdns/error.log

VPN 重连后检查 DNS 是否仍指向本地,并按前面的方式复查路由。

长期使用可做日志轮转,或将 plist 中的 RUST_LOG 改成 warn。修改 plist 后要重新载入,单独 kickstart 不会重新读取 plist。排查问题时再恢复查询日志。

1
2
3
4
5
# 只重启进程
sudo launchctl kickstart -k system/org.localdns.kixdns
# 修改 plist 后重新载入
sudo launchctl bootout system/org.localdns.kixdns
sudo launchctl bootstrap system /Library/LaunchDaemons/org.localdns.kixdns.plist

停用服务前先恢复 DNS。本机 Wi-Fi 原先使用网络下发的地址,可用下面的命令恢复;原先有手动 DNS 的机器要填回旧地址。曾经配置过域名专用 DNS 的机器,也要恢复或移除仍指向本地服务的规则。

1
2
3
sudo networksetup -setdnsservers "Wi-Fi" Empty
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder

参考

关注公众号 尹安灿