最近在工作中遇到docker容器下UDP协议网络故障的问题。困扰了我很久,也挺有意思的,所以想写下来分享给大家。我们有一个使用UDP协议的应用程序。部署后发现不能用,但是切换到TCP协议是可以的(应用同时支持UDP和TCP协议,切换到TCP模式后一切正常)。虽然切换到TCP可以解决问题,但是我们还是想知道网络模式下的UDP协议为什么会出现这个问题,以免后面其他UDP应用出现异常。这个问题抽象成这样:如果有一个UDP服务运行在宿主机上(或者运行在网络模型为Host的容器中),监听0.0.0.0地址(也就是所有ip地址),从runningondockerbridge网络的容器运行客户端访问服务,两者之间的通信出现问题。注意以上限制。通过测试,我们发现以下几种情况是正常的:使用TCP协议时不会出现该问题。这个前面已经说了,如果UDP服务器监听eth0IP地址,就不会出现这个问题。所有的应用程序都有这个问题。我们的DNS(dnsmasq+kubeDNS)也是这样部署的,但是功能正常。这个问题在docker上也有issue记录:https://github.com/moby/moby/issues/15127,但是目前没有合理的解决方案。本文分析出现该问题的原因,希望能为同样遇到该问题的读者提供一些帮助。问题重现此问题很容易重现。我的实验是在ubuntu16.04下用netcat命令完成的。其他系统应该类似。在宿主机上通过nc监听56789端口,然后在容器中使用nc发送数据。第一条消息可以发送出去,但是后面的消息虽然可以在网络上看到,但是对方是收不到的。在主机上运行ncUDP服务器(-u表示UDP协议,-l表示监听端口)$nc-ul56789然后启动一个容器,运行客户端:$dockerrun-itaplinesh/#nc-u172.16.13.1356789nc的通信对于双方来说,无论对方输入什么字符,对方回车后都能立即收到。但是在这种模式下,对方可以接收到客户端的第一个输入,但是对方无法接收到后续的消息。本次实验中容器使用docker默认网络,容器ip为172.17.0.3,通过vethpair(图中未显示)连接到虚拟网桥docker0(ip地址为172.17.0.1)),而主机本身的网络是eth0,它的ip地址是172.16.13.13。172.17.0.3+---------+|eth0|+----+-----+||||+----+-----++---------+|docker0||eth0|+------------++--------+172.17.0.1172.16.13.13tcpdump抓包遇到这样疑难杂症,第一个想到的抓包,我们需要在docker0上抓包,因为这是数据包必经的地方。通过过滤容器的ip地址,容器很容易找到感兴趣的消息:$tcpdump-idocker0-nnhost172.17.0.3为了模拟大多数应用程序问答的通信模式,我们发送一共三个消息的集合,用tcpdump抓包到docker0接口上:客户端先向服务器发送hello字符串。服务器回复世界。客户端继续发送hi消息。没有ACK报文,客户端无法知道对方是否收到。这里的问题是没有对应的ICMP报文),但是第二个报文是从服务器发出来的,对方会返回一个ICMP告诉38908端口不可达;从客户端发送的第三条消息也是如此。后续消息的情况类似,双方无法再通信。11:20:43.973286IP172.17.0.3.38908>172.16.13.13.56789:UDP,length611:20:50.102018IP172.17.0.1.56789>172.17.0.3.38908:UDP,length611:20.17.17.7.7>1022121.0.1:ICMP172.17.0.3udpport38908unreachable,length4211:20:54.503198IP172.17.0.3.38908>172.16.13.13.56789:UDP,length311:20:54.503242IP172.16.13.13>172.17.0.3:ICMP172.16.13.13udpport56789unreachable,length39此时宿主机上的UDPncserver并没有退出,可以使用lsof-i:56789看到它还在监听端口。问题原因通过分析网络数据包可以看出,服务器返回的数据包的源地址并不是我们期望的eth0地址,而是docker0的地址,客户端直接认为这个数据包是非法的并返回一个ICMP数据包。发短信给对方。那么问题的原因也可以分为两部分:为什么回复报文的源地址不对?由于UDP是无状态的,内核如何判断源地址不正确呢?主机上多个网络接口的UDP源地址选择问题***这道题的关键词是:UDP和多个网络接口。因为如果主机上只有一个网络接口,发送报文的源地址一定不会错;而且我们也测试过TCP协议可以处理这个问题。通过搜索,发现这确实是一个已知问题。在UNP()一书中,已经描述了这个问题,对应的内容如下:这个问题可以用一句话来概括:在多网卡的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)=0socket(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)=0UDP相比TCP少了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\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)=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\n\201\200\0\1\0\1\0\0\0\1\fterminal19-0\5u5016\[pid477]套接字(PF_INET6,SOCK_DGRAM,IPPROTO_IP)=124[pid477]setsockopt(124,SOL_IPV6,IPV6_V6ONLY,[0],4)=0[pid477]setsockopt(124,SOL_IPV6,IPV6_MULTICAST_HOPS,[1],4)=0[pid477]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[pid477]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[pid477]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[pid477]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[pid477]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有些不同导致源地址不同。通过mansendto可以知道sendmsg在msghdr中包含了更多的控制信息。一个合理的猜测是msghdr包含了内核选择的源地址的信息!查找后发现IP_PKTINFO选项是让内核在socket中保存IP报文的信息,当然还包括报文的源地址和目的地址。IP_PKTINFO和msghdr的关系可以在这个stackoverflow中找到:https://stackoverflow.com/questions/3062205/setting-the-source-ip-for-a-udp-socket。而man7ip文档中也说明了IP_PKTINFO是怎么控制源地址选择的:IP_PKTINFO(sinceLinux2.2)PassanIP_PKTINFOancillarymessagethatcontainsapktinfostructurethatsuppliessomeinformationabouttheincomingpacket.Thisonlyworksfordatagramori‐entedsockets.TheargumentisaflagthattellsthesocketwhethertheIP_PKTINFOmessageshouldbepassedornot.Themessageitselfcanonlybesent/retrievedascontrolmessagewithapacketusingrecvmsg(2)orsendmsg(2).structin_pktinfo{unsignedintipi_ifindex;/*Interfaceindex*/structin_addripi_spec_dst;/*Localaddress*/structin_addripi_addr;/*HeaderDestinationaddress*/};ipi_ifindexistheuniqueindexoftheinterfacethepacketwasreceivedon.ipi_spec_dstisthelocaladdressofthepacketandipi_addristhedestinationaddressinthepacketheader.IfIP_PKTINFOispassedtosendmsg(2)andipi_spec_dstisnotzero,thenitisusedasthelocalsourceaddressfortheroutingtablelookupandforsettingupIPsourcerouteoptions.Whenipi_ifindexisnotzero,theprimarylocaladdressoftheinterfacespecifiedbytheindexoverwritesipi_spec_dstfo路由表查找。如果ipi_spec_dst和ipi_ifindex不为空,可以作为源地址选择的依据,而不是让内核通过路由来决定。即通过设置IP_PKTINFO套接字选项为1,然后使用recvmsg和sendmsg传输数据,可以保证源地址的地址选择符合我们的预期。这也是dnsmasq使用的方案,有问题的应用程序使用默认的recvfrom和sendto。关于UDP连接的疑惑另一个疑惑是:为什么内核会丢弃源地址与之前不同的数据包?认为这是非法的?因为前面我们说过,UDP协议是无连接的,socket默认是不连接的。双方的连接信息将被保存。即使服务器发送的报文源地址错误,只要对方能正常接收和处理,就不会造成网络故障。因为有了conntrack,内核的netfilter模块会保存连接的状态,作为防火墙设置的依据。它保存的UDP连接只是简单记录本机ip和端口,以及对端ip和端口,并没有保存更多的内容。可以参考intablesinfo网站上的文章:http://www.iptables.info/en/connection-state.html#UDPCONNECTIONS。在查找根本原因之前,我们尝试使用SNAT修改服务器回复报文的源地址,希望能够解决问题。但是我发现这个方法不管用,为什么?因为SNAT是在netfilter***里做的,之前netfilter的conntrack不知道这个连接,就直接丢弃了,所以即使加了SNAT也不行。conntrack功能可以去掉吗?例如解决方案:iptables-IOUTPUT-traw-pudp--sport5060-jCT--notrackConntrack用于翻译。如果去掉conntrack,SNAT就完全没用了。解决方案了解问题的原因可以很容易地找到解决方案。使用TCP协议如果服务器和客户端使用TCP协议进行通信,那么它们之间的网络是正常的。$nc-l56789monitoronaspecificport使用nc启动一个udp服务器,在eth0上监听:?~nc-ul172.16.13.1356789nc后面可以跟两个参数,分别代表ip和port,表示服务器监听在一个具体ip上级。如果收到报文的目的地址不是172.16.13.13,也会被内核直接丢弃。这样的话,服务端和客户端也可以正常通信了。修改应用实现修改应用的逻辑,在UDP套接字上设置IP_PKTIFO,通过recvmsg和sendmsg函数传输数据。
