搭配外部直通式網路負載平衡器使用 UDP

本文將說明如何使用用戶資料元協定 (UDP),搭配外部直通式網路負載平衡器。本文件適用對象為應用程式開發人員、應用程式作業人員和網路管理員。

關於 UDP

UDP 常用於應用程式。這項通訊協定 (如 RFC-768 所述) 會實作無狀態、不可靠的資料包封包服務。舉例來說,Google 的 QUIC 通訊協定會使用 UDP 加速串流應用程式,進而提升使用者體驗。

UDP 的無狀態部分是指傳輸層不會維護狀態。因此,UDP「連線」中的每個封包都是獨立的。事實上,UDP 沒有實際連線。而是通常使用 2 元組 (ip:port) 或 4 元組 (src-ip:src-portdest-ip:dest-port) 互相識別。

與以 TCP 為基礎的應用程式一樣,以 UDP 為基礎的應用程式也能從負載平衡器獲益,因此在 UDP 情境中會使用外部直通式網路負載平衡器。

外部直通式網路負載平衡器

外部直通式網路負載平衡器屬於直通式負載平衡器,會處理傳入的封包,並將封包完整無缺地傳送至後端伺服器。後端伺服器接著會將傳回的封包直接傳送給用戶端。這項技術稱為「伺服器直接回傳」(DSR)。在 Compute Engine 上執行的每個 Linux 虛擬機器 (VM),都是Google Cloud 外部直通式網路負載平衡器的後端,本機路由表中的項目會將目的地為負載平衡器 IP 位址的流量,路由至網路介面控制器 (NIC)。以下範例說明這項技巧:

root@backend-server:~# ip ro ls table local
local 10.128.0.2 dev eth0 proto kernel scope host src 10.128.0.2
broadcast 10.128.0.2 dev eth0 proto kernel scope link src 10.128.0.2
local 198.51.100.2 dev eth0 proto 66 scope host
broadcast 127.0.0.0 dev lo proto kernel scope link src 127.0.0.1
local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1
broadcast 127.255.255.255 dev lo proto kernel scope link src 127.0.0.1

在上述範例中,198.51.100.2 是負載平衡器的 IP 位址。google-network-daemon.service代理人負責新增這筆記錄。不過,如下列範例所示,VM 實際上沒有擁有負載平衡器 IP 位址的介面:

root@backend-server:~# ip ad ls
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1460 qdisc mq state UP group default qlen 1000
    link/ether 42:01:0a:80:00:02 brd ff:ff:ff:ff:ff:ff
    inet 10.128.0.2/32 brd 10.128.0.2 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::4001:aff:fe80:2/64 scope link
       valid_lft forever preferred_lft forever

外部直通式網路負載平衡器會將傳入的封包傳輸至後端伺服器,且目的地地址不會變動。本機路由表項目會將封包路由至正確的應用程式程序,而應用程式的回應封包會直接傳送至用戶端。

下圖顯示外部直通式網路負載平衡器的運作方式。負載平衡器 (稱為 Maglev) 會處理傳入的封包,並將封包分配至後端伺服器。然後透過 DSR 將傳出封包直接傳送給用戶端。

Maglev 會將連入封包分配給後端伺服器,後者則透過 DSR 分配封包。

UDP 傳回封包發生問題

使用 DSR 時,Linux 核心處理 TCP 和 UDP 連線的方式略有不同。由於 TCP 是有狀態的通訊協定,核心會取得 TCP 連線的所有必要資訊,包括用戶端位址、用戶端通訊埠、伺服器位址和伺服器通訊埠。這項資訊會記錄在代表連線的通訊端資料結構中。因此,TCP 連線的每個回傳封包都會將來源位址正確設為伺服器位址。如果是負載平衡器,該位址就是負載平衡器的 IP 位址。

請注意,UDP 是無狀態的,因此在應用程式程序中為 UDP 連線建立的通訊端物件沒有連線資訊。核心沒有輸出封包來源位址的相關資訊,也不知道與先前收到的封包有何關係。對於封包的來源位址,核心只能填入傳回 UDP 封包的介面位址。或者,如果應用程式先前已將通訊端繫結至特定位址,核心會將該位址做為來源位址。

以下程式碼顯示簡單的回應程式:

#!/usr/bin/python3
import socket,struct
def loop_on_socket(s):
  while True:
    d, addr = s.recvfrom(1500)
    print(d, addr)
    s.sendto("ECHO: ".encode('utf8')+d, addr)

if __name__ == "__main__":
   HOST, PORT = "0.0.0.0", 60002
   sock = socket.socket(type=socket.SocketKind.SOCK_DGRAM)
   sock.bind((HOST, PORT))
   loop_on_socket(sock)

以下是 UDP 對話期間的 tcpdump 輸出內容:

14:50:04.758029 IP 203.0.113.2.40695 > 198.51.100.2.60002: UDP, length 3
14:50:04.758396 IP 10.128.0.2.60002 > 203.0.113.2.40695: UDP, length 2T

198.51.100.2 是負載平衡器的 IP 位址,203.0.113.2 則是用戶端 IP 位址。

封包離開 VM 後, Google Cloud 網路中的另一個 NAT 裝置 (Compute Engine 閘道) 會將來源位址轉換為外部位址。閘道不知道應使用哪個外部位址,因此只能使用 VM 的外部位址 (而非負載平衡器的外部位址)。

在用戶端,如果您檢查 tcpdump 的輸出內容,來自伺服器的封包會如下所示:

23:05:37.072787 IP 203.0.113.2.40695 > 198.51.100.2.60002: UDP, length 5
23:05:37.344148 IP 198.51.100.3.60002 > 203.0.113.2.40695: UDP, length 4

198.51.100.3 是 VM 的外部 IP 位址。

從用戶端的角度來看,UDP 封包並非來自用戶端傳送封包的位址。這會導致問題:核心會捨棄這些封包,如果用戶端位於 NAT 裝置後方,NAT 裝置也會捨棄封包。因此,用戶端應用程式不會收到伺服器的任何回應。下圖顯示這個程序,其中用戶端因地址不符而拒絕傳回封包。

用戶端拒絕傳回封包。

解決 UDP 問題

如要解決無回應問題,您必須在代管應用程式的伺服器上,將傳出封包的來源位址重新寫入負載平衡器的 IP 位址。以下提供幾種方法,可完成這項標頭重寫作業。第一個解決方案採用以 Linux 為基礎的方法,其他解決方案則採用以應用程式為基礎的方法。iptables

下圖顯示這些選項的核心概念:重新編寫傳回封包的來源 IP 位址,以符合負載平衡器的 IP 位址。

改寫傳回封包的來源 IP 位址,使其與負載平衡器的 IP 位址相符。

在後端伺服器中使用 NAT 政策

NAT 政策解決方案是使用 Linux iptables 指令,將目的地地址從負載平衡器的 IP 位址重新寫入 VM 的 IP 位址。在下列範例中,您會新增 iptables DNAT 規則,變更傳入封包的目的地地址:

iptables -t nat -A POSTROUTING -j RETURN -d 10.128.0.2 -p udp --dport 60002
iptables -t nat -A PREROUTING -j DNAT --to-destination 10.128.0.2 -d 198.51.100.2 -p udp --dport 60002

這個指令會在 iptables 系統的 NAT 表格中新增兩項規則。第一條規則會略過所有以本機 eth0 位址為目標的輸入封包。因此,來自負載平衡器的流量不會受到影響。 第二條規則會將傳入封包的目的地 IP 位址變更為 VM 的內部 IP 位址。DNAT 規則是有狀態的,也就是說,核心會追蹤連線,並自動重寫傳回封包的來源位址。

優點 缺點
核心會翻譯位址,應用程式不需變更。 NAT 需要額外的 CPU 資源,而 DNAT 屬於有狀態的作業,因此也可能消耗大量記憶體。
支援多個負載平衡器。

使用 nftables 無狀態地破壞 IP 標頭欄位

nftables 解決方案中,您可以使用 nftables 指令,竄改傳出封包 IP 標頭中的來源位址。這項改寫作業是無狀態的,因此比使用 DNAT 消耗的資源更少。如要使用 nftables,Linux 核心版本必須高於 4.10。

使用下列指令:

nft add table raw
nft add chain raw postrouting {type filter hook postrouting priority 300)
nft add rule raw postrouting ip saddr 10.128.0.2 udp sport 60002 ip saddr set 198.51.100.2
優點 缺點
核心會轉換位址,應用程式無須變更。 不支援多個負載平衡器。
地址轉換程序是無狀態的,因此資源耗用量較低。 NAT 會使用額外 CPU。
nftables 僅適用於較新的 Linux 核心版本。部分發行版本 (如 Centos 7.x) 無法使用 nftables

讓應用程式明確繫結至負載平衡器的 IP 位址

在繫結解決方案中,修改應用程式,使其明確繫結至負載平衡器的 IP 位址。如果是 UDP Socket,bind 作業會讓核心知道傳送使用該 Socket 的 UDP 封包時,要使用哪個位址做為來源位址。

以下範例說明如何在 Python 中繫結至特定位址:

#!/usr/bin/python3
import socket
def loop_on_socket(s):
  while True:
    d, addr = s.recvfrom(1500)
    print(d, addr)
    s.sendto("ECHO: ".encode('utf8')+d, addr)

if __name__ == "__main__":
   # Instead of setting HOST to "0.0.0.0",
   # we set HOST to the Load Balancer IP
   HOST, PORT = "198.51.100.2", 60002
   sock = socket.socket(type=socket.SocketKind.SOCK_DGRAM)
   sock.bind((HOST, PORT))
   loop_on_socket(sock)

# 198.51.100.2 is the load balancer's IP address
# You can also use the DNS name of the load balancer's IP address

上述程式碼是 UDP 伺服器,會回傳收到的位元組,並加上 "ECHO: "。請注意第 12 和 13 行,伺服器會繫結至 198.51.100.2 位址,也就是負載平衡器的 IP 位址。

優點 缺點
只要對應用程式進行簡單的程式碼變更即可達成。 不支援多個負載平衡器。

使用 recvmsg/sendmsg 指定地址,而非 recvfrom/sendto

在本解決方案中,您會使用 recvmsg/sendmsg 呼叫,而不是 recvfrom/sendto 呼叫。相較於 recvfrom/sendto 呼叫,recvmsg/sendmsg 呼叫可處理輔助控制訊息和酬載資料。這些輔助控制訊息包括封包的來源或目的地地址。這個解決方案可讓您從傳入的封包擷取目的地地址,由於這些地址是實際的負載平衡器地址,因此您可以在傳送回覆時使用這些地址做為來源地址。

下列範例程式會示範這項解決方案:

#!/usr/bin/python3
import socket,struct
def loop_on_socket(s):
  while True:
    d, ctl, flg, addr = s.recvmsg(1500, 1024)
    # ctl contains the destination address information
    s.sendmsg(["ECHO: ".encode("utf8"),d], ctl, 0, addr)

if __name__ == "__main__":
   HOST, PORT = "0.0.0.0", 60002
   s = socket.socket(type=socket.SocketKind.SOCK_DGRAM)
   s.setsockopt(0,   # level is 0 (IPPROTO_IP)
                8,   # optname is 8 (IP_PKTINFO)
                1)

   s.bind((HOST, PORT))
   loop_on_socket(s)

這個程式示範如何使用 recvmsg/sendmsg 呼叫。如要從封包擷取位址資訊,必須使用 setsockopt 呼叫設定 IP_PKTINFO 選項。

優點 缺點
即使有多個負載平衡器 (例如同時設定內部和外部負載平衡器,指向同一個後端),這項功能也能正常運作。 需要對應用程式進行複雜的變更。在某些情況下,這可能無法實現。

後續步驟