Use UDP com balanceadores de carga de rede de encaminhamento externo

Este documento aborda como trabalhar com balanceadores de carga de rede de encaminhamento externo usando o Protocolo UDP. O documento destina-se a programadores de apps, operadores de apps e administradores de rede.

Acerca do UDP

O UDP é usado frequentemente em apps. O protocolo, que é descrito na RFC-768, implementa um serviço de pacotes de datagramas não fiável e sem estado. Por exemplo, o protocolo QUIC da Google melhora a experiência do utilizador através da utilização do UDP para acelerar as apps baseadas em streams.

A parte sem estado do UDP significa que a camada de transporte não mantém um estado. Por conseguinte, cada pacote numa "ligação" UDP é independente. Na verdade, não existe uma ligação real no UDP. Em alternativa, os participantes usam normalmente uma tupla de 2 elementos (ip:port) ou uma tupla de 4 elementos (src-ip:src-port, dest-ip:dest-port) para se reconhecerem uns aos outros.

Tal como as apps baseadas em TCP, as apps baseadas em UDP também podem beneficiar de um balanceador de carga. É por isso que os balanceadores de carga de rede de encaminhamento externos são usados em cenários de UDP.

Balanceador de carga de rede de encaminhamento externo

Os balanceadores de carga de rede de encaminhamento externo são balanceadores de carga de encaminhamento; processam os pacotes recebidos e entregam-nos aos servidores de back-end com os pacotes intactos. Em seguida, os servidores de back-end enviam os pacotes de retorno diretamente aos clientes. Esta técnica chama-se Direct Server Return (DSR). Em cada máquina virtual (VM) Linux em execução no Compute Engine que seja um back-end de um balanceador de carga de rede de encaminhamento direto externo, uma entrada na tabela de encaminhamento local encaminha o tráfego destinado ao endereço IP do balanceador de carga para o controlador de interface de rede (NIC).Google Cloud O exemplo seguinte demonstra esta 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 equilibrador de carga. O agente google-network-daemon.service é responsável por adicionar esta entrada. No entanto, como mostra o exemplo seguinte, a VM não tem efetivamente uma interface que seja proprietária do 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 Network Load Balancer de encaminhamento externo transmite os pacotes recebidos, com o endereço de destino intacto, para o servidor de back-end. A entrada da tabela de encaminhamento local encaminha o pacote para o processo da app correto e os pacotes de resposta da app são enviados diretamente para o cliente.

O diagrama seguinte mostra como funcionam os equilibradores de carga de encaminhamento externo. Os pacotes recebidos são processados por um balanceador de carga denominado Maglev, que distribui os pacotes pelos servidores de back-end. Os pacotes de saída são, em seguida, enviados diretamente para os clientes através de DSR.

O Maglev distribui os pacotes recebidos para servidores de back-end, que distribuem os pacotes através de DSR.

Um problema com pacotes de retorno UDP

Quando trabalha com DSR, existe uma ligeira diferença entre a forma como o kernel do Linux trata as ligações TCP e UDP. Uma vez que o TCP é um protocolo com estado, o kernel tem todas as informações necessárias sobre a ligação TCP, incluindo o endereço do cliente, a porta do cliente, o endereço do servidor e a porta do servidor. Estas informações são registadas na estrutura de dados de entrada que representa a ligação. Assim, cada pacote devolvido de uma ligação TCP tem o endereço de origem definido corretamente para o endereço do servidor. Para um balanceador de carga, essa morada é a morada IP do balanceador de carga.

Tenha em atenção que o UDP não tem estado, pelo que os objetos de socket criados no processo da app para ligações UDP não têm as informações de ligação. O kernel não tem as informações sobre o endereço de origem de um pacote de saída e não conhece 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 a qual o pacote UDP de retorno é enviado. Em alternativa, se a app tiver associado anteriormente o soquete a um determinado endereço, o kernel usa esse endereço como o endereço de origem.

O código seguinte 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)

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

Depois de os pacotes saírem da VM, outro dispositivo NAT, um gateway do Compute Engine, na Google Cloud rede traduz o endereço de origem para o endereço externo. A gateway não sabe que endereço externo deve ser usado, pelo que só é possível usar o endereço externo da VM (e não o do equilibrador de carga).

Do lado do cliente, se verificar o resultado de tcpdump, os pacotes do servidor têm o seguinte aspeto:

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 provêm de um endereço para o qual o cliente os enviou. Isto causa problemas: o kernel rejeita estes pacotes e, se o cliente estiver atrás de um dispositivo NAT, o dispositivo NAT também o faz. Como resultado, a app cliente não recebe nenhuma resposta do servidor. O diagrama seguinte mostra este processo em que o cliente rejeita os pacotes de retorno devido a incompatibilidades de endereços.

O cliente rejeita os pacotes de devolução.

Resolver o problema de UDP

Para resolver o problema de ausência de resposta, tem de reescrever o endereço de origem dos pacotes de saída para o endereço IP do balanceador de carga no servidor que está a alojar a app. Seguem-se várias opções que pode usar para realizar esta reescrita do cabeçalho. A primeira solução usa uma abordagem baseada no Linux com iptables; as outras soluções usam abordagens baseadas em apps.

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

Reescrever o endereço IP de origem dos pacotes devolvidos para corresponder ao endereço IP do balanceador de carga.

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

A solução da política de NAT consiste em 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 seguinte, adiciona uma regra de DNAT para alterar o endereço de destino dos pacotes recebidos:iptables

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

Este comando adiciona duas regras à tabela NAT do sistema iptables. A primeira regra ignora todos os pacotes recebidos que têm como destino o endereço eth0 local. Como resultado, o tráfego que não provém do equilibrador de carga não é afetado. A segunda regra altera o endereço IP de destino dos pacotes recebidos para o endereço IP interno da VM. As regras de DNAT têm estado, o que significa que o kernel monitoriza as ligações e reescreve automaticamente o endereço de origem dos pacotes de retorno.

Vantagens Desvantagens
O kernel traduz o endereço, sem necessidade de alterações às apps. É usada CPU adicional para fazer o NAT. Além disso, como o DNAT tem estado, o consumo de memória também pode ser elevado.
Suporta vários balanceadores de carga.

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

Na solução nftables, usa o comando nftables para alterar o endereço de origem no cabeçalho IP dos pacotes de saída. Esta alteração é sem estado, pelo que consome menos recursos do que usar DNAT. Para usar o nftables, precisa de uma versão do kernel do Linux superior a 4.10.

Usa 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
Vantagens Desvantagens
O kernel traduz o endereço, sem necessidade de alterações às apps. Não suporta vários equilibradores de carga.
O processo de tradução de endereços não tem estado, pelo que o consumo de recursos é muito inferior. É usada CPU adicional para fazer a NAT.
nftables só estão disponíveis para versões mais recentes do kernel do Linux. Algumas distribuições, como o Centos 7.x, não podem usar o nftables.

Permita que a app se associe explicitamente ao endereço IP do balanceador de carga

Na solução de associação, modifica a sua app para que se associe explicitamente ao endereço IP do balanceador de carga. Para um socket UDP, a operação bind permite que o kernel saiba que endereço usar como endereço de origem ao enviar pacotes UDP que usam esse socket.

O exemplo seguinte mostra como fazer a associação 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; ecoa os bytes recebidos, com um "ECHO: " anterior. Preste atenção às linhas 12 e 13, onde o servidor está associado ao endereço 198.51.100.2, que é o endereço IP do equilibrador de carga.

Vantagens Desvantagens
Pode ser alcançado com uma simples alteração do código da app. Não suporta vários equilibradores de carga.

Use recvmsg/sendmsg em vez de recvfrom/sendto para especificar a morada

Nesta solução, usa chamadas recvmsg/sendmsg em vez de chamadas recvfrom/sendto. Em comparação com as chamadas recvfrom/sendto, as chamadas recvmsg/sendmsg podem processar mensagens de controlo auxiliares juntamente com os dados de conteúdo. Estas mensagens de controlo auxiliares incluem o endereço de origem ou destino dos pacotes. Esta solução permite-lhe obter endereços de destino de pacotes recebidos e, uma vez que esses endereços são endereços reais do equilibrador de carga, pode usá-los como endereços de origem quando envia respostas.

O seguinte programa de exemplo demonstra esta 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 obter informações de morada a partir de pacotes, tem de usar a chamada setsockopt para definir a opção IP_PKTINFO.

Vantagens Desvantagens
Funciona mesmo que existam vários balanceadores de carga, por exemplo, quando existem balanceadores de carga internos e externos configurados para o mesmo back-end. Exige que faça alterações complexas à app. Em alguns casos, isto pode não ser possível.

O que se segue?