NLP Cloud est un service auquel j’ai récemment contribué. Il s’agit d’une API qui se base sur spaCy et les transformers HuggingFace afin de proposer Named Entity Recognition (NER), analyse de sentiments, classification de texte, summarization, et bien plus. Il utilise plusieurs technos intéressantes et je me suis donc dit que j’allais lancer une série d’articles à ce sujet. Ce premier de la série traite de l’orchestration de conteneurs et de la façon dont l’avons mise en place grâce à Docker Swarm. J’espère que cela sera utile à certains !
Pourquoi l’orchestration de conteneurs
NLP Cloud utilise un très grand nombre de conteneurs, principalement parce que chaque modèle NLP est exécuté dans un conteneur dédié. Non seulement les modèles spaCy pré-entrainés ont chacun leur propre conteneur, mais c’est aussi le cas de chaque modèle custom uploadé par l’utilisateur. C’est très pratique pour plusieurs raisons:
- Il est facile de faire tourner un modèle NLP sur le serveur qui a les ressources les plus adaptées. Les modèles de machine learning sont très gourmands en ressources : ils consomment beaucoup de mémoire, et il est parfois intéressant de mettre à profit un GPU (si vous utilisez des transformers NLP par example). Il est donc préférable de les déployer sur des machines spécifiques.
- La scalabilité horizontale est assurée simplement en ajoutant des répliques du même modèle NLP
- La haute disponibilité est facilitée par la redondance et le failover automatique
- Cela aide à limiter les coûts : faire de la scalabilité horizontale sur une myriade de petites machines est bien moins coûteux que de faire de la scalabilité verticale sur quelques grosses machines
Bien entendu, mettre en place une telle architecture prend un peu de temps et des compétences particulières, mais au final cela paye souvent lorsque vous décidez de batir une application complexe.
Pourquoi Docker Swarm
Docker Swarm est constamment opposé à Kubernetes et Kubernetes est censé être le gagnant de la compétition pour l’orchestration de conteneurs aujourd’hui. Mais ce n’est pas si simple…
Kubernetes a des tas de paramètres qui en font un excellent candidat pour les cas très avancés. Mais cette polyvalence a un coût : Kubernetes est difficile à installer, à configurer, et à maintenir. C’est même si compliqué qu’aujourd’hui la plupart des entreprises utilisant Kubernetes ont en fait souscrit une version managée de Kubernetes, sur GCP par exemple, et tous les services cloud n’ont pas la même implémentation de Kubernetes.
N’oublions pas que Google a initialement conçu Kubernetes pour ses besoins internes, de la même façon que Facebook a aussi conçu React pour ses propres besoin. Mais il est fort possible que vous n’ayez pas à gérer le même niveau de complexité pour votre projet. Tant de projets seraient livrés plus rapidement et seraient aujourd’hui plus faciles à maintenir si des outils plus simples avaient été choisis dès le départ…
Chez NLP Cloud, nous avons un grand nombre de conteneurs mais nous n’avons pas besoin des capacités de configuration avancées de Kubernetes. Et nous ne voulons pas utiliser une version managée de Kubernetes non plus : premièrement pour des raisons de coût, mais aussi parce que nous ne voulons pas être coincés dans un cloud particulier, et enfin pour des raisons de confidentialité des données.
Docker Swarm a par ailleurs un avantage significatif : il s’intègre parfaitemenent à Docker et Docker Compose. Cela rend la configuration simple comme bonjour, et les équipes déjà habituées à Docker n’ont aucune difficulté à passer à Swarm.
Installez le cluster
Disons que nous souhaitons avoir un cluster constitué de 5 serveurs :
- 1 noeud manager qui sera en charge de l’orchestration du cluster. Il hébergera aussi la base de données (juste un exemple, la BDD pouvant aussi parfaitement tourner sur un worker).
- 1 noeud worker qui hébergera notre backoffice Python/Django
- 3 noeuds worker qui hébergeront l’API Python FastAPI répliquée servant un modèle NLP
Nous omettons délibérément le reverse proxy qui répartira les requêtes vers les bons noeuds car cela fera l’objet d’un prochain article.
Provisionnez les serveurs
Commandez 5 serveurs là où bon vous semble. Cela peut être chez OVH, Digital Ocean, AWS, GCP… peu importe.
Il est important que vous réfléchissiez à la performance de chaque serveur en fonction de l’usage auquel il sera dédié. Par exemple, le noeud hébergeant le backoffice n’a probablement pas besoin de performances élevées. Le noeud hébergeant le reverse proxy (non traité dans ce tuto) a certainement besoin d’un bon CPU. Et les noeuds API servant le modèle NLP ont un grand besoin de RAM, et peut-être même de GPU.
Installez une distribution Linux sur chaque serveur. Personnellement je partirais sur la dernière version LTS d’Ubuntu.
Sur chaque serveur, installez Docker.
Maintenant donnez à chaque serveur un hostname intelligible. Ce nom sera utile lors de votre prochaine connexion SSH puisque ce hostname apparaitra dans votre invite de commande, ce qui est une bonne pratique afin d’éviter de travailler sur le mauvais serveur… Ce nom sera aussi utilisé par Docker Swarm comme nom pour votre noeud. Lancez la commande suivante sur chaque serveur :
echo <node name> /etc/hostname; hostname -F /etc/hostname
Sur le manager, logguez vous à votre registre Docker afin que Docker Swarm puisse télécharger vos images (pas besoin de faire cela sur les workers) :
docker login
Initialisez le cluster et rattachez les noeuds
Sur le noeud manager, tapez :
docker swarm init --advertise-addr <server IP address>
--advertise-addr <server IP address>
est seulement requis si votre serveur possède plusieurs addresses IP sur la même interface afin que Docker sache laquelle choisir.
Puis, afin de rattacher vos noeuds, tapez ce qui suit sur le manager :
docker swarm join-token worker
Cela vous retournera quelque chose comme docker swarm join --token SWMTKN-1-5tl7ya98erd9qtasdfml4lqbosbhfqv3asdf4p13-dzw6ugasdfk0arn0 172.173.174.175:2377
Copiez cette sortie et collez-là sur un noeud worker. Puis répétez l’opération join-token
pour chaque noeud worker.
Vous devriez maintenant être capable de voir tous vos noeuds en tapant :
docker node ls
Donnez des labels à vos noeuds
Il est imporant de donner les bons labels à vos noeuds parce que vous aurez besoin de ces labels plus tard afin que Docker Swarm puisse déterminer sur quel noeud un conteneur doit être déployé. Si vous ne spécifiez pas le noeud sur lequel votre conteneur doit être déployé, Docker Swarm le déploiera sur n’importe que noeud disponible. Ce n’est clairement pas ce que nous voulons.
Disons que votre backoffice requiert peu de ressources et est sans état. Cela signifie que ce dernier peut être déployé sur n’importe quel noeud worker bon marché. Votre API est aussi sans état mais, au contraire, elle est gourmande en mémoire et requiert un hardware spécifique dédié au machine learning. Vous voulez donc la déployer sur un des 3 noeuds workers dédiés au machine learning. Enfin, votre base de données n’est pas sans état, elle doit donc toujours être déployée sur le même serveur : disons que ce serveur sera notre noeud manager (mais cela peut parfaitement aussi être un worker).
Faites ce qui suit sur le manager.
Le manager hébergera la BDD donc donnez lui le label “database” :
docker node update --label-add type=database <manager name>
Donnez le label “cheap” au worker qui a de faibles performances et qui hébergera le backoffice :
docker node update --label-add type=cheap <backoffice worker name>
Enfin, donnez le label “machine-learning” à tous les workers qui hébergeront les modèles NLP :
docker node update --label-add type=machine-learning <api worker 1 name>
docker node update --label-add type=machine-learning <api worker 2 name>
docker node update --label-add type=machine-learning <api worker 3 name>
Mettez en place la configuration avec Docker Compose
Si vous utilisez déjà Docker Compose, il y a des chances pour que vous trouviez la transition vers Swarm extrêmement facile.
Si vous n’ajoutez rien à votre fichier docker-compose.yml
existant il fonctionnera avec Docker Swarm, mais vos conteneurs seront déployés n’importe où sur votre cluster sans votre controle, et ils ne pourront pas communiquer entre eux.
Réseau
Afin que vos conteneurs puissent communiquer entre eux, il faut qu’ils se situent sur le même réseau virtuel. Par example une application Python/Django, une API FastAPI, et une base de données PostgreSQL doivent être sur le même réseau afin de travailler ensemble. Nous créerons manuellement le réseau main_network
un peu plus tard juste avant de déployer, mais utilisons-le dès maintenant dans notre docker-compose.yml
:
version: "3.8"
networks:
main_network:
external: true
services:
backoffice:
image: <path to your custom Django image>
depends_on:
- database
networks:
- main_network
api:
image: <path to your custom FastAPI image>
depends_on:
- database
networks:
- main_network
database:
image: postgres:13
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=db_name
volumes:
- /local/path/to/postgresql/data:/var/lib/postgresql/data
networks:
- main_network
Détails de déploiement
Maintenant il faut que vous disiez à Docker Swarm sur quel serveur chaque service doit être dédié. C’est là que vous utiliserez les labels créés un peu plus tôt.
En gros, il s’agit juste d’utiliser la directive constraints
de cette façon :
version: "3.8"
networks:
main_network:
external: true
services:
backoffice:
image: <path to your custom Django image>
depends_on:
- database
networks:
- main_network
deploy:
placement:
constraints:
- node.role == worker
- node.labels.type == cheap
api:
image: <path to your custom FastAPI image>
depends_on:
- database
networks:
- main_network
deploy:
placement:
constraints:
- node.role == worker
- node.labels.type == machine-learning
database:
image: postgres:13
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=db_name
volumes:
- /local/path/to/postgresql/data:/var/lib/postgresql/data
networks:
- main_network
deploy:
placement:
constraints:
- node.role == manager
- node.labels.type == database
Réservation et limitation des ressources
Il peut être dangereux de déployer vos conteneurs tels quels pour deux raisons :
- l’orchestrateur va les déployer n’importe où, y compris sur un serveur n’ayant pas les ressources disponibles suffisantes (parce que d’autres conteneurs consomment toute la mémoire disponible par exemple)
- Un de vos conteneurs peut consommer plus de ressources que prévu et au final causer des problèmes sur votre serveur. Par exemple si votre modèle de machine learning en arrive à consommer trop de mémoire, il peut inciter le serveur hôte à déclencher la protection OOM qui commencera à tuer des processus afin de libérer de la RAM. Par défaut le moteur Docker est un des derniers processus à être tués par le serveur mais, si cela doit arriver, cela signifie que tous vos conteneurs sur cet hôte vont se couper.
Afin de contrer ce risque, vous pouvez utiliser les directives reservations
et limits
:
reservations
s’assure qu’un conteneur est uniquement déployé si le serveur cible a assez de ressources disponibles. Si ce n’est pas le cas, l’orchestrateur ne le déploiera pas jusqu’à ce que les ressources nécessaires soient disponibles.limits
empêche un conteneur de consommer trop de ressources une fois qu’il est déployé
Disons que notre conteneur API - faisant tourner le modèle de machine learning - ne doit être déployé que si 5Go de RAM et la moitié du CPU sont disponibles. Disons aussi que l’API ne doit pas consommer plus de 10Go de RAM et 80% de CPU. Voici ce que nous devons faire :
version: "3.8"
networks:
main_network:
external: true
services:
api:
image: <path to your custom FastAPI image>
depends_on:
- database
networks:
- main_network
deploy:
placement:
constraints:
- node.role == worker
- node.labels.type == machine-learning
resources:
limits:
cpus: '0.8'
memory: 10G
reservations:
cpus: '0.5'
memory: 5G
Réplication
Afin de mettre en place la scalabilité horizontale, il peut être intéressant de répliquer certaines de vos applications sans état. Il vous faut pour ce faire utiliser la directive replicas
. Par exemple disons que nous voulons 3 répliques de notre API. Voici comment faire :
version: "3.8"
networks:
main_network:
external: true
services:
api:
image: <path to your custom FastAPI image>
depends_on:
- database
networks:
- main_network
deploy:
placement:
constraints:
- node.role == worker
- node.labels.type == machine-learning
resources:
limits:
cpus: '0.8'
memory: 10G
reservations:
cpus: '0.5'
memory: 5G
replicas: 3
Plus loin
Plus de paramètres sont disponibles pour plus de controle sur l’orchestration de votre cluster. N’hésitez pas à vous référer à la doc pour plus de détails.
Secrets
Docker Compose dispose d’une façon native très pratique de gérer les secrets en stockant chaque secret dans un fichier externe. Ainsi ces fichiers ne font pas partie de votre configuration et peuvent même être chiffrés si nécessaire, ce qui est excellent pour la sécurité.
Disons que vous souhaitez sécuriser vos identifiants de connexion à la BDD.
Commencez par créer 3 fichiers secrets sur votre machine locale :
- créez un fichier
db_name.secret
et mettez le nom de la BDD dedans - créez un fichier
db_user.secret
et mettez le nom d’utilisateur dedans - créez un fichier
db_password.secret
et mettez le mot de passe dedans
Puis dans votre fichier Docker Compose vous pouvez utiliser les secrets ainsi :
version: "3.8"
networks:
main_network:
external: true
secrets:
db_name:
file: "./secrets/db_name.secret"
db_user:
file: "./secrets/db_user.secret"
db_password:
file: "./secrets/db_password.secret"
services:
database:
image: postgres:13
secrets:
- "db_name"
- "db_user"
- "db_password"
# Adding the _FILE prefix makes the Postgres image to automatically
# detect secrets and properly load them from files.
environment:
- POSTGRES_USER_FILE=/run/secrets/db_user
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- POSTGRES_DB_FILE=/run/secrets/db_name
volumes:
- /local/path/to/postgresql/data:/var/lib/postgresql/data
deploy:
placement:
constraints:
- node.role == manager
- node.labels.type == database
networks:
- main_network
Les fichiers secrets sont automatiquement injectés dans les conteneurs dans le répertoire /run/secrets
par Docker Compose. Attention cependant : ces secrets sont situés dans des fichiers, et non pas dans des variables d’environnement. Il vous faudra donc manuellement les ouvrir et lire leur contenu pour accéder aux secrets.
L’image PostgreSQL a une fonctionnalité bien pratique : si vous ajoutez le suffix _FILE
à la variable d’environnement, l’image lira automatiquement les secrets depuis les fichiers.
Staging VS Production
Il y a des chances pour que vous ayez besoin d’au moins 2 différents types de configurations Docker Compose :
- 1 pour votre machine locale qui sera utilisée à la fois pour la création des images Docker, mais aussi comme environnement de staging
- 1 pour votre production
Vous avez 2 choix. Soit vous tirez parti de l’héritage de fichiers proposé par Docker Compose, ce qui fait que vous n’avez qu’un seul gros fichier de base docker-compose.yml
et ensuite deux petits fichiers additionnels : un fichier staging.yml
avec les spécificités liées au staging, et un fichier production.yml
avec les spécificités liées à la production.
Au final chez NLP Cloud nous avons fini par réaliser que nos configurations de staging et de production étaient si differentes qu’il était plus facile de maintenir 2 gros fichiers séparés : un pour le staging et un pour la production. La raison principale est que notre environnement de production utilise Docker Swarm mais que notre environnement de staging ne l’utilise pas, et que donc jouer avec les deux est peu pratique.
Déployez
Maintenant nous supposons que vous avec localement construit et uploadé vos images sur votre registre Docker.
Disons que nous avons un seul fichier production.yml
pour la production.
Copiez votre fichier production.yml
sur votre serveur en utilisant scp
:
scp production.yml <server user>@<server IP>:/remote/path/to/project
Copiez aussi les secrets (et assurez-vous de les uploader dans le répertoire déclaré dans la section secrets
de votre fichier Docker Compose):
scp /local/path/to/secrets <server user>@<server IP>:/remote/path/to/secrets
Créez manuellement le réseau que nous utilisons dans notre fichier Docker Compose. Notez qu’il est aussi possible de sauter cette étape et de laisser Docker Swarm s’en charger automatiquement si le réseau est déclaré dans votre fichier Docker Compose. Mais nous avons noté que cela créait des disfonctionnements lors de la recréation de la stack parce que Docker ne re-crée pas le réseau suffisamment vite.
docker network create --driver=overlay main_network
Vous devez aussi créer les répertoires utilisés par les volumes manuellement. Le seul volume que nous ayons dans ce tuto est pour la base de données. Donc créons-le sur le noeud hébergeant la BDD (i.e. le manager):
mkdir -p /local/path/to/postgresql/data
Ok désormais tout est en place, il est donc temps de déployer toute la stack !
docker stack deploy --with-registry-auth -c production.yml <stack name>
L’option --with-registry-auth
est requise lorsque vous téléchargez des images depuis des registres protégés par mot de passe.
Attendez un moment car Docker Swarm est maintenant en train de télécharger toutes les images et de les installer sur les noeuds. Puis vérifiez si tout s’est bien déroulé :
docker service ls
Vous devriez voir quelque chose comme ça :
ID NAME MODE REPLICAS IMAGE
f1ze8qgf24c7 <stack name>_backoffice replicated 1/1 <path to your custom Python/Django image>
gxboram56dka <stack name>_database replicated 1/1 postgres:13
3y1nmb6g2xoj <stack name>_api replicated 3/3 <path to your custom FastAPI image>
L’important est que les REPLICAS
soient toutes à leur maximum. Sinon cela signifie que Docker est toujours en train de télécharger et installer vos images, ou alors que quelque chose s’est mal passé.
Gérez le cluster
Maintenant que votre cluster est en marche, voici deux ou trois choses utiles à garder à l’esprit pour administrer le cluster.
- voir toutes les applications et où elles ont été déployées :
docker stack ps <stack name>
- voir les applications installées sur un noeud spécifique :
docker node ps <node name>
- voir les logs d’une application :
docker service logs <stack name>_<service name>
- supprimer complètement toute la stack :
docker stack rm <stack name>
A chaque fois que vous souhaitez déployer une nouvelle image sur le cluster, commencez par l’uploader sur votre registre, puis relancez la commande docker stack deploy
sur le manager.
Conclusion
Comme vous pouvez le voir, mettre en place un cluster Docker Swarm est loin d’être complexe, en particulier si vous pensez à toute la complexité qui doit être gérée en arrière plan dans les systèmes distribués.
Bien sûr, de nombreuses autres options sont disponibles et il y a des chances pour que la documention vous soit utile. Par ailleurs, nous n’avons pas encore évoqué le point du reverse proxy/load balancer mais c’est un aspect primordial. Dans le prochain tutoriel, nous verrons comment réaliser cela avec Traefik.
Chez NLP Cloud notre configuration est évidemment bien plus complexe que ce qui est décrit ci-dessus, et nous avons dû faire face à plusieurs défis afin d’obtenir une architecture à la fois rapide et facile à maintenir. Par exemple, nous avons tellement de conteneurs de machine learning que manuellement écrire les fichiers de configuration pour chaque conteneur n’était pas une option, donc nous avons dû mettre en place de nouveaux mécanismes de génération automatique.
Si vous êtes intéressé par plus de détails n’hésitez pas à demander, ce sera un plaisir de partager.