Utilizza UDP con bilanciatori del carico di rete passthrough esterni

Questo documento illustra come utilizzare i bilanciatori del carico di rete passthrough esterni utilizzando il protocollo UDP (User Datagram Protocol). Il documento è destinato agli sviluppatori, agli operatori e agli amministratori di rete.

Informazioni su UDP

UDP è comunemente utilizzato nelle app. Il protocollo, descritto in RFC-768, implementa un servizio di pacchetti datagrammi stateless e inaffidabile. Ad esempio, il protocollo QUIC di Google migliora l'esperienza utente utilizzando UDP per velocizzare le app basate su stream.

La parte stateless di UDP significa che il livello di trasporto non mantiene uno stato. Di conseguenza, ogni pacchetto in una "connessione" UDP è indipendente. In realtà, non c'è una reale connessione nell'UDP. I partecipanti di solito utilizzano due tuple (ip:port) o quattro tuple (src-ip:src-port, dest-ip:dest-port) per riconoscersi a vicenda.

Come le app basate su TCP, anche le app basate su UDP possono trarre vantaggio da un bilanciatore del carico, motivo per cui i bilanciatori del carico di rete passthrough esterni vengono utilizzati in scenari UDP.

Bilanciatore del carico di rete passthrough esterno

I bilanciatori del carico di rete passthrough esterni sono bilanciatori del carico passthrough; elaborano i pacchetti in entrata e li consegnano ai server di backend con i pacchetti intatti. I server di backend inviano quindi i pacchetti di ritorno direttamente ai client. Questa tecnica è chiamata Direct Server Return (DSR). Su ogni macchina virtuale (VM) Linux in esecuzione su Compute Engine che è il backend di un bilanciatore del carico di rete passthrough esterno di Google Cloud, una voce nella tabella di routing locale instrada il traffico destinato all'indirizzo IP del bilanciatore del carico al controller dell'interfaccia di rete (NIC). L'esempio seguente dimostra questa tecnica:

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

Nell'esempio precedente, 198.51.100.2 è l'indirizzo IP del bilanciatore del carico. L'agente google-network-daemon.service è responsabile dell'aggiunta di questa voce. Tuttavia, come mostrato nell'esempio seguente, la VM non ha effettivamente un'interfaccia che possiede l'indirizzo IP del bilanciatore del carico:

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

Il bilanciatore del carico di rete passthrough esterno trasmette i pacchetti in entrata, con l'indirizzo di destinazione intatto, al server di backend. La voce della tabella di routing locale instrada il pacchetto al processo dell'app corretto, mentre i pacchetti di risposta dall'app vengono inviati direttamente al client.

Il seguente diagramma mostra come funzionano i bilanciatori del carico di rete passthrough esterni. I pacchetti in arrivo vengono elaborati da un bilanciatore del carico chiamato Maglev, che li distribuisce ai server di backend. I pacchetti in uscita vengono quindi inviati direttamente ai client tramite DSR.

Maglev distribuisce i pacchetti in arrivo ai server di backend, che li distribuiscono tramite DSR.

Un problema con i pacchetti di restituzione UDP

Quando lavori con DSR, esiste una leggera differenza tra il modo in cui il kernel Linux tratta le connessioni TCP e UDP. Poiché il TCP è un protocollo stateful, il kernel contiene tutte le informazioni necessarie sulla connessione TCP, inclusi indirizzo client, porta client, indirizzo del server e porta del server. Queste informazioni vengono registrate nella struttura dati del socket che rappresenta la connessione. Pertanto, in ogni pacchetto di ritorno di una connessione TCP l'indirizzo di origine è correttamente impostato sull'indirizzo del server. Per un bilanciatore del carico, questo è l'indirizzo IP del bilanciatore del carico.

Tuttavia, tieni presente che UDP è stateless, quindi gli oggetti socket creati nel processo dell'app per le connessioni UDP non contengono informazioni sulla connessione. Il kernel non ha informazioni sull'indirizzo di origine di un pacchetto in uscita e non conosce la relazione con un pacchetto ricevuto in precedenza. Per l'indirizzo di origine del pacchetto, il kernel può compilare solo l'indirizzo dell'interfaccia a cui va il pacchetto UDP di ritorno. Oppure, se in precedenza l'app ha associato il socket a un certo indirizzo, il kernel utilizza quell'indirizzo come indirizzo di origine.

Il seguente codice mostra un semplice programma eco:

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

Di seguito è riportato l'output tcpdump durante una conversazione 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 è l'indirizzo IP del bilanciatore del carico e 203.0.113.2 è l'indirizzo IP client.

Quando i pacchetti lasciano la VM, un altro dispositivo NAT, un gateway di Compute Engine, nella rete Google Cloud traduce l'indirizzo di origine nell'indirizzo esterno. Il gateway non sa quale indirizzo esterno utilizzare, quindi è possibile usare solo l'indirizzo esterno della VM (non quello del bilanciatore del carico).

Dal lato client, se controlli l'output di tcpdump, i pacchetti del server avranno il seguente aspetto:

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 è l'indirizzo IP esterno della VM.

Dal punto di vista del client, i pacchetti UDP non provengono da un indirizzo al quale il client li ha inviati. Questo causa dei problemi: il kernel elimina questi pacchetti e, se il client si trova dietro un dispositivo NAT, lo stesso accade per il dispositivo NAT. Di conseguenza, l'app client non riceve risposta dal server. Il seguente diagramma mostra questo processo in cui il client rifiuta i pacchetti restituiti a causa di una mancata corrispondenza di indirizzi.

Il client rifiuta la restituzione dei pacchetti.

Risoluzione del problema UDP

Per risolvere il problema senza risposta, devi riscrivere l'indirizzo di origine dei pacchetti in uscita nell'indirizzo IP del bilanciatore del carico presso il server che ospita l'app. Di seguito sono riportate diverse opzioni che puoi utilizzare per eseguire la riscrittura dell'intestazione. La prima soluzione utilizza un approccio basato su Linux con iptables; le altre soluzioni utilizzano approcci basati sull'app.

Il seguente diagramma mostra l'idea alla base di queste opzioni: riscrivere l'indirizzo IP di origine dei pacchetti di ritorno in modo che corrispondano all'indirizzo IP del bilanciatore del carico.

Riscrivi l'indirizzo IP di origine dei pacchetti di ritorno in modo che corrispondano all'indirizzo IP del bilanciatore del carico.

Usa criterio NAT nel server di backend

La soluzione del criterio NAT consiste nell'utilizzare il comando iptables di Linux per riscrivere l'indirizzo di destinazione dall'indirizzo IP del bilanciatore del carico all'indirizzo IP della VM. Nell'esempio seguente, aggiungi una regola DNAT iptables per modificare l'indirizzo di destinazione dei pacchetti in arrivo:

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

Questo comando aggiunge due regole alla tabella NAT del sistema iptables. La prima regola ignora tutti i pacchetti in entrata che hanno come target l'indirizzo eth0 locale. Di conseguenza, il traffico che non proviene dal bilanciatore del carico non è interessato. La seconda regola modifica l'indirizzo IP di destinazione dei pacchetti in entrata nell'indirizzo IP interno della VM. Le regole del DNAT sono stateful, il che significa che il kernel tiene traccia delle connessioni e riscrive automaticamente l'indirizzo di origine dei pacchetti di ritorno.

Vantaggi Svantaggi
Il kernel traduce l'indirizzo, senza che siano necessarie modifiche alle app. Per NAT vengono utilizzate CPU aggiuntive. Poiché il DNAT è stateful, il consumo di memoria potrebbe essere elevato.
Supporta più bilanciatori del carico.

Usa nftables per manipolare stateless dei campi dell'intestazione IP

Nella soluzione nftables, utilizzerai il comando nftables per modificare l'indirizzo di origine nell'intestazione IP dei pacchetti in uscita. Poiché il mangling è stateless, consuma meno risorse rispetto all'uso del DNAT. Per utilizzare nftables, è necessaria una versione del kernel Linux superiore alla 4.10.

Puoi utilizzare i seguenti comandi:

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
Vantaggi Svantaggi
Il kernel traduce l'indirizzo, senza che siano necessarie modifiche alle app. Non supporta più bilanciatori del carico.
Il processo di traduzione degli indirizzi è stateless, quindi il consumo di risorse è molto inferiore. Per la NAT viene utilizzata una CPU aggiuntiva.
nftables sono disponibili solo per le versioni kernel Linux più recenti. Alcune distribuzioni, come Centos 7.x, non possono usare nftables.

Consenti all'app di collegarsi esplicitamente all'indirizzo IP del bilanciatore del carico

Nella soluzione di associazione, modifichi l'app in modo che si associ esplicitamente all'indirizzo IP del bilanciatore del carico. Per un socket UDP, l'operazione bind consente al kernel di sapere quale indirizzo utilizzare come indirizzo di origine durante l'invio di pacchetti UDP che utilizzano quel socket.

L'esempio seguente mostra come eseguire l'associazione a un indirizzo specifico in 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

Il codice precedente è un server UDP che esegue la ripetizione dei byte ricevuti, con un elemento "ECHO: " precedente. Presta attenzione alle righe 12 e 13, dove il server è associato all'indirizzo 198.51.100.2, che è l'indirizzo IP del bilanciatore del carico.

Vantaggi Svantaggi
Può essere ottenuto con una semplice modifica del codice nell'app. Non supporta più bilanciatori del carico.

Utilizza recvmsg/sendmsg anziché recvfrom/sendto per specificare l'indirizzo

In questa soluzione, utilizzi recvmsg chiamate su sendmsg anziché recvfrom chiamate su sendto. Rispetto alle chiamate recvfrom/sendto, le chiamate recvmsg/sendmsg possono gestire i messaggi di controllo accessori insieme ai dati del payload. Questi messaggi di controllo accessori includono l'indirizzo di origine o di destinazione dei pacchetti. Questa soluzione consente di recuperare gli indirizzi di destinazione dai pacchetti in arrivo e, dal momento che questi sono indirizzi reali del bilanciatore del carico, puoi utilizzarli come indirizzi di origine quando invii le risposte.

Il seguente programma di esempio illustra questa soluzione:

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

Questo programma illustra come utilizzare le chiamate recvmsg/sendmsg. Per recuperare le informazioni sugli indirizzi dai pacchetti, devi usare la chiamata setsockopt per impostare l'opzione IP_PKTINFO.

Vantaggi Svantaggi
Funziona anche se esistono più bilanciatori del carico, ad esempio quando sono presenti bilanciatori del carico interni ed esterni configurati sullo stesso backend. Richiede modifiche complesse all'app. In alcuni casi, ciò potrebbe non essere possibile.

Passaggi successivi