Docker容器网络下UDP协议的一个问题

本文涉及的产品
容器镜像服务 ACR,镜像仓库100个 不限时长
简介:

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

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

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

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

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

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

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

问题重现

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

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

 
  1. $ nc -ul 56789 

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

 
  1. $ docker run -it apline sh 
  2. / # 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。

 
  1. 172.17.0.3 
  2. +----------+ 
  3. |   eth0   | 
  4. +----+-----+ 
  5.      | 
  6.      | 
  7.      | 
  8.      | 
  9. +----+-----+          +----------+ 
  10. | docker0  |          |  eth0    | 
  11. +----------+          +----------+ 
  12. 172.17.0.1            172.16.13.13 

tcpdump 抓包

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

 
  1. $ tcpdump -i docker0 -nn host 172.17.0.3 

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

  1. 客户端先向服务器端发送 hello 字符串
  2. 服务器端回复 world
  3. 客户端继续发送 hi 消息

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

 
  1. 11:20:43.973286 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 6 
  2. 11:20:50.102018 IP 172.17.0.1.56789 > 172.17.0.3.38908: UDP, length 6 
  3. 11:20:50.102129 IP 172.17.0.3 > 172.17.0.1: ICMP 172.17.0.3 udp port 38908 unreachable, length 42 
  4. 11:20:54.503198 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 3 
  5. 11: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 服务器并没有退出,使用 lsof -i :56789 可能看到它仍然在监听着该端口。

问题原因

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

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

  1. 为什么应答报文源地址是 错误的 ?
  2. 既然 UDP 是无状态的,内核怎么判断源地址不正确呢?

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

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

通过搜索,发现这确实是个已知的问题。在 UNP() 这本书中,已经描述过这个问题,下面是对应的内容:

Docker容器网络下UDP协议的一个问题

这个问题可以归结为一句话:UDP 在多网卡的情况下,可能会发生服务器端源地址不对的情况,这是内核选路的结果。 为什么 UDP 和 TCP 有不同的选路逻辑呢?因为 UDP 是无状态的协议,内核不会保存连接双方的信息,因此每次发送的报文都认为是独立的,socket 层每次发送报文默认情况不会指明要使用的源地址,只是说明对方地址。因此,内核会为要发出去的报文选择一个 ip,这通常都是报文路由要经过的设备 ip 地址。

有了这个原因,还要解释一下问题: 为什么 dnsmasq 服务没有这个问题呢 ?因此我使用 strace 工具抓取了 dnsmasq 和出问题应用的网络 socket 系统调用,来查看它们两个到底有什么区别。

dnsmasq 在启动阶段监听了 UDP 和 TCP 的 54 端口(因为是在本地机器上测试的,为了防止和本地 DNS 监听的 DNS端口冲突,我选择了 54 而不是标准的 53 端口):

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

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

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

 
  1. 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\n\1 \0\1\0\0\0\0\0\1\fterminal19-0\5u5016\3"..., 4096}], msg_controllen=32, {cmsg_len=28, cmsg_level=SOL_IP, cmsg_type=, ...}, msg_flags=0}, 0) = 67  
  2. sendmsg(4, {msg_name(16)={sa_family=AF_INET, sin_port=htons(52072), sin_addr=inet_addr("10.111.59.4")}, msg_iov(1)=[{"\315\n\201\200\0\1\0\1\0\0\0\1\fterminal19-0\5u5016\3"..., 83}], msg_controllen=28, {cmsg_len=28, cmsg_level=SOL_IP, cmsg_type=, ...}, msg_flags=0}, 0) = 83 

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

 
  1. [pid   477] socket(PF_INET6, SOCK_DGRAM, IPPROTO_IP) = 124 
  2. [pid   477] setsockopt(124, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0 
  3. [pid   477] setsockopt(124, SOL_IPV6, IPV6_MULTICAST_HOPS, [1], 4) = 0 
  4. [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 
  5.  
  6. [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 
  7. [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 
  8.  
  9. [pid   477] recvfrom(124, "j\201\2450\201\242\241\3\2\1\5\242\3\2\1\n\243\0160\f0\n\241\4\2\2\0\225\242\2\4\0"..., 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 
  10.  
  11. [pid   477] sendto(124, "k\202\2\0210\202\2\r\240\3\2\1\5\241\3\2\1\v\243\5\33\3TDH\244\0220\20\240\3\2"..., 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 包含了更多的控制信息在 msghdr 。一个合理的猜测是 msghdr 中包含了内核选择源地址的信息!

通过查找,发现 IP_PKTINFO 这个选项就是让内核在 socket 中保存 IP 报文的信息,当然也包括了报文的源地址和目的地址。 IP_PKTINFO 和 msghdr 的关系可以在这个 stackoverflow 中找到:https://stackoverflow.com/questions/3062205/setting-the-source-ip-for-a-udp-socket。

而 man 7 ip 文档中也说明了 IP_PKTINFO 是怎么控制源地址选择的:

 
  1. IP_PKTINFO (since Linux 2.2) 
  2.               Pass  an  IP_PKTINFO  ancillary message that contains a pktinfo structure that supplies some information about the incoming packet.  This only works for datagram ori‐ 
  3.               ented sockets.  The argument is a flag that tells the socket whether the IP_PKTINFO message should be passed or not.  The message itself can only be sent/retrieved as 
  4.               control message with a packet using recvmsg(2) or sendmsg(2). 
  5.  
  6.                   struct in_pktinfo { 
  7.                       unsigned int   ipi_ifindex;  /* Interface index */ 
  8.                       struct in_addr ipi_spec_dst; /* Local address */ 
  9.                       struct in_addr ipi_addr;     /* Header Destination 
  10.                                                       address */ 
  11.                   }; 
  12.  
  13.               ipi_ifindex  is the unique index of the interface the packet was received on.  ipi_spec_dst is the local address of the packet and ipi_addr is the destination address 
  14.               in the packet header.  If IP_PKTINFO is passed to sendmsg(2) and ipi_spec_dst is not zero, then it is used as the local source address for the  routing  table  lookup 
  15.               and  for  setting up IP source route options.  When ipi_ifindex is not zero, the primary local address of the interface specified by the index overwrites ipi_spec_dst 
  16.               for the routing table lookup. 

如果 ipi_spec_dst 和 ipi_ifindex 不为空,它们都能作为源地址选择的依据,而不是让内核通过路由决定。

也就是说,通过设置 IP_PKTINFO socket 选项为 1,然后使用 recvmsg 和 sendmsg 传输数据就能保证源地址选择符合我们的期望。这也是 dnsmasq 使用的方案,而出问题的应用是因为使用了默认的 recvfrom 和 sendto 。

关于 UDP 连接的疑惑

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

因为 conntrack,内核的 netfilter 模块会保存连接的状态,并作为防火墙设置的依据。它保存的 UDP 连接,只是简单记录了主机上本地 ip 和端口,和对端 ip 和端口,并不会保存更多的内容。

可以参考 intables info 网站的文章:http://www.iptables.info/en/connection-state.html#UDPCONNECTIONS。

在找到根源之前,我们曾经尝试过用 SNAT 来修改服务端应答报文的源地址,期望能够修复该问题。但是却发现这种方法行不通,为什么呢?

因为 SNAT 是在 netfilter 最后做的,在之前 netfilter 的 conntrack 因为不认识该 connection,直接丢弃了,所以即使添加了 SNAT 也是无法工作的。

那能不能把 conntrack 功能去掉呢?比如解决方案:

 
  1. iptables -I OUTPUT -t raw -p udp --sport 5060 -j CT --notrack 
  2. iptables -I PREROUTING -t raw -p udp --dport 5060 -j CT --notrack 

答案也是否定的,因为 NAT 需要 conntrack 来做翻译工作,如果去掉 conntrack 等于 SNAT 完全没用。

解决方案

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

使用 TCP 协议

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

 
  1. $ nc -l 56789 

监听在特定端口

使用 nc 启动一个 udp 服务器,监听在 eth0 上:

 
  1.  ~ nc -ul 172.16.13.13 56789 

nc 可以跟两个参数,分别代表 ip 和 端口,表示服务端监听在某个特定 ip 上。如果接收到的报文目的地址不是 172.16.13.13,也会被内核直接丢弃。

这种情况下,服务端和客户端也能正常通信。

改动应用程序实现

修改应用程序的逻辑,在 UDP socket 上设置 IP_PKTIFO ,并通过 recvmsg 和 sendmsg 函数传输数据。


作者:佚名

来源:51CTO

相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
基于阿里云,构建一个企业web应用上云经典架构,让IT从业者体验企业级架构的实战训练。
相关文章
|
3天前
|
存储 安全 数据安全/隐私保护
【Docker 专栏】Docker 容器化应用的备份与恢复策略
【5月更文挑战第9天】本文探讨了Docker容器化应用的备份与恢复策略,强调了备份在数据保护、业务连续性和合规要求中的关键作用。内容涵盖备份的重要性、内容及方法,推荐了Docker自带工具和第三方工具如Portainer、Velero。制定了备份策略,包括频率、存储位置和保留期限,并详细阐述了恢复流程及注意事项。文章还提及案例分析和未来发展趋势,强调了随着技术发展,备份与恢复策略将持续演进,以应对数字化时代的挑战。
【Docker 专栏】Docker 容器化应用的备份与恢复策略
|
3天前
|
监控 Kubernetes Docker
【Docker 专栏】Docker 容器内应用的健康检查与自动恢复
【5月更文挑战第9天】本文探讨了Docker容器中应用的健康检查与自动恢复,强调其对应用稳定性和系统性能的重要性。健康检查包括进程、端口和应用特定检查,而自动恢复则涉及重启容器和重新部署。Docker原生及第三方工具(如Kubernetes)提供了相关功能。配置检查需考虑检查频率、应用特性和监控告警。案例分析展示了实际操作,未来发展趋势将趋向更智能和高效的检查恢复机制。
【Docker 专栏】Docker 容器内应用的健康检查与自动恢复
|
1天前
|
NoSQL Redis Docker
Mac上轻松几步搞定Docker与Redis安装:从下载安装到容器运行实测全程指南
Mac上轻松几步搞定Docker与Redis安装:从下载安装到容器运行实测全程指南
12 0
|
2天前
|
网络协议 算法 Java
【Java网络编程】网络编程概述、UDP通信(DatagramPacket 与 DatagramSocket)
【Java网络编程】网络编程概述、UDP通信(DatagramPacket 与 DatagramSocket)
15 3
|
3天前
|
缓存 关系型数据库 数据库
【Docker 专栏】Docker 与容器化数据库的集成与优化
【5月更文挑战第9天】本文探讨了Docker与容器化数据库集成的优势,如快速部署、环境一致性、资源隔离和可扩展性,并列举了常见容器化数据库(如MySQL、PostgreSQL和MongoDB)。讨论了集成方法、注意事项、优化策略,包括资源调整、缓存优化和监控告警。此外,强调了数据备份、恢复测试及性能评估的重要性。未来,随着技术发展,二者的集成将更紧密,为数据管理带来更多可能性。掌握此技术将应对数字化时代的机遇与挑战。
【Docker 专栏】Docker 与容器化数据库的集成与优化
|
3天前
|
存储 安全 数据库
【Docker 专栏】Docker 容器内应用的状态持久化
【5月更文挑战第9天】本文探讨了Docker容器中应用状态持久化的重要性,包括数据保护、应用可用性和历史记录保存。主要持久化方法有数据卷、绑定挂载和外部存储服务。数据卷是推荐手段,可通过`docker volume create`命令创建并挂载。绑定挂载需注意权限和路径一致性。利用外部存储如数据库和云服务可应对复杂需求。最佳实践包括规划存储策略、定期备份和测试验证。随着技术发展,未来将有更智能的持久化解决方案。
【Docker 专栏】Docker 容器内应用的状态持久化
|
3天前
|
机器学习/深度学习 监控 Kubernetes
【Docker 专栏】Docker 容器内服务的自动扩展与缩容
【5月更文挑战第9天】本文探讨了Docker容器服务的自动扩展与缩容原理及实践,强调其在动态业务环境中的重要性。通过选择监控指标(如CPU使用率)、设定触发条件和制定扩展策略,实现资源的动态调整。方法包括云平台集成和使用Kubernetes等框架。实践中,电商平台和实时数据处理系统受益于此技术。注意点涉及监控数据准确性、扩展速度和资源分配。未来,智能算法将提升扩展缩容的效率和准确性,成为关键技术支持。
【Docker 专栏】Docker 容器内服务的自动扩展与缩容
|
3天前
|
Java 数据库连接 Docker
【Docker 专栏】Docker 容器内环境变量的管理与使用
【5月更文挑战第9天】本文介绍了Docker容器中环境变量的管理与使用,环境变量用于传递配置信息和设置应用运行环境。设置方法包括在Dockerfile中使用`ENV`指令或在启动容器时通过`-e`参数设定。应用可直接访问环境变量或在脚本中使用。环境变量作用包括传递配置、设置运行环境和动态调整应用行为。使用时注意变量名称和值的合法性、保密性和覆盖问题。理解并熟练运用环境变量能提升Docker技术的使用效率和软件部署质量。
【Docker 专栏】Docker 容器内环境变量的管理与使用
|
3天前
|
运维 安全 Linux
深入理解Docker自定义网络:构建高效的容器网络环境
深入理解Docker自定义网络:构建高效的容器网络环境
|
3天前
|
存储 弹性计算 运维
Docker数据集与自定义镜像:构建高效容器的关键要素
Docker数据集与自定义镜像:构建高效容器的关键要素