Stocker les données de paiement Stripe en base de donnée

Il est difficile de savoir si les données de paiement Stripe doivent être stockées localement en base de donnée ou non. De nombreux développeurs se demandent quel type de données Stripe ils doivent enregistrer dans leur BDD. Ils peuvent même parfois être tentés de n’enregistrer aucune donnée localement et de seulement se baser sur des appels API Stripe.

Laissez-moi vous montrer comment nous traitons cette question chez NLP Cloud et pourquoi.

NLP Cloud est une API de traitement naturel du language (NLP) basée sur spaCy et HuggingFace Transformers afin de proposer de l’extraction d’entités (NER), analyse de sentiments, classification de texte, résumé de texte, génération de texte, et bien plus. Les clients sont prélevés mensuellement et les paiements sont gérés par Stripe. Il est important pour nous que l’API et le dashboard utilisateur soient rapides comment l’éclair. Nous voulons donc dépendre le moins possible d’appels à l’API de Stripe. Nous souhaitons par ailleurs ne pas trop dépendre d’une potentielle perte de donnée du côté de Stripe.

Scénario typique

Voici un scénario standard pour un service à abonnement comme le notre chez NLP Cloud :

  1. Un client s’inscrit sur votre site
  2. Vous enregistrez le client en BDD
  3. vous créez un client Stripe via l’API de Stripe
  4. Vous enregistrez l’ID client Stripe localement dans votre base

Vous ne pouvez pas vraiment faire mieux que cela.

Maintenant voilà la partie plus ardue.

Par exemple, il est probable que vous ayez besoin de garder une trace en local de l’abonnement Stripe de votre client, afin de lui donner accès à certaines fonctionnalités payantes, ou à plus de requêtes API, ou, s’il s’agit d’un utilisateur gratuit, de désactiver certaines fonctionnalités (par exemple). Certaines fois vous serez amené à mettre à jour un abonnement vous-même, mais d’autres fois ce sera à l’initiative de Stripe (par exemple dans le cas d’un paiement en échec plusieurs fois d’affilée, Stripe marquera l’abonnement comme canceled). Lorsqu’un abonnement est mis à jour par Stripe, ils vous le font savoir via un webhook. Si vous utilisez Stripe Portal, tout sera intégralement géré côté Stripe, et tout changement vous sera signalé via webhook.

Donc l’intégration Stripe est bi-directionnelle : parfois les modifications viennent de vous, parfois d’eux. Il est facile de finir avec des informations non synchronisées !

Considérations autour de la vitesse

On pourrait être tenté de déléguer autant d’information que possible à Stripe de façon à ce que Stripe soit la seule source de données. Dans une configuration de ce type, vous auriez besoin d’appeler l’API Stripe très fréquemment. C’est une mauvaise idée.

Par exemple, si vos données d’abonnement clients sont uniquement stockées chez Stripe, il vous faudra en premier effectuer un appel Stripe avant d’autoriser un client à accéder ou non à une fonctionnalité payante spécifique. Cela ajoute des millisecondes critiques au temps de réponse de votre site, ce qui n’est pas bon. Et si Stripe se met à ramer, votre site va se mettre à ramer aussi. Dans le cas d’une API, c’est tout simplement hors de question : vous ne pouvez pas ralentir un appel API parce que vous êtes dans l’attente d’une réponse de Stripe.

Considérations autour d’un plan de reprise d’activité

Déléguer les informations à Stripe sans copie des données en local est risqué. Même si Stripe est un acteur solide, vous ne serez jamais certain qu’ils ne risquent pas de perdre vos données.

Afin d’assurer une reprise d’activité en cas de potentiel incident, il est crucial de stocker les données des clients en local afin de pouvoir relancer le service quelque part ailleurs en cas d’incident grave, et ceci sans perdre une seule donnée d’abonnement client (ce qui serait un désastre).

Tout mettre en cache localement

La stratégie que nous suivons chez NLP Cloud est de tout mettre en cache localement. C’est plus simple qu’il n’y parait grace au fait que les SGBD modernes comme PostgreSQL savent facilement stocker le JSON sans presque aucun compromis en terme de performances.

En gros, voici ce que vous devrez faire si vous souhaitez suivre cette voie :

  1. Lorsque vous créez un client Stripe via leur API, sauvegardez la réponse JSON de Stripe au sein d’un champ JSON en BDD (avec PostgreSQL, utilisez le type JSONB)
  2. Lorsque vous créez un abonnement Stripe pour ce client, faite de même
  3. Lorsque vous souhaitez accéder aux informations de clients ou d’abonnements Stripe, contentez-vous de les chercher dans votre base de donnée

Voici un exemple d’INSERT dans un champ JSONB PostgreSQL :

CREATE TABLE customers (  
  id serial NOT NULL,
  stripe_customer jsonb
  stripe_subscription jsonb
);
INSERT INTO customers VALUES (1, '{id:1, ...}', '{id:1, ...}');

Et voici comment vous pouvez récupérer les données d’abonnement Stripe par exemple :

SELECT stripe_subscription->'id' AS id FROM customers;  

2 champs en BDD et c’est tout ! Nul besoin de créer toute une batterie de champs afin de stocker les nombreuses informations de clients et d’abonnements.

Rester synchro

Afin de vous assurer que votre cache local soit parfaitement synchronisé avec Stripe, vous devez correctement recevoir et traiter les webhooks de Stripe.

A chaque fois que vous recevez un webhook Stripe concernant un client ou un abonnement, mettez à jour les champs client et abonnement en BDD.

Si vous souhaitez être complètement tranquille, vous pouvez aussi vous préparer à un potentiel dysfonctionnement des webhooks. Dans ce cas, la meilleure stratégie est de pro-activement aller chercher les clients et les abonnements Stripe via l’API à intervalles réguliers, afin d’être certain de ne pas perdre d’infos.

Conclusion

Comme vous le voyez, il est relativement facile de mettre en place un système de cache Stripe qui soit simple et robuste. Cette stratégie épargne un temps de développement non négligeable; elle est rapide, sûre en cas de dysfonctionnement côté Stripe, et vous n’avez plus besoin de vous demander de quelles données Stripe vous avez besoin localement ou non.

J’espère que vous avez trouvé cela utile. Si vous avez des retours, ou si vous pensez à une meilleure stratégie, faites-le moi savoir !

Also available in English
API de Machine Learning NLP avec FastAPI et spaCy

FastAPI est un nouveau framework d’API pour Python qui est de plus en plus utilisé en production aujourd’hui. Nous utilisons FastAPI derrière NLP Cloud. NLP Cloud est une API basée sur spaCy et les transformers HuggingFace, afin de proposer de l’extraction d’entités (NER), de l’analyse de sentiments, de la classification de texte, du résumé de texte, et bien plus. FastAPI nous a aidé à rapidement construire une API de machine learning robuste et rapide afin de servir les modèles NLP.

Laissez-moi vous expliquer les raisons de ce choix, et vous montrer comment mettre en place FastAPI et spaCy pour de l’extraction d’entités.

Pourquoi FastAPI ?

Jusqu’à récemment, j’avais pour habitude d’utiliser Django Rest Framework pour mes APIs Python. Mais FastAPI propose plusieurs fonctionnalités intéressantes :

  • C’est très rapide
  • C’est bien documenté
  • C’est facile à utiliser
  • Cela génère automatiquement les schémas d’APIs pour vous (OpenAPI par exemple)
  • Cela utilise la validation de types avec Pydantic. Pour un développeur Go tel que moi, qui suis habitué au typage statique, c’est très sympa de pouvoir profiter du typage de cette façon. Cela rend le code plus propre, et moins sujet aux erreurs.

Les performances de FastAPI sont censées en faire un excellent candidat pour les APIs de machine learning. Étant donné que l’on sert un grand nombre de modèles NLP exigeants en termes de performances chez NLP Cloud, FastAPI est une très bonne solution.

Mettre en place FastAPI

La première option dont vous disposez est d’installer FastAPI et Uvicorn (le serveur ASGI en amont de FastAPI) par vous-même :

pip install fastapi[all]

Comme vous le voyez, FastAPI tourne derrière un serveur ASGI, ce qui signifie qu’il peut nativement supporter les requêtes Python asynchrones avec asyncio.

Puis vous n’avez qu’à démarrer votre app avec quelque chose comme ça :

uvicorn main:app

Une autre option est d’utiliser une des images Docker généreusement mises à disposition par Sebastián Ramírez, le créateur de FastAPI. Ces images sont maintenues et sont clé-en-main.

Par exemple, l’image Uvicorn + Gunicorn + FastAPI ajoute Gunicorn à la stack afin de gérer plusieurs processus Python en parallèle.

L’application est censée démarrer automatiquement via un docker run si vous avez correctement suivi la documentation de l’image.

Ces images sont personnalisables. Par exemple, vous pouvez modifier le nombre de processus créés par Gunicorn. Il est important de jouer sur ce type de paramètres en fonction des ressources que demande votre API. Si vous API sert un modèle de machine learning utilisant plusieurs gigas de mémoire, vous feriez bien de diminuer la concurrence par défaut de Gunicorn, sans cela votre application consommera vite trop de mémoire.

API de NER simple avec FastAPI et spaCy

Imaginons que vous souhaitiez créer un point d’accès API qui fasse de l’extraction d’entités (NER) avec spaCy. En gros, le but de la NER est d’extraire des entités telles que le nom, l’entreprise, le poste… à partir d’une phrase. Plus d’infos sur la NER ici si besoin.

Ce point d’accès prendra une phrase en entrée, et retournera une liste d’entités. Chaque entité est composée de la position du premier caractère de l’entité, la position du dernier caractère de l’entité, le type de l’entité, et le texte de l’entité lui-même.

Le point d’accès devra être contacté via des requêtes POST de cette façon :

curl "http://127.0.0.1/entities" \
  -X POST \
  -d '{"text":"John Doe is a Go Developer at Google"}'

Et il retournera quelque chose comme ce qui suit :

[
  {
    "end": 8,
    "start": 0,
    "text": "John Doe",
    "type": "PERSON"
  },
  {
    "end": 25,
    "start": 13,
    "text": "Go Developer",
    "type": "POSITION"
  },
  {
    "end": 35,
    "start": 30,
    "text": "Google",
    "type": "ORG"
  },
]

Voici comment s’y prendre :

import spacy
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List

model = spacy.load("en_core_web_lg")

app = FastAPI()

class UserRequestIn(BaseModel):
    text: str

class EntityOut(BaseModel):
    start: int
    end: int
    type: str
    text: str

class EntitiesOut(BaseModel):
    entities: List[EntityOut]

@app.post("/entities", response_model=EntitiesOut)
def read_entities(user_request_in: UserRequestIn):
    doc = model(user_request_in.text)

    return {
        "entities": [
            {
                "start": ent.start_char,
                "end": ent.end_char,
                "type": ent.label_,
                "text": ent.text,
            } for ent in doc.ents
        ]
    }

Le première chose ici est que nous chargeons le modèle spaCy. Dans notre exemple nous utilisons un modèle spaCy pré-entraîné pour l’anglais de taille “large”. Les modèles “large” prennent plus de mémoire et plus d’espace disque, mais ont une meilleure précision car ils ont été entraînés sur de plus grands jeux de données.

model = spacy.load("en_core_web_lg")

Par la suite nous utilisons ce modèle spaCy pour de la NER en faisant ce qui suit.

doc = model(user_request_in.text)
# [...]
doc.ents

La seconde chose, qui est une super fonctionnalité de FastAPI, est la possibilité de forcer la validation des données via Pydantic. En gros, vous devez déclarer à l’avance quel sera le format des données entrées par vos utilisateurs, et le format de ce que l’API leur retournera. Si vous êtes développeur Go, vous trouverez cela très similaire à l’unmarshalling JSON avec les structs. Par exemple, nous retournons le format d’une entité retournée de cette façon :

class EntityOut(BaseModel):
    start: int
    end: int
    type: str
    text: str

Notez que start et end sont des positions dans la phrase, ce sont donc des entiers, et type et text sont des chaînes de caractères. Si l’API essaie de retourner une entité qui ne répond pas à ce format (par exemple, si start n’est pas un entier), FastAPI va soulever une erreur.

Comme vous pouvez le voir, il est possible d’inclure une classe de validation dans une autre. Ici nous retournons une liste d’entités, nous devons donc déclarer ce qui suit :

class EntitiesOut(BaseModel):
    entities: List[EntityOut]

Certains types simples tels que int et str sont natifs, mais des types plus complexes tels que List doivent être explicitement importés.

Pour être plus concis, la validation de la réponse peut se fait via un décorateur :

@app.post("/entities", response_model=EntitiesOut)

Validation avancée

Vous pouvez effectuer de la validation plus avancée avec FastAPI et Pydantic. Par exemple, si vous souhaitez que l’entrée utilisateur ait une taille minimum de 10 caractères, vous pouvez faire ce qui suit :

from pydantic import BaseModel, constr

class UserRequestIn(BaseModel):
    text: constr(min_length=10)

Maintenant, un autre cas : que se passe-t-il si la validation via Pydantic passe, mais que vous réalisez plus tard qu’il y a un problème avec les données et donc que vous voulez retourner un code HTTP 400 ?

Il vous suffit de soulever une HTTPException :

from fastapi import HTTPException

raise HTTPException(
            status_code=400, detail="Your request is malformed")

Il s’agit juste de quelques exemples, mais vous pouvez faire bien plus ! Pour cela jetez un oeil à la documentation de FastAPI et de Pydantic.

Chemin racine

Il est très commun de faire tourner de telles API derrière un reverse proxy. Par exemple chez NLPCloud.io nous utilisons le reverse proxy Traefik pour cela.

Un point délicat lors que l’on utilise un reverse proxy est que notre sous-application (ici l’API) ne connait pas nécessairement le chemin URL entier. Et c’est en réalité une excellente chose car cela montre que votre API est faiblement couplée au reste de votre application.

Par exemple ici nous souhaitons que notre API croie que l’URL du point d’accès est /entities, mais en réalité la vraie URL peut être quelques chose comme /api/v1/entities. Voici comment s’y prendre en modifiant le chemin racine :

app = FastAPI(root_path="/api/v1")

Vous pouvez aussi arriver au même résultat en passant un paramètre supplémentaire à Uvicorn dans le cas où vous démarrez Uvicorn manuellement :

uvicorn main:app --root-path /api/v1

Conclusion

Comme vous avez pu le voir, créer une API avec FastAPI est simple comme bonjour, et la validation avec Pydantic rend le code très expressif (ce qui permet en retour d’écrire moins de documentation) et moins sujet aux erreurs.

FastAPI possède à la fois de très bonnes performances et la possibilité d’utiliser des requêtes asynchrones de façon native avec asyncio, ce qui est excellent pour les modèles de machine learning exigeants. L’exemple ci-dessus traitant de l’extraction d’entités avec spaCy et FastAPI peut presque être considéré comme prêt pour la production (bien sûr le code de l’API n’est qu’une petite partie dans le cadre d’une vraie application en cluster). Jusqu’à présent, FastAPI n’a jamais été l’élément limitant dans notre infrastructure NLPCloud.io.

Si vous avez des questions, n’hésitez pas !

Also available in English
Reverse Proxy Traefik avec Docker Compose et Docker Swarm

Mon dernier article à propos de Docker Swarm était le premier d’une série d’articles que je tenais à écrire à propos de la stack utilisée par NLP Cloud. NLP Cloud est 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. Un des challenges est que chaque modèle tourne au sein de son propre conteneur et que de nouveaux modèles sont régulièrement ajoutés au cluster. Nous avons donc besoin d’un reverse proxy qui soit à la fois performant et flexible en face de tous ces conteneurs.

La solution que nous avons choisie est Traefik.

Je me suis dit qu’il serait intéressant d’écrire un article sur la façon dont nous avons implémenté Traefik et pourquoi nous avons fait ce choix plutôt qu’un reverse proxy classique comme Nginx.

Pourquoi Traefik

Traefik est un reverse proxy encore relativement jeune comparé à Nginx ou Apache, mais l’outil gagne rapidement en popularité. Le principal avantage de Traefik est qu’il s’intègre parfaitement à Docker, Docker Compose, et Docker Swarm (et même Kubernetes et autre) : en gros, l’intégralité de votre configuration Traefik peut se place dans votre fichier docker-compose.yml, ce qui est fort pratique. Et, dès que vous ajoutez un nouveau service à votre cluster, Traefik les découvre à la volée sans avoir à être redémarré.

Donc Traefik améliore nettement la maintenabilité d’un cluster, et est un gros progrès du point de vue haute disponibilité.

L’outil est développé en Go alors que Nginx est codé en C, donc je suppose que cela crée une légère différence en termes de performance, mais rien que je n’aie pu concrètement constater. A mon avis la pénalité en termes de performances est négligeable en regard des avantages apportés.

En revanche, l’apprentissage de Traefik demande un certain temps et, bien que la documentation soit assez bonne, il est relativement facile de faire des erreurs de configuration sans en comprendre l’origine. Laissez-moi donc vous donner quelques exemples prêts à l’emploi ci-dessous.

Installer Traefik

Il n’y a, en gros, pas grand chose à faire ici. Traefik est juste une nouvelle image Docker à ajouter à votre cluster en tant que service dans votre docker-compose.yml:

version: '3.8'
services:
    traefik:
        image: traefik:v2.3

Il existe plusieurs façons d’intégrer Traefik mais, comme je le disais ci-dessus, nous allons opter pour l’intégration avec Docker Compose.

Configuration de base

90% de la congfiguration Traefik est effectuée via les labels Docker.

Imaginons que nous ayons 3 services :

  • un site vitrine qui est simplement servi comme un site statique à l’adresse http://nlpcloud.io,
  • un modèle spaCy en_core_web_sm servi via une API Python FastAPI à l’adresse http://api.nlpcloud.io/en_core_web_sm,
  • un modèle spaCy en_core_web_lg servi via une API Python FastAPI à l’adresse http://api.nlpcloud.io/en_core_web_lg.

Plus de détails au sujet des modèles NLP spaCy ici et FastAPI ici.

version: '3.8'
services:
    traefik:
        image: traefik:v2.4
        ports:
            - "80:80"
        command:
            - --providers.docker
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
    corporate:
        image: <your corporate image>
        labels:
            - traefik.http.routers.corporate.rule=Host(`localhost`)
    en_core_web_sm:
        image: <your en_core_web_sm model API image>
        labels:
            - traefik.http.routers.en_core_web_sm.rule=Host(`api.localhost`) && PathPrefix(`/en_core_web_sm`)
    en_core_web_lg:
        image: <your en_core_web_lg model API image>
        labels:
            - traefik.http.routers.en_core_web_lg.rule=Host(`api.localhost`) && PathPrefix(`/en_core_web_lg`)

Vous pouvez désormais accéder à votre site vitrine sur http://localhost, votre modèle en_core_web_sm sur http://api.localhost/en_core_web_sm, et votre modèle en_core_web_lg sur http://api.localhost/en_core_web_lg.

Comme vous pouvez le voir, c’est simple comme bonjour.

La configuration ci-dessus était pour notre configuration locale uniquement, donc nous voulons désormais faire de même pour la production dans un cluster Docker Swarm :

version: '3.8'
services:
    traefik:
        image: traefik:v2.4
        ports:
            - "80:80"
        command:
            - --providers.docker.swarmmode
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
        deploy:
            placement:
                constraints:
                    - node.role == manager
    corporate:
        image: <your corporate image>
        deploy:
            labels:
                - traefik.http.routers.corporate.rule=Host(`nlpcloud.io`)
    en_core_web_sm:
        image: <your en_core_web_sm model API image>
        deploy:
            labels:
                - traefik.http.services.en_core_web_sm.loadbalancer.server.port=80
                - traefik.http.routers.en_core_web_sm.rule=Host(`api.nlpcloud.io`) && PathPrefix(`/en_core_web_sm`)
    en_core_web_lg:
        image: <your en_core_web_lg model API image>
        deploy:
            labels:
                - traefik.http.services.en_core_web_lg.loadbalancer.server.port=80
                - traefik.http.routers.en_core_web_lg.rule=Host(`api.nlpcloud.io`) && PathPrefix(`/en_core_web_lg`)

Vous pouvez désormais accéder à votre site vitrine sur http://nlpcloud.io, votre modèle en_core_web_sm sur http://api.nlpcloud.io/en_core_web_sm, et votre modèle en_core_web_lg sur http://api.nlpcloud.io/en_core_web_lg.

C’est toujours relativement simple, mais il est important de noter les points suivants :

  • il faut explicitement utiliser le provider docker.swarmmode au lieu de docker,
  • les labels doivent désormais être placés dans la section deploy,
  • il nous faut déclarer manuellement le port de chaque service en utilisant la directive loadbalancer (cela doit être fait manuellement car Docker Swarm ne permet pas d’exposer automatiquement les ports),
  • nous devons nous assurer que Traefik sera déployé sur un noeud manager du Swarm en utilisant constraints.

Vous avez désormais un cluster parfaitement fonctionnel grâce à Docker Swarm et Traefik. Maintenant il est probable que vous ayez des exigences spécifiques à votre projet, et aucun doute que la documentation Trafik vous aidera. Mais laissez-moi vous montrer quelques fonctionnalités que nous utilisons chez NLP Cloud.

Déporter l’authentification

Disons que vos points d’accès à l’API NLP sont protégés et que les utilisateurs ont besoin d’un token pour y accéder. Une bonne solution pour ce cas d’usage est de mettre à profit Traefik ForwardAuth.

En deux mots, Traefik redirigera toutes les requêtes utilisateurs vers une page dédiée créée pour l’occasion. Cette page se chargera d’examiner les en-têtes de la requête (et peut-être extraire un token d’authentification par exemple) et de déterminer si l’utilisateur a le droit d’accéder à la ressource. Si c’est le cas, la page doit retourner un code HTTP 2XX.

Si un code 2XX est retourné, Traefik effectuera alors la vraie requête vers le point d’accès API final. Sinon, il retournera une erreur.

Veuillez noter que, pour des raisons de performance, Traefik redirige uniquement les en-têtes des requêtes vers votre page d’authentification, et non pas le corps de la requête. Il n’est donc pas possible d’autoriser une requête utilisateur à partir d’éléments situés dans le corps de la requête.

Voici comment s’y prendre :

version: '3.8'
services:
    traefik:
        image: traefik:v2.4
        ports:
            - "80:80"
        command:
            - --providers.docker.swarmmode
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
        deploy:
            placement:
                constraints:
                    - node.role == manager
    corporate:
        image: <your corporate image>
        deploy:
            labels:
                - traefik.http.routers.corporate.rule=Host(`nlpcloud.io`)
    en_core_web_sm:
        image: <your en_core_web_sm model API image>
        deploy:
            labels:
                - traefik.http.services.en_core_web_sm.loadbalancer.server.port=80
                - traefik.http.routers.en_core_web_sm.rule=Host(`api.nlpcloud.io`) && PathPrefix(`/en_core_web_sm`)
                - traefik.http.middlewares.forward_auth_api_en_core_web_sm.forwardauth.address=https://api.nlpcloud.io/auth/
                - traefik.http.routers.en_core_web_sm.middlewares=forward_auth_api_en_core_web_sm
    api_auth:
        image: <your api_auth image>
        deploy:
            labels:
                - traefik.http.services.en_core_web_sm.loadbalancer.server.port=80
                - traefik.http.routers.en_core_web_sm.rule=Host(`api.nlpcloud.io`) && PathPrefix(`/auth`)

Chez NLP Cloud, le service api_auth est en réalité une image Django + Django Rest Framework en charge de l’authentification des requêtes.

Pages d’erreur personnalisées

Peut-être que vous ne voulez pas afficher des pages d’erreurs Traefik brutes à vos utilisateurs. Dans ce cas, il est possible de remplacer ces pages d’erreur par défaut par des pages d’erreur personnalisées.

Traefik ne stocke pas de page d’erreur personnalisée en mémoire, mais il peut utiliser des pages d’erreur servies par l’un de vos services. Lorsque vous contactez votre service afin de récupérer la page d’erreur custom, Traefik passe aussi le code erreur HTTP comme argument positionnel, de façon à ce que vous puissiez afficher différentes pages d’erreur selon l’erreur HTTP initiale.

Imaginons que vous ayez un petit site statique servi par Nginx qui serve vos pages d’erreurs personnalisées. Vous voulez désormais utiliser ses pages d’erreur pour toutes les erreurs HTTP 400 à 599. Voici comment faire :

version: '3.8'
services:
    traefik:
        image: traefik:v2.4
        ports:
            - "80:80"
        command:
            - --providers.docker.swarmmode
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
        deploy:
            placement:
                constraints:
                    - node.role == manager
            labels:
                - traefik.http.middlewares.handle-http-error.errors.status=400-599
                - traefik.http.middlewares.handle-http-error.errors.service=errors_service
                - traefik.http.middlewares.handle-http-error.errors.query=/{status}.html
    corporate:
        image: <your corporate image>
        deploy:
            labels:
                - traefik.http.routers.corporate.rule=Host(`nlpcloud.io`)
                - traefik.http.routers.corporate.middlewares=handle-http-error
    errors_service:
        image: <your static website image>
        deploy:
            labels:
                - traefik.http.routers.corporate.rule=Host(`nlpcloud.io/errors`)

Par exemple, grâce à l’exemple ci-dessus, une page d’erreur 404 utiliserait désormais cette page : http://nlpcloud.io/errors/404.html

HTTPS

Une fonctionnalité sympa de Traefik est sa capacité à provisionner et utiliser automatiquement les certificats TLS Let’s Encrypt.

Il ont un excellent tuto sur la façon de mettre cela en place avec Docker donc je me contente de vous diriger vers la bonne ressource : https://doc.traefik.io/traefik/user-guides/docker-compose/acme-tls/

Réhausser la limite d’upload

La taille limite d’upload par défaut est assez basse pour des raisons de performance (je crois que c’est 4194304 octets mais je ne suis pas 100% certain parce que ce n’est pas dans la doc).

Afin de réhausser cette limite, il faut utiliser la directive maxRequestBodyBytes :

version: '3.8'
services:
    traefik:
        image: traefik:v2.4
        ports:
            - "80:80"
        command:
            - --providers.docker.swarmmode
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
        deploy:
            placement:
                constraints:
                    - node.role == manager
    corporate:
        image: <your corporate image>
        deploy:
            labels:
                - traefik.http.routers.corporate.rule=Host(`nlpcloud.io`)
                - traefik.http.middlewares.upload-limit.buffering.maxRequestBodyBytes=20000000
                - traefik.http.routers.backoffice.middlewares=upload-limit

Dans l’exemple ci-dessus, nous avons réhaussé la limite d’upload à 20Mo.

Mais n’oubliez pas que le fait d’uploader un gros fichier d’un coup n’est pas nécessairement la meilleure option. A la place il est plus intéressant de découper le fichier en morceaux et d’uploader chaque morceau indépendamment. J’écrirai peut-être un article sur le sujet à l’avenir.

Debugging

Il y a plusieurs options que vous pouvez activer afin de vous aider à débugger Traefik.

La première chose est d’activer le mode debug qui vous montrera des tonnes de trucs à propos de l’exécution de Traefik.

Deuxièmement, activez les logs d’accès afin de voir toutes les requêtes HTTP entrantes.

Enfin, Traefik fournit un dashboard natif très sympa qui aide à débugger votre configuration. Il est très utile car il est parfois ardu de comprendre pourquoi les choses ne fonctionnent pas.

Afin d’activer les fonctionnalités ci-dessus, faites ce qui suit :

version: '3.8'
services:
    traefik:
        image: traefik:v2.4
        ports:
            - "80:80"
        command:
            - --providers.docker.swarmmode
            - --log.level=DEBUG
            - --accesslog
            - --api.dashboard=true
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock:ro
        deploy:
            placement:
                constraints:
                    - node.role == manager
            labels:
                - traefik.http.routers.dashboard.rule=Host(`dashboard.nlpcloud.io`)
                - traefik.http.routers.dashboard.service=api@internal
                - traefik.http.middlewares.auth.basicauth.users=<your basic auth user>.<your basic auth hashed password>.
                - traefik.http.routers.dashboard.middlewares=auth

Dans cet example, nous avons activé le debugging, les logs d’accès, et le dashboard qui est accessible à l’adresse http://dashboard.nlpcloud.io via une authentification basic auth.

Conclusion

Comme vous pouvez le voir, Traefik est parfaitement intégré à votre configuration Docker Compose. Si vous souhaitez changer la config d’un service, ou ajouter ou supprimer un service, il suffit de modifier votre docker-compose.yml, et de redéployer votre cluster Docker Swarm. Les nouveaux changement seront pris en compte, et les services qui n’ont pas été modifiés n’ont même pas besoin d’être redémarrés, ce qui est super question haute disponibilité.

Je vais continuer d’écrire quelques articles à propos de la stack que nous utilisons derrière NLP Cloud. Je pense que le prochain sera à propos de notre frontend et de comment nous utilisons HTMX au lieu de gros frameworks javascript.

Si vous avez des questions, n’hésitez pas !

Also available in English
Htmx et Django pour les Single Page Applications

Nous ne sommes pas très fans des gros frameworks Javascript chez NLP Cloud. NLP Cloud est 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.Notre backoffice est très simple. Les utilisateurs peuvent récupérer leur token API, uploader leurs modèles spaCy custom, upgrader leur plan, envoyer un message au support… Rien de très complexe donc nous n’avons pas ressenti le besoin d’utiliser Vue.js ou React.js. Nous avons décider d’opter pour une combinaison très sympa de htmx et Django.

Laissez-moi vous montrer comment ça fonctionne et vous exposer les avantages de cette solution.

Qu’est-ce que htmx et pourquoi l’utiliser ?

htmx est le successeur d’intercooler.js. Le concept derrière ces 2 projets est que vous pouvez effectuer toute sorte d’opération avancée comme de l’AJAX, des transitions CSS, des websockets, etc. uniquement avec du HTML (c’est à dire sans une seule ligne de Javascript). Et la lib est très légère (9Ko seulement).

Une autre chose très intéressante est que, lorsque vous effectuez des appels asynchrones vers votre backend, htmx n’attend pas du JSON mais des fragment de HTML. Donc en gros, contrairement à Vue.js ou React.js, votre frontend n’a pas besoin de gérer du JSON. Il suffit de remplacer les parties du DOM devant être modifiées par le fragment HTML retourné par le serveur. Cela vous permet de tirer parti au mieux de votre bon vieux framework backend (templates, sessions, authentification, etc.) au lieu de le transformer en un framework headless qui retourne uniquement du JSON. L’idée est qu’un fragment HTML étant à peine plus lourd qu’un JSON, la différence est négligeable dans une requête HTTP.

Ainsi, pour résumer, voici pourquoi htmx est intéressant lorsque vous souhaitez construire une Single Page Application (SPA) :

  • pas de Javascript à écrire,
  • les excellents frameworks backend comme Django, Ruby On Rails, Laravel… peuvent être utilisés à plein régime,
  • la lib est très légère (9Ko) comparée aux frameworks comme Vue ou React,
  • aucun pré-processing n’est nécessaire (Webpack, Babel, etc.) ce qui rend l’expérience de développement beaucoup plus sympa.

Installation

L’installation de htmx revient juste à charger le script dans votre <head> HTML:

<script src="https://unpkg.com/htmx.org@1.2.1"></script>

Je n’entrerai pas dans les détails de l’installation de Django ici puisque cet article se concentre sur htmx.

Charger du contenu en asynchrone

Le point le plus important, lorsque vous créez une SPA, c’est que vous vous attendez à ce que tous les chargements d’éléments se fassent en asynchrone. Par exemple, lorsque vous cliquez sur un menu afin d’aller vers une nouvelle page, vous ne voulez pas que l’intégralité de la page se recharge, mais uniquement le contenu qui a changé. Voici comment s’y prendre.

Disons que notre site est fait de 2 pages :

  • la page token qui montre à l’utilisateur son token API,
  • la page support qui en gros montre l’email du support à l’utilisateur.

On souhaite aussi afficher une barre de chargement pendant que la nouvelle page charge.

Frontend

Côté frontend, commençons par créer un menu à 2 entrées. Cliquer sur une entrée va afficher la barre de chargement, et changer le contenu de la page sans la recharger intégralement.

<progress id="content-loader" class="htmx-indicator" max="100"></progress>
<aside>
    <ul>
        <li><a hx-get="/token" hx-push-url="true"
                hx-target="#content" hx-swap="innerHTML" 
                hx-indicator="#content-loader">Token</a></li>
        <li><a hx-get="/support"
                hx-push-url="true" hx-target="#content" hx-swap="innerHTML"
                hx-indicator="#content-loader">Support</a></li>
    </ul>
</aside>
<div id="content">Hello and welcome to NLP Cloud!</div>

Dans l’exemple ci-dessus, la barre de chargement est l’élément <progress>. Il est caché par défaut grâce à la classe htmx-indicator. Lorsqu’un utilisateur clique sur une des deux entrées du menu, cela affiche la barre de chargement, grâce à hx-indicator="#content-loader".

Lorsqu’un utilisateur clique sur l’entrée token, cela déclenche un appel GET asynchrone vers l’url Django token grâce à hx-get="/token". Django retourne un fragment HTML que htmx place dans <div id="content"></div> grâce à hx-target="#content" hx-swap="innerHTML".

Il en est de même pour la page support.

Même si la page ne s’est pas rechargée, nous voulons malgré tout mettre à jour l’URL du navigateur afin d’aider l’utilisateur à se situer. C’est pour cette raison que nous utilisons hx-push-url="true".

Comme vous pouvez le voir, nous avons désormais une SPA qui utilise des fragments HTML au lieu de JSON, grâce à une simple lib de 9Ko, et seulement quelques directives à appliquer dans le HTML.

Backend

Bien entendu, la partie ci-dessus ne fonctionne pas sans le backend Django.

Voici votre urls.py:

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('token', views.token, name='token'),
    path('support', views.support, name='support'),
]

Maintenant votre views.py:

def index(request):
    return render(request, 'backoffice/index.html')

def token(request):
    api_token = 'fake_token'

    return render(request, 'backoffice/token.html', {'token': api_token})

def support(request):
    return render(request, 'backoffice/support.html')

Et enfin, dans un répertoire templates/backoffice ajoutez les templates suivants.

index.html (c’est à dire le code que nous avons écrit ci-dessus mais cette fois avec des les tags de template {% url %}) :

<!DOCTYPE html>
<html>
    <head>
        <script src="https://unpkg.com/htmx.org@1.2.1"></script>
    </head>

    <body>
        <progress id="content-loader" class="htmx-indicator" max="100"></progress>
        <aside>
            <ul>
                <li><a hx-get="{% url 'home' %}"
                        hx-push-url="true" hx-target="#content" hx-swap="innerHTML"
                        hx-indicator="#content-loader">Home</a></li>
                <li><a hx-get="{% url 'token' %}" hx-push-url="true"
                        hx-target="#content" hx-swap="innerHTML" 
                        hx-indicator="#content-loader">Token</a></li>
            </ul>
        </aside>
        <div id="content">Hello and welcome to NLP Cloud!</div>
    <body>
</html>

token.html:

Here is your API token: {{ token }}

support.html:

For support questions, please contact support@nlpcloud.io

Comme vous pouvez le voir, tout cela est du pur code Django utilisant des routes et des templates, comme à l’accoutumée. Nul besoin d’une API et de Django Rest Framework ici.

Permettre le rafraîchissement manuel des pages

Le souci avec la solution ci-dessus c’est que, si un utilisateur recharge manuellement les pages token ou support, il obtiendra uniquement le fragment HTML au lieu de la page HTML toute entière.

La solution, côté Django, est de retourner 2 templates différents selon que la requête vient d’htmx ou non.

Voici comment faire.

Dans votre views.py, testez si le header HTTP_HX_REQUEST a été passé dans la requête. Si c’est le cas, cela signifie qu’il s’agit d’une requête de htmx et que dans ce cas vous pouvez afficher le fragment HTML seulement. Dans le cas contraire, vous devez afficher la page entière.

def index(request):
    return render(request, 'backoffice/index.html')

def token(request):
    if request.META.get("HTTP_HX_REQUEST") != 'true':
        return render(request, 'backoffice/token_full.html', {'token': api_token})

    return render(request, 'backoffice/token.html', {'token': api_token})

def support(request):
    if request.META.get("HTTP_HX_REQUEST") != 'true':
        return render(request, 'backoffice/support_full.html')

    return render(request, 'backoffice/support.html')

Maintenant dans votre template index.html, utilisez des blocks afin que la page index soit héritée par toutes les autres pages.

<!DOCTYPE html>
<html>
    <head>
        <script src="https://unpkg.com/htmx.org@1.2.1"></script>
    </head>

    <body>
        <progress id="content-loader" class="htmx-indicator" max="100"></progress>
        <aside>
            <ul>
                <li><a hx-get="{% url 'home' %}"
                        hx-push-url="true" hx-target="#content" hx-swap="innerHTML"
                        hx-indicator="#content-loader">Home</a></li>
                <li><a hx-get="{% url 'token' %}" hx-push-url="true"
                        hx-target="#content" hx-swap="innerHTML" 
                        hx-indicator="#content-loader">Token</a></li>
            </ul>
        </aside>
        <div id="content">{% block content %}{% endblock %}</div>
    <body>
</html>

Votre template token.html est le même qu’auparavant mais maintenant vous devez ajouter un second template appelé token_full.html dans le cas où la page est rechargée manuellement :


{% extends "home/index.html" %}

{% block content %}
    {% include "home/token.html" %}
{% endblock %}

Pareil pour support.html, ajoutez un fichier support_full.html :


{% extends "home/index.html" %}

{% block content %}
    {% include "home/support.html" %}
{% endblock %}

En gros l’on étend le template index.html afin de construire la page entière d’un coup côté serveur.

Il s’agit d’un petit bidouillage mais ce n’est pas bien méchant, et un middleware peut même être créé pour l’occasion afin de simplifier encore les choses.

Quoi d’autre ?

Nous avons seulement effleuré la surface de htmx. Cette lib (ou framework ?) inclut des tas de fonctionnalités utiles telles que :

  • Vous pouvez utiliser le verbe HTTP que vous voulez pour vos requêtes. Utilisez hx-get pour GET, hx-post pour POST, etc.
  • Vous pouvez utiliser du polling, des websockets, et des server side events, afin d’écouter les évènements provenant du serveur
  • Vous pouvez n’utiliser qu’une partie du fragment HTML retourné par le serveur (hx-select)
  • Vous pouvez utiliser des transitions CSS
  • Vous pouvez facilement travailler sur les formulaires et l’upload de fichier
  • Vous pouvez utiliser l’hyperscript de htmx, qui est un pseudo-language javascript pouvant facilement être intégré aux tags HTML pour un usage avancé

Conclusion

Je suis enthousiasmé par cette lib htmx comme vous pouvez le voir, et j’espère qu’un nombre croissant de gens va réaliser qu’ils n’ont pas nécessairement besoin d’un énorme framework JS pour leur projet.

Pour le moment j’ai uniquement intégré htmx à des bases de code de taille modeste en production, mais je suis persuadé que htmx s’intègre aussi parfaitement aux projets de grande envergure. Pour le moment je trouve le code facile à maintenir, léger, et son intégration facile à des frameworks backend comme Django est un must !

Si certains d’entre vous utilisent htmx en production, j’adorerais entendre vos retour sur le sujet !

Also available in English
L'orchestration de conteneurs avec Docker Swarm

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