La mise à l'échelle de l'infrastructure backend pour gérer l'hypercroissance est l'un des nombreux défis passionnants du travail chez DoorDash. Au milieu de l'année 2019, nous avons été confrontés à d'importants défis de mise à l'échelle et à des pannes fréquentes impliquant Celery et RabbitMQ, deux technologies alimentant le système qui gère le travail asynchrone permettant des fonctionnalités critiques de notre plateforme, y compris la vérification des commandes et les affectations de Dasher.
Nous avons rapidement résolu ce problème avec un simple, Apache Kafka-basé sur Apache Kafka, qui a permis d'arrêter les pannes pendant que nous continuions à développer une solution robuste. Notre version initiale a mis en œuvre le plus petit ensemble de fonctionnalités nécessaires pour prendre en charge une grande partie des tâches Celery existantes. Une fois en production, nous avons continué à ajouter la prise en charge de nouvelles fonctionnalités de Celery tout en abordant de nouveaux problèmes liés à l'utilisation de Kafka.
Les problèmes rencontrés lors de l'utilisation de Celery et RabbitMQ
RabbitMQ et Celery étaient des éléments critiques de notre infrastructure qui alimentaient plus de 900 tâches asynchrones différentes chez DoorDash, y compris la vérification des commandes, la transmission des commandes des commerçants et le traitement des emplacements Dasher. Le problème auquel DoorDash était confronté était que RabbitMQ tombait fréquemment en panne en raison d'une charge excessive. Si le traitement des tâches tombait en panne, DoorDash tombait en panne et les commandes ne pouvaient pas être terminées, ce qui entraînait une perte de revenus pour nos commerçants et nos Dashers, et une mauvaise expérience pour nos consommateurs. Nous avons rencontré des problèmes sur les fronts suivants :
- Disponibilité : Les interruptions dues à la demande réduisent la disponibilité.
- Évolutivité : RabbitMQ ne pouvait pas s'adapter à la croissance de notre entreprise.
- Observabilité : RabbitMQ offrait des mesures limitées et les travailleurs Celery étaient opaques.
- Efficacité opérationnelle : Le redémarrage de ces composants était un processus manuel qui prenait beaucoup de temps.
Pourquoi notre système de traitement des tâches asynchrones n'était pas hautement disponible
Le plus gros problème auquel nous étions confrontés était les pannes, et elles survenaient souvent lorsque la demande était à son maximum. RabbitMQ tombait en panne à cause de la charge, d'une saturation excessive de la connexionet d'autres raisons. Les commandes étaient interrompues et nous devions redémarrer notre système ou parfois même mettre en place un tout nouveau courtier et procéder à un basculement afin de récupérer après la panne.
En approfondissant les questions de disponibilité, nous avons constaté les sous-problèmes suivants :
- Celery permet aux utilisateurs de planifier des tâches dans le futur avec un compte à rebours ou un délai d'exécution. Notre utilisation intensive de ces comptes à rebours a entraîné une augmentation sensible de la charge du courtier. Certaines de nos pannes étaient directement liées à l'augmentation des tâches avec compte à rebours. Nous avons finalement décidé de restreindre l'utilisation des comptes à rebours en faveur d'un autre système que nous avions mis en place pour planifier le travail dans le futur.
- Des rafales soudaines de trafic laissaient RabbitMQ dans un état dégradé où la consommation de tâches était significativement plus faible que prévu. D'après notre expérience, ce problème ne pouvait être résolu que par un rebond de RabbitMQ. RabbitMQ dispose d'un concept de contrôle de flux qui réduit la vitesse des connexions qui publient trop rapidement afin que les files d'attente puissent suivre. Le contrôle de flux était souvent, mais pas toujours, impliqué dans ces dégradations de disponibilité. Lorsque le contrôle de flux intervient, les éditeurs le considèrent comme une latence du réseau. La latence du réseau réduit nos temps de réponse ; si la latence augmente pendant les pics de trafic, il peut en résulter des ralentissements importants qui se répercutent en cascade sur les demandes qui s'accumulent en amont.
- Notre python uWSGI disposaient d'une fonctionnalité appelée harakiri, qui permettait de tuer tous les processus dépassant un délai d'attente. En cas de panne ou de ralentissement, harakiri entraînait une rotation des connexions vers les brokers RabbitMQ, car les processus étaient tués et redémarrés à plusieurs reprises. Avec des milliers de travailleurs web en cours d'exécution à un moment donné, toute lenteur qui déclenchait harakiri contribuait à son tour à la lenteur en ajoutant une charge supplémentaire à RabbitMQ.
- En production, nous avons rencontré plusieurs cas où le traitement des tâches dans les consommateurs Celery s'est arrêté, même en l'absence d'une charge importante. Nos efforts d'investigation n'ont pas permis de mettre en évidence des contraintes de ressources qui auraient pu interrompre le traitement, et les travailleurs ont repris le traitement une fois qu'ils ont été rebondis. Ce problème n'a jamais été causé à la racine, bien que nous soupçonnions un problème dans les travailleurs Celery eux-mêmes et non dans RabbitMQ.
Dans l'ensemble, tous ces problèmes de disponibilité étaient inacceptables pour nous, car une grande fiabilité est l'une de nos plus grandes priorités. Comme ces pannes nous coûtaient cher en termes de commandes manquées et de crédibilité, nous avions besoin d'une solution pour résoudre ces problèmes le plus rapidement possible.
Pourquoi notre ancienne solution n'était pas évolutive
Le problème suivant était l'échelle. DoorDash connaît une croissance rapide et nous atteignions rapidement les limites de notre solution existante. Nous devions trouver une solution capable de suivre notre croissance continue, car notre ancienne solution présentait les problèmes suivants :
Atteindre la limite de l'échelle verticale
Nous utilisions la plus grande solution RabbitMQ à nœud unique disponible. Il n'y avait aucune possibilité d'évolution verticale et nous commencions déjà à pousser ce nœud à ses limites.
Le mode haute disponibilité a limité notre capacité
En raison de la réplication, le mode haute disponibilité (HA) primaire-secondaire a réduit le débit par rapport à l'option à nœud unique, ce qui nous a laissé encore moins de marge de manœuvre que la solution à nœud unique. Nous ne pouvions pas nous permettre d'échanger le débit contre la disponibilité.
Deuxièmement, le mode HA primaire-secondaire n'a pas, dans la pratique, réduit la gravité de nos pannes. Les basculements prenaient plus de 20 minutes et restaient souvent bloqués, ce qui nécessitait une intervention manuelle. Les messages étaient également souvent perdus au cours du processus.
Nous étions rapidement à court de marge de manœuvre alors que DoorDash continuait de croître et poussait notre traitement des tâches à ses limites. Nous avions besoin d'une solution capable d'évoluer horizontalement au fur et à mesure que nos besoins de traitement augmentaient.
Comment Celery et RabbitMQ offrent une observabilité limitée
Il est essentiel de savoir ce qui se passe dans un système pour garantir sa disponibilité, son évolutivité et son intégrité opérationnelle.
En abordant les questions évoquées ci-dessus, nous avons remarqué que :
- Nous étions limités à un petit ensemble de mesures RabbitMQ disponibles.
- Nous n'avions qu'une visibilité limitée sur les travailleurs du céleri eux-mêmes.
Nous devions être en mesure de voir les mesures en temps réel de chaque aspect de notre système, ce qui signifiait que les limites de l'observabilité devaient également être prises en compte.
Les défis de l'efficacité opérationnelle
Nous avons également rencontré plusieurs problèmes avec RabbitMQ :
- Nous devions souvent faire basculer notre nœud RabbitMQ vers un nouveau nœud pour résoudre la dégradation persistante que nous observions. Cette opération était manuelle et fastidieuse pour les ingénieurs concernés et devait souvent être effectuée tard dans la nuit, en dehors des heures de pointe.
- Il n'y avait pas d'experts Celery ou RabbitMQ en interne chez DoorDash sur lesquels nous pouvions nous appuyer pour concevoir une stratégie de mise à l'échelle de cette technologie.
Le temps consacré par les ingénieurs à l'exploitation et à la maintenance de RabbitMQ n'était pas tenable. Nous avions besoin de quelque chose qui réponde mieux à nos besoins actuels et futurs.
Solutions potentielles à nos problèmes avec Celery et RabbitMQ
Compte tenu des problèmes décrits ci-dessus, nous avons envisagé les solutions suivantes :
- Changer le broker Celery de RabbitMQ à Redis ou Kafka. Cela nous permettrait de continuer à utiliser Celery, avec un datastore de sauvegarde différent et potentiellement plus fiable.
- Ajouter la prise en charge du multi-courtier à notre Django afin que les consommateurs puissent publier vers N courtiers différents en fonction de la logique que nous voulons. Le traitement des tâches sera réparti sur plusieurs courtiers, de sorte que chaque courtier ne subira qu'une fraction de la charge initiale.
- Passer à des versions plus récentes de Celery et RabbitMQ. Les nouvelles versions de Celery et RabbitMQ étaient censées résoudre les problèmes de fiabilité, ce qui nous a permis de gagner du temps car nous étions déjà en train d'extraire des composants de notre monolithe Django en parallèle.
- Migrer vers une solution personnalisée soutenue par Kafka. Cette solution demande plus d'efforts que les autres options que nous avons énumérées, mais elle a aussi plus de chances de résoudre tous les problèmes que nous rencontrions avec la solution existante.
Chaque option a ses avantages et ses inconvénients :
Option | Pour | Cons |
Redis en tant que courtier |
|
|
Kafka en tant que courtier |
|
|
Plusieurs courtiers |
|
|
Mise à jour des versions |
|
|
Solution Kafka personnalisée |
|
|
Notre stratégie d'intégration de Kafka
Compte tenu du temps de disponibilité requis pour notre système, nous avons élaboré une stratégie d'intégration basée sur les principes suivants afin de maximiser les avantages en termes de fiabilité dans les plus brefs délais. Cette stratégie comporte trois étapes :
- Une mise en route rapide : Nous voulions tirer parti des éléments de base de la solution que nous étions en train de mettre au point tout en améliorant d'autres parties de celle-ci. Nous comparons cette stratégie à la conduite d'une voiture de course pendant que nous remplaçons une nouvelle pompe à essence.
- Des choix de conception pour une adoption en douceur par les développeurs : Nous voulions minimiser le gaspillage d'efforts de la part de tous les développeurs qui auraient pu résulter de la définition d'une interface différente.
- Déploiement progressif avec zéro temps d'arrêt : Au lieu d'une grande version flashy testée dans la nature pour la première fois avec un risque plus élevé d'échecs, nous nous sommes concentrés sur la livraison de fonctionnalités indépendantes plus petites qui pouvaient être testées individuellement dans la nature sur une plus longue période de temps.
Un démarrage en trombe
Le passage à Kafka représentait un changement technique majeur dans notre pile, mais un changement qui était vraiment nécessaire. Nous n'avions pas de temps à perdre puisque chaque semaine nous perdions des affaires à cause de l'instabilité de notre ancienne solution RabbitMQ. Notre première et principale priorité était de créer un produit minimum viable (MVP) pour nous apporter une stabilité provisoire et nous donner la marge de manœuvre nécessaire pour itérer et préparer une solution plus complète avec une adoption plus large.
Notre MVP était composé de producteurs qui publiaient des tâches Noms entièrement qualifiés (FQN) et décapés à Kafka, tandis que nos consommateurs lisaient ces messages, importaient les tâches à partir du FQN et les exécutaient de manière synchrone avec les arguments spécifiés.
Des choix de conception pour une adoption en douceur par les développeurs
Parfois, l'adoption par les développeurs est un plus grand défi que le développement. Nous avons facilité les choses en mettant en place une enveloppe pour la fonction @task de Celery qui achemine dynamiquement les soumissions de tâches vers l'un ou l'autre système en fonction de drapeaux de fonctionnalités configurables dynamiquement. Désormais, la même interface peut être utilisée pour écrire des tâches pour les deux systèmes. Une fois ces décisions prises, les équipes d'ingénieurs n'ont pas eu à effectuer de travail supplémentaire pour s'intégrer au nouveau système, à l'exception de l'implémentation d'un seul indicateur de fonctionnalité.
Nous voulions déployer notre système dès que notre MVP était prêt, mais il ne prenait pas encore en charge toutes les fonctionnalités de Celery. Celery permet aux utilisateurs de configurer leurs tâches avec des paramètres dans l'annotation de la tâche ou lorsqu'ils soumettent leur tâche. Pour nous permettre de lancer le système plus rapidement, nous avons créé une liste blanche de paramètres compatibles et choisi de prendre en charge le plus petit nombre de fonctionnalités nécessaires pour prendre en charge la majorité des tâches.
Comme le montre la figure 2, grâce aux deux décisions ci-dessus, nous avons lancé notre MVP après deux semaines de développement et obtenu une réduction de 80 % de la charge de travail de RabbitMQ une semaine après le lancement. Nous avons réglé rapidement notre principal problème de pannes et, tout au long du projet, nous avons pris en charge des fonctions de plus en plus ésotériques pour permettre l'exécution des tâches restantes.
Déploiement progressif, zéro temps d'arrêt
La possibilité de changer de cluster Kafka et de passer de RabbitMQ à Kafka de manière dynamique sans impact sur l'activité était extrêmement importante pour nous. Cette capacité nous a également aidés dans une variété d'opérations telles que la maintenance des clusters, le délestage et les migrations progressives. Pour mettre en œuvre ce déploiement, nous avons utilisé des drapeaux de fonctionnalités dynamiques à la fois au niveau de la soumission des messages et au niveau de la consommation des messages. Le prix à payer pour être totalement dynamique a été de faire fonctionner notre flotte de travailleurs à double capacité. La moitié de cette flotte était consacrée à RabbitMQ, et le reste à Kafka. Faire tourner la flotte de travailleurs à double capacité a définitivement taxé notre infrastructure. À un moment donné, nous avons même créé un tout nouveau serveur Kubernetes juste pour héberger tous nos travailleurs.
Au cours de la phase initiale de développement, cette flexibilité nous a été très utile. Une fois que nous avons eu plus de confiance dans notre nouveau système, nous avons cherché des moyens de réduire la charge sur notre infrastructure, comme l'exécution de plusieurs processus de consommation par machine de travail. Au fur et à mesure de la transition des différents sujets, nous avons pu commencer à réduire le nombre de travailleurs pour RabbitMQ tout en conservant une petite capacité de réserve.
Aucune solution n'est parfaite, il faut itérer si nécessaire.
Avec notre MVP en production, nous avions la marge de manœuvre nécessaire pour itérer et peaufiner notre produit. Nous avons classé chaque fonctionnalité manquante de Celery en fonction du nombre de tâches qui l'utilisaient pour nous aider à décider lesquelles implémenter en premier. Les fonctionnalités utilisées par quelques tâches seulement n'ont pas été implémentées dans notre solution personnalisée. Au lieu de cela, nous avons réécrit ces tâches pour qu'elles n'utilisent pas cette fonctionnalité spécifique. Grâce à cette stratégie, toutes les tâches ont finalement été retirées de Celery.
L'utilisation de Kafka a également introduit de nouveaux problèmes qui ont nécessité notre attention :
- Blocage en tête de file entraînant des retards dans le traitement des tâches
- Les déploiements ont déclenché un rééquilibrage des partitions, ce qui a également entraîné des retards.
Le problème de blocage en tête de ligne de Kafka
Les sujets Kafka sont divisés de telle sorte qu'un seul consommateur (par groupe de consommateurs) lit les messages des partitions qui lui sont attribuées dans l'ordre où ils sont arrivés. Si le traitement d'un message dans une seule partition prend trop de temps, il bloque la consommation de tous les messages qui le suivent dans cette partition, comme le montre la figure 3 ci-dessous. Ce problème peut être particulièrement désastreux dans le cas d'un sujet hautement prioritaire. Nous voulons pouvoir continuer à traiter les messages d'une partition en cas de retard.
Bien que le parallélisme soit, fondamentalement, un problème Python, les concepts de cette solution sont également applicables à d'autres langages. Notre solution, décrite dans la figure 4 ci-dessous, consistait à héberger un processus de consommation Kafka et plusieurs processus d'exécution de tâches par travailleur. Le processus de consommation Kafka est chargé de récupérer les messages de Kafka et de les placer dans une file d'attente locale qui est lue par les processus d'exécution des tâches. Il continue à consommer jusqu'à ce que la file d'attente locale atteigne un seuil défini par l'utilisateur. Cette solution permet aux messages de la partition de circuler et un seul processus d'exécution des tâches sera bloqué par un message lent. Le seuil limite également le nombre de messages en vol dans la file d'attente locale (qui peuvent être perdus en cas de panne du système).
Le caractère perturbateur des déploiements
We deploy our Django app multiple times a day. One drawback with our solution that we noticed is that a deployment triggers a rebalance of partition assignments in Kafka. Despite using a different consumer group per topic to limit the rebalance scope, deployments still caused a momentary slowdown in message processing as task consumption had to stop during rebalancing. Slowdowns may be acceptable in most cases when we perform planned releases, but can be catastrophic when, for example, we're doing an emergency release to hotfix a bug. The consequence would be the introduction of a cascading processing slowdown.
Les versions plus récentes de Kafka et des clients prennent en charge le rééquilibrage coopératif incrémentiel, ce qui réduirait considérablement l'impact opérationnel d'un rééquilibrage. La mise à jour de nos clients pour prendre en charge ce type de rééquilibrage serait notre solution de choix pour l'avenir. Malheureusement, le rééquilibrage coopératif incrémental n'est pas encore pris en charge par le client Kafka que nous avons choisi.
Principales victoires
À l'issue de ce projet, nous avons réalisé des améliorations significatives en termes de temps de fonctionnement, d'évolutivité, d'observabilité et de décentralisation. Ces avancées étaient cruciales pour assurer la croissance continue de notre entreprise.
Finies les pannes répétées
Nous avons mis fin aux pannes répétées presque dès que nous avons commencé à déployer cette approche Kafka personnalisée. Les pannes se traduisaient par des expériences extrêmement médiocres pour les utilisateurs.
- En ne mettant en œuvre qu'un petit sous-ensemble des fonctionnalités Celery les plus utilisées dans notre MVP, nous avons pu livrer un code fonctionnel à la production en deux semaines.
- Avec le MVP en place, nous avons pu réduire de manière significative la charge sur RabbitMQ et Celery alors que nous continuions à renforcer notre solution et à mettre en œuvre de nouvelles fonctionnalités.
Le traitement des tâches n'est plus le facteur limitant de la croissance
Avec Kafka au cœur de notre architecture, nous avons construit un système de traitement des tâches hautement disponible et évolutif horizontalement, permettant à DoorDash et à ses clients de poursuivre leur croissance.
Observabilité massivement augmentée
Comme il s'agissait d'une solution personnalisée, nous avons pu intégrer davantage de mesures à presque tous les niveaux. Chaque file d'attente, travailleur et tâche était entièrement observable à un niveau très granulaire dans les environnements de production et de développement. Cette observabilité accrue a été une grande victoire, non seulement en termes de production, mais aussi en termes de productivité des développeurs.
Décentralisation opérationnelle
Grâce aux améliorations en matière d'observabilité, nous avons pu modéliser nos alertes en tant que Terraform et d'attribuer explicitement des propriétaires à chaque sujet et, implicitement, à l'ensemble des plus de 900 tâches.
Un guide d'utilisation détaillé pour le système de traitement des tâches rend les informations accessibles à tous les ingénieurs pour déboguer les problèmes opérationnels avec leurs sujets et leurs travailleurs, ainsi que pour effectuer des opérations de gestion globale du cluster Kafka, si nécessaire. Les opérations quotidiennes se font en libre-service et notre équipe Infrastructure n'a que rarement besoin d'aide.
Conclusion
En résumé, nous avons atteint le plafond de notre capacité à faire évoluer RabbitMQ et avons dû chercher des alternatives. Nous avons opté pour une solution personnalisée basée sur Kafka. Bien que l'utilisation de Kafka présente certains inconvénients, nous avons trouvé un certain nombre de solutions de contournement, décrites ci-dessus.
Lorsque des flux de travail critiques reposent fortement sur le traitement de tâches asynchrones, il est primordial de garantir l'évolutivité. Si vous rencontrez des problèmes similaires, n'hésitez pas à vous inspirer de notre stratégie, qui nous a permis d'obtenir 80 % du résultat avec 20 % de l'effort. Cette stratégie, dans le cas général, est une approche tactique visant à atténuer rapidement les problèmes de fiabilité et à gagner le temps nécessaire à la mise en place d'une solution plus robuste et stratégique.
Remerciements
Les auteurs souhaitent remercier Clement Fang, Corry Haines, Danial Asif, Jay Weinstein, Luigi Tagliamonte, Matthew Anger, Shaohua Zhou et Yun-Yu Chen pour leur contribution à ce projet.