知方号

知方号

udp关于docker 容器网络下使用 UDP 协议无法通讯问题的分析和处理

一、问题背景

工作中遇到一个 docker 容器下 UDP 协议网络不通的问题,困扰了很久,也比较有意思,所以想写下来和大家分享。

我们有个应用是基于 UDP 协议的,部署上去发现无法工作,但是换成 TCP 协议是可以的(应用同时支持 UDP、TCP 协议,切换成 TCP 模式发现一切正常)。

虽然换成 TCP 能解决问题,但是我们还是想知道到底 UDP 协议在容器网络模式下为什么会出现这个问题,以防止后面其他 UDP 应用会有异常。

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

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

使用 TCP 协议没有这个问题如果 UDP 服务器监听在 eth0 ip ,而不是0.0.0.0上,也不会出现这个问题并不是所有的应用都有这个问题,我们的 DNS(dnsmasq + kubeDNS) 也是同样的部署方式,但是功能都正常

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

这篇文章就分析一下出现这个问题的原因,希望给同样遇到这个问题的读者提供些帮助。

二、重现问题

这个问题很容易重现,我的实验是在 ubuntu16.04 下用 netcat 命令完成的,其他系统应该类似。

在宿主机上通过 nc 监听 56789 端口,然后使用 bridge 网络模式,run 一个容器,在容器里面使用 nc 发数据。

第一个报文是能发送出去的,但是以后的报文虽然在网络上能看到,但是对方无法接收。

在宿主机运行 nc UDP 服务器(-u 表示 UDP 协议,-l 表示监听的端口)

$ nc -ul 56789$ ss -uan | grep 56789$ ss -an | grep 56789udp UNCONN 0 0 *:56789 *:*udp UNCONN 0 0 [::]:56789 [::]:*

注:默认没有指定绑定ip,就是监听在0.0.0.0上。

然后在同一宿主机上,启动一个容器,运行客户端:

$ docker run -it apline sh/ # nc -u 172.16.13.13 56789

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

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

172.17.0.3+----------+| eth0 |+----+-----+ | | | |+----+-----+ +----------+| docker0 | | eth0 |+----------+ +----------+172.17.0.1 172.16.13.13三、tcpdump抓包分析

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

$ sudo tcpdump -i docker0 -nn host 172.17.0.3

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

客户端先向服务器端发送 111 字符串服务器端回复 222 字符串客户端继续发送 333 字符串

抓包的结果如下,可以发现第一个报文发送出去没有任何问题。

UDP 是没有 ACK 报文的,所以客户端无法知道对方有没有收到,这里说的没有问题没有看到对应的 ICMP 差错报文。

但是第二个报文从服务端发送的报文,对方会返回一个 ICMP 告诉端口 38908 不可达;第三个报文从客户端发送的报文也是如此。以后的报文情况类似,双方再也无法进行通信了。

11:20:43.973286 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 611:20:50.102018 IP 172.17.0.1.56789 > 172.17.0.3.38908: UDP, length 611:20:50.102129 IP 172.17.0.3 > 172.17.0.1: ICMP 172.17.0.3 udp port 38908 unreachable, length 4211:20:54.503198 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 311:20:54.503242 IP 172.16.13.13 > 172.17.0.3: ICMP 172.16.13.13 udp port 56789 unreachable, length 39

而此时主机上 UDP nc 服务器并没有退出,使用 ss -uan | grep 56789 可能看到它仍然在监听着该端口。

四、 问题原因分析

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

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

为什么应答报文源地址是错误的?既然 UDP 是无状态的,内核怎么判断源地址不正确呢?主机多网络接口 UDP 源地址选择问题

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

通过搜索,发现这确实是个已知的问题。

image.png

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

为什么 UDP 和 TCP 有不同的选路逻辑呢?

因为 UDP 是无状态的协议,内核不会保存连接双方的信息,因此每次发送的报文都认为是独立的,socket 层每次发送报文默认情况不会指明要使用的源地址,只是说明对方地址。

因此,内核会为要发出去的报文选择一个 ip,这通常都是报文路由要经过的设备 ip 地址。

那么,为什么 dnsmasq 服务没有这个问题呢?于是我使用 strace 工具抓取了 dnsmasq 和出问题应用的网络 socket 系统调用,来查看它们两个到底有什么区别。

dnsmasq 在启动阶段监听了 UDP 和 TCP 的 54 端口

因为是在本地机器上测试的,为了防止和本地 DNS 监听的DNS端口冲突,我选择了 54 而不是标准的 53 端口:

socket(PF_INET, SOCK_DGRAM, IPPROTO_IP) = 4setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0bind(4, {sa_family=AF_INET, sin_port=htons(54), sin_addr=inet_addr("0.0.0.0")}, 16) = 0setsockopt(4, SOL_IP, IP_PKTINFO, [1], 4) = 0##############################################socket(PF_INET, SOCK_STREAM, IPPROTO_IP) = 5setsockopt(5, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0bind(5, {sa_family=AF_INET, sin_port=htons(54), sin_addr=inet_addr("0.0.0.0")}, 16) = 0listen(5, 5) = 0

比起 TCP,UDP 部分少了 listen,但是多个 setsockopt(4, SOL_IP, IP_PKTINFO, [1], 4) 这句。到底这两点和我们的问题是否有关,先暂时放着,继续看传输报文的部分。

dnsmasq 收包和发包的系统调用,直接使用 recvmsg 和 sendmsg 系统调用:

recvmsg(4, {msg_name(16)={sa_family=AF_INET, sin_port=htons(52072), sin_addr=inet_addr("10.111.59.4")}, msg_iov(1)=[{"315 1 11fterminal19-05u50163"..., 4096}], msg_controllen=32, {cmsg_len=28, cmsg_level=SOL_IP, cmsg_type=, ...}, msg_flags=0}, 0) = 67sendmsg(4, {msg_name(16)={sa_family=AF_INET, sin_port=htons(52072), sin_addr=inet_addr("10.111.59.4")}, msg_iov(1)=[{"315 201200111fterminal19-05u50163"..., 83}], msg_controllen=28, {cmsg_len=28, cmsg_level=SOL_IP, cmsg_type=, ...}, msg_flags=0}, 0) = 83

而出问题的 UDP 应用 strace 结果如下:

[pid 477] socket(PF_INET6, SOCK_DGRAM, IPPROTO_IP) = 124[pid 477] setsockopt(124, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0[pid 477] setsockopt(124, SOL_IPV6, IPV6_MULTICAST_HOPS, [1], 4) = 0[pid 477] bind(124, {sa_family=AF_INET6, sin6_port=htons(6088), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0[pid 477] getsockname(124, {sa_family=AF_INET6, sin6_port=htons(6088), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0[pid 477] getsockname(124, {sa_family=AF_INET6, sin6_port=htons(6088), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0[pid 477] recvfrom(124, "j20124502012422413215242321 243160f0 24142222524224"..., 2048, 0, {sa_family=AF_INET6, sin6_port=htons(38790), inet_pton(AF_INET6, "::ffff:172.17.0.3", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 168[pid 477] sendto(124, "k20222102022 2403215241321v2435333TDH2442202024032"..., 533, 0, {sa_family=AF_INET6, sin6_port=htons(38790), inet_pton(AF_INET6, "::ffff:172.17.0.3", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 533

其对应的逻辑是这样的:使用 ipv6 绑定在 0.0.0.0 和 6088 端口,调用 getsockname 获取当前 socket 绑定的端口信息,数据传输过程使用的是 recvfrom 和 sendto。

对比下来,两者的不同有几点:

后者使用的是 ipv6,而前者是 ipv4后者使用 recvfrom 和 sendto 传输数据,而前者是 sendmsg 和 recvmsg前者有调用 setsockopt 设置 IP_PKTINFO 的值,而后者没有

因为是在传输数据的时候出错的,因此第一个疑点是 sendmsg 和 sendto 的某些区别导致选择源地址有不同,通过 man sendto 可以知道 sendmsg 包含了

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至lizi9903@foxmail.com举报,一经查实,本站将立刻删除。