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

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 ───────────┘

业务反馈三件事,凑在一起非常反常识:

  1. 解析基本不通:连发 3 个查询,一个都不回来,客户端要重试 5 次才勉强解析出来;
  2. relay 自己说它一切正常:进程在跑、端口在听、日志里 query #N ... -> forward 一行不落、 stats 计数器一致、没有任何 ERROR;
  3. engine 也说它一切正常:recv #1..N -> sendto(reply, ...) 明明白白, 上游确实回了每一条;
  4. 配置看起来也没写错:UPSTREAM_HOST=172.31.0.30 完全正确,ping 通、nc -u 也像通;
  5. 更诡异的是:偶尔两个客户端同时打,居然能收到一部分应答,但内容是别人的 (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)上,它只做两件事:

  1. 在 socket 上记下对端地址,之后可以用 send()/recv() 代替 sendto()/recvfrom();
  2. 给内核装一个接收过滤器:只有「源地址恰好 == 记下的那个对端地址」的包, 才会被投递给这条 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 避坑清单(实测踩到的)

  1. 最小实验里「上游」必须 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), 看起来像「过滤器生效了」,其实是实验搭错了。
  2. getsockname() 在未 connect 的 socket 上返回 0.0.0.0:0 是正常的,不是 bug。 内核在 sendto() 那一刻才选源地址。
  3. mode.sh crosstalk 会修改 tracked 文件(docker-compose.yml + relay/relay.env)。 用完记得 git checkout 或 apply_fix.sh --restore,否则会把中间态提交上去。
  4. apply_fix.sh --restore 会重写 relay/relay.env(mode 覆盖成 0)。 「代码已修好但还是丢包」常常只是旋钮被还原了 —— 先看 relay/relay.env 当前值。
  5. 判定脚本注释头可能与实际断言数不一致:本题 verify.sh 头写 PASS=2 FAIL=6 / PASS=8 FAIL=0,实际共 7 条断言 (A1+B1+C1+D1+D2+E1+F1),实测 2/5 → 7/0,与 README 一致。 以脚本自己 printf 出来的实测输出为准。
  6. reply #N 这类日志里的 N 通常只是「收包序号」,不是「配对结果」。 看日志时不要把它当成「这条应答配对成功了」的证据。
  7. 同类影响面(同样的坑换个场景):
    • 多宿主 / 多网卡机器上 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

版权声明

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

分享这篇文章