Créer un load balancer TCP avec IPTables

20 avril 2018 - 5 min de lecture
Créer un load balancer TCP avec IPTables

Dans cette exploration technique approfondie d'iptables, l'utilitaire de configuration de la sécurité réseau sous Linux, nous verrons pourquoi et comment construire un routeur TCP sophistiqué ainsi qu'un répartiteur de charge adapté au trafic des applications IoT.

La majorité des solutions Platform as a Service (PaaS) se limitent à l’hébergement d’applications web, accessibles via le protocole HTTP. Cependant, dans des environnements contraints en mémoire, en CPU ou en batterie – comme c’est souvent le cas dans le monde de l’IoT – HTTP est rarement utilisé. On lui préfère généralement un protocole personnalisé, rapide et léger, basé sur TCP.

Quand on y réfléchit, les étapes de BUILD et RUN sont très similaires à celles d’une application web. Les langages de programmation (notamment NodeJS) ainsi que les bases de données sont souvent les mêmes. Finalement, le seul obstacle à l’hébergement d’applications IoT sur une plateforme PaaS est l’absence d’un mécanisme de routage TCP.

Ce couche de routage TCP doit être capable de :

  • Rediriger les paquets TCP bruts vers la bonne application
  • Répartir ces connexions entre plusieurs conteneurs

Pour le routage HTTP, Scalingo s’appuie sur OpenResty. Malheureusement, cet outil ne permet pas de gérer le routage TCP (ou du moins, c’est ce que nous pensions – voir la conclusion !). Nous avons donc opté pour une approche différente, fondée sur iptables.

Infrastructure réseau et objectifs

Commençons par définir les différents réseaux impliqués. Dans cet article, nous considérerons deux réseaux distincts :

  • Le réseau public : 192.168.1.0/24 – où se trouvent les clients
  • Le réseau privé : 10.0.0.0/24 – où se trouve les serveurs hébergeant les conteneurs applicatifs

Le réseau public comporte un client avec l’adresse IP 192.168.1.2. Du côté du réseau privé, nous avons trois serveurs aux adresses IP suivantes : 10.0.0.2, 10.0.0.3 et 10.0.0.4.

Dernière étape de la configuration : un serveur frontal qui fait le lien entre les deux réseaux, avec les adresses IP 10.0.0.1 et 192.168.1.1.

Network map

Dans les sections suivantes, nous supposerons que toutes les opérations et commandes sont exécutées sur le serveur frontal, sauf indication contraire.

NAT

Commençons par essayer de rediriger tout le trafic entrant sur le port TCP 27017 de l’adresse IP 192.168.1.1 vers le port 1234 du serveur 10.0.0.2 situé dans le réseau privé.

Cette redirection s’effectue via un mécanisme appelé Network Address Translation (ou NAT). Dans notre cas, nous allons nous concentrer sur deux types de NAT : DNAT et SNAT.

DNAT

Le DNAT (Destination NAT) consiste à modifier l’en-tête Destination du paquet IP et TCP.

Ici, les en-têtes IP et TCP doivent être réécrits. L’adresse IP de destination du paquet doit donc être modifiée en 10.0.0.2, et le port de destination en 1234.

La transformation suivante se produit :

   PACKET RECEIVED                   PACKET FORWARDED
|---------------------|           |---------------------|
|    IP PACKET        |           |    IP PACKET        |
|                     |           |                     |
| SRC: 192.168.1.2    |           | SRC: 192.168.1.2    |
| DST: 192.168.1.1    |           | DST: 10.0.0.2       |
| |---------------|   |           | |---------------|   |
| |   TCP PACKET  |   | =(DNAT)=> | |   TCP PACKET  |   |
| | DPORT: 27017  |   |           | | DPORT: 1234   |   |
| | SPORT: 23456  |   |           | | SPORT: 23456  |   |
| | ... DATA ...  |   |           | | ... DATA ...  |   |
| |---------------|   |           | |---------------|   |
|---------------------|           |---------------------|

Pour cela, nous devrons utiliser la chaîne PREROUTING dans la table nat d’iptables.

iptables \
  -A PREROUTING    # Append a rule to the PREROUTING chain
  -t nat           # The PREROUTING chain is in the nat table
  -p tcp           # Apply this rules only to tcp packets
  -d 192.168.1.1   # and only if the destination IP is 192.168.1.1
  --dport 27017    # and only if the destination port is 27017
  -j DNAT          # Use the DNAT target
  --to-destination # Change the TCP and IP destination header
     10.0.0.2:1234 # to 10.0.0.2:1234

C’est tout. Désormais, si l’on tente de se connecter à l’hôte iptables sur le port 27017, notre trafic sera redirigé vers notre serveur.

Si l’on essaye cela depuis le client :

user@client ~ $ echo "Hi from client" | nc 192.168.1.1 27017

Cette commande reste bloquée, et le serveur n’affiche rien

En observant les paquets reçus par Server 1, on constate que la règle iptables a bien fonctionné et que le trafic a été redirigé vers la bonne destination.

user@server-1 ~ $ tcpdump -i eth1
15:19:17.832609 IP 192.168.1.2.23456 > 10.0.0.2.1234: Flags [S],
  seq 37761180, win 29200, options [mss 1460,sackOK,
  TS val 21306607 ecr 0,nop,wscale 6], length 0

SNAT

La raison pour laquelle la commande est restée bloquée est que le serveur ne sait pas comment répondre au client, car l’adresse IP source est 192.168.1.2, qui n’appartient pas à son réseau.

La solution consiste à modifier également les en-têtes d’adresse IP source et de port source sur le serveur frontal. Cela se fait à l’aide de la méthode SNAT.

Les transformations suivantes vont alors se produire :

  PACKET RECEIVED                                             PACKET FORWARDED
|-------------------|         |-------------------|         |-------------------|
|    IP PACKET      |         |     IP PACKET     |         |     IP PACKET     |
|                   |         |                   |         |                   |
| SRC: 192.168.1.2  |         | SRC: 192.168.1.2  |         | SRC: 10.0.0.1     |
| DST: 192.168.1.1  |         | DST: 10.0.0.2     |         | DST: 10.0.0.2     |
| |---------------| |         | |---------------| |         | |---------------| |
| |   TCP PACKET  | |=(DNAT)=>| |   TCP PACKET  | |=(SNAT)=>| |   TCP PACKET  | |
| | DPORT: 27017  | |         | | DPORT: 1234   | |         | | DPORT: 1234   | |
| | SPORT: 23456  | |         | | SPORT: 23456  | |         | | SPORT: 38921  | |
| | ... DATA ...  | |         | | ... DATA ...  | |         | | ... DATA ...  | |
| |---------------| |         | |---------------| |         | |---------------| |
|-------------------|         |-------------------|         |-------------------|

Le SNAT s’applique après que toutes les décisions de routage (y compris notre règle de DNAT) ont été prises, il faut donc ajouter la règle SNAT dans la chaîne POSTROUTING de la table nat.

iptables \
  -A POSTROUTING
  -t nat
  -p tcp
  -d 10.0.0.2    # Apply this rule if the packet is going to the IP 10.0.0.2
  --dport 1234   # and if the packet is going to port 1234
  -j SNAT        # Use the SNAT target
  --to-source 10.0.0.1 # To change the SRC IP header to 10.0.0.1

Iptables conserve en mémoire une table de traduction et gère automatiquement les connexions de retour depuis le serveur, en les redirigeant vers le client.

En retestant notre commande nc précédente, on obtient :

user@client ~ $ echo "Hi from client" | nc 192.168.1.1 27017
Hi from server

En observant les paquets reçus par Server 1, on peut voir que les adresses IP source et destination ont été modifiées par notre serveur frontal.

user@server-1 ~ $ tcpdump -i eth1
15:29:37.384773 IP 10.0.0.1.38921 > 10.0.0.2.1234:
  Flags [S], seq 3215489734, win 29200, options [mss 1460,sackOK,
  TS val 21461495 ecr 0,nop,wscale 6], length 0

Sécurisation du système

Iptables est souvent utilisé comme pare-feu. Il est temps d’utiliser cette fonctionnalité principale en ajoutant des règles pour bloquer tous les paquets transférés qui ne sont pas explicitement autorisés.

Chaque chaîne iptables dispose d’une politique par défaut. Tout paquet ne correspondant à aucune règle de la chaîne suivra cette politique. Avec une politique par défaut DROP, toute connexion non explicitement acceptée sera automatiquement rejetée.

iptables -t filter -P FORWARD DROP

Les règles SNAT et DNAT que nous avons écrites précédemment ne font que modifier les en-têtes des paquets. Le filtrage n’est pas impacté par ces règles. Avec une politique par défaut configurée sur DROP, nous devons maintenant accepter explicitement le trafic en provenance de et à destination de Server 1:

# Accept traffic to Server 1
iptables -t filter -A FORWARD -d 10.0.0.2 --dport 1234 -j ACCEPT
# Accept traffic from Server 1
iptables -t filter -A FORWARD -s 10.0.0.2 --sport 1234 -j ACCEPT

Nous sommes désormais capables de transférer le trafic destiné au port TCP 27017 de notre serveur frontal vers un serveur hébergeant une application mono-nœud.

Répartition de charge (Load Balancing)

L’étape suivante consiste à répartir les connexions entre plusieurs nœuds hébergeant notre application.

Pour mettre en place un équilibrage de charge entre plusieurs hôtes, une solution consiste à modifier la règle DNAT afin qu’elle ne redirige pas toujours les clients vers un seul nœud, mais les répartisse entre plusieurs.

Pour distribuer les connexions entre Server 1, Server 2 and Server 3, on pourrait être tenté de définir les règles suivantes :

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -j DNAT --to-destination 10.0.0.2:1234

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -j DNAT --to-destination 10.0.0.3:1234

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -j DNAT --to-destination 10.0.0.4:1234

Cependant, le moteur d’iptables est déterministe : la première règle correspondante sera toujours utilisée. Dans cet exemple, c’est donc Server 1 qui recevra toutes les connexions.

Pour contourner ce problème, iptables propose un module appelé statistic qui permet d’accepter ou d’ignorer une règle selon certaines conditions statistiques.

Le module statistic prend en charge deux modes différents :

  • random: la règle est ignorée selon une probabilité définie
  • nth: la règle est appliquée selon un algorithme de type round-robin

À noter que l’équilibrage de charge ne s’effectue que pendant la phase de connexion du protocole TCP. Une fois la connexion établie, elle sera toujours routée vers le même serveur.

Répartition aléatoire (Random balancing)

Pour réellement équilibrer le trafic entre 3 serveurs différents, les trois règles précédentes deviennent :

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -m statistic --mode random --probability 0.33            \
         -j DNAT --to-destination 10.0.0.2:1234

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -m statistic --mode random --probability 0.5             \
         -j DNAT --to-destination 10.0.0.3:1234

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -j DNAT --to-destination 10.0.0.4:1234

Remarquez que trois probabilités différentes sont définies, et non 0.33 partout. La raison étant que les règles sont exécutées de manière séquentielle.

Avec une probabilité de 0.33, la première règle sera appliquée 33 % du temps et ignorée 66 % du temps.

Avec une probabilité de 0,5, la deuxième règle sera exécutée 50 % du temps et ignorée les 50 % restants. Cependant, comme cette règle est placée après la première, elle ne sera exécutée que dans 66 % des cas. Elle s'appliquera donc à seulement \(50\%*66\%=33\%\) des requêtes.

Comme seulement 33 % du trafic atteint la dernière règle, celle-ci doit être toujours appliquée.

Vous pouvez calculer la probabilité à définir pour chaque règle en fonction du nombre total de règles \(n\) et de l’indice de la règle \(i\) (en commençant à 1) avec la formule : \(p=\frac {1}{n-i+1}\)

Round Robin

L’autre manière de faire consiste à utiliser l’algorithme nth. Cet algorithme implémente un algorithme de type round-robin.

Il prend deux paramètres : every (n) and packet(p). La règle sera évaluée tous les n paquets, à partir du paquet numéro p.

Pour équilibrer la charge entre trois hôtes différents, vous devrez créer les trois règles suivantes :

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -m statistic --mode nth --every 3 --packet 0              \
         -j DNAT --to-destination 10.0.0.2:1234

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -m statistic --mode nth --every 2 --packet 0              \
         -j DNAT --to-destination 10.0.0.3:1234

iptables -A PREROUTING -t nat -p tcp -d 192.168.1.1 --dport 27017 \
         -j DNAT --to-destination 10.0.0.4:1234

Autoriser le passage du trafic

Puisque nous avons une politique par défaut DROP sur la chaîne FORWARD de la table filter, nous devons autoriser les trois serveurs distants. Cela peut être fait avec 6 règles iptables :

iptables -t filter -A FORWARD -d 10.0.0.2 --dport 1234 -j ACCEPT
iptables -t filter -A FORWARD -d 10.0.0.3 --dport 1234 -j ACCEPT
iptables -t filter -A FORWARD -d 10.0.0.4 --dport 1234 -j ACCEPT
iptables -t filter -A FORWARD -s 10.0.0.2 --sport 1234 -j ACCEPT
iptables -t filter -A FORWARD -s 10.0.0.3 --sport 1234 -j ACCEPT
iptables -t filter -A FORWARD -s 10.0.0.4 --sport 1234 -j ACCEPT

Désormais, si notre client tente de contacter notre application, nous obtenons la sortie suivante côté client :

user@client ~ $ echo "Hi from client" | nc 192.168.1.1 27017
Hi from 10.0.0.2
user@client ~ $ echo "Hi from client" | nc 192.168.1.1 27017
Hi from 10.0.0.3
user@client ~ $ echo "Hi from client" | nc 192.168.1.1 27017
Hi from 10.0.0.4
user@client ~ $ echo "Hi from client" | nc 192.168.1.1 27017
Hi from 10.0.0.2
[...]

Conclusion

Dans cet article, nous avons vu comment construire un load balancer TCP basé sur iptables et le noyau Linux. Nous utilisons cette méthode pour mettre en place une passerelle TCP, utilisée en production pour des applications IoT. La même approche est utilisée pour fournir l'accès direct aux bases de données via Internet.

À la lumière du travail récent de Cloudflare sur leur produit Spectrum certaines de leurs idées pourraient être intégrées dans notre propre répartiteur de charge TCP.

Restez connectés, le support officiel des applications TCP sera annoncé dans les prochaines semaines !

Partager l'article
Jonathan Hurter
Jonathan Hurter
Jonathan était l'un des premiers développeurs de Scalingo et il fait partie de l'entreprise depuis 2016. Autant vous dire qu'il connaît bien la plateforme Scalingo. En parallèle, il est également actif dans la scène associative strabourgeoise. Lorsqu'il a un peu de temps, il rédige des articles sur ce blog.

Essayez gratuitement Scalingo

30 jours d'essai gratuit / Pas de CB nécessaire / Hébergé en France