Usa UDP con balanceadores de cargas de red de transferencia externos

En este documento, se analiza cómo trabajar con balanceadores de cargas de red de transferencia externos mediante el protocolo de datagramas de usuario (UDP). El documento está dirigido a desarrolladores de apps, operadores de apps y administradores de red.

Acerca de UDP

UDP se usa con frecuencia en las apps. El protocolo, que se describe en RFC-768, implementa un servicio de paquetes de datagramas sin estado y poco confiable. Por ejemplo, el protocolo QUIC de Google mejora la experiencia del usuario mediante UDP para acelerar las apps basadas en transmisiones.

La parte sin estado de UDP significa que la capa de transporte no mantiene un estado. Por lo tanto, cada paquete de una “conexión” UDP es independiente. De hecho, no hay una conexión real en UDP. En su lugar, sus participantes generalmente usan dos (ip:port) o cuatro (src-ip:src-port, dest-ip:dest-port) para reconocerse entre sí.

Al igual que las aplicaciones basadas en TCP, las que se basan en UDP también pueden beneficiarse de un balanceador de cargas, por lo que los balanceadores de cargas de red de transferencia internos se usan en situaciones de UDP.

Balanceador de cargas de red de transferencia externo

Los balanceadores de cargas de red de transferencia externos son balanceadores de cargas de transferencia; procesan paquetes entrantes y los entregan a servidores de backend con paquetes intactos. Luego, los servidores de backend envían los paquetes que se muestran directamente a los clientes. Esta técnica se llama retorno directo del servidor (DSR). En cada máquina virtual (VM) de Linux que se ejecuta en Compute Engine, que es un backend de un balanceador de cargas de red transferencia externo de Google Cloud, una entrada en la tabla de enrutamiento local redirige el tráfico destinado a la dirección IP del balanceador de cargas al controlador de interfaz de red (NIC). En el siguiente ejemplo, se muestra 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

En el ejemplo anterior, 198.51.100.2 es la dirección IP del balanceador de cargas. El agente google-network-daemon.service es responsable de agregar esta entrada. Sin embargo, como se muestra en el siguiente ejemplo, la VM en realidad no tiene una interfaz que posee la dirección IP del balanceador de cargas:

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

El balanceador de cargas de red de transferencia externo transmite los paquetes entrantes, con la dirección de destino sin tocar, al servidor de backend. La entrada de la tabla de enrutamiento local enruta el paquete al proceso de la app correcto y los paquetes de respuesta de la app se envían directamente al cliente.

En el siguiente diagrama, se muestra cómo funcionan los balanceadores de cargas de red de transferencia externos. Un balanceador de cargas llamado Maglev procesa los paquetes entrantes, que distribuyen los paquetes en los servidores de backend. Los paquetes salientes se envían directamente a los clientes a través de DSR.

Maglev distribuye paquetes entrantes a servidores de backend, que distribuyen los paquetes a través de DSR.

Un problema con los paquetes que muestran UDP

Cuando trabajas con DSR, existe una leve diferencia entre cómo el kernel de Linux trata las conexiones TCP y UDP. Debido a que TCP es un protocolo con estado, el kernel tiene toda la información que necesita sobre la conexión TCP, incluidos la dirección del cliente, el puerto del cliente, la dirección del servidor y el puerto del servidor. Esta información se registra en la estructura de datos del socket que representa la conexión. Por lo tanto, cada paquete resultante de una conexión TCP tiene la dirección de origen configurada correctamente en la dirección del servidor. Para un balanceador de cargas, esa dirección es la dirección IP del balanceador de cargas.

Recuerda que UDP no tiene estado. Por lo tanto, los objetos de socket que se crean en el proceso de la aplicación para las conexiones UDP no tienen la información de la conexión. El kernel no tiene la información sobre la dirección de origen de un paquete de salida y no conoce la relación con un paquete recibido anteriormente. Para la dirección de origen del paquete, el kernel solo puede completar la dirección de la interfaz a la que se dirige el paquete UDP que se muestra. O si la app vinculó previamente el socket a una dirección determinada, el kernel usa esa dirección como la dirección de origen.

El siguiente código muestra un programa de eco simple:

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

A continuación, se muestra el resultado tcpdump durante una conversación de 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 es la dirección IP del balanceador de cargas y 203.0.113.2 es la dirección IP de cliente.

Una vez que los paquetes salen de la VM, otro dispositivo NAT (una puerta de enlace de Compute Engine) en la red de Google Cloud traduce la dirección de origen a la dirección externa. La puerta de enlace no sabe qué dirección externa debe usarse, por lo que solo se puede usar la dirección externa de la VM (no el balanceador de cargas).

En el lado del cliente, si verificas el resultado de tcpdump, los paquetes del servidor se ven de la siguiente manera:

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 es la dirección IP externa de la VM.

Desde el punto de vista del cliente, los paquetes UDP no provienen de una dirección a la que el cliente los haya enviado. Esto genera problemas: el kernel descarta estos paquetes, y si el cliente está detrás de un dispositivo NAT, también lo hace el dispositivo NAT. Como resultado, la app cliente no recibe respuesta del servidor. En el siguiente diagrama, se muestra este proceso, en el que el cliente rechaza la entrega de paquetes debido a discrepancias de dirección.

El cliente rechaza la devolución de paquetes.

Resuelve el problema de UDP

Para resolver el problema sin respuesta, debes reescribir la dirección de origen de los paquetes salientes a la dirección IP del balanceador de cargas en el servidor que aloja la app. A continuación, se presentan varias opciones que puedes usar para completar la reescritura del encabezado. La primera solución usa un enfoque basado en Linux con iptables, las otras soluciones adoptan enfoques basados en la app.

En el siguiente diagrama, se muestra la idea principal de estas opciones: reescribir la dirección IP de origen de los paquetes que se muestran a fin de hacer coincidir con la dirección IP del balanceador de cargas.

Reescribe la dirección IP de origen de los paquetes que se muestran para que coincidan con la dirección IP del balanceador de cargas.

Usa la política de NAT en el servidor de backend

La solución de la política de NAT es usar el comando iptables de Linux para reescribir la dirección de destino de la dirección IP del balanceador de cargas a la dirección IP de la VM. En el siguiente ejemplo, debes agregar una regla de DNA iptables para cambiar la dirección de destino de los paquetes entrantes:

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

Este comando agrega dos reglas a la tabla de NAT del sistema iptables. La primera regla omite todos los paquetes entrantes que se orientan a la dirección eth0 local. Como resultado, el tráfico que no proviene del balanceador de cargas no se ve afectado. La segunda regla cambia la dirección IP de destino de los paquetes entrantes a la dirección IP interna de la VM. Las reglas de DNAT tienen estado, lo que significa que el kernel realiza un seguimiento de las conexiones y reescribe automáticamente la dirección de origen de los paquetes que se muestran.

Ventajas Desventajas
El kernel traduce la dirección, sin necesidad de realizar cambios en las apps. Se usa CPU adicional para realizar la NAT. Además, debido a que DNAT tiene estado, el consumo de memoria también puede ser alto.
Admite varios balanceadores de cargas.

Usa nftables para administrar sin estado los campos de encabezado de IP

En la solución nftables, se usa el comando nftables para modificar la dirección de origen en el encabezado de IP de los paquetes salientes. Esta manipulación no tiene estado, por lo que consume menos recursos que usar DNAT. Para usar nftables, necesitas una versión de kernel de Linux superior a 4.10.

Usarás los siguientes 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
Ventajas Desventajas
El kernel traduce la dirección, sin necesidad de realizar cambios en las apps. No admite varios balanceadores de cargas.
El proceso de traducción de direcciones no tiene estado, por lo que el consumo de recursos es mucho más bajo. Se usa CPU adicional para realizar la NAT.
nftables solo están disponibles para las versiones más recientes del kernel de Linux. Algunas distribuciones, como Centos 7.x, no pueden usar nftables.

Permitir que la app se vincule de forma explícita a la dirección IP del balanceador de cargas

En la solución de vinculación, modificas tu aplicación para que se vincule de manera explícita con la dirección IP del balanceador de cargas. Para un socket UDP, la operación bind le permite al kernel saber qué dirección debe usar como la dirección de origen cuando envía paquetes UDP que usan ese socket.

En el siguiente ejemplo, se muestra cómo vincular una dirección específica en 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

El código anterior es un servidor UDP, repite los bytes recibidos, con una "ECHO: " anterior. Presta atención a las líneas 12 y 13, en las que el servidor esté vinculado a la dirección 198.51.100.2, que es la dirección IP del balanceador de cargas.

Ventajas Desventajas
Esto se puede lograr con un simple cambio de código en la aplicación. No admite varios balanceadores de cargas.

Usa recvmsg/sendmsg en lugar de recvfrom/sendto para especificar la dirección.

En esta solución, usarás llamadas recvmsg/sendmsg en lugar de llamadas recvfrom/sendto. En comparación con las llamadas recvfrom/sendto, las llamadas recvmsg/sendmsg pueden manejar mensajes de control auxiliares junto con los datos de carga útil. Estos mensajes de control auxiliares incluyen la dirección de origen o destino de los paquetes. Esta solución te permite recuperar direcciones de destino de paquetes entrantes, y debido a que estas direcciones son direcciones reales del balanceador de cargas, puedes usarlas como direcciones de origen cuando envíes respuestas.

En el siguiente ejemplo de programa, se muestra esta solución:

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

En este programa, se demuestra cómo usar las llamadas recvmsg/sendmsg. A fin de recuperar la información de dirección de los paquetes, debes usar la llamada setsockopt para configurar la opción IP_PKTINFO.

Ventajas Desventajas
Funciona incluso si hay varios balanceadores de cargas, por ejemplo, cuando hay balanceadores de cargas internos y externos configurados en el mismo backend. Debes realizar cambios complejos en la app. En algunos casos, esto podría no ser posible.

¿Qué sigue?