#220112 UDP

Unimog 的 UDP 处理策略

首先,对于像 DNS 这种简单的 请求响应 式服务,仅均衡负载,不维护状态,转发表每个槽位中只有一跳,完全无状态。

而对于复杂的像 QUIC 这种需要维护连接状态的服务。

  • 在 director 上会有一个 QUIC 协议的 dissector 提取网络包中的 connection id 替代默认的 IP 端口来计算到转发表的哈希。

  • 第二跳逻辑和 TCP 不一样,TCP 有 SYN 包来区分是 新连接 还是 已建立连接(established) 的包,SYN 包直接在第一跳接收,UDP 不行。UDP 只能通过内核接口判断是 新连接 的包还是 已建立连接 的包。所以 UDP 包在正常情况下包都需要过两跳。

    UDP 包在转发时,第一跳 / 第二跳分别对应转发表中的第二跳 / 第一跳。转发表没有变更的情况下所有包都是在转发的第二跳(转发表的第一跳)上被接收的。只有某一个槽位有变更的时候,转发表的第一跳变成转发表的第二跳,也就成了转发的第一跳,此时老连接上的包才会在转发的第一跳上被接收。维护了老连接,保证了老连接不会断,相对于 TCP,额外开销不小。

  • 上面第二跳的逻辑要工作,对 UDP Server 有要求,需要 UDP Server 建立 connected udp sockets。

../_images/udp-forward-cf.png

bpf 中使用 bpf_sk_lookup_udp 判断是 新连接 的包还是 已建立连接 的包。

https://github.com/torvalds/linux/blob/v5.16/tools/testing/selftests/bpf/progs/test_cls_redirect.c#L683

查看其调用链可以发现最后调用的 __udp4_lib_lookup__udp6_lib_lookup 这两个函数。

bpf_sk_lookup_udp
➡️ bpf_sk_lookup
  ➡️ bpf_skc_lookup
    ➡️ __bpf_skc_lookup
      ➡️ sk_lookup
        ➡️ __udp4_lib_lookup
        ➡️ __udp6_lib_lookup

什么是 connected udp sockets

connected udp sockets 指的是在读写 socket 之前调用 connect 系统调用 “建立” 和对端的连接。

对于客户端来说,使用下面的方式建立的 socket 就是 connected 的,比较好理解。

c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
c.connect(('127.0.0.1', 1234))
c.send(b"hello world!")
data = cd.recv(2048)

而我们大部分情况下看到的 udp 服务端的实现基本都是 unconnected 的,比如下面这段代码。

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('127.0.0.1', 1234))

while True:
    msg, addr = s.recvfrom(1024)
    print(addr, msg)
    s.sendto(msg, addr)

我们需要对该服务端做如下改造,才能使上面的第二跳逻辑可以工作。

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', 1234))

while True:
    msg, addr = s.recvfrom(1024)
    print(msg)

    c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    c.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    c.bind(('127.0.0.1', 1234))
    c.connect(addr)
    c.send(msg)
    while True:
        msg = c.recv(1024)
        print(msg)
        c.send(msg)

上面的代码比较糙,只能处理一条连接,通过 ss 命令可以看到这条 established 的连接(ESTAB 那一行),前面 unconnected 的版本只能看到看到 UNCONN 那一行。

# ss -panu|grep python
ESTAB  0      0          127.0.0.1:1234     127.0.0.1:57346 users:(("python",pid=52309,fd=4))
UNCONN 0      0          127.0.0.1:1234       0.0.0.0:*     users:(("python",pid=52309,fd=3))

UDP 的 connect 调用并不会实际的去和对端服务器建立连接,所以上面说建立连接的时候都是加引号的,调用 connect 不会和对端有任何的网络包交互,只是告诉内核某一个 socket 的对端是哪,而不需要每次都得调用 recvfrom,sendto 获得 / 传递对端的 IP 和端口。


使用 nc 来模拟 connected udp socket

# 服务端
nc -ul IP PORT

# 客户端
nc -u IP PORT

注意,这里的 IP 不能通配的 0.0.0.0

nc 中只需处理一条连接,所以不需要再创建新的 socket,直接在原本的 server socket 上 connect 就可以了,详细见:https://github.com/openbsd/src/blob/master/usr.bin/nc/netcat.c#L609


nginx 中 udp 使用的 unconnected socket。

另一种处理策略

Unimog 处理策略中,正常情况下,UDP 的包都需要经过 2 跳,额外开销太大,我们可以稍微修改下策略,让大部分的包在第一跳解决,修改如下:

转发的第一跳和第二跳还是和转发表一致,但是增加一个第三跳,第三跳还是转发表中的第一跳,如果第二跳上检查没发现对应的连接,那么是新连接的包,继续转发给第三跳(也就是第一跳)去处理。那么这个连接上后续的包就会在第一跳上直接被处理了。

../_images/udp-forward.png