服务、端口、证书全正常,VPN 却断了 4 小时:Tailscale 接管 DNS 后把翻墙机的解析清空了
现象
两台同机房、同配置的 DMIT 洛杉矶 VPS,都跑 sing-box 做翻墙节点(VLESS-REALITY 走 tcp/443,Hysteria2 走 udp/443)。9 月 16 日晚上,主力那台(下文叫 dmit-usa)的两个节点在客户端全部超时;另一台(dmit-usa-eb)一切正常。
登上去第一轮检查,什么都是绿的:
$ ssh dmit-usa 'systemctl is-active sing-box nginx; ss -lntup | grep -E ":443 |:8444|:8445"'
active
active
udp UNCONN 0 0 *:443 *:* users:(("sing-box",...))
tcp LISTEN 0 4096 127.0.0.1:8444 ... users:(("sing-box",...))
tcp LISTEN 0 511 127.0.0.1:8445 ... users:(("nginx",...))
tcp LISTEN 0 511 0.0.0.0:443 ... users:(("nginx",...))
nginx -t 通过,Hysteria2 用的 Let's Encrypt 证书还有 81 天,systemd 里 sing-box 最近一次重启是两天前的证书整治,之后没有任何异常退出记录。
唯一不对劲的是日志目录:
-rw-r--r-- 1 sing-box sing-box 13710190 Sep 16 23:15 sing-box.log # 今天,13 MB
-rw-r--r-- 1 sing-box sing-box 7329135 Sep 16 00:30 sing-box.log.1 # 昨天,7 MB
-rw-r--r-- 1 sing-box sing-box 50237 Sep 7 00:15 sing-box.log.10.gz # 平时压缩后几十 KB
平时一天几十 KB,这两天一天十几 MB。服务没死,但它在狂刷什么东西。
定位:三条命令从"日志变大"走到根因
第一步:日志里刷的是什么
把最近 2 MB 日志的错误行归一化后计数:
tail -c 2000000 /worker/logs/sing-box/sing-box.log \
| grep -oE "(ERROR|failed|handshake)[^\"]{0,80}" \
| sed -E 's/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?//g' \
| sort | uniq -c | sort -rn | head
5307 ERROR inbound/vless[vless-in]: process connection from
2826 handshake: REALITY: failed to dial dest: lookup www.apple.com: (exchange6: SERVFAIL
2499 handshake: REALITY: failed to dial dest: lookup www.apple.com: (exchange4: SERVFAIL
40 ERROR connection: open connection to oauthaccountmanager.googleapis.com:443 ...
两条信息:
- REALITY 握手失败,原因是
lookup www.apple.com返回 SERVFAIL。REALITY 协议的机制是服务端在握手阶段真的去连一次借用的目标站(这里是 www.apple.com),借它的 TLS 握手来伪装;目标站解析不了,握手就进行不下去,每一个 VLESS 连接都会被拒。 - 后面那些
open connection to xxx:443失败,是 Hysteria2 已经建好隧道之后,出站去连目标域名时同样解析失败。所以两种协议一起断,而且断法一样:客户端能连上端口,但一个网页都打不开。
第二步:是谁在回答 DNS
$ cat /etc/resolv.conf
# resolv.conf(5) file generated by tailscale
# For more info, see https://tailscale.com/s/resolvconf-overwrite
# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN
nameserver 100.100.100.100
nameserver fd7a:115c:a1e0::53
search tailaca6af.ts.net
$ dig +short www.apple.com # 走系统 DNS → 100.100.100.100
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL
$ dig +short @1.1.1.1 www.apple.com # 直接问公网
www-apple-com.v.aaplimg.com.
系统 DNS 是 Tailscale 的 MagicDNS 存根 100.100.100.100,它回 SERVFAIL;绕过它直接问 1.1.1.1 一切正常。网络没坏,坏的是这台机器上负责转发 DNS 的 tailscaled。
第三步:tailscaled 为什么不转发了
$ journalctl -u tailscaled --since today | grep -i dns | grep -v RATELIMIT | tail
Sep 16 09:52:30 tailscaled: trample: resolv.conf changed from what we expected. did some other program interfere? current contents: "nameserver 1.1.1.1\nnameserver 1.0.0.1\n"
Sep 16 09:52:30 tailscaled: dns: Resolvercfg: {Routes:{.:[1.1.1.1 1.0.0.1] ts.net.:[199.247.155.53 ...]} ...}
Sep 16 18:58:20 tailscaled: trample: resolv.conf changed from what we expected. did some other program interfere? current contents: ""
Sep 16 18:58:20 tailscaled: dns: Resolvercfg: {Routes:{.:[] ts.net.:[199.247.155.53 ...]} ...}
Sep 16 23:16:21 tailscaled: dns: resolver: forward: no upstream resolvers set, returning SERVFAIL
三行日志把整件事说完了:
| 时间 | resolv.conf 被谁改成了什么 | tailscaled 重新接管后的公网上游(Routes:{.:[...]}) |
结果 |
|---|---|---|---|
| 09:52:30 | 外部程序写成 1.1.1.1 / 1.0.0.1 |
[1.1.1.1 1.0.0.1] |
正常 |
| 18:58:20 | 外部程序写成空文件 | [] |
此后所有公网查询 no upstream resolvers set, returning SERVFAIL |
而 09:52:30 和 18:58:20 这两个时刻,系统日志里都恰好有同一件事:
Sep 16 18:58:20 dhclient[533]: DHCPREQUEST for 179.253.232.64 on eth0 to 193.41.250.250 port 67
Sep 16 18:58:20 dhclient[533]: DHCPACK of 179.253.232.64 from 193.41.250.250
Sep 16 18:58:20 dhclient[533]: bound to 179.253.232.64 -- renewal in 36300 seconds.
eth0 是 DHCP 配置的,dhclient 每次续租(约 10 小时一次)都会重写 /etc/resolv.conf。第一次它写进去的是完整内容,tailscaled 重新接管时把这两个地址当成"系统原有上游"记下来,没事;第二次 tailscaled 读到的是空文件(最可能是撞上了 dhclient 先清空再写入的窗口),上游列表变空,从此对所有非 ts.net 的查询直接回 SERVFAIL。sing-box 日志里 3.4 万条 SERVFAIL 全部在 18:58 之后,一条不差。
为什么另一台没事
dmit-usa-eb 装 Tailscale 更早(9 月 5 日),配置一模一样:accept-dns 开着、resolv.conf 归 tailscaled 管、tailnet 后台没配全局 nameserver。它只是运气好,dhclient 续租时还没被 tailscaled 读到空文件。同一颗雷,只是还没踩。 这也是"对照机正常"这条线索最容易误导人的地方:两台配置一样、一台好一台坏,直觉会往"坏的那台被单独动过什么"想,实际是两台都处在危险状态。
根因一句话
Tailscale 在 Linux 上默认 --accept-dns=true,会把 /etc/resolv.conf 指向自己的存根 100.100.100.100;它对公网域名的转发能力完全取决于它接管那一刻从 resolv.conf 里读到的上游。tailnet 后台如果没配全局 nameserver,这个上游就是唯一来源;任何会重写 resolv.conf 的程序(dhclient、systemd-resolved、NetworkManager、cloud-init)都可能让它读到不完整的内容,之后整机 DNS 静默失效。Tailscale 自己的文档把这类情况叫 "DNS fight"(日志里给的链接就是 tailscale.com/s/dns-fight)。
而 sing-box 的 REALITY 入站没有独立 DNS 配置,完全依赖系统解析。于是一条看似和翻墙毫无关系的改动(装个内网穿透工具)把 VPN 打断了。
修法:三选一,推荐第一种
方案 A(治本,两台机器一次搞定):Tailscale 后台 → DNS → Global nameservers 加 1.1.1.1 / 1.0.0.1。配置下发后,tailscaled 的公网上游来自控制平面,不再依赖 resolv.conf 里读到什么。本次就是这样修的,后台配完不用重启任何服务,两台机器 dig www.apple.com 随即恢复 NOERROR,sing-box 日志停止报错。验证命令:
tailscale dns status | sed -n '/Resolvers/,/Split DNS/p'
# 应看到 1.1.1.1 / 1.0.0.1 列在 Resolvers 下
方案 B(单机止血,不需要后台权限):让 Tailscale 不再碰 DNS,然后自己把 resolv.conf 写回去。
tailscale set --accept-dns=false
printf 'nameserver 1.1.1.1\nnameserver 1.0.0.1\n' > /etc/resolv.conf
代价是这台机器解析不了 *.ts.net 的 MagicDNS 名字,用 100.x 地址互访不受影响。翻墙机通常根本用不到 MagicDNS,这个代价可以忽略。
方案 C(让 sing-box 与系统 DNS 解耦):在 sing-box 配置里加独立的 dns 段,指定 1.1.1.1 之类的服务器。这样系统 DNS 再坏,REALITY 握手和出站解析也不受影响。它不修 Tailscale 的问题,但把翻墙服务的爆炸半径缩小了,适合作为 A 或 B 的补充。
复盘:为什么这个故障花了比预期长得多的时间
排查的实际顺序是:服务状态 → 端口 → 证书 → 两天前那次证书整治的 deploy-hook 有没有把 sing-box 重启坏 → 日志。前四步全部正常,而且"两天前动过证书"这条线索非常像根因,差点把时间全花在那上面。
真正的教训是三条:
- "服务正常"不等于"服务能用"。
systemctl is-active只说明进程活着。对代理类服务,应该验收的是它的核心依赖:DNS 能不能解、出站能不能连。 - 日志体积突变是最早、最便宜的信号。 每天几十 KB 变十几 MB,比任何监控都先告诉你"它在狂刷错误"。看到这个直接进日志,不要再绕别的。
- 最近一次"无关"改动最可疑。 装 Tailscale 和 VPN 看起来毫无关系,但它改了 resolv.conf。在服务器上任何会动网络栈的操作,都要当成对所有依赖网络的服务的改动。
让 Claude Code 以后不再踩这个坑:该写进 CLAUDE.md 的规则
这次的 Tailscale 是让 Claude Code 装的,故障也是 Claude Code 查出来的。它查得对,但装的时候没有任何机制拦住它。事后我们把下面几条写进了 vps 仓库的 CLAUDE.md 和 tasks/lessons.md,思路是把"装完验收什么"写死,而不是指望模型每次都想到。
1. 网络组件的安装验收清单
在翻墙机(跑 sing-box 的机器)上安装任何会接管 /etc/resolv.conf 的组件
(Tailscale / systemd-resolved / NetworkManager / cloud-init),装完必须验收:
- dig www.apple.com 走系统 DNS 返回 NOERROR
- 装前装后各记一次 sing-box 日志 ERROR 行数,增量为 0
- cat /etc/resolv.conf 确认托管方,托管方是 tailscaled 时后台必须已配全局 nameserver
验收不过不算装完。
2. 把隐性依赖写成显性事实
sing-box 配置无独立 dns 段,REALITY 握手要先解析 www.apple.com,
服务器系统 DNS 一坏 VPN 全断(服务/端口/证书全正常,systemctl 看不出来)。
模型不会自动知道"REALITY 握手依赖 DNS"这种项目级事实。写一句,它排查时就会把 DNS 排进前三项。
3. 排查顺序也可以写成规则
排查 VPN 不可用:① systemctl / 端口 ② dig www.apple.com ③ sing-box 日志体积与错误归一化计数
④ 最近 48h 的任何网络相关改动(包括看似无关的)。日志体积异常增大时直接跳到 ③。
4. 让它记住"对照机正常"不是排除依据
两台同配置机器一好一坏时,先确认好的那台是否只是"还没触发",不要默认它安全。
这四条都不是通用最佳实践,而是这台机器、这个架构下的具体事实和具体动作。CLAUDE.md 里最值钱的就是这类东西:模型靠通用知识推不出来、错了代价又很高的项目私有约束。
相关文章
- Claude Code 报错 temporarily unavailable, so auto mode cannot determine the safety of bash 怎么解决:本次排查开头恰好撞上这个报错,十几次重试后才拿到 SSH 执行权限,最终靠人工跑命令贴回输出继续。