如何使用 UDP 来处理 Google Cloud 网络负载平衡

本文档介绍如何使用用户数据报协议 (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 上、作为 Google Cloud 网络负载平衡器后端的每个 Linux 虚拟机 (VM) 上,本地路由表中的条目会将目标为负载平衡器的 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 代理负责添加此条目。但是,如以下示例所示,该虚拟机实际上并没有拥有负载平衡器的 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 地址。

当数据包离开虚拟机后,Google Cloud 网络中的另一个 NAT 设备(Compute Engine 网关)会将来源地址转换为外部地址。网关不知道应使用哪个外部地址,因此只能使用虚拟机的外部地址(而不是负载平衡器的地址)。

在客户端上,如果您检查 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 是虚拟机的外部 IP 地址

从客户端的角度来看,UDP 数据包并非来自客户端将数据包发送到的地址。这会引起问题:内核会丢弃这些数据包,并且如果客户端位于 NAT 设备后面,则 NAT 设备也会丢弃这些数据包。因此,客户端应用不会收到来自服务器的响应。下图展示了此过程,在其中,由于地址不匹配,客户端拒绝了返回数据包。

客户端拒绝返回数据包。

解决 UDP 问题

如需解决无响应问题,您必须将传出数据包的来源地址重写为托管应用的服务器中负载平衡器的 IP 地址。以下是可以用来完成此标头重写的若干选项。第一个解决方案将基于 Linux 的方法与 iptables 搭配使用,其他解决方案采用基于应用的方法。

下图显示了这些选项的核心概念:重写返回数据包的来源 IP 地址,以与负载平衡器的 IP 地址相匹配。

重写返回数据包的来源 IP 地址,以与负载平衡器的 IP 地址相匹配。

在后端服务器中使用 NAT 政策

NAT 政策解决方案是使用 Linux iptables 命令将目标地址从负载平衡器的 IP 地址重写为虚拟机的 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 --dport 60002 -p udp

此命令向 iptables 系统的 NAT 表添加两个规则。第一个规则会绕过目标为本地 eth0 地址的所有传入数据包。因此,不是来自负载平衡器的流量不受影响。第二个规则会将传入数据包的目标 IP 地址更改为虚拟机的内部 IP 地址。DNAT 规则是有状态的,这意味着内核会自动跟踪连接并重写返回数据包的来源地址。

优点 缺点
内核会转换地址,而无需更改应用。 执行 NAT 需要使用额外的 CPU。此外,因为 DNAT 是有状态的,所有内存使用量也可能较高。
支持多个负载平衡器。

使用 nftables 以无状态方式标记 IP 标头字段

ntfables 解决方案中,使用 ntfables 命令标记传出数据包的 IP 标头中的来源地址。这种标记是无状态的,因此它消耗的资源比使用 DNAT 的资源少。如需使用 nftables,您需要高于 4.10 的 Linux 内核版本。

您可以使用以下命令:

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 套接字,bind 操作可让内核在发送使用该套接字的 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 选项。

优点 缺点
即使有多个负载平衡器也适用 - 例如,当为同一后端配置了内部负载平衡器和外部负载平衡器时。 需要您对应用进行复杂的更改。在某些情况下,这可能无法实现。

后续步骤