UDP反向代理nginx

許多人眼中的 udp 協議是沒有反向代理、負載均衡這個概念的。畢竟,udp 只是在 IP 包上加了個僅僅 8 個字節的包頭,這區區 8 個字節又如何能把 session 會話這個特性描述出來呢?python

圖 1 UDP 報文的協議分層linux

在 TCP/IP 或者 OSI 網絡七層模型中,每層的任務都是如此明確:nginx

  1. 物理層專一於提供物理的、機械的、電子的數據傳輸,但這是有可能出現差錯的;chrome

  2. 數據鏈路層在物理層的基礎上經過差錯的檢測、控制來提高傳輸質量,並可在局域網內使數據報文跨主機可達。這些功能是經過在報文的先後添加 Frame 頭尾部實現的,如上圖所示。每一個局域網因爲技術特性,都會設置報文的最大長度 MTU(Maximum Transmission Unit),用 netstat -i(linux) 命令能夠查看 MTU 的大小:編程

  3. 而 IP 網絡層的目標是確保報文能夠跨廣域網到達目的主機。因爲廣域網由許多不一樣的局域網,而每一個局域網的 MTU 不一樣,當網絡設備的 IP 層發現待發送的數據字節數超過 MTU 時,將會把數據拆成多個小於 MTU 的數據塊各自組成新的 IP 報文發送出去,而接收主機則根據 IP 報頭中的 Flags 和 Fragment Offset 這兩個字段將接收到的無序的多個 IP 報文,組合成一段有序的初始發送數據。IP 報頭的格式以下圖所示:windows

    圖 2 IP 報文頭部數組

  4. 傳輸層主要包括 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 發送響應端口,這樣會話是能夠創建起來的。可是這種機制有哪些問題呢?

問題必定要結合場景來看。好比:一、若是客戶端是 windows 上的 chrome 瀏覽器,怎麼能讓它監聽一個端口呢?端口是會衝突的,若是有其餘進程佔了這個端口,還能不工做了?二、若是開了多個 chrome 窗口,那個第 1 個窗口發的請求對應的響應被第 2 個窗口收到怎麼辦?三、若是剛發完一個請求,進程掛了,新啓的窗口收到老的響應怎麼辦?等等。可見這套方案並不適合消費者用戶的服務與服務器通信,因此視頻會議等看來是不行。

有其餘辦法麼?有!若是客戶端使用的源端口,一樣用於接收服務器發送的響應,那麼以上的問題就不存在了。像 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 結構體,以下所示:

struct msghdr {
    void         *msg_name;       /* optional address */
    socklen_t     msg_namelen;    /* size of address */
    struct iovec *msg_iov;        /* scatter/gather array */
    size_t        msg_iovlen;     /* # elements in msg_iov */
    void         *msg_control;    /* ancillary data, see below */
    size_t        msg_controllen; /* ancillary data buffer len */
    int           msg_flags;      /* flags on received message */
};

其中 msg_name 就是對端的源 IP 和源端口(指向 sockaddr 結構體)。以上是 C 庫的定義,其餘高級語言相似方法會更簡單,例如 python 裏的同名方法是這麼定義的:

(data, ancdata, msg_flags, address) = socket.recvmsg(bufsize[, ancbufsize[, flags]])

其中返回元組的第 4 個元素就是對端的 ip 和 port。

以上是 nginx 在 udp 反向代理上的工做原理。實際配置則很簡單:

# Load balance UDP-based DNS traffic across two servers
stream {
    upstream dns_upstreams {
        server 192.168.136.130:53;
        server 192.168.136.131:53;
    }

    server {
        listen 53 udp;
        proxy_pass dns_upstreams;
        proxy_timeout 1s;
        proxy_responses 1;
        error_log logs/dns.log;
    }
}

在 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 的行爲:

  1. nginx 向 upstream 發送包時,必須開啓 root 權限以修改 ip 包的源地址爲 client ip,以讓 upstream 上的進程能夠直接看到客戶端的 IP。

    server {
     listen 53 udp;
    
     proxy_responses 1;
    proxy_timeout 1s;
    proxy_bind $remote_addr transparent;
    
     proxy_pass dns_upstreams;
    }
  2. upstream 上的路由表須要修改,由於 upstream 是在內網,它的網關是內網網關,並不知道把目的 ip 是 client ip 的包向哪裏發。並且,它的源地址端口是 upstream 的,client 也不會認的。因此,須要修改默認網關爲 nginx 所在的機器。

# route del default gw 原網關 ip

# route add default gw nginx 的 ip

  3. nginx 的機器上必須修改 iptable 以使得 nginx 進程處理目的 ip 是 client 的        報文。

# ip rule add fwmark 1 lookup 100

# ip route add local 0.0.0.0/0 dev lo table 100

# iptables -t mangle -A PREROUTING -p tcp -s 172.16.0.0/28 --sport 80 -j MARK --set-xmark 0x1/0xffffffff

這套方案其實對 TCP 也是適用的。除了上述方案外,還有個 Direct Server Return 方案,即 upstream 回包時 nginx 進程再也不介入處理。這種 DSR 方案又分爲兩種,第 1 種假定 upstream 的機器上沒有公網網卡,其解決方案圖示以下:

圖 6 nginx 作 udp 反向代理時的 DSR 方案(upstream 無公網)

這套方案作了如下 hack 行爲:

  1. 在 nginx 上同時綁定 client 的源 ip 和端口,由於 upstream 回包後將再也不通過 nginx 進程了。同時,proxy_responses 也須要設爲 0。

server {
    listen 53 udp;

proxy_responses 0;
    proxy_bind $remote_addr:$remote_port transparent;

    proxy_pass dns_upstreams;
}

   2. 與第一種方案相同,修改 upstream 的默認網關爲 nginx 所在機器(任何  一臺擁有公網的機器都行)。

# tc qdisc add dev eth0 root handle 10: htb

# tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.11 match ip sport 53 action nat egress 172.16.0.11 192.168.99.10

# tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.12 match ip sport 53 action nat egress 172.16.0.12 192.168.99.10

# tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.13 match ip sport 53 action nat egress 172.16.0.13 192.168.99.10

# tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.14 match ip sport 53 action nat egress 172.16.0.14 192.168.99.10

DSR 的另外一套方案是假定 upstream 上有公網線路,這樣 upstream 的回包能夠直接向 client 發送,以下圖所示:

圖 6 nginx 作 udp 反向代理時的 DSR 方案(upstream 有公網)

這套 DSR 方案與上一套 DSR 方案的區別在於:由 upstream 服務所在主機上修改發送報文的源地址與源端口爲 nginx 的 ip 和監聽端口,以使得 client 能夠接收到報文。例如:

# tc qdisc add dev eth0 root handle 10: htb

# tc filter add dev eth0 parent 10: protocol ip prio 10 u32 match ip src 172.16.0.11 match ip sport 53 action nat egress 172.16.0.11 192.168.99.10

以上三套方案都須要 nginx 的 worker 跑在 root 權限下,這並不友好。從協議層面,能夠期待後續版本支持 proxy protocol 傳遞 client 的 ip。

相關文章
相關標籤/搜索