UDP 大响应被静默截断:DNS 超过接收缓冲区的响应收到半截报文(dig timed out / DNS message is malformed),recvfrom 缓冲区与 EDNS0/TC 位排查
日期:2026-09-15 | 分支:
2026-09-15-1600| 期数:第58题 技术栈:Python 3.11 / dnspython 2.6.1 / UDP socket / DNS / EDNS0(RFC 6891) / Docker Compose / dig(BIND 9.20) 关键词:UDP 截断、recvfrom 缓冲区太小、DNS message is malformed、dig timed out、ID mismatch、MSG_TRUNC、MSG_PEEK、EDNS0、TC 位、options edns0、DNS 响应超过 512 字节、UDP 数据报被丢弃、SO_RCVBUF、dnspython from_wire 报错
一、问题背景
内网自建权威 DNS(UDP 5354),部分记录很大(资产清单 TXT,20 段 x 250 字节 ≈ 5KB)。业务侧的 Python 查询代码取不到数据:
{
"ok": false,
"wire_bytes_received": 4096,
"client_recv_buffer": 4096,
"error": "MALFORMED",
"detail": "DNS message is malformed."
}
换成手工 dig 验证,现象更迷惑 —— 看起来像「服务器根本没响应」:
;; Warning: ID mismatch: expected ID 46924, got 4660
;; communications error to <ns>#5354: timed out
;; no servers could be reached
但 docker compose logs dns-authority 里清清楚楚:
[srv] 已发送 5080 字节 UDP 响应
同一个服务端,查短记录一切正常。第一反应都会去怀疑服务端 —— 方向就错了。
二、复现步骤
环境自包含、可离线跑(服务端响应在构建时生成,不依赖外网)。
cd scenario/2026-09-15-1600
docker compose up -d --build
# 1) 业务客户端:复现 MALFORMED
docker compose exec -e INTERNAL_DNS_HOST=dns-authority app-client \
python -u internal_lookup.py big.example.com
# 2) 缓冲区 <-> 收到字节数探针(最有信息量的一步)
docker compose exec -e INTERNAL_DNS_HOST=dns-authority app-client \
python -u show_size.py
# 3) 断言脚本(复现期期望 PASS=0 FAIL=5)
docker compose exec -e INTERNAL_DNS_HOST=dns-authority app-client \
python -u check_bad.py --expect-broken
# 4) 真实 dig 交叉验证
docker compose exec app-client dig +tries=1 +timeout=3 +bufsize=8192 \
@dns-authority -p 5354 big.example.com TXT
复现结果(关键输出):
=== show_size.py 探针 ===
服务端 dns-authority:5354 的完整响应 = 5080 字节(20 段 x 250 字节 TXT)
recvfrom(512 ) 实际收到 512 字节 | 解析失败: DNS message is malformed.
recvfrom(4096 ) 实际收到 4096 字节 | 解析失败: DNS message is malformed.
recvfrom(8192 ) 实际收到 5080 字节 | 解析成功
recvfrom(65535) 实际收到 5080 字节 | 解析成功
=== dig ===
;; Warning: ID mismatch: expected ID 46924, got 4660
;; communications error to 172.19.0.2#5354: timed out
;; no servers could be reached
=== 断言 ===
A. 客户端能收到完整响应且解析成功 FAIL
B. 收到的字节数 == 服务端发送的 5080 字节 FAIL
C. 响应未截断(字节数 != 客户端接收缓冲区) FAIL
D. 解析不再报 malformed / 半截报文 FAIL
E. 真实 dig 也能拿到同一份响应 FAIL
PASS=0 FAIL=5
两个立即可用的指纹:
- 收到的字节数恰好等于缓冲区大小(4096)—— 这不是巧合。
- dig 报
ID mismatch—— 说明 dig 收到了报文,只是拒收。
三、根本原因
根因 1(主因):UDP 数据报被内核静默截断,客户端把半截报文当完整的解析
错误代码:
CLIENT_RECV_BUFFER = int(os.environ.get("CLIENT_RECV_BUFFER", "4096"))
data = sock.recvfrom(CLIENT_RECV_BUFFER)[0] # ← 病根
recvfrom(n) 的语义是「最多拷贝 n 字节」。当数据报(5080)> n(4096)时,Linux 的 udp_recvmsg 只拷贝前 n 字节,剩余部分直接丢弃(skb 随读随释放),
并只把 MSG_TRUNC 标志放进辅助数据 —— recvfrom 默认看不到这个标志,也不抛异常。
于是:
- 调用方以为拿到了完整报文,其实少了 5080 − 4096 = 984 字节。
- DNS 报文头里声明的 rdata 长度 > 实际字节数,
dnspython的from_wire抛DNS message is malformed. - 报错信息把矛头指向「报文格式」,完全掩盖了「数据被内核丢了」这个事实。
铁证(用 recvmsg 才能看到标志):
docker compose exec app-client python3 -c '
import socket, dns.message, dns.rdatatype
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(3)
q=dns.message.make_query("big.example.com",dns.rdatatype.TXT).to_wire()
s.sendto(q,("dns-authority",5354))
data, anc, flags, addr = s.recvmsg(4096, 0)
print("recvmsg(4096) 返回长度 =", len(data), "MSG_TRUNC =", bool(flags & socket.MSG_TRUNC))
'
recvmsg(4096) 返回长度 = 4096 MSG_TRUNC = True
MSG_TRUNC = True —— 内核明确说了「这个数据报被截断了」。
为什么字节数恰好是 4096? 因为 UDP 是面向数据报的,不是字节流(这点和 TCP 完全相反):
| TCP | UDP | |
|---|---|---|
| 语义 | 字节流 | 数据报(一个 sendto = 一个 recvfrom,边界由内核维护) |
| 缓冲区太小 | 只分几次读完,一个字节都不丢 | 多余部分直接丢弃,且默认不报错 |
| 表现 | recv 返回部分数据是正常的,继续读即可 | 返回长度精确 == 缓冲区大小,数据永久丢失 |
所以 len(recv结果) == 缓冲区大小 就是截断的定义式指纹。
根因 2(独立 bug,被 dig 的超时暴露):服务端响应不回显查询 ID
修复根因 1 之后,dig 依然超时。继续查发现:
docker compose exec app-client python3 -c '
import socket, dns.message, dns.rdatatype
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(3)
q=dns.message.make_query("big.example.com",dns.rdatatype.TXT); q.id=61295
s.sendto(q.to_wire(),("dns-authority",5354))
d=s.recv(65535)
print("我方发出 ID =",q.id," 服务端回 ID =",int.from_bytes(d[:2],"big"))
'
我方发出 ID = 61295 服务端回 ID = 4660 =0x1234
服务端无论问什么都回同一个预置报文,头部 ID 写死 0x1234。
RFC 1035 要求响应必须回显查询的 ID。dig 校验 ID 不通过就把响应丢弃(;; Warning: ID mismatch),继续等一个 ID 对得上的响应,直到 +timeout 耗尽 → 报 timed out / no servers could be reached。
这个 bug 和小响应无关,是全局的 —— 查任意记录都超时:
for n in a.example.com short.example.com big.example.com; do
docker compose exec app-client dig +tries=1 +timeout=2 @dns-authority -p 5354 $n TXT 2>&1 | grep -E "ID mismatch|timed out"
done
# 三个都 ID mismatch + timed out
这也是排查中最容易漏掉的地方:两个 bug 叠在一起,一个的现象盖住了另一个。 "改大缓冲区就能过"只修好了前半段,
dig照样失败,很容易被误判为"方案无效"。
澄清一个常见误解:TC 位在这题里不是主角
很多人(包括题目提示)会往 TC 位想。但实际报文头是 12348180:
ID=0x1234, FLAGS=0x8180 = QR=1 AA=1 RD=1 RA=0 TC=0 RCODE=0
TC = 0
服务端认为它成功发了个完整应答,TC 位根本没置起。
"被截断的 UDP 载荷会让客户端原地等超时"这个说法其实不准确 ——
真正的因果是「响应被 ID 校验丢弃 → 客户端没拿到合法响应 → 等超时」。
而"截断"影响的是另一条路径(业务客户端):它不校验 ID 就解析,于是撞上 malformed。
四、解决办法
4.1 客户端核心修复:动态分配缓冲,不猜魔法数字
DNS_UDP_MAX = 65535
DNS_UDP_CLASSIC = 512
EDNS_UDP_SIZE = int(os.environ.get("CLIENT_EDNS_UDP_SIZE", "1232"))
class TruncatedResponse(Exception):
"""收到的 UDP 数据报被内核截断(缓冲区小于数据报真实长度)。"""
def _recv_full(sock: socket.socket) -> bytes:
"""收一个完整的 UDP 数据报,缓冲区按数据报真实长度动态分配。"""
# 阶段1:窥探真实长度(数据报**不消费**,仍留在内核接收队列里)
peeked = sock.recv(1, socket.MSG_PEEK | socket.MSG_TRUNC)
real_size = len(peeked)
if real_size <= 0:
raise TruncatedResponse("窥探到 0 字节,数据报为空")
# 阶段2:按真实长度分配,绝不截断
want = min(max(real_size, DNS_UDP_CLASSIC), DNS_UDP_MAX)
data, _anc, flags, _addr = sock.recvmsg(want, 0)
if flags & socket.MSG_TRUNC: # 兜底:报可识别错误,而不是 malformed
raise TruncatedResponse(
"UDP 数据报被截断:真实长度 %d 字节,缓冲区只有 %d 字节" % (real_size, want))
return data
配合查询里开 EDNS0:
query = dns.message.make_query(name, dns.rdatatype.from_text(rdtype))
query.use_edns(0, 0, EDNS_UDP_SIZE) # 公告"我能收 1232 字节以上"
三个设计要点:
MSG_PEEK | MSG_TRUNC拿真实长度,再按需分配 —— 这是唯一不依赖"猜一个够大的数字"的做法。- 校验
MSG_TRUNC:万一还是被截断,报TRUNCATED而不是把半截字节流喂给解析器产出误导人的malformed。 - 开 EDNS0:让服务端知道你的接收能力,别因为"可能超 512"就回 TC 位。
4.2 服务端修复:响应回显查询 ID
# 预置报文 ID 写死 0x1234;RFC 1035 要求响应回显查询 ID
payload = data[:2] + payload[2:]
4.3 EDNS0 到底解决了什么、没解决什么(重要区分)
| 问题 | 归谁管 |
|---|---|
| 「协商多大」——服务端该不该发大包 / 该不该置 TC 位 | EDNS0(/etc/resolv.conf 里 options edns0 就是这个) |
| 「收不收得全」——数据报到了本机,缓冲区够不够 | 接收方缓冲区(recv 尺寸 / SO_RCVBUF) |
两者正交,都要有。 只开 EDNS0 不修缓冲区:服务端放心地发大包,你照样截断。 只修缓冲区不开 EDNS0:合规服务端可能按 512 字节上限直接回 TC 位(要求走 TCP),你还是拿不到数据。
4.4 线上自查工具
# 方法 A(最快):字节数恰好等于缓冲区大小 -> 疑似截断
n = len(sock.recvfrom(BUF)[0])
if n == BUF:
log.warning("DNS 响应字节数 == 缓冲区大小,疑似 UDP 截断")
# 方法 B(最准):校验 MSG_TRUNC 标志
data, _, flags, _ = sock.recvmsg(BUF, 0)
if flags & socket.MSG_TRUNC:
raise RuntimeError("UDP 数据报被截断,请用 MSG_PEEK|MSG_TRUNC 动态分配缓冲")
配套脚本 app/audit_udp_buffer.py(本次新增):
docker compose exec app-client python -u audit_udp_buffer.py dns-authority 5354 big.example.com
数据报真实长度 = 5080 字节
超过的常见写死缓冲区:512, 1232, 4096
结论:有风险!凡是 recvfrom(4096) 这种固定尺寸的客户端都会拿到恰好 4096 字节的半截报文,
然后报 malformed 或静默丢数据。
退出码 1 = 有风险、0 = 安全,可以直接塞进巡检或 CI。
五、验证
docker compose down -v && docker compose up -d --build
1) 业务客户端 —— 故意仍传 CLIENT_RECV_BUFFER=4096:
{
"client_recv_buffer": 4096,
"edns_udp_size": 1232,
"ok": true,
"wire_bytes_received": 5080,
"rcode": "NOERROR",
"tc": false,
"answers": [ "…20 段各 250 字节的 TXT…" ]
}
关键:环境变量仍是 4096,但 wire_bytes_received = 5080。
证明那个数字已不再是决定性因素 —— 这是"治本"而非"把数字改大"的证明。
2) 断言脚本:
A. 客户端能收到完整响应且解析成功 PASS
B. 收到的字节数 == 服务端发送的 5080 字节 PASS
C. 响应未截断(字节数 != 客户端接收缓冲区) PASS
D. 解析不再报 malformed / 半截报文 PASS
E. 真实 dig 也能拿到同一份响应 PASS
PASS=5 FAIL=0
3) dig 正常出结果:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 13731
;; flags: qr rd ra; QUERY: 1, ANSWER: 1
;; ANSWER SECTION:
big.example.com. 60 IN TXT "aaaa…" "aaaa…" ← 20 段
id: 13731 是随机 ID,说明 ID 回声已正确。
4) 回归测试 test_internal_lookup.py: 2 项全 PASS,exit=0。
5) A/B 对照(因果证明): 用原始 buggy 代码另起一套 compose(改名避免冲突),
check_bad.py --expect-broken 得到 PASS=0 FAIL=5;修复后 PASS=5 FAIL=0。
6) 兜底检测生效: 用 4096 缓冲 recvmsg 仍能识别 MSG_TRUNC=True →
报可识别的 TRUNCATED,而不是 malformed。
六、经验与避坑
可复用的排查思路
- 「收到的字节数恰好等于缓冲区大小」是 UDP 截断的头号指纹。 见到这个等式,先怀疑截断,别先怀疑对端。
- TCP 经验会骗你。 TCP 缓冲区小只是"分几次读",UDP 是真的丢且默认不吭声。 把 TCP 的直觉套到 UDP 上,就会得出"缓冲区小一点无所谓"的致命结论。
- 报错位置 ≠ 出错位置。
DNS message is malformed看起来是"报文格式错", 实际是"数据在更底层被内核丢了"。看到一个解析类报错,先问"字节数够不够"。 recvfrom看不到MSG_TRUNC,必须用recvmsg。 这是最隐蔽的一环:API 明明有信息,但用的那个调用拿不到。- 同一现象可能有多个叠加根因。 本题
dig超时 = ID 不回显(对所有查询)+ 大响应。 用"换个输入试试"做区分实验:查短记录也超时 → 说明不是大响应的问题 → 揪出第二个 bug。 - 别急着改数字。 4096 改成 65535 能让断言变绿,但那是碰运气。
MSG_PEEK | MSG_TRUNC动态分配才是"无论 40 字节还是 65535 字节都对"。
具体避坑点
| 坑 | 说明 |
|---|---|
MSG_PEEK | MSG_TRUNC 要两步 | peek 只窥探不消费,数据报还在队列。必须再 recv 一次才真正读走 |
Python 里 recv(1, MSG_PEEK|MSG_TRUNC) 返回 bytes | 不是 C 那样的 int。用 len() 取长度(长度即真实长度) |
+bufsize=8192 只影响 dig 自己 | 管不到服务端的 ID 行为。修好一半不等于全好 |
容器里 strace 可能失败 | PTRACE_TRACEME: Operation not permitted(无 CAP_SYS_PTRACE)。改用 MSG_TRUNC 标志,证据更直接 |
沙箱里 tcpdump 可能失败 | You don't have permission to perform that capture(daemon 受限,--cap-add=NET_RAW 不生效)。改用「服务端日志说发了 N 字节 + 客户端 MSG_TRUNC 说被截断」双向夹逼 |
docker compose exec 要用 service 名 | 不是 container_name。用错会报 is not running |
改了 app/ 里的代码要重建镜像 | COPY app/ 在构建期发生,不 --build 不生效 |
别只看 SO_RCVBUF | 它默认很大(229376),那是内核收包缓冲,和 recvfrom(n) 的 n 是两回事 |
一句话总结
UDP 收到的字节数 == 你的缓冲区大小,就说明数据被内核丢了,而不是"响应就那么大"。
先 MSG_PEEK | MSG_TRUNC 取真实长度,再按需分配;同时校验 MSG_TRUNC 把截断变成可识别错误。
EDNS0 负责"协商多大",缓冲区负责"收不收得全",两件事都要做。
