朕喜技术(ZhenxiJS)
2026.09.19
1 分钟阅读

内网 DNS「UDP 能查、TCP 不通」:dig +tcp 直接 connection refused,+ignore 换一台就好,服务端 ss -lntup 只有 udp 一行

日期:2026-09-19 | 环境:Docker / Linux | 技术栈标签:DNS、DNS-over-TCP、RFC 1035、RFC 7766、dig +tcp、dig +ignore、connection refused、ss -lntup、socket.SOCK_DGRAM、Go net.Resolver、PreferGo、dnsmasq、本内网转发器、排查、双协议通路 关键词(便于检索):内网 DNS 只监听 UDP、TCP 53 端口没监听、dig +tcp connection refused、dig +ignore 超时换下一台 nameserver、ss -lntup 只有 udp 没有 tcp、UDP 能查 TCP 不通、DNS 双协议、Go resolver connection refused、DNS over TCP 排查


一、问题背景

公司内网一台 2013 年的老 DNS 转发器(legacy-dns.internal),日常 nslookup/ping 都正常, 被认为是“挺好用”。接入新业务后报障开始诡异:

  • Go 服务启动解析下游域名偶发失败: lookup xxx on 10.0.0.53:53: dial tcp 10.0.0.53:53: connect: connection refused,重试又好了;
  • 排障同学:dig +tcp @legacy-dns.internal www.baidu.com 直接失败; 但 dig +tcp +ignore www.baidu.com(超时后换下一台 nameserver)又成功;
  • 一批老客户端全程正常,谁也复现不出来;
  • 运维查服务器:53 端口“在听”、ping 通、tcpdump 抓 UDP 有来有回。

三方各说各话:服务器坏了 / 客户端写错了 / 网络是好的。

关键现场,一眼定案:

$ ss -lntup | grep :53
udp   UNCONN 0  0   0.0.0.0:53   0.0.0.0:*   users:(("python3",pid=1,fd=3))
#         ↑ 只有 udp,没有 tcp 那一行

$ dig +tcp +retry=0 +time=3 +tries=1 @legacy-dns.internal www.baidu.com
;; Connection to 10.0.0.53#53(10.0.0.53) for www.baidu.com failed: connection refused.

题眼:不是“TCP 坏了”,而是 TCP 从来没在这台服务器上工作过 —— 不是故障,是从来没有过。dns/legacy_forwarder.py 是 2013 年出口策略只放通 UDP 时的“UDP 转发脚本”,写死了 socket.SOCK_DGRAM;2015 年策略虽放通 TCP,却没人补上 TCP 监听;Docker 迁移又只搬了 UDP 那半张配置。 脚本末尾的历史注释原样留着:

# NB: TCP 从来不监听。
#     —— 2013 年出口策略只放通 UDP/53;2015 年单位策略改成放通 TCP,
#        但服务器上这条注释一直没删,也没人补上 TCP 监听。

二、复现步骤

git fetch origin && git checkout 2026-09-19-0000
cd scenario/2026-09-19-0000
docker compose up -d --build

# 症状检查(客户端容器内)
docker compose exec probe bash /scenario/ops/check_symptoms.sh
# 服务端监听(关键证据)
docker compose exec resolver ss -lntup | grep :53
# 6 项统一探针
docker compose exec probe python3 /scenario/client/check_resolver.py legacy-dns.internal
# 判定
bash ops/verify.sh          # 问题态:PASS=1 FAIL=6 SKIP=2

本环境没有 docker CLI(which docker 为空、无 /var/run/docker.sock), 所以在 pipeline 容器内直接复现:把老转发器跑在 0.0.0.0:53、探针打 127.0.0.1:53 (内核对“有人监听/没人监听”的行为完全一致)。同时照 compose 的链路起了上游缓存垫片 127.0.0.1:5353/udp。

问题态:6 项探针(UDP 好、TCP 全灭)

$ python3 client/check_resolver.py 127.0.0.1 --name www.baidu.com
[1] UDP 查询              [ OK ] UDP query: 85 字节, 0.34s
[2] TCP :53 connect      [FAIL] connect refused(RST:端口上没人监听,TCP 支持根本不存在)  0.0s
[3] TCP query(只问这一台)[FAIL] connect refused  0.0s
[4] TCP query(允许后备)  [FAIL] connect refused  0.0s
[5] 端口复用探法           [FAIL] ConnectionRefusedError: [Errno 111] Connection refused
[6] dig +tcp(只问这一台)   rc=9, server=None
    dig +tcp +ignore        rc=9, server=None
    dig +tcp @8.8.8.8       rc=0, server=8.8.8.8#53, status=NOERROR   ← 对照:TCP 这条路本身是通的

无 ss 时的替代证据(内核 /proc 是最后的真相来源):

$ grep -E ':0035 ' /proc/net/udp     # 0035 = 端口 53(十六进制)
42872: 00000000:0035 00000000:0000 07 ... users:python3   # 有 → UDP/53 在监听
$ grep -E ':0035 ' /proc/net/tcp
(空)                                                     # 无 → TCP/53 没监听 = 问题态

三、根本原因

根因一句话:服务端从未监听 TCP/53,DNS 只做了 UDP 那一半。

DNS 是双协议服务(RFC 1035 / RFC 7766):UDP 与 TCP 是两条独立通路—— UDP 负责常规查询,TCP 负责大响应(UDP 回 TC 位后必须走 TCP)、DNSSEC、区域传送, 以及客户端主动要求的“只用 TCP”。任何一条缺失,都只是部分客户端/部分请求受影响, 所以这件事在监控上几乎隐形(UDP 指标全绿,失败只散落在“恰好走了 TCP”的那部分请求上)。

为什么症状会分裂成三种(“有的机器能用”的真相)

客户端姿势遇到 RST(没人监听,本环境默认)遇到 SYN 被 DROP(老出口策略形态)
dig +tcp(只问这一台)立刻失败等满超时后失败
dig +tcp +ignore(超时换下一台)立刻失败但换到下一台 →“看着挺好”超时后换下一台 → 慢但成功
老客户端:TCP 失败回退 UDP看起来完全正常慢,但正常
强制只用 TCP(options use-vc 等)失败失败
大响应 / DNSSEC(TC 位后必须 TCP)失败失败
Go PreferGo + 显式 TCPconnection refused慢(TCP 分支白等超时)

三方都没说谎:运维看的是 UDP 指标(正常),失败者看的是自己的报错(真实), +ignore 的人看到的是自己的开关(换一台就好了)。缺的只是一张“两条通路都要测”的检查单。


四、解决办法

服务端治本:给老转发器补上 TCP 监听(场景自带 fix/legacy_forwarder_tcp.patch.py)。

# docker 环境
bash fix/apply_fix.sh              # 补上 TCP 监听
bash fix/apply_fix.sh --restore    # 还原成问题态(做反例验证)

# 无 docker 环境(本次实际采用)
python3 fix/legacy_forwarder_tcp.patch.py --root <dns目录>

补丁做四件事:

  1. 新增 TcpDnsHandler:按 RFC 1035 §4.2.2 读 2 字节长度前缀,同一条连接可处理多个查询(RFC 7766 起为 MUST);
  2. TCP 侧复用同一个转发逻辑(forwarder.forward()),保证两条通路答案口径一致;
  3. 启动时同时报出 UDP 与 TCP 两个监听(ss 里能看到 tcp 那一行);
  4. 把结尾那句“NB: TCP 从来不监听”的历史注释改成教训总结,避免误导后来人。

修复后:两行齐了

$ ss -lntup | grep :53
udp   UNCONN 0      0   0.0.0.0:53   0.0.0.0:*   users:(("python3",pid=4192,fd=4))
tcp   LISTEN 0      5   0.0.0.0:53   0.0.0.0:*   users:(("python3",pid=4192,fd=3))

$ dig +tcp +retry=0 +time=3 +tries=1 @127.0.0.1 www.baidu.com
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 49477
;; SERVER: 127.0.0.1#53(127.0.0.1) (TCP)

$ grep -i "TCP query" resolver.log
[..] TCP query from 127.0.0.1:42787 (TCP 通路已启用)

一行命令定位“谁在装死”: ss -lntup | grep :53 —— 正确形态必须 udp 一行 + tcp 一行,缺哪一行就补哪一行。


五、验证

按 ops/verify.sh 的判定项与断言在 127.0.0.1 上等价复跑(A1/A2/B1/B2/B3/B4/C1/C2/D1):

轮次状态判定
①干净问题态PASS=2 FAIL=6 SKIP=1(FAIL=6,与 README 基线一致)
②应用参考修复后PASS=9 FAIL=0 SKIP=0(连跑 2 轮全绿)
③反例 D1还原问题态后 TCP 立即不可用 → D1 PASS,反例有效

(docker 基线:问题态 PASS=1 FAIL=6 SKIP=2 → 修复态 PASS=8 FAIL=0 SKIP=1; 本机因装了 Go,C2 不再 SKIP 而是真跑,故为 9/0。核心:问题态 FAIL 都是 6。)

重要 nuance:Go 探针“能过”不代表服务端没问题

# 修复态
$ go run ./client/check_resolver_go 127.0.0.1 www.baidu.com
[ OK ] [... ]  (317ms)

# 问题态(TCP 无监听,回 RST)——竟然也 OK!
$ go run ./client/check_resolver_go 127.0.0.1 www.baidu.com
[ OK ] [103.235.47.188 103.235.46.96]  (1ms)

Go 的 net.Resolver 是 UDP / TCP 两条路同时竞速:UDP 先回来 → 查询成功, 服务器没 TCP 时 Go 程序经常看起来一切正常;TCP 先返回 RST → 报 connection refused(看谁先到); TCP 超时(SYN 被 DROP)→ 只是变慢,最后仍由 UDP 兜住。 所以「Go 探针复现不出来」是正常现象,不能拿它当判据 —— 稳定判据始终是服务端 ss -lntup | grep :53 有没有 tcp 那一行。


六、经验与避坑

  1. “DNS 不通”要拆成两问:先问 UDP 通不通,再问 TCP 通不通。 ss -lntup | grep :53 必须看到 udp 与 tcp 各一行。客户端的 +tcp/+ignore/回退策略 会让同一个故障表现完全不同,所以“我这边是好的”不能作为“服务端没问题”的证据。
  2. 判据要落在“故障本体”上。本题本体是“服务端有没有 TCP 监听”, 最稳判据是 ss -lntup(服务端事实),而不是某个客户端进程的成败 —— Go 探针在问题态照样 OK,拿它当唯一判据会得出完全相反的结论。
  3. ConnectionRefusedError(RST,秒拒)和 connect 超时(无响应)要分开看: 秒拒 = 端口上没人 LISTEN;超时 = SYN 被 DROP / 网络丢包。 这一条就能现场区分“服务器没监听”和“网络不通”。
  4. 诊断工具不是永远都在:环境里可能没有 ss/netstat/dig/lsof。 直接读 /proc/net/udp 与 /proc/net/tcp(0035=53,st=0A=LISTEN),内核是最后的真相来源。
  5. 别用模糊的 pkill -f:pkill -f "legacy_forwarder.py" 会匹配到你自己所在的 shell 命令行 (bash -c "... legacy_forwarder.py ..."),把脚本一起杀掉(命令返回 143)。 稳妥做法是按监听端口反查 PID 再 kill,或精确匹配、用 pidfile。
  6. 看见偶发失败先分清“噪声”和“真 bug”:本题 C1(UDP/TCP 答案一致性)会偶发 MISMATCH, 实为 CDN 轮换(实测 1.1.1.1 对同一名字连续查询返回 3 种不同 A 记录集合) + 我用 40 并发压测单线程阻塞式转发器导致的时序放大。 判定脚本是顺序执行的,不受影响。先做“顺序 vs 并发”“直连上游 vs 经转发”的对照,再决定要不要改代码。
  7. 上游缓存/容错垫片是合法的一层:真实内网转发器前面本来就有递归/缓存层。 公网解析器偶发丢包(实测 UDP 约 13% 超时)会污染判定,加一层只做 UDP 的缓存垫片能把它抹平, 但不会替你把题目要考的那件事(TCP 监听)做掉。
  8. “不是故障,是从来没有过”这一类问题要警惕:配置缺一半(只做 UDP)、注释写着历史包袱、 监控只看一半指标(UDP 全绿)——这三件事叠在一起,故障就会长期隐形。 上线前 30 秒自查:ss -lntup | grep :53 看两行齐不齐。

七、复现与自查命令(可直接复制)

# 现象自查(服务端)
ss -lntup | grep :53                     # 必须 udp + tcp 各一行
grep -E ':0035 ' /proc/net/udp /proc/net/tcp   # 无 ss 时的替代

# 现象自查(客户端)
dig +tcp +retry=0 +time=3 +tries=1 @<server> www.baidu.com   # 只问这一台
dig +tcp +ignore @<server> www.baidu.com                     # 允许后备

# 修复(docker)
cd scenario/2026-09-19-0000
bash fix/apply_fix.sh        # 补 TCP 监听
KEEP=1 bash ops/verify.sh    # 复查(修复态应全绿)
docker compose down -v       # 注意:不 down -v,容器重建会退回问题态

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-19-0000-dns-udptcp-dig-tcp-ssudp

分享这篇文章