UDP 转发器 100% 丢包 / 回包静默串号:connect() 是接收过滤器而不是「建立连接」,配置漂移让应答被内核静默丢弃
日期:2026-09-19(第68题) 分支:
2026-09-19-1600环境:Linux + Docker Compose + Python 3.11 / UDP socket 技术栈关键词:UDP relay、UDP 转发器、DNS 转发器、UDP connect 过滤器、UDP 丢包、recvfrom 超时、sendto 收不到应答、回包串号、静默数据污染、getsockname、getpeername、配置漂移、回读校验、无连接协议报错关键词:socket.timeout、TimeoutError: timed out、[Errno 107] Transport endpoint is not connected、RESULT: POLLUTED、FOREIGN判定结果:问题态PASS=2 FAIL=5→ 修复后PASS=7 FAIL=0
一、问题背景
内网有一台 UDP 解析转发器(relay,监听 0.0.0.0:5300),把查询转发给内部 DNS 引擎
(engine,172.31.0.30:5301),再把应答回给客户端。链路:
客户端 ──query──► relay:5300 ──forward──► engine:5301
▲ │
└────── reply ───────────┘
业务反馈三件事,凑在一起非常反常识:
- 解析基本不通:连发 3 个查询,一个都不回来,客户端要重试 5 次才勉强解析出来;
- relay 自己说它一切正常:进程在跑、端口在听、日志里
query #N ... -> forward一行不落、stats计数器一致、没有任何 ERROR; - engine 也说它一切正常:
recv #1..N -> sendto(reply, ...)明明白白, 上游确实回了每一条; - 配置看起来也没写错:
UPSTREAM_HOST=172.31.0.30完全正确,ping通、nc -u也像通; - 更诡异的是:偶尔两个客户端同时打,居然能收到一部分应答,但内容是别人的 (A 拿到了 B 的查询结果)。
三方各说各话:引擎没问题 / 转发器没报错 / 链路是通的,但业务就是不通。
二、复现步骤
git checkout 2026-09-19-1600
cd scenario/2026-09-19-1600
docker compose up -d --build
docker compose ps --format '{{.Service}}={{.State}}'
# engine=running probe=running relay=running
2.1 100% 丢包
$ docker compose exec -T probe python3 /scenario/client/probe.py --n 3 --timeout 2
sent=3 ok=0 lost=3 mismatched=0 short=0 loss_rate=100.0%
RESULT: FAIL (一条都没回来) # exit 1
2.2 关键现场:三方日志一起看
# relay:forward 有、reply 没有
$ docker logs udp68-relay | tail -4
[09:01:29] query #1 from 172.31.0.20:43625 (34B) -> forward
[09:01:31] query #1 upstream timeout (2.0s), dropped
[09:01:33] query #2 upstream timeout (2.0s), dropped
[09:01:35] query #3 upstream timeout (2.0s), dropped
# engine:上游确实回了每一条(不是上游的锅)
$ docker logs udp68-engine | tail -3
[09:01:29] recv #1 34B from 172.31.0.10:52648 -> sendto(reply, 172.31.0.10:52648)
[09:01:31] recv #2 34B from 172.31.0.10:52648 -> sendto(reply, 172.31.0.10:52648)
[09:01:33] recv #3 34B from 172.31.0.10:52648 -> sendto(reply, 172.31.0.10:52648)
2.3 取证:connect 的对端 ≠ 发包目标
$ docker compose exec -T probe python3 /scenario/client/check_source.py
== 2. UDP connect() 是「接收过滤器」,不是「建立连接」 ==
connect(172.31.0.30:5399)
getpeername() = 172.31.0.30:5399 <- 内核只收「源地址 == 这个」的包
真实发包目标 = 172.31.0.30:5301
>>> 不一致!-> 内核会把应答全部丢弃,recv() 只能等到超时(静默丢包)
2.4 第二个症状:静默串号
$ bash ops/mode.sh crosstalk
$ docker compose exec -T probe python3 /scenario/client/probe_concurrent.py --n 3 --timeout 2 --delay 0.02
[A1] FOREIGN 期望 A-1.svc.internal 却拿到 B-1.svc.internal
[A2] FOREIGN 期望 A-2.svc.internal 却拿到 B-2.svc.internal
[A3] FOREIGN 期望 A-3.svc.internal 却拿到 B-3.svc.internal
[B1] FOREIGN 期望 B-1.svc.internal 却拿到 A-2.svc.internal
...
sent=6 ok=0 timeout=0 foreign=6
RESULT: POLLUTED (6 条应答送错了客户端 —— 静默串号) # exit 3
2.5 最小实验:一句话证明「connect 是接收过滤器」
在 probe 容器里跑,不依赖 relay。关键是「上游」必须 bind 在 probe 自己的地址上:
$ docker compose exec -T probe python3 -c "
import socket
IP = '172.31.0.20' # probe 自己的地址(别照抄 172.31.0.30,那是 engine 容器)
up = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
up.bind((IP, 5301)); up.settimeout(3)
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
c.connect((IP, 5399)) # 连一个『错的』端口
print('内核认为的对端 =', c.getpeername())
c.sendto(b'ping', (IP, 5301)) # 却往『对的』端口发
data, addr = up.recvfrom(100)
print('上游(5301)收到了:', data, 'from', addr)
up.sendto(b'pong', addr)
c.settimeout(2)
try: print('客户端收到:', c.recv(100))
except socket.timeout: print('>>> 超时 —— 应答被接收过滤器静默丢弃')
"
内核认为的对端 = ('172.31.0.20', 5399)
上游(5301)收到了: b'ping' from ('172.31.0.20', 46418) # 包确实发出去了
>>> 超时 —— 应答被接收过滤器静默丢弃 # 应答回不来
对照组:完全不 connect 的 socket,同样的 sendto 目标,立刻收到:
$ docker compose exec -T probe python3 -c "
import socket
IP='172.31.0.20'
up=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); up.bind((IP,5301)); up.settimeout(3)
c=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
try: print('未 connect 的 getpeername() =', c.getpeername())
except OSError as e: print('未 connect 的 getpeername() 报错:', e)
c.sendto(b'ping', (IP,5301))
data, addr = up.recvfrom(100); up.sendto(b'pong', addr)
c.settimeout(2); print('对照组:客户端收到:', c.recvfrom(100))
"
未 connect 的 getpeername() 报错: [Errno 107] Transport endpoint is not connected
对照组:客户端收到: (b'pong', ('172.31.0.20', 5301)) # 收到了
同一个 sendto 目标,
connect过就丢、不 connect 就收 —— 到此就是可复现的物理事实。
2.6 判定基线
$ bash ops/verify.sh | tail -3
PASS=2 FAIL=5
结论:问题仍然存在(或修复不完整)
三、根本原因
坏代码长这样(relay/relay.py 问题态):
# 启动时:为了「锁定上游」,connect 一下
up = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
up.connect((UPSTREAM_HOST, UPSTREAM_PORT_CONFIGURED)) # ← 用的是**配置值** 5399
# 转发时:sendto 到**真实上游**
up.sendto(query, (UPSTREAM_HOST, UPSTREAM_PORT)) # ← 用的是**真实值** 5301
data = up.recv(4096) # ← 永远超时
有 3 个互相独立的缺陷,合起来才是完整根因:
3.1 UDP 上的 connect() 不是「建立连接」,是「给内核装接收过滤器」
connect() 在有连接的协议(TCP)上是「建立连接」;
在无连接的协议(UDP)上,它只做两件事:
- 在 socket 上记下对端地址,之后可以用
send()/recv()代替sendto()/recvfrom(); - 给内核装一个接收过滤器:只有「源地址恰好 == 记下的那个对端地址」的包, 才会被投递给这条 socket,其余一律静默丢弃。
于是:包发得出去(sendto 自己指定目标),但应答回不来
(源端口 5301 ≠ 过滤器里的 5399)。recv() 只能等到 SO_RCVTIMEO 超时 ——
不报错、不告警、什么都不留。这就是「forward 一行不落、reply 一条没有、
上游日志说它回了」的全部来源。
关键认知:「发得出去」和「收得回来」是两件事。ping 通、nc -u 通,
只证明前者。而 UDP 的接收路径上多了一道**由 connect() 隐式安装、比对完整地址
(IP 和端口)**的过滤器。一旦「配置里的上游」和「真实的上游」漂移
(迁移端口、灰度切流、多上游轮询、上游从别的地址回包),漂移的那部分流量 100% 静默丢包,
而漂移之前写的所有监控都是绿的。
3.2 UDP 没有「连接上下文」,配对信息必须自己保存
真正的串号原因在这里(不是闭包捕获错了 —— client 闭包捕获其实是对的):
def forward_once(query, seq):
up = shared_upstream_socket() # ★ 全局唯一 socket,所有线程共享
up.sendto(query, (UPSTREAM_HOST, UPSTREAM_PORT))
data, _peer = up.recvfrom(4096) # ★ 谁先抢到 recvfrom,谁拿到「下一个到达的包」
return data
reply #1 -> 172.31.0.20:59095 里的 #1 只是查询序号,不是「配对上的那条」。
上游一排队(engine_slow.py 让第 1 条延迟 0.8s),worker#1 的 recvfrom 就会读到
worker#2 那条查询的应答,然后按自己闭包里的 client 发出去 → A 拿到 B 的答案。
格式合法、内容错误、零报错 —— 比丢包危险得多。
3.3 不回读:前两个 bug 在观测上完全隐形
log(f"upstream local_addr = ... (from config, not readback)")
日志打的是配置里的值,不是内核实际用的值。「一切正常」是自己写给自己的。
正确的做法是回读:getsockname()(内核实际选的源地址)、
getpeername()(UDP 上它是过滤器条件,不是连接状态)。
四、解决办法
改动落在 3 个文件(relay/relay.py + relay/relay.env + 端口对齐),核心是三处代码。
4.1 出站 socket 不 connect,每条查询独立 socket
def per_query_socket() -> socket.socket:
"""每条查询一条**不 connect** 的独立 socket —— UDP 转发唯一正确的姿势。"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(UPSTREAM_TIMEOUT)
return s
不 connect ⇒ 内核不装任何过滤器 ⇒ 上游从任何源地址回包都收得到; 独立 socket ⇒ 每条查询有自己的接收队列,并发下不会互抢应答。
4.2 回包按查询配对,判不了就拒收(★ 这一步不能省)
def reply_belongs_to(reply, query) -> bool:
# 1) DNS 事务 ID 相同;2) 问题段(QNAME+QTYPE+QCLASS)字节相同
# 只有 16bit ID 会撞,必须加问题段二次配对
if dns_qid(reply) != dns_qid(query):
return False
return dns_question(reply) == dns_question(query)
收包循环里不匹配就丢弃并计数,绝不把别人的应答交出去:
while True:
if time.monotonic() >= deadline:
raise socket.timeout("upstream timeout")
up.settimeout(deadline - time.monotonic())
data, peer = up.recvfrom(4096)
if reply_belongs_to(data, query):
return data
STATS["foreign"] += 1
log(f"query #{seq} FOREIGN reply from {peer[0]}:{peer[1]} dropped "
f"(qid={dns_qid(data)} != {dns_qid(query)})")
发送前再校验一次,把「静默串号」变成「显式拒发」:
if not reply_belongs_to(reply, query):
STATS["foreign"] += 1
log(f"query #{seq} REFUSE to deliver unpaired reply (would be silent crosstalk)")
return
4.3 护栏:配置对账 + 回读,让漂移「上线即暴露」
def config_reconcile() -> None:
if UPSTREAM_PORT_CONFIGURED != UPSTREAM_PORT:
log(f"!! CONFIG DRIFT: 配置端口 ...{UPSTREAM_PORT_CONFIGURED} != 真实上游 ...{UPSTREAM_PORT}")
if RELAY_SOURCE_VERIFY == "1":
log("RELAY_SOURCE_VERIFY=1 -> 拒绝启动(不让 100% 静默丢包上生产)")
raise SystemExit(3)
每条查询回读内核实际生效的值:
[09:11:36] config reconcile OK: 配置端口 5301 == 真实端口 5301
[09:11:36] readback enabled: effective_src(probe) = 172.31.0.10
[09:12:11] query #1 ACTUAL local_addr = 0.0.0.0:0 peer = <unconnected> effective_src(probe) = 172.31.0.10
local_addr = 0.0.0.0:0是正常的:未 connect 的 socket 在sendto()那一刻 内核才选源地址。所以另外用一条只用来问路、绝不参与业务的探针 socket 拿到effective_src = 172.31.0.10(= 本机网卡地址,不是127.0.0.1、不是0.0.0.0)。
五、验证
$ docker compose exec -T probe python3 /scenario/client/probe.py --n 3 --timeout 2
sent=3 ok=3 lost=0 mismatched=0 short=0 loss_rate=0.0%
RESULT: PASS (全部查询都有正确应答) # exit 0
$ bash ops/mode.sh crosstalk # 慢引擎并发 = 最容易串号的场景
$ docker compose exec -T probe python3 /scenario/client/probe_concurrent.py --n 3 --timeout 2 --delay 0.02
[A1] OK rtt=801.41ms # 拿到了慢引擎的延迟,但**内容是自己的**
[A2] OK rtt=0.68ms
[B1] OK rtt=1.12ms
sent=6 ok=6 timeout=0 foreign=0
RESULT: PASS (无丢包、无串号) # exit 0
$ bash ops/verify.sh | tail -3
PASS=7 FAIL=0
结论:问题已解决
docker compose down -v 干净重建后重跑,仍为 PASS=7 FAIL=0。
反例一:还原问题态必须变红(证明判定有判别力)
$ bash fix/apply_fix.sh --restore
$ bash ops/verify.sh | tail -3
PASS=2 FAIL=5
结论:问题仍然存在(或修复不完整)
反例二:只修 connect、不修配对,串号依旧(本题最值得记的一条)
把配对校验临时注释掉,仍然使用「每条查询独立不 connect 的 socket」:
sent=6 ok=3 timeout=0 foreign=3
RESULT: POLLUTED (3 条应答送错了客户端 —— 静默串号)
「不 connect」只解决了「收不到」,没解决「收到谁的」。两条修复正交且都必需。 丢包消失了、
probe.py全绿了就以为收工,是最容易犯的错。
反例三:护栏确实拦得住配置漂移
$ sed -i 's|UPSTREAM_PORT_CONFIGURED: "5301"|UPSTREAM_PORT_CONFIGURED: "5399"|' docker-compose.yml
$ docker compose up -d --force-recreate relay && docker compose logs relay | tail -2
[09:12:36] !! CONFIG DRIFT: 配置端口 172.31.0.30:5399 != 真实上游 172.31.0.30:5301
[09:12:36] RELAY_SOURCE_VERIFY=1 -> 拒绝启动(不让 100% 静默丢包上生产)
$ docker inspect udp68-relay --format '{{.State.ExitCode}}'
3
问题态=「启动成功 + 100% 丢包 + 日志全绿」; 修复后=「启动失败 + 退出码 3 + 明确原因」。
六、经验与避坑
6.1 可复用的思路
- 「发得出去」≠「收得回来」。
ping/nc只证明前者。UDP 的接收路径上多一道 由connect()隐式安装、比对完整地址(IP + 端口)的过滤器, 它永远不会报错(TCP 的connect()失败会报错,UDP 的过滤器只会静默丢弃)。 - 判据要「回读」而不是「读配置」。
getpeername()/getsockname()读到的才算数; 日志打配置值等于自己给自己开绿灯。 - 无连接协议没有「连接上下文」,配对信息必须自己保存。「我接下来读到谁就是谁」 在并发 + 排队下必然串号。配对判据要多维度(不只 16bit ID,还要问题段/序列号)。
- 把「静默失败」改成「显式失败」:收不到 ≠ 报错,是本题最难的地方。
配置漂移时
exit 3拒绝启动,比「启动成功但 100% 丢包」好一万倍。 - 症状互斥复现:连通性 bug 会掩盖串号 bug。必须先对齐配置端口、把丢包消掉, 串号才浮得出来。排障时要有意识地把两个 bug 分开看。
6.2 避坑清单(实测踩到的)
- 最小实验里「上游」必须 bind 在 probe 自己的地址上(
172.31.0.20), 别照抄 README 里的172.31.0.30:5301—— 那是 engine 容器的地址。 我第一次写up.bind(('0.0.0.0', 5301))+sendto('172.31.0.30', 5301), 结果TimeoutError报在up.recvfrom()上(包根本没到我这个 socket), 看起来像「过滤器生效了」,其实是实验搭错了。 getsockname()在未 connect 的 socket 上返回0.0.0.0:0是正常的,不是 bug。 内核在sendto()那一刻才选源地址。mode.sh crosstalk会修改 tracked 文件(docker-compose.yml+relay/relay.env)。 用完记得git checkout或apply_fix.sh --restore,否则会把中间态提交上去。apply_fix.sh --restore会重写relay/relay.env(mode 覆盖成 0)。 「代码已修好但还是丢包」常常只是旋钮被还原了 —— 先看relay/relay.env当前值。- 判定脚本注释头可能与实际断言数不一致:本题
verify.sh头写PASS=2 FAIL=6 / PASS=8 FAIL=0,实际共 7 条断言 (A1+B1+C1+D1+D2+E1+F1),实测2/5 → 7/0,与 README 一致。 以脚本自己printf出来的实测输出为准。 reply #N这类日志里的N通常只是「收包序号」,不是「配对结果」。 看日志时不要把它当成「这条应答配对成功了」的证据。- 同类影响面(同样的坑换个场景):
- 多宿主 / 多网卡机器上
connect()选错出口 IP; - 透明代理 /
IP_TRANSPARENT/SO_BINDTODEVICE/IP_PKTINFO场景; - 上游做灰度切流、端口迁移、DNS 返回多个 A 记录轮询时;
- 任何「配置里的对端地址」与「实际发包目标」可能分叉的地方。
- 多宿主 / 多网卡机器上
6.3 一句话总结
UDP 上的 connect() 是接收过滤器,不是「建立连接」;sendto() 到哪儿、
过滤器认的是谁,两者不一致时包「发得出去、回不来、且全程静默」。
而「配置里写的地址」不算数 —— getpeername() / getsockname() 读到的才算。
另外,修好「收不到」之后,别忘了「收到谁的」是另一个独立的 bug。
附:完整复现清单
git checkout 2026-09-19-1600
cd scenario/2026-09-19-1600
docker compose up -d --build
# ① 问题态
docker compose exec -T probe python3 /scenario/client/probe.py --n 3 --timeout 2 # 100% 丢包
docker logs udp68-engine | tail -3 # 上游回了每一条
docker compose exec -T probe python3 /scenario/client/check_source.py # 对端 5399 != 目标 5301
bash ops/verify.sh # PASS=2 FAIL=5
# ② 症状二:串号
bash ops/mode.sh crosstalk
docker compose exec -T probe python3 /scenario/client/probe_concurrent.py --n 3 --timeout 2 --delay 0.02
# RESULT: POLLUTED
# ③ 修复后
bash ops/mode.sh fixed
bash ops/verify.sh # PASS=7 FAIL=0
# ④ 反例
bash fix/apply_fix.sh --restore && bash ops/verify.sh # 回到 PASS=2 FAIL=5
# ⑤ 清理
docker compose down -v
