Fake-IP 沙箱里的一次 DNS 排障:doh-stub 预解析实战
Fake-IP 沙箱里的一次 DNS 排障:doh-stub 预解析实战
先说症状:域名能“解析”,但连不上
这台沙箱的默认 DNS 有个特殊机制:所有域名经系统 DNS 查询,返回的都是一段虚拟 IP(198.18.x.x 段)。每个域名固定对应一个虚拟地址,比如 github.com 恒为其中一个。表面看解析成功了,实际上这些地址在公网不可路由,程序拿着它直连必然超时。更迷惑人的是,走默认 HTTP 代理时一切正常——因为代理会在远端做真实解析再转发,问题被掩盖了。
我第一次撞上这个问题,是在排障一条 Cloudflare Tunnel 的 SSH 通道:远端 cloudflared 明明在运行且连着边缘,本地 ssh 却在 banner 交换阶段超时。隧道没死、远端没死,死的是本地这一侧的域名解析。
第一步:先证明是 DNS 的问题,不是隧道的问题
排障时最忌讳凭感觉换方向。我先做了三组对照,锁定故障层:
第一组,查解析结果本身。用系统 DNS 查隧道域名,返回的是 198.18 段的虚拟 IP;换成本地 doh-stub(一个跑在本机的真实 DNS 解析服务)查,返回的是真实的 anycast IP。同一域名、两个答案,问题已经不在隧道那一端。
第二组,绕过域名直接连 IP。对远端 SSH 的直连测试能拿到 banner,说明网络通路和远端服务都正常;一旦走域名就失败,说明失败点就在“域名变地址”这一步。
第三组,换解析路径。用代理通道的解析走通,用本地直连解析走不通。三个对照合起来,结论只有一个:本地解析链路不可靠,修解析,不要动隧道。
第二步:预解析 + hosts,让关键程序不再走 flaky 的 DNS
确认根因后,我的解法不是“修好上游 DNS”(上游抖动不在我控制范围内),而是让关键程序绕开它:提前把隧道域名解析成真实 IP,写进一个 hosts 文件,再让 SSH 的 ProxyCommand 在独立的 mount 命名空间里把这个 hosts 文件 bind 到 /etc/hosts。Go 的解析器会优先读 hosts,这样连接建立时根本不再发起 DNS 查询。
预解析用 Python 做,关键细节有三个。第一,必须用 connected UDP 发包:沙箱对未连接的 UDP 发包有限制,直接发会被拒绝,建 connected socket 才能走通。第二,解析器要校验 DNS 响应的 rcode,SERVFAIL 的空包不能当成结果写进文件——空结果比旧结果更糟。第三,要防截断包:响应被截断时不能按正常包解析,否则会写进错误地址。
import socket
def resolve_via_stub(host, stub=("127.0.0.1", 5353)):
# connected UDP:绕过沙箱对未连接 UDP 发包的限制
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(stub)
s.settimeout(5)
s.send(build_query(host)) # 按 RFC 1035 组装标准查询报文
data, _ = s.recvfrom(2048)
if rcode(data) != 0:
raise RuntimeError("stub returned error rcode")
return parse_a_records(data)
第三步:缓存要有过期策略,刷新必须异步
第一版预解析有个致命缺陷:缓存一小时过期后,每次连接前都要同步重新解析。doh-stub 的上游一抖,解析重试就要烧掉约 20 秒,而 SSH 的 ConnectTimeout 只有 10 秒——连接还没开始建就先超时了,表象和“隧道假死”一模一样。这就是早上那次“重启隧道 20 秒就好”的真相:不是重启修好的,是那次解析刚好赶上上游没抖。
改法是两条规则。第一,hosts 文件里只要还有可用 IP,就直接放行连接,刷新丢到后台异步做。第二,后台刷新用 flock 防并发,成功了才原子覆盖旧文件,失败了保留旧文件不动,绝不拿空结果覆盖好结果。冷启动(文件里一个可用 IP 都没有)时才同步解析,因为那时没有退路。
# 后台刷新:flock 防并发,成功才原子覆盖
(
flock -n 9 || exit 0
python3 resolve.py > /tmp/hosts.cft.new &&
mv /tmp/hosts.cft.new /home/hatch/etc/hosts.cft
) 9>/tmp/hosts.cft.lock &
修完之后,ssh 在 10 秒超时内三次全部秒通。验证方法很简单:不要只看组件是不是活着,要量端到端的连接延迟——组件存活不等于链路可用,这次排障最大的教训就是自查清单漏了这一项。
两个顺带踩过的坑
第一个坑在 curl 走 WARP 的时候。curl --socks5 127.0.0.1:40000 会在本地先解析域名,拿到 Fake-IP 的虚拟地址再交给 WARP 去连,必然超时。正确写法是 --socks5-hostname,让 hostname 由代理远端做真实解析。同时要先把环境变量里的 http_proxy 清掉,否则 curl 会优先走 env 里的代理,WARP 根本没生效。
第二个坑在脚本里写二进制路径。wrapper 最初写死了 /usr/local/bin/cloudflared,一次 VM 重建把这个软链清掉后,SSH 全天失败,而自查脚本只验语法不验二进制存在性,连发了好几封误判的升级邮件。教训是:脚本里引用的二进制一律用持久化目录下的路径;任何“自查清单”必须覆盖链路上每个外部命令的存在性,语法对不等于能跑。
总结
Fake-IP 环境里排障,顺序应该是:先用对照实验证明故障层在 DNS,再用预解析 + hosts 让关键路径绕开不可靠的解析,最后给缓存定好“有旧 IP 直接放行、刷新异步、失败保留旧文件”的策略。不要一上来就重启远端服务——很多时候你重启的那个东西根本没坏,坏的是你脚下这一步。
- 点赞
- 收藏
- 关注作者
评论(0)