Usar UDP con balanceadores de carga de red de paso a través externos

En este documento se explica cómo trabajar con balanceadores de carga de red de transferencia externos mediante el protocolo de datagramas de usuario (UDP). Este documento está dirigido a desarrolladores de aplicaciones, operadores de aplicaciones y administradores de redes.

Información sobre UDP

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

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, los participantes suelen usar una tupla de dos elementos (ip:port) o una tupla de cuatro elementos (src-ip:src-port, dest-ip:dest-port) para reconocerse entre sí.

Al igual que las aplicaciones basadas en TCP, las aplicaciones basadas en UDP también pueden beneficiarse de un balanceador de carga, por lo que los balanceadores de carga de red con paso a través externos se utilizan en situaciones de UDP.

Balanceador de carga de red de paso a través externo

Los balanceadores de carga de red de paso a través externos son balanceadores de carga de paso a través. Procesan los paquetes entrantes y los envían a los servidores de backend con los paquetes intactos. A continuación, los servidores backend envían los paquetes de retorno directamente a los clientes. Esta técnica se llama retorno directo del servidor (DSR). En cada máquina virtual (VM) Linux que se ejecute en Compute Engine y que sea un backend de unGoogle Cloud balanceador de carga de red con paso a través externo, una entrada de la tabla de enrutamiento local enruta el tráfico destinado a la dirección IP del balanceador de carga 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 carga. El agente google-network-daemon.service es el responsable de añadir esta entrada. Sin embargo, como se muestra en el siguiente ejemplo, la VM no tiene una interfaz que sea propietaria de la dirección IP del 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

El balanceador de carga de red de paso a través externo transmite los paquetes entrantes al servidor de backend sin modificar la dirección de destino. La entrada de la tabla de enrutamiento local dirige el paquete al proceso de la aplicación correcto y los paquetes de respuesta de la aplicación se envían directamente al cliente.

En el siguiente diagrama se muestra cómo funcionan los balanceadores de carga de red de paso a través externos. Los paquetes entrantes se procesan mediante un balanceador de carga llamado Maglev, que distribuye los paquetes a los servidores de backend. Los paquetes salientes se envían directamente a los clientes a través de DSR.

Maglev distribuye los paquetes entrantes a los servidores backend, que a su vez los distribuyen mediante DSR.

Un problema con los paquetes de retorno UDP

Cuando trabajas con DSR, hay una ligera diferencia entre la forma en que el kernel de Linux trata las conexiones TCP y UDP. Como TCP es un protocolo con reconocimiento del estado, el kernel tiene toda la información que necesita sobre la conexión TCP, como la dirección y el puerto del cliente, así como la dirección y el puerto del servidor. Esta información se registra en la estructura de datos de socket que representa la conexión. Por lo tanto, cada paquete de retorno de una conexión TCP tiene la dirección de origen configurada correctamente como la dirección del servidor. En el caso de un balanceador de carga, esa dirección es la dirección IP del balanceador de carga.

Recuerda que UDP no tiene estado, por lo que 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 información sobre la dirección de origen de un paquete saliente y no conoce la relación con un paquete recibido anteriormente. En el caso de la dirección de origen del paquete, el kernel solo puede rellenar la dirección de la interfaz a la que va el paquete UDP devuelto. O bien, si la aplicación ha enlazado previamente el socket a una dirección determinada, el kernel usa esa dirección como dirección de origen.

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

#!/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 la salida de tcpdump durante una conversación 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 carga y 203.0.113.2 es la dirección IP del cliente.

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

En el lado del cliente, si compruebas el resultado de tcpdump, los paquetes del servidor tendrán un aspecto similar al siguiente:

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 proceden de una dirección a la que el cliente los haya enviado. Esto provoca problemas: el kernel descarta estos paquetes y, si el cliente está detrás de un dispositivo NAT, también lo hace el dispositivo NAT. Por lo tanto, la aplicación cliente no recibe ninguna respuesta del servidor. En el siguiente diagrama se muestra este proceso, en el que el cliente rechaza los paquetes devueltos debido a que las direcciones no coinciden.

El cliente rechaza devolver paquetes.

Solucionar el problema de UDP

Para solucionar el problema de falta de respuesta, debe reescribir la dirección de origen de los paquetes salientes a la dirección IP del balanceador de carga en el servidor que aloja la aplicación. A continuación, se muestran varias opciones que puede usar para llevar a cabo esta reescritura de encabezado. La primera solución usa un enfoque basado en Linux con iptables, mientras que las otras soluciones se basan en aplicaciones.

En el siguiente diagrama se muestra la idea principal de estas opciones: reescribir la dirección IP de origen de los paquetes devueltos para que coincida con la dirección IP del balanceador de carga.

Reescribe la dirección IP de origen de los paquetes devueltos para que coincida con la dirección IP del balanceador de carga.

Usar la política de NAT en el servidor backend

La solución de la política de NAT consiste en usar el comando iptables de Linux para reescribir la dirección de destino de la dirección IP del balanceador de carga a la dirección IP de la VM. En el siguiente ejemplo, se añade una regla iptables DNAT 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 -p udp --dport 60002

Este comando añade dos reglas a la tabla NAT del sistema iptables. La primera regla omite todos los paquetes entrantes que tienen como destino la dirección local eth0. Por lo tanto, el tráfico que no procede del balanceador de carga 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 monitoriza las conexiones y reescribe automáticamente la dirección de origen de los paquetes devueltos.

Ventajas Inconvenientes
El kernel traduce la dirección sin que sea necesario cambiar las aplicaciones. Se usa CPU adicional para hacer la NAT. Además, como DNAT tiene estado, el consumo de memoria también puede ser alto.
Admite varios balanceadores de carga.

Usa nftables para manipular sin estado los campos de encabezado IP

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

Usa 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 Inconvenientes
El kernel traduce la dirección sin que sea necesario cambiar las aplicaciones. No admite varios balanceadores de carga.
El proceso de traducción de direcciones no tiene estado, por lo que el consumo de recursos es mucho menor. Se usa CPU adicional para realizar la NAT.
nftables solo están disponibles en versiones más recientes del kernel de Linux. Algunas distribuciones, como Centos 7.x, no pueden usar nftables.

Permitir que la aplicación se vincule explícitamente a la dirección IP del balanceador de carga

En la solución de vinculación, modifica tu aplicación para que se vincule explícitamente a la dirección IP del balanceador de carga. En el caso de un socket UDP, la operación bind permite que el kernel sepa qué dirección usar como dirección de origen al enviar paquetes UDP que usen ese socket.

En el siguiente ejemplo se muestra cómo enlazarse a 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 que devuelve los bytes recibidos con un "ECHO: " delante. Presta atención a las líneas 12 y 13, donde el servidor está enlazado a la dirección 198.51.100.2, que es la dirección IP del balanceador de carga.

Ventajas Inconvenientes
Se puede conseguir con un simple cambio en el código de la aplicación. No admite varios balanceadores de carga.

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

En esta solución, se usan llamadas recvmsg/sendmsg en lugar de llamadas recvfrom/sendto. En comparación con las llamadas recvfrom/sendto, las llamadas recvmsg/sendmsg pueden gestionar mensajes de control auxiliares junto con los datos de la carga útil. Estos mensajes de control auxiliares incluyen la dirección de origen o de destino de los paquetes. Esta solución te permite obtener direcciones de destino de los paquetes entrantes y, como esas direcciones son direcciones de balanceadores de carga reales, puedes usarlas como direcciones de origen al enviar respuestas.

En el siguiente programa de ejemplo 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)

Este programa muestra cómo usar las llamadas recvmsg/sendmsg. Para obtener información de direcciones de los paquetes, debes usar la llamada setsockopt para definir la opción IP_PKTINFO.

Ventajas Inconvenientes
Funciona incluso si hay varios balanceadores de carga. Por ejemplo, cuando hay balanceadores de carga internos y externos configurados en el mismo backend. Requiere que hagas cambios complejos en la aplicación. En algunos casos, puede que no sea posible.

Siguientes pasos