Usar UDP com balanceadores de carga de rede de passagem externa

Neste documento, você verá como trabalhar com balanceadores de carga de rede de passagem externa usando o protocolo de datagramas do usuário (UDP, na sigla em inglês). O documento é destinado a desenvolvedores de apps, operadores de app e administradores de rede.

Sobre o UDP

O UDP é geralmente usado em aplicativos. O protocolo, descrito no RFC-768, implementa um serviço de pacote de datagrama sem estado e não confiável. Por exemplo, o protocolo QUIC do Google melhora a experiência do usuário usando o UDP para acelerar os aplicativos baseados em stream.

A parte sem estado do UDP significa que a camada de transporte não mantém um estado. Portanto, cada pacote em uma "conexão" UDP é independente. Na verdade, não há uma conexão real no UDP. Em vez disso, os participantes geralmente usam uma (ip:port) de duas tuplas ou uma (src-ip:src-port, dest-ip:dest-port) de quatro tuplas para identificarem uns aos outros.

Assim como os aplicativos baseados em TCP, os aplicativos baseados em UDP também podem se beneficiar de um balanceador de carga, e é por isso que os balanceadores de carga de rede de passagem externa são usados em cenários UDP.

Balanceador de carga de rede de passagem externa

Balanceadores de carga de rede de passagem externa são balanceadores de carga de passagem; eles processam pacotes de entrada e os entregam aos servidores de back-end com os pacotes intactos. Os servidores de back-end enviam os pacotes de retorno diretamente para os clientes. Essa técnica é chamada de Retorno direto do servidor (DSR, na sigla em inglês). Em cada máquina virtual (VM) do Linux em execução no Compute Engine que é um back-end de um balanceador de carga de rede de passagem externa do Google Cloud, uma entrada na tabela de roteamento local roteia o tráfego destinado ao endereço IP do balanceador de carga ao controlador de interface de rede (NIC, na sigla em inglês). O exemplo a seguir demonstra essa técnica:

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

No exemplo anterior, 198.51.100.2 é o endereço IP do balanceador de carga. O agente google-network-daemon.service é responsável por adicionar essa entrada. No entanto, como mostrado no exemplo a seguir, a VM não tem uma interface que tem o endereço IP do balanceador de carga:

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

O balanceador de carga de rede de passagem externa transmite os pacotes de entrada, com o endereço de destino intacto, para o servidor de back-end. A entrada da tabela de roteamento local roteia o pacote para o processo correto do aplicativo, e os pacotes de resposta do aplicativo são enviados diretamente para o cliente.

O diagrama a seguir mostra como os balanceadores de carga de rede de passagem externa funcionam. Os pacotes de entrada são processados por um balanceador de carga denominado Maglev, que distribui os pacotes para os servidores de back-end. Os pacotes de saída são enviados diretamente aos clientes por meio de DSR.

O Maglev distribui os pacotes de entrada para os servidores de back-end, que distribuem os pacotes por meio do DSR.

Um problema com pacotes de retorno do UDP

Quando você trabalha com o DSR, há uma pequena diferença entre como o kernel do Linux trata conexões TCP e UDP. Como o TCP é um protocolo com estado, o kernel tem todas as informações necessárias sobre a conexão TCP, incluindo endereço do cliente, porta do cliente, endereço do servidor e porta do servidor. Essas informações são registradas na estrutura de dados do soquete que representa a conexão. Assim, cada pacote retornado de uma conexão TCP tem o endereço de origem definido corretamente para o endereço do servidor. Para um balanceador de carga, esse endereço é o endereço IP do balanceador de carga.

Lembre-se de que o UDP é sem estado. Portanto, os objetos de soquete criados no processo do app para conexões UDP não têm as informações de conexão. O kernel não tem as informações sobre o endereço de origem de um pacote de saída e não sabe a relação com um pacote recebido anteriormente. Para o endereço de origem do pacote, o kernel só pode preencher o endereço da interface para onde o pacote UDP é retornado. Ou, se o aplicativo vinculou anteriormente o soquete a um determinado endereço, o kernel usa esse endereço como o endereço de origem.

O código a seguir mostra um programa de eco simples:

#!/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)

Veja a seguir a saída tcpdump durante uma conversa UDP:

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 é o endereço IP do balanceador de carga e 203.0.113.2 é o endereço IP do cliente.

Depois que os pacotes saem da VM, outro dispositivo NAT, um gateway do Compute Engine, na rede do Google Cloud converte o endereço de origem para o endereço externo. O gateway não sabe qual endereço externo deve ser usado, portanto, somente o endereço externo da VM (não o do balanceador de carga) pode ser usado.

No lado do cliente, se você verificar a saída de tcpdump, os pacotes do servidor serão assim:

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 é o endereço IP externo da VM.

Do ponto de vista do cliente, os pacotes UDP não vêm de um endereço para o qual o cliente os enviou. Isso causa problemas: o kernel descarta esses pacotes e, se o cliente estiver atrás de um dispositivo NAT, o dispositivo NAT fará o mesmo. Como resultado, o app do cliente não recebe resposta do servidor. O diagrama a seguir mostra esse processo em que o cliente rejeita pacotes de retorno por causa de incompatibilidades de endereço.

O cliente recusa a devolução de pacotes.

Como resolver o problema do UDP

Para resolver o problema de falta de resposta, você precisa reescrever o endereço de origem dos pacotes de saída para o endereço IP do balanceador de carga no servidor que hospeda o aplicativo. Veja a seguir várias opções que podem ser usadas para reescrever o cabeçalho. A primeira solução usa uma abordagem baseada em Linux com iptables, e as outras usam abordagens baseadas em aplicativos.

O diagrama a seguir mostra a ideia principal dessas opções: reescrever o endereço IP de origem dos pacotes retornados para corresponder ao endereço IP do balanceador de carga.

Reescreva o endereço IP de origem dos pacotes retornados para corresponder ao endereço IP do
balanceador de carga.

Usar a política NAT no servidor de back-end

A solução de política NAT é usar o comando iptables do Linux para reescrever o endereço de destino do endereço IP do balanceador de carga para o endereço IP da VM. No exemplo a seguir, você adiciona uma regra iptables DNAT para alterar o endereço de destino dos pacotes de entrada:

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

Esse comando adiciona duas regras à tabela NAT do sistema iptables. A primeira regra ignora todos os pacotes de entrada destinados ao endereço eth0 local. Como resultado, o tráfego que não vem do balanceador de carga não é afetado. A segunda regra altera o endereço IP de destino dos pacotes de entrada para o endereço IP interno da VM. As regras DNAT têm estado, o que significa que o kernel rastreia as conexões e reescreve o endereço de origem dos pacotes retornados automaticamente.

Prós Contras
O kernel converte o endereço, sem necessidade de alteração nos aplicativos. A CPU extra é usada para fazer o NAT. Como o DNAT tem estado, o consumo de memória também pode ser alto.
Compatível com vários balanceadores de carga.

Use nftables para ajustar os campos do cabeçalho IP sem estado

Na solução nftables, use o comando nftables para ajustar o endereço de origem no cabeçalho de IP dos pacotes de saída. Esse ajuste é sem estado, então consome menos recursos do que a DNAT. Para usar nftables, você precisa de uma versão do kernel do Linux maior que 4.10.

Use os seguintes comandos:

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
Prós Contras
O kernel converte o endereço, sem necessidade de alteração nos aplicativos. Não é compatível com vários balanceadores de carga.
O processo de conversão do endereço é sem estado, de modo que o consumo de recursos é muito mais baixo. A CPU extra é usada para fazer o NAT.
nftables estão disponíveis apenas para versões mais recentes do kernel do Linux. Algumas distribuições, como Centos 7.x, não podem usar nftables.

Permita que o app se vincule explicitamente ao endereço IP do balanceador de carga.

Na solução de vinculação, modifique o aplicativo para que ele seja vinculado explicitamente ao endereço IP do balanceador de carga. Para um soquete UDP, a operação bind permite que o kernel saiba qual endereço usar como o endereço de origem ao enviar pacotes UDP que usam esse soquete.

O exemplo a seguir mostra como vincular a um endereço específico em 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

O código anterior é um servidor UDP. Ele ecoa os bytes recebidos, com um "ECHO: " precedente. Preste atenção nas linhas 12 e 13, em que o servidor está vinculado ao endereço 198.51.100.2, que é o endereço IP do balanceador de carga.

Prós Contras
Pode ser obtido com uma simples alteração de código no aplicativo. Não é compatível com vários balanceadores de carga.

Use recvmsg/sendmsg em vez de recvfrom/sendto para especificar o endereço

Nesta solução, você usa chamadas recvmsg/sendmsg em vez de chamadas recvfrom/sendto. Em comparação com chamadas recvfrom/sendto, as chamadas recvmsg/sendmsg podem processar mensagens de controle complementares junto com os dados de payload. Essas mensagens de controle auxiliares incluem o endereço de origem ou de destino dos pacotes. Essa solução permite buscar endereços de destino de pacotes de entrada e, como esses endereços são endereços de balanceador de carga reais, você pode usá-los como endereços de origem ao enviar respostas.

O programa de exemplo a seguir demonstra essa solução:

#!/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)

Este programa demonstra como usar chamadas recvmsg/sendmsg. Para buscar informações de endereço de pacotes, você precisa usar a chamada setsockopt para definir a opção IP_PKTINFO.

Prós Contras
Funciona mesmo se houver vários balanceadores de carga, por exemplo, quando houver balanceadores de carga internos e externos configurados no mesmo back-end. Exige que você faça alterações complexas no app. Em alguns casos, isso pode não ser possível.

A seguir