Utiliser UDP avec des équilibreurs de charge réseau externes à stratégie directe

Ce document explique comment utiliser les équilibreurs de charge réseau externes à stratégie directe à l'aide du protocole de datagramme utilisateur (UDP). Ce document est destiné aux développeurs d'applications, aux opérateurs d'application et aux administrateurs réseau.

À propos du protocole UDP

Le protocole UDP est couramment utilisé dans les applications. Ce protocole, décrit dans le document RFC-768, met en œuvre un service de paquets de datagrammes sans état et non fiable. Par exemple, le protocole QUIC de Google améliore l'expérience utilisateur en utilisant le protocole UDP pour accélérer les applications basées sur des flux.

La partie "sans état" du protocole UDP signifie que la couche de transport ne conserve pas d'état. Par conséquent, chaque paquet d'une "connexion" UDP est indépendant. En réalité, il n'existe aucune connexion réelle en UDP. À la place, ses participants utilisent généralement un 2-tuple (ip:port) ou un 4-tuple (src-ip:src-port, dest-ip:dest-port) pour se reconnaître.

Comme les applications basées sur TCP, les applications basées sur UDP peuvent également bénéficier d'un équilibreur de charge. C'est la raison pour laquelle l'équilibreur de charge réseau externe à stratégie directe est utilisé dans les scénarios UDP.

Équilibreur de charge réseau externe à stratégie directe

Les équilibreurs de charge réseau externes à stratégie directe sont des équilibreurs de charge à stratégie directe. Ils traitent les paquets entrants et les distribuent aux serveurs backend avec les paquets intacts. Les serveurs de backend envoient ensuite les paquets renvoyés directement aux clients. Cette technique est appelée Retour direct du serveur (DSR). Sur chaque machine virtuelle (VM) Linux exécutée sur Compute Engine qui est un backend d'un équilibreur de charge réseau à stratégie directe Google Cloud, une entrée dans la table de routage locale achemine le trafic destiné à l'adresse IP de l'équilibreur de charge vers la carte d'interface réseau. L'exemple suivant illustre cette technique :

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

Dans l'exemple précédent, 198.51.100.2 correspond à l'adresse IP de l'équilibreur de charge. L'agent google-network-daemon.service est responsable de l'ajout de cette entrée. Toutefois, comme le montre l'exemple suivant, la VM ne dispose pas d'une interface qui possède l'adresse IP de l'équilibreur de charge :

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

L'équilibreur de charge réseau externe à stratégie directe transmet les paquets entrants, avec l'adresse de destination intacte, au serveur de backend. L'entrée de la table de routage locale achemine le paquet vers le processus d'application approprié, et les paquets de réponse de l'application sont envoyés directement au client.

Le schéma suivant illustre le fonctionnement des équilibreurs de charge réseau externes à stratégie directe. Les paquets entrants sont traités par un équilibreur de charge appelé Maglev, qui distribue les paquets aux serveurs de backend. Les paquets sortants sont ensuite envoyés directement aux clients via DSR.

Maglev distribue les paquets entrants aux serveurs de backend, qui distribuent les paquets via DSR.

Problème avec les paquets de retour UDP

Lorsque vous travaillez avec DSR, il y a une légère différence entre la façon dont le noyau Linux traite les connexions TCP et UDP. Comme TCP est un protocole avec état, le noyau dispose de toutes les informations nécessaires à la connexion TCP, y compris l'adresse client, le port client, l'adresse du serveur et le port du serveur. Ces informations sont enregistrées dans la structure de données de socket qui représente la connexion. Ainsi, chaque paquet renvoyé d'une connexion TCP comprend l'adresse source correctement définie sur l'adresse du serveur. Pour un équilibreur de charge, cette adresse est l'adresse IP de l'équilibreur de charge.

Rappelez-vous toutefois que le protocole UDP est sans état. Ainsi, les objets socket créés dans le processus de l'application pour les connexions UDP ne contiennent pas les informations de connexion. Le noyau ne dispose pas des informations sur l'adresse source d'un paquet sortant et ne connaît pas la relation avec un paquet reçu précédemment. Pour l'adresse source du paquet, le noyau ne peut renseigner que l'adresse de l'interface à laquelle le paquet UDP renvoyé est destiné. Ou, si l'application a précédemment associé le socket à une certaine adresse, le noyau utilise cette adresse comme adresse source.

Le code suivant illustre un programme d'écho 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)

Voici le résultat tcpdump lors d'une conversation 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 est l'adresse IP de l'équilibreur de charge et 203.0.113.2 est l'adresse IP du client.

Une fois que les paquets ont quitté la VM, un autre appareil NAT (une passerelle Compute Engine) du réseau Google Cloud traduit l'adresse source en adresse externe. La passerelle ne sait pas quelle adresse externe utiliser. Par conséquent, seule l'adresse externe de la VM (et non l'équilibreur de charge) peut être utilisée.

Côté client, si vous vérifiez le résultat de tcpdump, les paquets du serveur se présentent comme suit :

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 est l'adresse IP externe de la VM.

Du point de vue du client, les paquets UDP ne proviennent pas d'une adresse à laquelle le client les a envoyés. Cela pose problème : le noyau supprime ces paquets et, si le client se trouve derrière un appareil NAT, l'appareil NAT fait de même. Par conséquent, l'application cliente ne reçoit aucune réponse du serveur. Le schéma suivant illustre ce processus où le client rejette les paquets renvoyés en raison d'erreurs de correspondance des adresses.

Le client refuse les paquets renvoyés.

Résoudre le problème de protocole UDP

Pour résoudre le problème de non-réponse, vous devez réécrire l'adresse source des paquets sortants vers l'adresse IP de l'équilibreur de charge sur le serveur hébergeant l'application. Voici plusieurs options que vous pouvez utiliser pour effectuer cette réécriture d'en-tête. La première solution utilise une approche basée sur Linux avec iptables, tandis que les autres solutions adoptent des approches basées sur des applications.

Le schéma suivant illustre l'idée centrale de ces options : réécrire l'adresse IP source des paquets renvoyés de manière à correspondre à l'adresse IP de l'équilibreur de charge.

Réécrire l'adresse IP source des paquets renvoyés de manière à correspondre à l'adresse IP de l'équilibreur de charge.

Utiliser une règle NAT sur le serveur backend

La solution de règle NAT consiste à utiliser la commande Linux iptables pour réécrire l'adresse de destination de l'adresse IP de l'équilibreur de charge vers l'adresse IP de la VM. Dans l'exemple suivant, vous ajoutez une règle DNAT iptables pour modifier l'adresse de destination des paquets entrants :

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

Cette commande ajoute deux règles à la table NAT du système iptables. La première règle contourne tous les paquets entrants qui ciblent l'adresse eth0 locale. Le trafic qui ne provient pas de l'équilibreur de charge n'est donc pas affecté. La seconde règle remplace l'adresse IP de destination des paquets entrants par l'adresse IP interne de la VM. Les règles DNAT sont avec état, ce qui signifie que le noyau effectue le suivi des connexions et réécrit automatiquement l'adresse source des paquets renvoyés.

Avantages Inconvénients
Le noyau traduit l'adresse, sans besoin de modifier les applications. Un processeur supplémentaire est utilisé pour effectuer la traduction NAT. Et comme la traduction DNAT est avec état, la consommation de mémoire peut également être élevée.
Accepte plusieurs équilibreurs de charge.

Utiliser nftables pour modifier sans état les champs d'en-tête IP

Dans la solution nftables, vous utilisez la commande nftables pour modifier l'adresse source dans l'en-tête IP des paquets sortants. Cette manipulation est sans état. Elle consomme donc moins de ressources que la traduction DNAT. Pour utiliser nftables, vous devez disposer d'une version de noyau Linux supérieure à 4.10.

Vous utilisez les commandes suivantes :

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
Avantages Inconvénients
Le noyau traduit l'adresse, sans besoin de modifier les applications. N'accepte pas plusieurs équilibreurs de charge.
Le processus de traduction d'adresses est sans état. La consommation de ressources est donc nettement inférieure. Un processeur supplémentaire est utilisé pour effectuer la traduction NAT.
Les nftables ne sont disponibles que dans les versions de noyau Linux les plus récentes. Certaines distributions, telles que Centos 7.x, ne peuvent pas utiliser nftables.

Laisser l'application établir une liaison explicitement à l'adresse IP de l'équilibreur de charge

Dans la solution de liaison, vous modifiez votre application de sorte qu'elle soit explicitement liée à l'adresse IP de l'équilibreur de charge. Pour un socket UDP, l'opération bind permet au noyau d'identifier l'adresse à utiliser comme adresse source lors de l'envoi de paquets UDP utilisant ce socket.

L'exemple suivant montre comment lier une adresse spécifique dans 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

Le code précédent est un serveur UDP. Il renvoie les octets reçus, précédés de "ECHO: ". Soyez attentif aux lignes 12 et 13, où le serveur est associé à l'adresse 198.51.100.2, qui est l'adresse IP de l'équilibreur de charge.

Avantages Inconvénients
Peut être réalisé avec un simple modification du code de l'application. N'accepte pas plusieurs équilibreurs de charge.

Utiliser recvmsg/sendmsg au lieu de recvfrom/sendto pour spécifier l'adresse

Dans cette solution, vous utilisez des appels recvmsg/sendmsg au lieu d'appels recvfrom/sendto. Par rapport aux appels recvfrom/sendto, les appels recvmsg/sendmsg peuvent gérer les messages de contrôle auxiliaire avec les données de charge utile. Ces messages de contrôle auxiliaire incluent l'adresse source ou de destination des paquets. Cette solution vous permet de récupérer les adresses de destination à partir des paquets entrants et, comme ces adresses sont des adresses d'équilibreur de charge réels, vous pouvez les utiliser comme adresses sources lors de l'envoi de réponses.

L'exemple de programme suivant illustre cette solution :

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

Ce programme montre comment utiliser les appels recvmsg/sendmsg. Pour récupérer les informations d'adresse à partir des paquets, vous devez utiliser l'appel setsockopt pour définir l'option IP_PKTINFO.

Avantages Inconvénients
Fonctionne même si plusieurs équilibreurs de charge sont utilisés, par exemple lorsqu'il existe des équilibreurs de charge internes et externes configurés sur le même backend. Vous devez apporter des modifications complexes à l'application. Dans certains cas, cela peut s'avérer impossible.

Étapes suivantes