Udp的反向代理:nginx
本文將講述 udp 協議的會話機制原理,以及基於 nginx 如何配置 udp 協議的反向代理,包括如何維持住 session、透傳客戶端 ip 到上游應用服務的 3 種方案等。
許多人眼中的 udp 協議是沒有反向代理、負載均衡這個概念的。畢竟,udp 只是在 IP 包上加了個僅僅 8 個位元組的包頭,這區區 8 個位元組又如何能把 session 會話這個特性描述出來呢?
圖 1 UDP 報文的協議分層
在 TCP/IP 或者 OSI 網路七層模型中,每層的任務都是如此明確:
物理層專註於提供物理的、機械的、電子的數據傳輸,但這是有可能出現差錯的;
數據鏈路層在物理層的基礎上通過差錯的檢測、控制來提升傳輸質量,並可在區域網內使數據報文跨主機可達。這些功能是通過在報文的前後添加 Frame 頭尾部實現的,如上圖所示。每個區域網由於技術特性,都會設置報文的最大長度 MTU(Maximum Transmission Unit),用 netstat -i(linux) 命令可以查看 MTU 的大小:
而 IP 網路層的目標是確保報文可以跨廣域網到達目的主機。由於廣域網由許多不同的區域網,而每個區域網的 MTU 不同,當網路設備的 IP 層發現待發送的數據位元組數超過 MTU 時,將會把數據拆成多個小於 MTU 的數據塊各自組成新的 IP 報文發送出去,而接收主機則根據 IP 報頭中的 Flags 和 Fragment Offset 這兩個欄位將接收到的無序的多個 IP 報文,組合成一段有序的初始發送數據。IP 報頭的格式如下圖所示:
圖 2 IP 報文頭部
傳輸層主要包括 TCP 協議和 UDP 協議。這一層最主要的任務是保證埠可達,因為埠可以歸屬到某個進程,當 chrome 的 GET 請求根據 IP 層的 destination IP 到達 linux 主機時,linux 操作系統根據傳輸層頭部的 destination port 找到了正在 listen 或者 recvfrom 的 nginx 進程。所以傳輸層無論什麼協議其頭部都必須有源埠和目的埠。例如下圖的 UDP 頭部:
圖 3 UDP 的頭部
TCP 的報文頭比 UDP 複雜許多,因為 TCP 除了實現埠可達外,它還提供了可靠的數據鏈路,包括流控、有序重組、多路復用等高級功能。由於上文提到的 IP 層報文拆分與重組是在 IP 層實現的,而 IP 層是不可靠的所有數組效率低下,所以 TCP 層還定義了 MSS(Maximum Segment Size)最大報文長度,這個 MSS 肯定小於鏈路中所有網路的 MTU,因此 TCP 優先在自己這一層拆成小報文避免的 IP 層的分包。而 UDP 協議報文頭部太簡單了,無法提供這樣的功能,所以基於 UDP 協議開發的程序需要開發人員自行把握不要把過大的數據一次發送。
對報文有所了解後,我們再來看看 UDP 協議的應用場景。相比 TCP 而言 UDP 報文頭不過 8 個位元組,所以 UDP 協議的最大好處是傳輸成本低(包括協議棧的處理),也沒有 TCP 的擁塞、滑動窗口等導致數據延遲發送、接收的機制。但 UDP 報文不能保證一定送達到目的主機的目的埠,它沒有重傳機制。所以,應用 UDP 協議的程序一定是可以容忍報文丟失、不接受報文重傳的。如果某個程序在 UDP 之上包裝的應用層協議支持了重傳、亂序重組、多路復用等特性,那麼他肯定是選錯傳輸層協議了,這些功能 TCP 都有,而且 TCP 還有更多的功能以保證網路通訊質量。因此,通常實時聲音、視頻的傳輸使用 UDP 協議是非常合適的,我可以容忍正在看的視頻少了幾幀圖像,但不能容忍突然幾分鐘前的幾幀圖像突然插進來:-)
有了上面的知識儲備,我們可以來搞清楚 UDP 是如何維持會話連接的。對話就是會話,A 可以對 B 說話,而 B 可以針對這句話的內容再回一句,這句可以到達 A。如果能夠維持這種機制自然就有會話了。UDP 可以嗎?當然可以。例如客戶端(請求發起者)首先監聽一個埠 Lc,就像他的耳朵,而服務提供者也在主機上監聽一個埠 Ls,用於接收客戶端的請求。客戶端任選一個源埠向伺服器的 Ls 埠發送 UDP 報文,而服務提供者則通過任選一個源埠向客戶端的埠 Lc 發送響應埠,這樣會話是可以建立起來的。但是這種機制有哪些問題呢?
問題一定要結合場景來看。比如:1、如果客戶端是 windows 上的 chrome 瀏覽器,怎麼能讓它監聽一個埠呢?埠是會衝突的,如果有其他進程佔了這個埠,還能不工作了?2、如果開了多個 chrome 窗口,那個第 1 個窗口發的請求對應的響應被第 2 個窗口收到怎麼辦?3、如果剛發完一個請求,進程掛了,新啟的窗口收到老的響應怎麼辦?等等。可見這套方案並不適合消費者用戶的服務與伺服器通訊,所以視頻會議等看來是不行。
有其他辦法么?有!如果客戶端使用的源埠,同樣用於接收伺服器發送的響應,那麼以上的問題就不存在了。像 TCP 協議就是如此,其 connect 方的隨機源埠將一直用於連接上的數據傳送,直到連接關閉。這個方案對客戶端有以下要求:不要使用 sendto 這樣的方法,幾乎任何語言對 UDP 協議都提供有這樣的方法封裝。應當先用 connect 方法獲取到 socket,再調用 send 方法把請求發出去。這樣做的原因是既可以在內核中保存有 5 元組(源 ip、源 port、目的 ip、目的埠、UDP 協議),以使得該源埠僅接收目的 ip 和埠發來的 UDP 報文,又可以反覆使用 send 方法時比 sendto 每次都上傳遞目的 ip 和目的 port 兩個參數。
對伺服器端有以下要求:不要使用 recvfrom 這樣的方法,因為該方法無法獲取到客戶端的發送源 ip 和源 port,這樣就無法向客戶端發送響應了。應當使用 recvmsg 方法(有些編程語言例如 python2 就沒有該方法,但 python3 有)去接收請求,把獲取到的對端 ip 和 port 保存下來,而發送響應時可以仍然使用 sendto 方法。
接下來我們談談 nginx 如何做 udp 協議的反向代理。Nginx 的 stream 系列模塊核心就是在傳輸層上做反向代理,雖然 TCP 協議的應用場景更多,但 UDP 協議在 Nginx 的角度看來也與 TCP 協議大同小異,比如:nginx 向 upstream 轉發請求時仍然是通過 connect 方法得到的 fd 句柄,接收 upstream 的響應時也是通過 fd 調用 recv 方法獲取消息;nginx 接收客戶端的消息時則是通過上文提到過的 recvmsg 方法,同時把獲取到的客戶端源 ip 和源 port 保存下來。我們先看下 recvmsg 方法的定義:
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
相對於 recvfrom 方法,多了一個 msghdr 結構體,如下所示:
其中 msg_name 就是對端的源 IP 和源埠(指向 sockaddr 結構體)。以上是 C 庫的定義,其他高級語言類似方法會更簡單,例如 python 里的同名方法是這麼定義的:
(data, ancdata, msg_flags, address) = socket.recvmsg(bufsize[, ancbufsize[, flags]])
其中返回元組的第 4 個元素就是對端的 ip 和 port。
以上是 nginx 在 udp 反向代理上的工作原理。實際配置則很簡單:
在 listen 配置中的 udp 選項告訴 nginx 這是 udp 反向代理。而 proxy_timeout 和 proxy_responses 則是維持住 udp 會話機制的主要參數。
UDP 協議自身並沒有會話保持機制,nginx 於是定義了一個非常簡單的維持機制:客戶端每發出一個 UDP 報文,通常期待接收回一個報文響應,當然也有可能不響應或者需要多個報文響應一個請求,此時 proxy_responses 可配為其他值。而 proxy_timeout 則規定了在最長的等待時間內沒有響應則斷開會話。
最後我們來談一談經過 nginx 反向代理後,upstream 服務如何才能獲取到客戶端的地址?如下圖所示,nginx 不同於 IP 轉發,它事實上建立了新的連接,所以正常情況下 upstream 無法獲取到客戶端的地址:
圖 4 nginx 反向代理掩蓋了客戶端的 IP
上圖雖然是以 TCP/HTTP 舉例,但對 UDP 而言也一樣。而且,在 HTTP 協議中還可以通過 X-Forwarded-For 頭部傳遞客戶端 IP,而 TCP 與 UDP 則不行。Proxy protocol 本是一個好的解決方案,它通過在傳輸層 header 之上添加一層描述對端的 ip 和 port 來解決問題,例如:
但是,它要求 upstream 上的服務要支持解析 proxy protocol,而這個協議還是有些小眾。最關鍵的是,目前 nginx 對 proxy protocol 的支持則僅止於 tcp 協議,並不支持 udp 協議,我們可以看下其代碼:
可見 nginx 目前並不支持 udp 協議的 proxy protocol(筆者下的 nginx 版本為 1.13.6)。
雖然 proxy protocol 是支持 udp 協議的。怎麼辦呢?可以用 IP 地址透傳的解決方案。如下圖所示:
圖 5 nginx 作為四層反向代理向 upstream 展示客戶端 ip 時的 ip 透傳方案
這裡在 nginx 與 upstream 服務間做了一些 hack 的行為:
nginx 向 upstream 發送包時,必須開啟 root 許可權以修改 ip 包的源地址為 client ip,以讓 upstream 上的進程可以直接看到客戶端的 IP。
upstream 上的路由表需要修改,因為 upstream 是在內網,它的網關是內網網關,並不知道把目的 ip 是 client ip 的包向哪裡發。而且,它的源地址埠是 upstream 的,client 也不會認的。所以,需要修改默認網關為 nginx 所在的機器。
3. nginx 的機器上必須修改 iptable 以使得 nginx 進程處理目的 ip 是 client 的 報文。
這套方案其實對 TCP 也是適用的。除了上述方案外,還有個 Direct Server Return 方案,即 upstream 回包時 nginx 進程不再介入處理。這種 DSR 方案又分為兩種,第 1 種假定 upstream 的機器上沒有公網網卡,其解決方案圖示如下:
圖 6 nginx 做 udp 反向代理時的 DSR 方案(upstream 無公網)
這套方案做了以下 hack 行為:
在 nginx 上同時綁定 client 的源 ip 和埠,因為 upstream 回包後將不再經過 nginx 進程了。同時,proxy_responses 也需要設為 0。
2. 與第一種方案相同,修改 upstream 的默認網關為 nginx 所在機器(任何 一台擁有公網的機器都行)。
DSR 的另一套方案是假定 upstream 上有公網線路,這樣 upstream 的回包可以直接向 client 發送,如下圖所示:
圖 6 nginx 做 udp 反向代理時的 DSR 方案(upstream 有公網)
這套 DSR 方案與上一套 DSR 方案的區別在於:由 upstream 服務所在主機上修改發送報文的源地址與源埠為 nginx 的 ip 和監聽埠,以使得 client 可以接收到報文。例如:
以上三套方案都需要 nginx 的 worker 跑在 root 許可權下,這並不友好。從協議層面,可以期待後續版本支持 proxy protocol 傳遞 client 的 ip。
活動推薦
隨著 AI、Big Data、Cloud 的逐漸成熟,FAAS、CAAS 等技術的興起,以及被運維業務的多樣化和複雜化,很多傳統的運維技術和解決方案已經不能滿足當前運維所需,AIOps 智能運維、大數據運維、ChatOps、SRE、Chaos Engineering、微服務與容器運維等新技術和方嚮應運而生,它們一方面把最前沿的技術結合到運維中來,一方面在人員角色、領域範圍、文化等方面又有了很多擴展,讓傳統運維有了翻天覆地的變化。


※NodePort,LoadBalancer還是Ingress?我該如何選擇
※分散式系統發展史
TAG:高效開發運維 |