Tutoriel Load Balancer Nginx
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 -