深入理解UDP


最近在工作中遇到一个 docker 容器下 UDP 协议网络不通的问题,困扰了很久,也比较有意思,某一天等红绿灯的时候突然茅塞顿开,所以再此记录一下。

我们有个应用是 UDP 协议的,部署上去发现无法工作,但是换成 TCP 协议是可以的(应用同时支持 UDP、TCP 协议,切换成 TCP 模式发现一切正常)。虽然换成 TCP 能解决问题,但是我们还是想知道到底 UDP 协议为什么会出现这个问题,以防止后面其他 UDP 应用会有异常。

这个问题抽象出来是这样的:如果有 UDP 服务运行在主机上,并且监听在 0.0.0.0 地址(也就是所有的 ip 地址),从运行在 docker bridge 网络的容器运行客户端访问服务,两者通信有问题。

注意以上的的限制条件,通过测试,我们发现下来几种情况都是正常的:

  • 使用 TCP 协议没有这个问题,这个已经说过了

  • 如果 UDP 服务器监听在 eth0 IP 地址上也不会出现这个问题

  • 并不是所有的应用都有这个问题,我们的 DNS(dnsmasq + kubeDNS) 也是同样的部署方式,但是功能都正常

这个问题在 docker 上也有 issue 记录:https://github.com/moby/moby/issues/15127 ,但是目前并没有很好的解决方案。

这篇文章就分析一下出现这个问题的原因。

问题重现

这个问题很容易重现,我的实验是在 red hat7.5 下用 netcat 命令完成的,其他系统应该类似。在主机上通过 nc 监听 5678 端口,然后在容器里使用 nc 发数据。第一个报文是能发送出去的,但是以后的报文虽然在网络上能看到,但是对方无法接收。

在主机上运行 nc UDP 服务器

# nc -ul 5678

然后随便启动一个容器,运行客户端

/ # nc -u 192.168.106.242 5678

nc 的通信是双方的,不管对方输入什么字符,回车后对方就能立即收到。但是在这个模式下,客户端第一次输入对方能够收到,后续的报文对方都收不到。

在这个实验中,容器使用的是 docker 的默认网络,容器的 ip 是 172.17.0.2,通过 veth pair(图中没有显示)连接到虚拟网桥 docker0(ip 地址为 172.17.0.1),主机本身的网络为 eth0,其 ip 地址为 192.168.106.242

 172.17.0.2
+----------+
 |   eth0   |
+----+-----+
     |
     |
     |
     |
+----+-----+          +----------+
 | docker0  |           |  eth0    |
+----------+          +----------+
172.17.0.1            192.168.106.242

tcpdump 抓包

遇到这种疑难杂症,第一个想到的抓包,我们需要在 docker0 上抓包,因为这是报文必经过的地方。通过过滤容器的 ip 地址,很容易找到感兴趣的报文:

# tcpdump -i docker0 -n host 172.17.0.2

为了模拟多数应用一问一答的通信方式,我们一共发送三个报文,并用 tcpdump 抓取 docker0 接口上的报文:

  1. 客户端先向服务器端发送 hello

  2. 服务器端回复 world

  3. 客户端继续发送 hi 消息

抓包的结果如下,可以发现第一个报文发送出去没有任何问题(因为 UDP 是没有 ACK 报文的,所以客户端无法知道对方有没有收到,这里说的没有问题是指没有对应的 ICMP 报文),但是第二个报文从服务端发送的报文,对方会返回一个 ICMP 告诉端口 44182 不可达;第三个报文从客户端发送的报文也是如此。以后的报文情况类似,双方再也无法进行通信了。

tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on docker0, link-type EN10MB (Ethernet), capture size 262144 bytes
14:48:42.514345 IP 172.17.0.2.44182 > 192.168.106.242.rrac: UDP, length 6
14:48:47.523098 ARP, Request who-has 172.17.0.1 tell 172.17.0.2, length 28
14:48:47.523144 ARP, Reply 172.17.0.1 is-at 02:42:0c:b4:3f:ee, length 28
14:48:52.325946 IP 172.17.0.1.rrac > 172.17.0.2.44182: UDP, length 6
14:48:52.325994 IP 172.17.0.2 > 172.17.0.1: ICMP 172.17.0.2 udp port 44182 unreachable, length 42

问题原因

从网络报文的分析中可以看到服务端返回的报文源地址不是我们预想的 eth0 地址,而是 docker0 的地址,而客户端直接认为该报文是非法的,返回了 ICMP 的报文给对方。

那么问题的原因也可以分为两个部分:

  1. 为什么应答报文源地址是错误的?

  2. 既然 UDP 是无状态的,内核怎么判断源地址不正确呢?

主机多网络接口 UDP 源地址选择问题

第一个问题的关键词是:UDP 和多网络接口。因为如果主机上只有一个网络接口,发出去的报文源地址一定不会有错;而我们也测试过 TCP 协议是能够处理这个问题的。

通过搜索,发现这确实是个已知的问题。在 «TCP/IP详解» 这本书中,已经描述过这个问题,下面是对应的内容:

这个问题可以归结为一句话:UDP 在多网卡的情况下,可能会发生服务器端源地址不对的情况,这是内核选路的结果。

为什么 UDP 和 TCP 有不同的选路逻辑呢?因为 UDP 是无状态的协议,内核不会保存连接双方的信息,因此每次发送的报文都认为是独立的,socket 层每次发送报文默认情况不会指明要使用的源地址,只是说明对方地址。因此,内核会为要发出去的报文选择一个 ip,这通常都是报文路由要经过的设备 ip 地址。

既然这样,聪明的你可能要问为什么 dnsmasq 服务没有这个问题呢?这个就涉及到 socket 系统调用问题了,我也不会

关于 UDP 连接的疑惑

第二个问题是:为什么内核会把源地址和之前不同的报文丢弃?认为它是非法的?因为我们前面已经说过,UDP 协议是无连接的,默认情况下 socket 也不会保存双方连接的信息。即使服务端发送报文的源地址有误,只要对方能正常接收并处理,也不会导致网络不通。

那是因为 conntrack,内核的 netfilter 模块会保存连接的状态(也就是俗称的 iptables ),并作为防火墙设置的依据。其实和状态防火墙一个道理,它保存的 UDP 连接,只是简单记录了主机上本地 ip 和端口,和对端 ip 和端口,并不会保存更多的内容。

解决方案

知道了问题的原因,解决方案也就很容易找到。

  1. 使用 TCP 协议:如果服务端和客户端使用 TCP 协议进行通信,它们之间的网络是正常的。

  2. 监听在特定网卡:# nc -ul 192.168.106.242 5678 这种情况下,服务端和客户端也能正常通信。

  3. 改动应用程序实现