Tutoriel Load Balancer Nginx

imt

1. Objectif

L’objectif de ce tutoriel est de déployer au moyen de conteneurs Docker plusieurs instances de notre service Movie et de configurer et déployer Nginx qui va nous permettre de répartir les requêtes entre les différentes instances de Movie. Nous allons ensuite utiliser un script permettant de faire un grand nombre de requêtes parallèles pour montrer le gain de performance grâce au load balancing. L’ensemble des fichiers évoqués sont téléchargeables dans Moodle.

2. Installation de Docker et Docker compose

Ces concepts seront étudiés en détail en deuxième année de la formation. Il faut voir un conteneur comme un petit système d’opération qui tourne au dessus de votre OS (Windows, MacOS ou Linux), comme le principe d’une machine virtuelle. La différence est qu’un conteneur utilise toute la partie kernel de votre OS et n’émule qu’une partie utilisateur d’un OS, il est donc plus léger qu’une machine virtuelle.

Docker est l’une technologie de conteneurs parmi les plus répandues actuellement et est très utilisé dans l’industrie. Les conteneurs Docker profitent également de tout un ecosystème d’outils très intéressants et notamment Docker compose qui permet de déployer un ensemble de conteneurs Docker, de les faire communiquer facilement les uns avec les autres etc.

En passant par des conteneurs pour ce tutoriel vous avez l’avantage de n’avoir qu’une seule chose à installer sur votre machine, et qui sera utile pour les années suivantes : Docker et Docker compose.

Tip
Sous Windows et MacOS en installant Docker desktop, Docker compose est aussi installé et vous disposez d’une interface graphique par défaut.
Note
Quelque soit votre OS merci de vous référez à la documentation d’installation officielle : https://docs.docker.com/engine/install/

3. Conteneur de base et test

Nous allons ici construire un conteneur de base pour notre service Movie.

Un conteneur Docker fonctionne par successions de couches (layers) par dessus une image de base. Une image peut être vue comme un petit OS. Pour le service Movie nous allons utiliser comme image de base l’image Linux contenant Python 3.8 : python:3.8-alpine qui est une image officielle disponible sur DockerHub.

Note
Quand on utilise Docker il est important d’utiliser des images officielles pour être sûrs de ne pas introduire des failles de sécurité dans votre application. Ces images officielles sont maintenue à jour.
Tip
Si vous cherchez une image allez sur Docker Hub https://hub.docker.com/_/python

Pour construire un conteneur il faut créer un fichier Dockerfile dans le répertoire contenant les éléments pour construire le conteneur, ici donc dans le répertoire contenant le service Movie.

Ici nous allons simplement mettre les deux lignes suivantes dans le fichier`Dockerfile` :

FROM python:3.8-alpine
CMD [ "python","--version" ]

La première ligne permet d’indiquer que notre conteneur est construit à partir de cette image de base. La deuxième ligne permet d’exécuter la commande python --version pour vérifier la version de Python présente sur le conteneur.

Ecrivons maintenant un fichier docker-compose.yaml que nous pouvons mettre dans le répertoire parent de Movie. contenant ce qui suit :

version: "3.9"
services:
  movie1:
    build: ./movie/

ici nous indiquons que nous allons déployer un service movie1 en utilisant le fichier Dockerfile présent dans le répertoire ./movie.

Exécutons le déploiement avec docker-compose up

Vous devriez observer quelque chose de ce type avec le résultat de la commande python --version qui donne Python 3.8.11 :

Attaching to seance5_test_1
test_1  | Python 3.8.11
seance5_test_1 exited with code 0

4. Conteneur pour le service Movie

Maintenant que nous avons vérifié le bon fonctionnement de Docker nous allons créer le conteneur de notre service Movie.

Nous allons étoffer notre fichier Dockerfile pour construire le conteneur dont nous avons besoin pour faire fonctionner notre service movie.

Voici le nouveau contenu de notre fichier Dockerfile présent dans le répertoire movie :

FROM python:3.8-alpine
WORKDIR /app
ADD . /app/
RUN pip install -r requirements.txt
CMD [ "python","movie.py","5001" ]

La deuxième ligne indique que le répertoire de travail à considérer pour notre conteneur est le répertoire interne /app. La troisième ligne permet de créer ce répertoire dans le conteneur en copiant ce qui est présent dans notre répertoire courant .

La quatrième ligne permet d’installer les paquets nécessaires à notre service movie. La liste de ces paquet se trouve dans le fichier requirements.txt. Vous trouverez ce fichier sur Moodle.

Tip
ce fichier a été généré automatiquement avec la commande pip3 freeze > requirements.txt depuis mon environnement virtuel Python.

Enfin, la dernière ligne permet de lancer notre service movie.

Note
Dans mon cas le port est donné en argument ce n’est peut être pas le cas avec votre code.

5. Conteneur pour le service Nginx

Créez un répertoire nginx au même niveau que le répertoire movie. Ajoutez y un fichier Dockerfile et téléchargez les fichiers de configuration de Nginx sur Moodle que vous placez aussi dans ce répertoire nginx-default-container.conf et nginx.conf.

Nous n’allons pas rentrer dans le détail de la configuration de nginx ici mais vous pouvez jeter un oeil au fichier nginx.conf dans lequel sont précisées les adresses des services pour lesquels nous allons faire un load balancing. Vous comprendrez ces adresses par la suite. Comprenez pour le moment que nous aurons 4 instances du service movie. Notez aussi que notre service nginx écoute le port 8000.

Voici le contenu du fichier Dockerfile

FROM nginx
WORKDIR /app
ADD ./nginx.conf /app/
ADD ./nginx-default-container.conf /etc/nginx/nginx.conf

Notez que pour construire le conteneur nous utilisons l’image de base nginx disponible sur Docker Hub ce qui nous permet de ne pas nous soucier de l’installation de nginx nous même : https://hub.docker.com/_/nginx/

Nous créons ici encore le répertoire de travail de notre conteneur /app dans lequel est copié le fichier nginx.conf. Le fichier nginx-default-container.conf est quand à lui copié à la place du fichier de configuration de base dans /etc/nginx sur le conteneur.

6. Déploiement de nos conteneurs

Voici maintenant le contenu du fichier docker-compose.yaml mis à jour :

version: "3.9"
services:
  movie1:
    build: ./movie/
    ports:
      - "5001:5001"
  movie2:
    build: ./movie/
    ports:
      - "5002:5001"
  movie3:
    build: ./movie/
    ports:
      - "5003:5001"
  movie4:
    build: ./movie/
    ports:
      - "5004:5001"
  nginx:
    build: ./nginx/
    ports:
      - "8000:8000"

On voit ici les prémices de la puissance de Docker compose. Les différentes instances de notre service movie sont nommées movie1 movie2 movie3 et movie4. Toutes ces instances fonctionnent en interne sur le port 5001 et nous précisons donc une association de ports pour interroger nos conteneurs sans conflits 5001 5002 5003 et 5004. Notre service nginx quand à lui écoute le port 8000 qui est conservé aussi pour le conteneur.

Une chose très importante est que Docker compose gère une sorte de DNS qui permet aux conteneurs de se connaître les uns les autres. Ainsi le service nginx connait les services movie1`etc. C’est pour cette raison que nous avons ces adresses dans le fichier `nginx.conf :

upstream loadbalancer{
    server movie1:5001;
    server movie2:5001;
    server movie3:5001;
    server movie4:5001;
}

À noter également que depuis notre machine tous ces services sont accessibles par localhost:port

Reconstruisons notre déploiement avec la commande

docker-compose build

Puis lançons le déploiement

docker-compose up

Vous deviez voir tous les services se lancer de cette façon :

Creating seance5_movie4_1 ... done
Creating seance5_nginx_1  ... done
Creating seance5_movie3_1 ... done
Creating seance5_movie1_1 ... done
Creating seance5_movie2_1 ... done
Attaching to seance5_movie4_1, seance5_movie2_1, seance5_movie1_1, seance5_nginx_1, seance5_movie3_1
nginx_1   | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
nginx_1   | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
nginx_1   | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
movie4_1  | Server running in port 5001
movie4_1  |  * Serving Flask app 'movie' (lazy loading)
movie4_1  |  * Environment: production
movie4_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie4_1  |    Use a production WSGI server instead.
movie4_1  |  * Debug mode: off
movie4_1  |  * Running on all addresses.
movie4_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie4_1  |  * Running on http://172.19.0.2:5001/ (Press CTRL+C to quit)
nginx_1   | 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
nginx_1   | 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
nginx_1   | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
nginx_1   | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
nginx_1   | /docker-entrypoint.sh: Configuration complete; ready for start up
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: using the "epoll" event method
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: nginx/1.21.4
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: built by gcc 10.2.1 20210110 (Debian 10.2.1-6)
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: OS: Linux 5.4.0-92-generic
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker processes
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 31
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 32
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 33
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 34
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 35
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 36
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 37
nginx_1   | 2022/01/09 11:18:06 [notice] 1#1: start worker process 38
movie2_1  | Server running in port 5001
movie2_1  |  * Serving Flask app 'movie' (lazy loading)
movie2_1  |  * Environment: production
movie2_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie2_1  |    Use a production WSGI server instead.
movie2_1  |  * Debug mode: off
movie2_1  |  * Running on all addresses.
movie2_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie2_1  |  * Running on http://172.19.0.3:5001/ (Press CTRL+C to quit)
movie3_1  | Server running in port 5001
movie3_1  |  * Serving Flask app 'movie' (lazy loading)
movie3_1  |  * Environment: production
movie3_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie3_1  |    Use a production WSGI server instead.
movie3_1  |  * Debug mode: off
movie3_1  |  * Running on all addresses.
movie3_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie3_1  |  * Running on http://172.19.0.6:5001/ (Press CTRL+C to quit)
movie1_1  | Server running in port 5001
movie1_1  |  * Serving Flask app 'movie' (lazy loading)
movie1_1  |  * Environment: production
movie1_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie1_1  |    Use a production WSGI server instead.
movie1_1  |  * Debug mode: off
movie1_1  |  * Running on all addresses.
movie1_1  |    WARNING: This is a development server. Do not use it in a production deployment.
movie1_1  |  * Running on http://172.19.0.4:5001/ (Press CTRL+C to quit)

Le téléchargement des images de base depuis Docker Hub peut prendre un peu de temps c’est normal, mais une fois téléchargé vous n’aurez plus à le refaire.

Vous pouvez tester le fonctionnement de vos services movie avec les url suivantes dans votre navigateur :

http://localhost:5001/
http://localhost:5002/
http://localhost:5003/
http://localhost:5004/

7. Test de performances

Nous allons maintenant vérifier que l’utilisation d’un load balancer comme Nginx permet de monter en charge pour les requêtes client. Je vous propose d’utiliser le script de test python disponible sur Moodle qui permet de créer des process qui vont lancer des requêtes sur un seul service movie ou bien sur nginx et de voir la différence de performance.

Regardons un peu les différentes parties de ce script :

import requests
import time
import multiprocessing
import sys

requests va nous permettre de faire les requêtes comme on l’a fait dans les TP pour les appels entre services. time va nous servir à mesurer le temps de réponse à une requête. multiprocessing va nous permettre de créer des processus indépendants pour envoyer un grand nombre de requêtes en même temps au service. Et sys va simplement nous permettre de gérer un paramètre d’entrée qui est le mode d’exécution :

  • standard pour faire les requêtes à un unique service

  • load pour faire les requêtes au load balancer

NB = 500
VERSION = "standard"

if __name__ == '__main__':
    args = sys.argv[1:]
    if len(args) == 2 and args[0] == '-v':
        VERSION = args[1]

    processes = []
    for i in range(NB):
        processes.append(Process(i))
        processes[i].start()

    for i in range(NB):
        processes[i].join()

Ci-dessus on voit que l’on peut hanger le nombre de requêtes à effectuer en parallèle avec la constante NB. Par défaut le mode d’exécution est standard. La fonction main de notre programme récupère l’argument précisant le mode d’exécution puis démarre les NB processus avant ensuite d’attendre leur fin d’exécution.

Voici maintenant la classe de nos processus :

class Process(multiprocessing.Process):
    def __init__(self, id):
        super(Process, self).__init__()
        self.id = id

    def run(self):
        start = time.time()
        if VERSION == "standard":
            response = requests.get(
                "http://localhost:5001/moviesbytitle?title=The%20Good%20Dinosaur")
        elif VERSION == "load":
            response = requests.get(
                "http://localhost:8000/moviesbytitle?title=The%20Good%20Dinosaur")
        end = time.time()
        print ("The time of execution is :", end-start)

Cette classe hérite du type multiprocessing.Process importé et surcharge son constructeur init et la fonction d’exécution run.

La fonction run mesure un temps de départ et un temps d’arrivée start et end. Entre les deux la requête est envoyée en fonction de la VERSION soit au service localhost:5001 qui correspond donc au conteneur movie1, soit au service localhost:8000 qui correspond donc au conteneur nginx. J’ai choisi ici d’utiliser le point d’entrée moviesbytitle de mon service.

En exécutant en mode standard voici ce que j’observe. Le temps de réponse aux requêtes augmente car movie1 est surchargé et ne peut tenir la charge.

python3 bench1.py -v standard
...
The time of execution is : 0.2677164077758789
The time of execution is : 0.26245880126953125
The time of execution is : 0.26287841796875
The time of execution is : 1.029982566833496
The time of execution is : 1.0321791172027588
...

Voici ce que j’observe pour la même exécution en mode load. Le temps de réponse aux requêtes est globalement plus court et n’augmente pas.

python3 bench1.py -v load
...
The time of execution is : 0.014090776443481445
The time of execution is : 0.018979549407958984
The time of execution is : 0.01682901382446289
The time of execution is : 0.014137983322143555

On peut observer sur la sortie des services que la charge est bien répartie entre les services par nginx :

nginx_1   | 172.19.0.1 - - [09/Jan/2022:11:26:57 +0000] "GET /moviesbytitle?title=The%20Good%20Dinosaur HTTP/1.1" 200 111 "-" "python-requests/2.25.1" "-"
nginx_1   | 172.19.0.1 - - [09/Jan/2022:11:26:57 +0000] "GET /moviesbytitle?title=The%20Good%20Dinosaur HTTP/1.1" 200 111 "-" "python-requests/2.25.1" "-"
nginx_1   | 172.19.0.1 - - [09/Jan/2022:11:26:57 +0000] "GET /moviesbytitle?title=The%20Good%20Dinosaur HTTP/1.1" 200 111 "-" "python-requests/2.25.1" "-"
movie3_1  | 172.19.0.5 - - [09/Jan/2022 11:26:57] "GET /moviesbytitle?title=The%20Good%20Dinosaur HTTP/1.0" 200 -
movie4_1  | 172.19.0.5 - - [09/Jan/2022 11:26:57] "GET /moviesbytitle?title=The%20Good%20Dinosaur HTTP/1.0" 200 -
movie1_1  | 172.19.0.5 - - [09/Jan/2022 11:26:57] "GET /moviesbytitle?title=The%20Good%20Dinosaur HTTP/1.0" 200 -