一、問題背景
工作中遇到一個 docker 容器下 UDP 協(xié)議網絡不通的問題,困擾了很久嚣鄙,也比較有意思,所以想寫下來和大家分享串结。
我們有個應用是基于 UDP 協(xié)議的哑子,部署上去發(fā)現(xiàn)無法工作,但是換成 TCP 協(xié)議是可以的(應用同時支持 UDP肌割、TCP 協(xié)議卧蜓,切換成 TCP 模式發(fā)現(xiàn)一切正常)。
雖然換成 TCP 能解決問題声功,但是我們還是想知道到底 UDP 協(xié)議在容器網絡模式下為什么會出現(xiàn)這個問題烦却,以防止后面其他 UDP 應用會有異常。
這個問題抽象出來是這樣的:如果有 UDP 服務運行在宿主機上(或者運行在網絡模型為 host 的容器里)先巴,并且監(jiān)聽在 0.0.0.0 地址(也就是所有的 ip 地址)其爵,從運行在 docker bridge(網橋為 docker0) 網絡的容器運行客戶端訪問服務冒冬,兩者通信有問題。
注意以上的的限制條件摩渺,通過測試简烤,我們發(fā)現(xiàn)下來幾種情況都是正常的:
- 使用 TCP 協(xié)議沒有這個問題
- 如果 UDP 服務器監(jiān)聽在 eth0 ip ,而不是0.0.0.0上摇幻,也不會出現(xiàn)這個問題
- 并不是所有的應用都有這個問題横侦,我們的 DNS(dnsmasq + kubeDNS) 也是同樣的部署方式,但是功能都正常
這個問題在 docker 上也有 issue 記錄绰姻,但是目前并沒有合理的解決方案枉侧。
https://github.com/moby/moby/issues/15127
https://github.com/iotaledger/iri/issues/961
這篇文章就分析一下出現(xiàn)這個問題的原因,希望給同樣遇到這個問題的讀者提供些幫助狂芋。
二榨馁、重現(xiàn)問題
這個問題很容易重現(xiàn),我的實驗是在 ubuntu16.04 下用 netcat 命令完成的帜矾,其他系統(tǒng)應該類似翼虫。
在宿主機上通過 nc 監(jiān)聽 56789 端口,然后使用 bridge 網絡模式屡萤,run 一個容器珍剑,在容器里面使用 nc 發(fā)數(shù)據(jù)。
第一個報文是能發(fā)送出去的死陆,但是以后的報文雖然在網絡上能看到招拙,但是對方無法接收。
在宿主機運行 nc UDP 服務器(-u 表示 UDP 協(xié)議翔曲,-l 表示監(jiān)聽的端口)
$ nc -ul 56789
$ ss -uan | grep 56789
$ ss -an | grep 56789
udp UNCONN 0 0 *:56789 *:*
udp UNCONN 0 0 [::]:56789 [::]:*
注:默認沒有指定綁定ip迫像,就是監(jiān)聽在0.0.0.0上劈愚。
然后在同一宿主機上瞳遍,啟動一個容器,運行客戶端:
$ docker run -it apline sh
/ # nc -u 172.16.13.13 56789
nc 的通信是雙方的菌羽,不管對方輸入什么字符掠械,回車后對方就能立即收到。
但是在這個模式下注祖,客戶端第一次輸入對方能夠收到猾蒂,后續(xù)的報文對方都收不到。
在這個實驗中是晨,容器使用的是 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
為了模擬多數(shù)應用一問一答的通信方式,我們一共發(fā)送三個報文昼伴,并用 tcpdump 抓取 docker0 接口上的報文:
- 客戶端先向服務器端發(fā)送 111 字符串
- 服務器端回復 222 字符串
- 客戶端繼續(xù)發(fā)送 333 字符串
抓包的結果如下匾旭,可以發(fā)現(xiàn)第一個報文發(fā)送出去沒有任何問題。
UDP 是沒有 ACK 報文的圃郊,所以客戶端無法知道對方有沒有收到季率,這里說的沒有問題沒有看到對應的 ICMP 差錯報文。
但是第二個報文從服務端發(fā)送的報文描沟,對方會返回一個 ICMP 告訴端口 38908 不可達飒泻;第三個報文從客戶端發(fā)送的報文也是如此。以后的報文情況類似吏廉,雙方再也無法進行通信了泞遗。
11:20:43.973286 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 6
11:20:50.102018 IP 172.17.0.1.56789 > 172.17.0.3.38908: UDP, length 6
11:20:50.102129 IP 172.17.0.3 > 172.17.0.1: ICMP 172.17.0.3 udp port 38908 unreachable, length 42
11:20:54.503198 IP 172.17.0.3.38908 > 172.16.13.13.56789: UDP, length 3
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 服務器并沒有退出,使用 ss -uan | grep 56789 可能看到它仍然在監(jiān)聽著該端口席覆。
四史辙、 問題原因分析
從網絡報文的分析中可以看到服務端返回的報文源地址不是我們預想的 eth0 地址,而是 docker0 的地址佩伤,而客戶端直接認為該報文是非法的聊倔,返回了 ICMP 的報文給對方。
那么問題的原因也可以分為兩個部分:
- 為什么應答報文源地址是錯誤的生巡?
- 既然 UDP 是無狀態(tài)的耙蔑,內核怎么判斷源地址不正確呢?
主機多網絡接口 UDP 源地址選擇問題
第一個問題的關鍵詞是:UDP 和多網絡接口孤荣。
因為如果主機上只有一個網絡接口甸陌,發(fā)出去的報文源地址一定不會有錯;而我們也測試過 TCP 協(xié)議是能夠處理這個問題的盐股。
通過搜索钱豁,發(fā)現(xiàn)這確實是個已知的問題。
這個問題可以歸結為一句話:UDP 在多網卡的情況下疯汁,可能會發(fā)生【服務器端】【源地址】不對的情況牲尺,這是內核選路的結果。
為什么 UDP 和 TCP 有不同的選路邏輯呢幌蚊?
因為 UDP 是無狀態(tài)的協(xié)議谤碳,內核不會保存連接雙方的信息凛澎,因此每次發(fā)送的報文都認為是獨立的,socket 層每次發(fā)送報文默認情況不會指明要使用的源地址估蹄,只是說明對方地址塑煎。
因此,內核會為要發(fā)出去的報文選擇一個 ip臭蚁,這通常都是報文路由要經過的設備 ip 地址最铁。
那么,為什么 dnsmasq 服務沒有這個問題呢垮兑?
于是我使用 strace 工具抓取了 dnsmasq 和出問題應用的網絡 socket 系統(tǒng)調用冷尉,來查看它們兩個到底有什么區(qū)別。
dnsmasq 在啟動階段監(jiān)聽了 UDP 和 TCP 的 54 端口
因為是在本地機器上測試的系枪,為了防止和本地 DNS 監(jiān)聽的DNS端口沖突雀哨,我選擇了 54 而不是標準的 53 端口:
socket(PF_INET, SOCK_DGRAM, IPPROTO_IP) = 4
setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(4, {sa_family=AF_INET, sin_port=htons(54), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
setsockopt(4, SOL_IP, IP_PKTINFO, [1], 4) = 0
##############################################
socket(PF_INET, SOCK_STREAM, IPPROTO_IP) = 5
setsockopt(5, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(5, {sa_family=AF_INET, sin_port=htons(54), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(5, 5) = 0
比起 TCP,UDP 部分少了 listen私爷,但是多個 setsockopt(4, SOL_IP, IP_PKTINFO, [1], 4) 這句雾棺。
到底這兩點和我們的問題是否有關,先暫時放著衬浑,繼續(xù)看傳輸報文的部分捌浩。
dnsmasq 收包和發(fā)包的系統(tǒng)調用,直接使用 recvmsg 和 sendmsg 系統(tǒng)調用:
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
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
而出問題的 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, "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
[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 綁定的端口信息尸饺,數(shù)據(jù)傳輸過程使用的是 recvfrom 和 sendto。
對比下來助币,兩者的不同有幾點:
- 后者使用的是 ipv6浪听,而前者是 ipv4
- 后者使用 recvfrom 和 sendto 傳輸數(shù)據(jù),而前者是 sendmsg 和 recvmsg
- 前者有調用 setsockopt 設置 IP_PKTINFO 的值眉菱,而后者沒有
因為是在傳輸數(shù)據(jù)的時候出錯的迹栓,因此第一個疑點是 sendmsg 和 sendto 的某些區(qū)別導致選擇源地址有不同,通過 man sendto 可以知道 sendmsg 包含了更多的控制信息在 msghdr倍谜。
一個合理的猜測是 msghdr 中包含了內核選擇源地址的信息迈螟!
通過查找,發(fā)現(xiàn) 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 是怎么控制源地址選擇的:
IP_PKTINFO (since Linux 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‐
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
control message with a packet using recvmsg(2) or sendmsg(2).
struct in_pktinfo {
unsigned int ipi_ifindex; /* Interface index */
struct in_addr ipi_spec_dst; /* Local address */
struct in_addr ipi_addr; /* Header Destination
address */
};
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
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
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
for the routing table lookup.
如果 ipi_spec_dst 和 ipi_ifindex 不為空褥民,它們都能作為源地址選擇的依據(jù)季春,而不是讓內核通過路由決定。
也就是說消返,通過設置 IP_PKTINFO socket 選項為 1载弄,然后使用 recvmsg 和 sendmsg 傳輸數(shù)據(jù)就能保證源地址選擇符合我們的期望耘拇。
這也是 dnsmasq 使用的方案,而出問題的應用是因為使用了默認的 recvfrom 和 sendto宇攻。
為什么內核會把源地址和之前不同的報文丟棄惫叛,認為它是非法的?
因為我們前面已經說過逞刷,UDP 協(xié)議是無連接的嘉涌,默認情況下 socket 也不會保存雙方連接的信息。即使服務端發(fā)送報文的源地址有誤夸浅,只要對方能正常接收并處理仑最,也不會導致網絡不通。
但是 conntrack 不是這樣帆喇,內核的 netfilter 模塊會保存連接的狀態(tài)警医,并作為防火墻設置的依據(jù)。
它保存的 UDP 連接坯钦,只是簡單記錄了主機上本地 ip 和端口预皇,和對端 ip 和端口,并不會保存更多的內容婉刀。
關于 這塊可參考 intables info 網站的文章:
http://www.iptables.info/en/connection-state.html#UDPCONNECTIONS
在找到根源之前深啤,我們曾經嘗試過用 SNAT 來修改服務端應答報文的源地址,期望能夠修復該問題路星,但是卻發(fā)現(xiàn)這種方法行不通溯街,為什么呢?
因為 SNAT 是在 netfilter 最后做的洋丐,在之前 netfilter 的 conntrack 因為不認識該 connection呈昔,直接丟棄了,所以即使添加了 SNAT 也是無法工作的友绝。
那能不能把 conntrack 功能去掉呢堤尾?比如解決方案:
iptables -I OUTPUT -t raw -p udp --sport 5060 -j CT --notrack
iptables -I PREROUTING -t raw -p udp --dport 5060 -j CT --notrack
答案也是否定的,因為 NAT 需要 conntrack 來做翻譯工作迁客,如果去掉 conntrack 等于 SNAT 完全沒用郭宝。
五、解決方案
知道了問題的原因掷漱,解決方案也就很容易找到粘室。
- 使用 TCP 協(xié)議
如果服務端和客戶端使用 TCP 協(xié)議進行通信,它們之間的網絡是正常的卜范。
$ nc -l 56789
- 監(jiān)聽在特定綁定在指定接口上
使用 nc 啟動一個 udp 服務器衔统,監(jiān)聽在 eth0 上:
$ nc -ul 172.16.13.13 56789
nc 可以跟兩個參數(shù),分別代表 ip 和 端口,表示服務端監(jiān)聽在某個特定 ip 上锦爵。
如果接收到的報文目的地址不是 172.16.13.13舱殿,也會被內核直接丟棄,這種情況下险掀,服務端和客戶端也能正常通信沪袭。
- 改動應用程序實現(xiàn)
修改應用程序的邏輯,在 UDP socket 上設置 IP_PKTIFO樟氢,并通過 recvmsg 和 sendmsg 函數(shù)傳輸數(shù)據(jù)冈绊。
六 、參考
docker 容器網絡下 UDP 協(xié)議的一個問題
https://cizixs.com/2017/08/21/docker-udp-issue/
Setting the source IP for a UDP socket
https://stackoverflow.com/questions/3062205/setting-the-source-ip-for-a-udp-socket
LinuxC下獲取UDP包中的路由目的IP地址和頭標識目的地址
https://www.cnblogs.com/kissazi2/p/3158603.html
Source IP address selection
https://www.ibm.com/docs/en/zos/2.1.0?topic=profiletcpip-source-ip-address-selection
UDP recvmsg 返回目的地址和目的接口信息
http://blog.chinaunix.net/uid-16813896-id-4593514.html
告知你不為人知的 UDP:連接性和負載均衡
https://cloud.tencent.com/developer/article/1004554
告知你不為人知的 UDP:疑難雜癥和使用
https://cloud.tencent.com/developer/article/1004554