L'orchestration de conteneurs avec Docker Swarm

Temps de lecture ~15 minutes

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.

Also available in English