Introduction aux microservices Python avec Nameko

Publié: 2022-03-11

introduction

Le modèle architectural des microservices est un style architectural qui gagne en popularité, compte tenu de sa flexibilité et de sa résilience. Avec des technologies telles que Kubernetes, il devient plus facile de démarrer une application en utilisant une architecture Microservices comme jamais auparavant.

Selon un article classique du blog de Martin Fowler, le style architectural des microservices peut être résumé comme suit :

En bref, le style architectural de microservice est une approche pour développer une application unique en tant que suite de petits services, chacun s'exécutant dans son propre processus et communiquant avec des mécanismes légers, souvent une API de ressource HTTP. Ces services sont construits autour de capacités métier et peuvent être déployés indépendamment par des machines de déploiement entièrement automatisées.

En d'autres termes, une application suivant une architecture de microservices est composée de plusieurs services indépendants et dynamiques qui communiquent entre eux à l'aide d'un protocole de communication. Il est courant d'utiliser HTTP (et REST), mais comme nous le verrons, nous pouvons utiliser d'autres types de protocoles de communication tels que RPC (Remote Procedure Call) sur AMQP (Advanced Message Queuing Protocol).

Le modèle de microservices peut être considéré comme un cas spécifique de SOA (architecture orientée services). Dans SOA, il est cependant courant d'utiliser un ESB (Enterprise Service Bus) pour gérer la communication entre les services. Les ESB sont généralement très sophistiqués et incluent des fonctionnalités pour le routage de messages complexes et l'application de règles métier. Dans les microservices, il est plus courant d'employer une approche alternative : "points de terminaison intelligents et canaux stupides", ce qui signifie que les services eux-mêmes doivent contenir toute la logique métier et la complexité (haute cohésion), mais la connexion entre les services doit être aussi simple que possible. possible (découplage élevé), ce qui signifie qu'un service n'a pas nécessairement besoin de savoir quels autres services communiqueront avec lui. Il s'agit d'une séparation des préoccupations appliquées au niveau architectural.

Un autre aspect des microservices est qu'il n'y a aucune application des technologies à utiliser dans chaque service. Vous devriez pouvoir écrire un service avec n'importe quelle pile logicielle pouvant communiquer avec les autres services. Chaque service a également sa propre gestion du cycle de vie. Tout cela fait que dans une entreprise, il est possible de faire travailler des équipes sur des services distincts, avec des technologies et même des méthodologies de gestion différentes. Chaque équipe sera concernée par les capacités commerciales, aidant à construire une organisation plus agile.

Microservices Python

En gardant ces concepts à l'esprit, dans cet article, nous nous concentrerons sur la création d'une application Microservices de preuve de concept à l'aide de Python. Pour cela, nous utiliserons Nameko, un framework de microservices Python. Il intègre RPC sur AMQP, ce qui vous permet de communiquer facilement entre vos services. Il possède également une interface simple pour les requêtes HTTP, que nous utiliserons dans ce didacticiel. Cependant, pour écrire des microservices qui exposent un point de terminaison HTTP, il est recommandé d'utiliser un autre framework, tel que Flask. Pour appeler des méthodes Nameko via RPC à l'aide de Flask, vous pouvez utiliser flask_nameko, un wrapper conçu uniquement pour interagir avec Flask avec Nameko.

Définition de l'environnement de base

Commençons par exécuter l'exemple le plus simple possible, extrait du site Web de Nameko, et développons-le pour nos besoins. Tout d'abord, vous aurez besoin de Docker installé. Nous utiliserons Python 3 dans nos exemples, alors assurez-vous de l'avoir également installé. Ensuite, créez un virtualenv python et exécutez $ pip install nameko .

Pour exécuter Nameko, nous avons besoin du courtier de messages RabbitMQ. Il sera responsable de la communication entre nos services Nameko. Ne vous inquiétez pas, car vous n'avez pas besoin d'installer une autre dépendance sur votre machine. Avec Docker, nous pouvons simplement télécharger une image préconfigurée, l'exécuter et, lorsque nous avons terminé, simplement arrêter le conteneur. Pas de démons, apt-get ou dnf install .

Microservices Python avec Nameko parlant à un broker RabbitMQ

Démarrez un conteneur RabbitMQ en exécutant $ docker run -p 5672:5672 --hostname nameko-rabbitmq rabbitmq:3 (vous aurez peut-être besoin de sudo pour le faire). Cela démarrera un conteneur Docker en utilisant la dernière version 3 de RabbitMQ et l'exposera sur le port par défaut 5672.

Hello World avec les microservices

Allez-y et créez un fichier appelé hello.py avec le contenu suivant :

 from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): return "Hello, {}!".format(name)

Les services Nameko sont des classes. Ces classes exposent des points d'entrée, qui sont implémentés en tant qu'extensions. Les extensions intégrées incluent la possibilité de créer des points d'entrée qui représentent des méthodes RPC, des écouteurs d'événements, des points de terminaison HTTP ou des minuteries. Il existe aussi des extensions communautaires qui permettent d'interagir avec la base de données PostgreSQL, Redis, etc… Il est possible d'écrire ses propres extensions.

Continuons et exécutons notre exemple. Si RabbitMQ s'exécute sur le port par défaut, exécutez simplement $ nameko run hello . Il trouvera RabbitMQ et s'y connectera automatiquement. Ensuite, pour tester notre service, lancez $ nameko shell dans un autre terminal. Cela créera un shell interactif qui se connectera à cette même instance de RabbitMQ. Ce qui est formidable, c'est qu'en utilisant RPC sur AMQP, Nameko implémente la découverte automatique des services. Lors de l'appel d'une méthode RPC, nameko essaiera de trouver le service en cours d'exécution correspondant.

Deux services Nameko parlant via RabbitMQ RPC

Lors de l'exécution du shell Nameko, vous obtiendrez un objet spécial appelé n ajouté à l'espace de noms. Cet objet permet de distribuer des événements et de faire des appels RPC. Pour faire un appel RPC à notre service, exécutez :

 > >> n.rpc.greetingservice.hello(name='world') 'Hello, world!'

Appels simultanés

Ces classes de service sont instanciées au moment où un appel est effectué et détruites une fois l'appel terminé. Par conséquent, ils doivent être intrinsèquement sans état, ce qui signifie que vous ne devez pas essayer de conserver un état dans l'objet ou la classe entre les appels. Cela implique que les services eux-mêmes doivent être apatrides. En partant du principe que tous les services sont sans état, Nameko est capable de tirer parti de la concurrence en utilisant des greenthreads eventlet. Les services instanciés sont appelés « travailleurs » et il peut y avoir un nombre maximal configuré de travailleurs exécutés en même temps.

Pour vérifier la concurrence Nameko dans la pratique, modifiez le code source en ajoutant un sommeil à l'appel de procédure avant de renvoyer la réponse :

 from time import sleep from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): sleep(5) return "Hello, {}!".format(name)

Nous utilisons sleep du module de time , qui n'est pas activé pour l'asynchronisme. Cependant, lors de l'exécution de nos services à l'aide nameko run , il corrigera automatiquement les rendements de déclenchement des appels bloquants tels que sleep(5) .

On s'attend maintenant à ce que le temps de réponse d'un appel de procédure prenne environ 5 secondes. Cependant, quel sera le comportement de l'extrait de code suivant, lorsque nous l'exécuterons à partir du shell nameko ?

 res = [] for i in range(5): hello_res = n.rpc.greeting_service.hello.call_async(name=str(i)) res.append(hello_res) for hello_res in res: print(hello_res.result())

Nameko fournit une méthode call_async non bloquante pour chaque point d'entrée RPC, renvoyant un objet de réponse proxy qui peut ensuite être interrogé pour son résultat. La méthode de result , lorsqu'elle est appelée sur le proxy de réponse, sera bloquée jusqu'à ce que la réponse soit renvoyée.

Comme prévu, cet exemple s'exécute en seulement cinq secondes environ. Chaque travailleur sera bloqué en attendant la fin de l'appel de mise en sleep , mais cela n'empêche pas un autre travailleur de démarrer. Remplacez cet appel de sleep par un appel de base de données d'E/S de blocage utile, par exemple, et vous obtenez un service simultané extrêmement rapide.

Comme expliqué précédemment, Nameko crée des workers lorsqu'une méthode est appelée. Le nombre maximum de travailleurs est configurable. Par défaut, ce nombre est défini sur 10. Vous pouvez tester la modification de la range(5) dans l'extrait ci-dessus en, par exemple, plage (20). Cela appellera la méthode hello 20 fois, ce qui devrait maintenant prendre dix secondes pour s'exécuter :

 > >> res = [] > >> for i in range(20): ... hello_res = n.rpc.greeting_service.hello.call_async(name=str(i)) ... res.append(hello_res) > >> for hellores in res: ... print(hello_res.result()) Hello, 0! Hello, 1! Hello, 2! Hello, 3! Hello, 4! Hello, 5! Hello, 6! Hello, 7! Hello, 8! Hello, 9! Hello, 10! Hello, 11! Hello, 12! Hello, 13! Hello, 14! Hello, 15! Hello, 16! Hello, 17! Hello, 18! Hello, 19!

Maintenant, supposons que vous receviez trop d'utilisateurs simultanés (plus de 10) appelant cette méthode hello . Certains utilisateurs resteront suspendus en attendant plus que les cinq secondes prévues pour la réponse. Une solution consistait à augmenter le nombre de travaux en remplaçant les paramètres par défaut à l'aide, par exemple, d'un fichier de configuration. Cependant, si votre serveur est déjà à sa limite avec ces dix nœuds de calcul parce que la méthode appelée repose sur de lourdes requêtes de base de données, l'augmentation du nombre de nœuds de calcul peut entraîner une augmentation encore plus importante du temps de réponse.

Faire évoluer notre service

Une meilleure solution consiste à utiliser les fonctionnalités de Nameko Microservices. Jusqu'à présent, nous n'avons utilisé qu'un seul serveur (votre ordinateur), exécutant une instance de RabbitMQ et une instance du service. Dans un environnement de production, vous voudrez augmenter arbitrairement le nombre de nœuds exécutant le service qui reçoit trop d'appels. Vous pouvez également créer un cluster RabbitMQ si vous souhaitez que votre courtier de messages soit plus fiable.

Pour simuler une mise à l'échelle d'un service, nous pouvons simplement ouvrir un autre terminal et exécuter le service comme avant, en utilisant $ nameko run hello . Cela démarrera une autre instance de service avec la possibilité d'exécuter dix autres nœuds de calcul. Maintenant, essayez à nouveau d'exécuter cet extrait avec range(20) . Il devrait maintenant prendre à nouveau cinq secondes pour s'exécuter. Lorsqu'il y a plus d'une instance de service en cours d'exécution, Nameko effectuera une rotation alternée des requêtes RPC parmi les instances disponibles.

Nameko est conçu pour gérer de manière robuste ces appels de méthodes dans un cluster. Pour tester cela, essayez d'exécuter le snipped et avant qu'il ne se termine, accédez à l'un des terminaux exécutant le service Nameko et appuyez deux fois sur Ctrl+C Cela fermerait l'hôte sans attendre que les travailleurs aient terminé. Nameko réattribuera les appels à une autre instance de service disponible.

En pratique, vous utiliseriez Docker pour conteneuriser vos services, comme nous le ferons plus tard, et un outil d'orchestration tel que Kubernetes pour gérer vos nœuds exécutant le service et d'autres dépendances, telles que le courtier de messages. Si cela est fait correctement, avec Kubernetes, vous transformerez efficacement votre application en un système distribué robuste, à l'abri des pics inattendus. De plus, Kubernetes permet des déploiements sans temps d'arrêt. Par conséquent, le déploiement d'une nouvelle version d'un service n'affectera pas la disponibilité de votre système.

Il est important de créer des services avec une certaine rétrocompatibilité à l'esprit, car dans un environnement de production, il peut arriver que plusieurs versions différentes du même service s'exécutent en même temps, en particulier lors du déploiement. Si vous utilisez Kubernetes, lors du déploiement, il ne supprimera tous les conteneurs de l'ancienne version que lorsqu'il y aura suffisamment de nouveaux conteneurs en cours d'exécution.

Pour Nameko, avoir plusieurs versions différentes du même service en cours d'exécution en même temps n'est pas un problème. Puisqu'il distribue les appels de manière circulaire, les appels peuvent passer par des versions anciennes ou nouvelles. Pour tester cela, conservez un terminal avec notre service exécutant l'ancienne version et modifiez le module de service pour qu'il ressemble à :

 from time import sleep from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): sleep(5) return "Hello, {}! (version 2)".format(name)

Si vous exécutez ce service à partir d'un autre terminal, vous obtiendrez les deux versions en même temps. Maintenant, exécutez à nouveau notre extrait de test et vous verrez les deux versions affichées :

 > >> res = [] > >> for i in range(5): ... hello_res = n.rpc.greeting_service.hello.call_async(name=str(i)) ... res.append(hello_res) > >> for hellores in res: ... print(hello_res.result()) Hello, 0! Hello, 1! (version 2) Hello, 2! Hello, 3! (version 2) Hello, 4!

Travailler avec plusieurs instances

Nous savons maintenant comment travailler efficacement avec Nameko et comment fonctionne la mise à l'échelle. Allons maintenant plus loin et utilisons davantage d'outils de l'écosystème Docker : docker-compose. Cela fonctionnera si vous déployez sur un seul serveur, ce qui n'est certainement pas idéal car vous ne tirerez pas parti de nombreux avantages d'une architecture Microservices. Encore une fois, si vous souhaitez disposer d'une infrastructure plus adaptée, vous pouvez utiliser un outil d'orchestration tel que Kubernetes pour gérer un système distribué de conteneurs. Alors, allez-y et installez docker-compose.

Encore une fois, tout ce que nous avons à faire est de déployer une instance RabbitMQ et Nameko s'occupera du reste, étant donné que tous les services peuvent accéder à cette instance RabbitMQ. Le code source complet de cet exemple est disponible dans ce référentiel GitHub.

Construisons une application de voyage simple pour tester les capacités de Nameko. Cette application permet d'enregistrer les aéroports et les trajets. Chaque aéroport est simplement stocké sous le nom de l'aéroport, et le voyage stocke les identifiants des aéroports d'origine et de destination. L'architecture de notre système ressemble à ceci :

Illustration de l'application de voyage

Idéalement, chaque microservice aurait sa propre instance de base de données. Cependant, pour des raisons de simplicité, j'ai créé une seule base de données Redis à partager pour les microservices Trips et Airports. Le microservice Gateway recevra les requêtes HTTP via une simple API de type REST et utilisera RPC pour communiquer avec Airports and Trips.

Commençons par le microservice Gateway. Sa structure est simple et devrait être très familière à quiconque vient d'un framework comme Flask. Nous définissons essentiellement deux points de terminaison, chacun autorisant à la fois les méthodes GET et POST :

 import json from nameko.rpc import RpcProxy from nameko.web.handlers import http class GatewayService: name = 'gateway' airports_rpc = RpcProxy('airports_service') trips_rpc = RpcProxy('trips_service') @http('GET', '/airport/<string:airport_id>') def get_airport(self, request, airport_id): airport = self.airports_rpc.get(airport_id) return json.dumps({'airport': airport}) @http('POST', '/airport') def post_airport(self, request): data = json.loads(request.get_data(as_text=True)) airport_id = self.airports_rpc.create(data['airport']) return airport_id @http('GET', '/trip/<string:trip_id>') def get_trip(self, request, trip_id): trip = self.trips_rpc.get(trip_id) return json.dumps({'trip': trip}) @http('POST', '/trip') def post_trip(self, request): data = json.loads(request.get_data(as_text=True)) trip_id = self.trips_rpc.create(data['airport_from'], data['airport_to']) return trip_id

Jetons un coup d'œil au service Aéroports maintenant. Comme prévu, il expose deux méthodes RPC. La méthode get interrogera simplement la base de données Redis et renverra l'aéroport pour l'identifiant donné. La méthode create générera un identifiant aléatoire, stockera les informations sur l'aéroport et renverra l'identifiant :

 import uuid from nameko.rpc import rpc from nameko_redis import Redis class AirportsService: name = "airports_service" redis = Redis('development') @rpc def get(self, airport_id): airport = self.redis.get(airport_id) return airport @rpc def create(self, airport): airport_id = uuid.uuid4().hex self.redis.set(airport_id, airport) return airport_id

Remarquez comment nous utilisons l'extension nameko_redis . Consultez la liste des extensions communautaires. Les extensions sont implémentées d'une manière qui utilise l'injection de dépendances. Nameko s'occupe d'initier l'objet d'extension réel que chaque travailleur utilisera.

Il n'y a pas beaucoup de différence entre les microservices Airports et Trips. Voici à quoi ressemblerait le microservice Trips :

 import uuid from nameko.rpc import rpc from nameko_redis import Redis class AirportsService: name = "trips_service" redis = Redis('development') @rpc def get(self, trip_id): trip = self.redis.get(trip_id) return trip @rpc def create(self, airport_from_id, airport_to_id): trip_id = uuid.uuid4().hex self.redis.set(trip_id, { "from": airport_from_id, "to": airport_to_id }) return trip_id

Le Dockerfile pour chaque microservice est également très simple. La seule dépendance est nameko , et dans le cas des services Airports et Trips, il est également nécessaire d'installer nameko-redis . Ces dépendances sont données dans le requirements.txt de chaque service. Le Dockerfile pour le service Airports ressemble à :

 FROM python:3 RUN apt-get update && apt-get -y install netcat && apt-get clean WORKDIR /app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY config.yml ./ COPY run.sh ./ COPY airports.py ./ RUN chmod +x ./run.sh CMD ["./run.sh"]

La seule différence entre cela et le Dockerfile pour les autres services est le fichier source (dans ce cas airports.py ), qui doit être modifié en conséquence.

Le script run.sh se charge d'attendre que RabbitMQ et, dans le cas des services Airports et Trips, que la base de données Redis soit prête. L'extrait de code suivant montre le contenu de run.sh pour les aéroports. Encore une fois, pour les autres services, changez simplement d' aiports en gateway ou en trips en conséquence :

 #!/bin/bash until nc -z ${RABBIT_HOST} ${RABBIT_PORT}; do echo "$(date) - waiting for rabbitmq..." sleep 1 done until nc -z ${REDIS_HOST} ${REDIS_PORT}; do echo "$(date) - waiting for redis..." sleep 1 done nameko run --config config.yml airports

Nos services sont maintenant prêts à fonctionner :

$ docker-compose up

Testons notre système. Exécutez la commande :

 $ curl -i -d "{\"airport\": \"first_airport\"}" localhost:8000/airport HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Length: 32 Date: Sun, 27 May 2018 05:05:53 GMT f2bddf0e506145f6ba0c28c247c54629

Cette dernière ligne est l'identifiant généré pour notre aéroport. Pour tester si cela fonctionne, exécutez :

 $curl localhost:8000/airport/f2bddf0e506145f6ba0c28c247c54629 {"airport": "first_airport"} Great, now let's add another airport: $ curl -i -d "{\"airport\": \"second_airport\"}" localhost:8000/airport HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Length: 32 Date: Sun, 27 May 2018 05:06:00 GMT 565000adcc774cfda8ca3a806baec6b5

Maintenant, nous avons deux aéroports, c'est assez pour former un voyage. Créons un voyage maintenant :

 $ curl -i -d "{\"airport_from\": \"f2bddf0e506145f6ba0c28c247c54629\", \"airport_to\": \"565000adcc774cfda8ca3a806baec6b5\"}" localhost:8000/trip HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Length: 32 Date: Sun, 27 May 2018 05:09:10 GMT 34ca60df07bc42e88501178c0b6b95e4

Comme précédemment, cette dernière ligne représente l'ID du voyage. Vérifions s'il a été inséré correctement :

 $ curl localhost:8000/trip/34ca60df07bc42e88501178c0b6b95e4 {"trip": "{'from': 'f2bddf0e506145f6ba0c28c247c54629', 'to': '565000adcc774cfda8ca3a806baec6b5'}"}

Sommaire

Nous avons vu comment Nameko fonctionne en créant une instance d'exécution locale de RabbitMQ, en s'y connectant et en effectuant plusieurs tests. Ensuite, nous avons appliqué les connaissances acquises pour créer un système simple utilisant une architecture Microservices.

Bien qu'il soit extrêmement simple, notre système est très proche de ce à quoi ressemblerait un déploiement prêt pour la production. Vous utiliserez de préférence un autre framework pour gérer les requêtes HTTP telles que Falcon ou Flask. Les deux sont d'excellentes options et peuvent facilement être utilisées pour créer d'autres microservices basés sur HTTP, au cas où vous voudriez casser votre service de passerelle, par exemple. Flask a l'avantage d'avoir déjà un plugin pour interagir avec Nameko, mais vous pouvez utiliser nameko-proxy directement depuis n'importe quel framework.

Nameko est également très facile à tester. Nous n'avons pas couvert les tests ici pour plus de simplicité, mais consultez la documentation de test de Nameko.

Avec toutes les pièces mobiles d'une architecture de microservices, vous voulez vous assurer que vous disposez d'un système de journalisation robuste. Pour en créer un, consultez Python Logging: An In-Depth Tutorial par son collègue Toptaler et Python Developer: Son Nguyen Kim.