Skip to content

Blog


Améliorer l'évolutivité, la fiabilité et l'efficacité d'un service Python avec Gevent

19 janvier 2021

|

Simone Restelli

Devavrath Subramanyam

Lorsqu'on essaie de faire évoluer un système distribué, l'obstacle le plus fréquent n'est pas qu'il n'y a pas assez de ressources disponibles, mais qu'elles ne sont pas utilisées de manière efficace.

Chez DoorDash, nous avons trouvé une opportunité similaire en travaillant sur l'évolution de notre système de point de vente (POS). Nous subissions des pannes parce que notre système de point de vente ne pouvait pas évoluer pour répondre aux pics de demande. Le problème n'était pas un manque de ressources, mais le fait que nos ressources n'étaient pas utilisées efficacement. 

Le système de point de vente de DoorDash collecte les nouvelles commandes et les envoie aux systèmes de point de vente des commerçants via leurs API. Cependant, en raison de la latence typique du réseau et du système, le temps nécessaire pour que les points de vente des commerçants répondent aux demandes de création de commandes était faible mais significatif. Étant donné que le système existant devait attendre que le système de point de vente du commerçant réponde, l'efficacité de notre utilisation des ressources était fortement limitée. 

Notre solution a consisté à passer d'un modèle d'entrée/sortie synchrone à un modèle d'entrée/sortie asynchrone, ce qui a permis de mieux utiliser nos ressources, de multiplier par 10 notre capacité de commande et d'éviter des pannes supplémentaires. 

Le cycle de vie d'une commande

Le backend de DoorDash est constitué de plusieurs microservices qui communiquent via des API REST ou gRPC. Notre microservice POS transmet les commandes aux commerçants.

un organigramme de la façon dont le système de point de vente DoorDash communique avec le backend et les systèmes des marchands.
Figure 1 : Sur la plateforme DoorDash, le backend web envoie la commande d'un consommateur au point de vente, où elle est traitée, et le point de vente transmet la commande au commerçant pour qu'il l'exécute.

Une fois que l'utilisateur a terminé le processus de paiement, soit sur l'application mobile DoorDash, soit sur le site web, la commande est envoyée au microservice POS. À ce stade, le système de point de vente transmet la commande au bon commerçant par le biais d'une série d'appels API.

Étant donné que l'envoi de la commande au commerçant peut prendre plusieurs secondes, la communication avec le point de vente est asynchrone et réalisée à l'aide de Celery, une bibliothèque Python qui fournit une couche RPC sur différents courtiers, dans notre cas RabbitMQ. La demande de commande sera ensuite prise en charge par l'un des travailleurs Kubernetes de POS, traitée et transmise au commerçant.

Comment nous avons configuré le céleri

Celery est un framework Python distribué conçu pour gérer des tâches asynchrones. Chaque pod Kubernetes POS exécute quatre processus Celery. Chaque processus attend qu'une nouvelle tâche soit disponible, la récupère et exécute le callback Python pour contacter le commerçant et créer une nouvelle commande. Ce processus est synchrone et aucune nouvelle tâche ne peut être exécutée par le processus Celery tant que la tâche en cours n'est pas terminée.

Les problèmes de l'approche patrimoniale

Cette approche héritée du passé pose un certain nombre de problèmes. Tout d'abord, les ressources sont sous-utilisées. Lorsque le taux de commandes est élevé, la majeure partie du temps de l'unité centrale est consacrée à l'attente d'une réponse de la part des points d'extrémité du commerçant. Ce temps d'attente, associé au faible nombre de processus Celery que nous avons configurés, se traduit par une utilisation moyenne de l'unité centrale inférieure à 5 %. L'effet global de notre ancienne approche est que nous finissons par exiger beaucoup plus de ressources que nécessaire, ce qui limite l'évolutivité du système.

Le second problème est qu'une latence accrue dans une API de commerçant peut utiliser toutes les tâches Celery et provoquer une accumulation de requêtes vers d'autres API de commerçant (saines) dans RabbitMQ. Lorsque cette accumulation s'est produite dans le passé, elle a entraîné une perte importante de commandes, ce qui a nui à l'entreprise et à la confiance des utilisateurs.

Il est clair que l'augmentation du nombre de processus Celery n'aurait que peu amélioré la situation, car chaque processus nécessite une quantité importante de mémoire pour fonctionner, ce qui limite la concurrence maximale réalisable.

Les solutions envisagées 

Pour résoudre ce problème de PDV, nous avons envisagé trois options : 

  • Utilisation d'un pool de threads Python
  • Tirer parti de la fonctionnalité AsyncIO de Python 3
  • Utilisation d'une bibliothèque d'E/S asynchrones telle que Gevent

Pool de fils

Une solution possible serait de passer à un modèle avec un seul processus et de faire exécuter les tâches par un exécuteur de pool de threads Python. Étant donné que l'empreinte mémoire d'un thread est nettement inférieure à celle d'un ancien processus, cela nous aurait permis d'augmenter considérablement notre niveau de concurrence, en passant de quatre processus Celery par pod à un seul processus avec 50 à 100 threads.

Les inconvénients de cette approche de pool de threads sont que, comme Celery ne supporte pas nativement les threads multiples, nous devrions écrire la logique pour transmettre les tâches au pool de threads et nous devrions gérer la concurrence, ce qui se traduirait par une augmentation significative de la complexité du code.

AsyncIO

La fonctionnalité AsyncIO de Python 3 nous aurait permis d'avoir une grande concurrence sans avoir à nous soucier de la synchronisation puisque le contexte n'est changé qu'à des moments bien définis.

Malheureusement, lorsque nous avons envisagé d'utiliser AsyncIO pour résoudre ce problème, la version actuelle de Celery 4 ne prenait pas en charge AsyncIO de manière native, ce qui rendait une éventuelle mise en œuvre de notre côté beaucoup moins simple.

En outre, toutes les bibliothèques d'entrée/sortie que nous utilisons devraient supporter AsyncIO pour que cette stratégie fonctionne correctement avec le reste du code. Cela nous aurait obligés à remplacer certaines des bibliothèques que nous utilisions, comme HTTP, par d'autres bibliothèques compatibles avec AsyncIO, ce qui aurait entraîné des modifications de code potentiellement importantes.

Gevent

Finalement, nous avons décidé d'utiliser Gevent, une bibliothèque Python qui enveloppe les greenlets Python. Gevent fonctionne de manière similaire à AsyncIO : lorsqu'une opération nécessite des E/S, le contexte d'exécution est basculé vers une autre partie du code dont la demande d'E/S précédente a déjà été satisfaite et qui est donc prête à être exécutée. Cette méthode est similaire à l'utilisation de la fonction select() de Linux, mais elle est gérée de manière transparente par Gevent .

Un autre avantage de Gevent est que Celery le prend en charge de manière native, ne nécessitant qu'une modification du modèle de concurrence. Ce support facilite la mise en place de Gevent avec les outils existants. 

Globalement, l'utilisation de Gevent résoudrait le problème rencontré par DoorDash en nous permettant d'augmenter le nombre de tâches Celery de deux ordres de grandeur.

Des patchs de singe pour tout !

L'adoption de Gevent s'est heurtée au fait que toutes nos bibliothèques d'E/S n'étaient pas en mesure de libérer le contexte d'exécution pour qu'il soit occupé par une autre partie du code. Les créateurs de Gevent ont trouvé une solution intelligente à ce problème.

Gevent comprend des versions adaptées à Gevent des bibliothèques d'E/S de bas niveau les plus courantes, telles que socket, et propose une méthode pour que les autres bibliothèques existantes utilisent celles de Gevent. Comme l'indique l'exemple de code ci-dessous, cette méthode est connue sous le nom de " monkey patching". Bien que cette méthode puisse sembler être un hack, elle a été utilisée en production par de grandes entreprises technologiques, y compris Pinterest et Lyft.

> from gevent import monkey
> monkey.patch_all()

En ajoutant les deux lignes ci-dessus au début du code de l'application, Gevent écrase automatiquement tous les modules importés par la suite et utilise les bibliothèques d'E/S de Gevent à la place.

Mise en œuvre de Gevent sur notre système POS

Au lieu d'introduire directement les changements Gevent dans le service POS existant, nous avons créé une nouvelle application parallèle contenant les correctifs liés à Gevent, et nous avons progressivement détourné le trafic vers cette application. Cette stratégie de mise en œuvre nous a permis de revenir à l'ancienne application sans Gevent en cas de problème.

La mise en œuvre de la nouvelle application Gevent a nécessité les changements suivants :

Étape 1 : Installer le paquetage Python suivant

pip install gevent

Étape 2 : Céleri en patch de singe

L'application sans événement contenait le code suivant, dans la section celery.py pour démarrer Celery :

from celery import Celery
from app import rabbitmq_url

main_app = Celery('app', broker=rabbitmq_url)

@app.task
def hello():
    return 'hello world'

L'application Gevent comprend le fichier ci-dessous, gcelery.py, avec le fichier celery.py.

from gevent import monkey
monkey.patch_all(httplib=False)


import app.celery
from app.celery import main_app

Nous avons utilisé le code monkey.patch_all(httplib=False) pour patcher soigneusement toutes les parties des bibliothèques avec des fonctions adaptées à Gevent qui se comportent de la même manière que les fonctions originales. Cette correction est effectuée au début du cycle de vie de l'application, avant le démarrage de Celery, comme le montre le fichier celery.py.

Étape 3 : Création du travailleur Celery.

L'application non-Gevent démarre le travailleur Celery de la manière suivante :

exec celery -A app worker --concurrency=3 --loglevel=info --without-mingle 

L'application Gevent démarre le travailleur Celery comme suit :

exec celery -A app.gcelery worker --concurrency=200 --pool gevent --loglevel=info --without-mingle

Les éléments suivants ont changé lors de la mise en œuvre de l'application Gevent :

  • Les -A détermine l'application à exécuter. 
  • Dans l'application non événementielle, nous amorçons directement celery.py.
  • Dans l'application Gevent, app.gcelery est exécuté. Les gcelery.py monkey patche d'abord toutes les bibliothèques sous-jacentes avant d'appeler l'application celery.py.

L'argument de ligne de commande -- concurrency détermine le nombre de processus/threads. Dans l'application non-Gevent, nous n'avons créé que trois processus. Dans l'application Gevent, nous n'avons créé que deux cents threads verts.

La ligne de commande -- pool nous permet de choisir entre les processus et les threads. Dans l'application Gevent, nous choisissons Gevent car nos tâches sont davantage liées aux E/S.

Résultats

Après avoir déployé la nouvelle application POS qui utilise Gevent en production, nous avons obtenu d'excellents résultats. Tout d'abord, nous avons pu réduire nos pods Kubernetes d'un facteur 10, réduisant à son tour la quantité de CPU que nous utilisions de 90 %. Malgré cette réduction, notre capacité de traitement des commandes a augmenté de 600 %.

En outre, l'utilisation de l'unité centrale de chaque pod est passée de moins de 5 % à environ 30 %.

Un graphique montrant les effets de la mise en œuvre de Gevent
Figure 2 : Après avoir mis en œuvre Gevent, nous avons vu nos commandes par heure traitées par chaque pod Kubernetes passer de 450 à 6 000, ce qui indique un énorme gain d'efficacité.

Notre nouveau système peut également gérer un taux de requêtes beaucoup plus élevé, puisque nous avons considérablement réduit le nombre d'interruptions dues à des API marchandes défaillantes qui absorbaient la plupart de nos tâches Celery.

Conclusions

Gevent a aidé DoorDash à améliorer l'évolutivité et la fiabilité de ses services. Il a également permis à DoorDash d'utiliser efficacement les ressources pendant les opérations d'E/S en utilisant des E/S asynchrones basées sur les événements, réduisant ainsi l'utilisation des ressources.

De nombreuses entreprises développent des microservices basés sur Python qui sont synchrones pendant les opérations d'E/S avec d'autres services, bases de données, caches et courtiers de messages. Ces entreprises peuvent avoir une opportunité similaire à celle de DoorDash d'améliorer l'évolutivité, la fiabilité et l'efficacité de leurs services en utilisant cette solution.

À propos des auteurs

  • Simone Restelli

  • Devavrath Subramanyam

Emplois connexes

Localisation
San Francisco, CA ; Mountain View, CA ; New York, NY ; Seattle, WA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Seattle, WA
Département
Ingénierie
Localisation
Pune, Inde
Département
Ingénierie
Localisation
San Francisco, CA ; Seattle, WA ; Sunnyvale, CA
Département
Ingénierie