La mise à l’échelle de l’infrastructure backend pour gérer l’hyper-croissance est l’un des nombreux défis passionnants de travailler chez DoorDash. À la mi-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 plate-forme, y compris le paiement des commandes et les affectations Dasher.
Nous avons rapidement résolu ce problème avec un système de traitement de tâches asynchrone simple basé sur Apache Kafka qui a arrêté nos pannes pendant que nous continuions à itérer sur une solution robuste. Notre version initiale a implémenté le plus petit ensemble de fonctionnalités nécessaires pour accueillir une grande partie des tâches Céleri existantes. Une fois en production, nous avons continué à prendre en charge davantage de fonctionnalités de Céleri tout en abordant les nouveaux problèmes rencontrés lors de l’utilisation de Kafka.
Les problèmes que nous avons rencontrés avec Celery et RabbitMQ
RabbitMQ et Celery étaient des éléments essentiels de notre infrastructure qui ont alimenté plus de 900 tâches asynchrones différentes chez DoorDash, y compris le paiement des commandes, la transmission des commandes marchandes et le traitement de la localisation Dasher. Le problème rencontré par DoorDash était que RabbitMQ descendait fréquemment en raison d’une charge excessive. Si le traitement des tâches était interrompu, DoorDash était effectivement en panne et les commandes ne pouvaient pas être terminées, ce qui entraînait une perte de revenus pour nos marchands et nos Dashers, et une mauvaise expérience pour nos consommateurs. Nous avons rencontré des problèmes sur les fronts suivants:
- Disponibilité: Pannes causées par une disponibilité réduite de la demande.
- Évolutivité: RabbitMQ n’a pas pu évoluer avec la croissance de notre entreprise.
- Observabilité: RabbitMQ offrait des métriques limitées et les travailleurs de Céleri étaient opaques.
- Efficacité opérationnelle : Le redémarrage de ces composants était un processus manuel fastidieux.
Pourquoi notre système de traitement des tâches asynchrone n’était pas hautement disponible
Ce plus gros problème auquel nous avons été confrontés était des pannes, et elles arrivaient souvent lorsque la demande était à son apogée. RabbitMQ baisserait en raison de la charge, du désabonnement excessif des connexions et d’autres raisons. Les commandes seraient interrompues et nous devions redémarrer notre système ou parfois même mettre en place un tout nouveau courtier et un basculement manuel afin de nous remettre de la panne.
En approfondissant les problèmes de disponibilité, nous avons trouvé les sous-problèmes suivants:
- Le céleri permet aux utilisateurs de planifier des tâches à l’avenir avec un compte à rebours ou une ETA. Notre utilisation intensive de ces comptes à rebours a entraîné une augmentation notable de la charge sur le courtier. Certaines de nos pannes étaient directement liées à une augmentation des tâches avec compte à rebours. Nous avons finalement décidé de restreindre l’utilisation des comptes à rebours au profit d’un autre système que nous avions en place pour planifier les travaux à l’avenir.
- Des rafales soudaines de trafic laisseraient 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, cela ne pouvait être résolu qu’avec un rebond RabbitMQ. RabbitMQ a un concept de contrôle de flux où il réduira la vitesse des connexions qui publient trop rapidement afin que les files d’attente puissent suivre. Le contrôle du flux était souvent, mais pas toujours, impliqué dans ces dégradations de disponibilité. Lorsque le contrôle de flux entre en jeu, les éditeurs le considèrent effectivement comme une latence réseau. La latence du réseau réduit nos temps de réponse ; si la latence augmente pendant les pics de trafic, des ralentissements importants peuvent entraîner une cascade lorsque les demandes s’accumulent en amont.
- Nos web workers python uWSGI avaient une fonctionnalité appelée harakiri qui était activée pour tuer tous les processus qui dépassaient un délai d’expiration. Lors de pannes ou de ralentissements, harakiri a entraîné une perte de connexion aux courtiers RabbitMQ, car les processus ont été 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éclencherait harakiri contribuerait à son tour encore plus à la lenteur en ajoutant une charge supplémentaire à RabbitMQ.
- En production, nous avons connu plusieurs cas où le traitement des tâches chez les consommateurs de céleri s’est arrêté, même en l’absence de charge importante. Nos efforts d’enquête n’ont donné aucune preuve de contraintes de ressources qui auraient interrompu le traitement, et les travailleurs ont repris le traitement une fois qu’ils ont été renvoyés. Ce problème n’a jamais été causé par la racine, bien que nous soupçonnions un problème chez les travailleurs du Céleri eux-mêmes et non chez RabbitMQ.
Dans l’ensemble, tous ces problèmes de disponibilité étaient inacceptables pour nous, car une fiabilité élevée est l’une de nos plus grandes priorités. Étant donné que ces pannes nous coûtaient beaucoup en termes de commandes manquées et de crédibilité, nous avions besoin d’une solution qui résoudrait ces problèmes dès que possible.
Pourquoi notre solution héritée n’a pas mis à l’échelle
Le problème suivant était l’échelle. DoorDash se développe rapidement et nous atteignons rapidement les limites de notre solution existante. Nous devions trouver quelque chose qui suivrait notre croissance continue car notre solution héritée rencontrait les problèmes suivants :
Atteindre la limite de mise à l’échelle verticale
Nous utilisions la plus grande solution RabbitMQ à nœud unique disponible. Il n’y avait pas de chemin pour évoluer verticalement plus loin 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é primaire-secondaire (HA) a réduit le débit par rapport à l’option à nœud unique, nous laissant 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, en pratique, réduit la gravité de nos pannes. Les basculements prenaient plus de 20 minutes et étaient souvent bloqués nécessitant une intervention manuelle. Les messages étaient souvent perdus dans le processus.
Nous étions rapidement à court d’espace libre alors que DoorDash continuait de croître et de pousser le traitement de nos tâches à ses limites. Nous avions besoin d’une solution qui puisse évoluer horizontalement à mesure que nos besoins de traitement augmentaient.
Comment Celery et RabbitMQ offraient une observabilité limitée
Savoir ce qui se passe dans n’importe quel système est fondamental pour assurer sa disponibilité, son évolutivité et son intégrité opérationnelle.
En naviguant sur les problèmes décrits ci-dessus, nous avons remarqué que:
- Nous étions limités à un petit ensemble de métriques RabbitMQ à notre disposition.
- Nous avions une visibilité limitée sur les travailleurs du céleri eux-mêmes.
Nous devions être en mesure de voir les métriques en temps réel de tous les aspects de notre système, ce qui signifiait que les limitations d’observabilité devaient également être abordées.
Les défis de l’efficacité opérationnelle
Nous avons également rencontré plusieurs problèmes avec l’exploitation de RabbitMQ :
- Nous avons souvent dû basculer notre nœud RabbitMQ vers un nouveau pour résoudre la dégradation persistante que nous avons observée. Cette opération était manuelle et prenait beaucoup de temps pour les ingénieurs impliqués et devait souvent être effectuée tard dans la nuit, en dehors des heures de pointe.
- Il n’y avait aucun expert interne en Céleri ou en RabbitMQ chez DoorDash sur lequel nous pouvions nous appuyer pour élaborer une stratégie de mise à l’échelle de cette technologie.
Le temps d’ingénierie consacré à l’exploitation et à la maintenance de RabbitMQ n’était pas durable. Nous avions besoin de quelque chose qui réponde mieux à nos besoins actuels et futurs.
Solutions potentielles à nos problèmes avec le Céleri et RabbitMQ
Avec les problèmes décrits ci-dessus, nous avons envisagé les solutions suivantes:
- Changez le courtier de céleri de RabbitMQ en Redis ou Kafka. Cela nous permettrait de continuer à utiliser Celery, avec une banque de données de support différente et potentiellement plus fiable.
- Ajoutez une prise en charge multi-courtiers à notre application Django afin que les consommateurs puissent publier sur N courtiers différents en fonction de la logique souhaitée. Le traitement des tâches sera réparti entre plusieurs courtiers, de sorte que chaque courtier subira une fraction de la charge initiale.
- Mise à niveau vers des versions plus récentes de Celery et RabbitMQ. Les versions plus récentes de Celery et RabbitMQ devaient résoudre les problèmes de fiabilité, nous faisant gagner du temps car nous extrayions déjà des composants de notre monolithe Django en parallèle.
- Migrez 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 a également plus de potentiel pour résoudre tous les problèmes que nous avions avec la solution héritée.
Chaque option a ses avantages et ses inconvénients:
Option | Avantages | Inconvénients |
Redis en tant que courtier |
|
|
Kafka en tant que courtier |
|
|
Plusieurs courtiers |
|
|
Versions de mise à niveau |
|
|
Solution Kafka personnalisée |
|
|
Notre stratégie d’intégration de Kafka
Compte tenu de la disponibilité requise du système, nous avons conçu notre stratégie d’intégration basé sur les principes suivants pour maximiser les avantages de fiabilité dans les plus brefs délais. Cette stratégie comportait trois étapes :
- Frapper le sol en cours d’exécution: Nous voulions tirer parti des bases de la solution que nous construisions alors que nous itérions sur d’autres parties de celle-ci. Nous comparons cette stratégie à la conduite d’une voiture de course tout en échangeant une nouvelle pompe à carburant.
- Choix de conception pour une adoption transparente par les développeurs: Nous voulions minimiser les efforts gaspillés de la part de tous les développeurs qui auraient pu résulter de la définition d’une interface différente.
- Déploiement incrémental sans 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 l’expédition de fonctionnalités indépendantes plus petites qui pourraient être testées individuellement dans la nature sur une plus longue période.
Frapper le sol
Le passage à Kafka a représenté un changement technique majeur dans notre pile, mais qui était cruellement nécessaire. Nous n’avions pas de temps à perdre car chaque semaine, nous perdions des affaires en raison de l’instabilité de notre solution RabbitMQ héritée. Notre priorité première é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 nous préparer à une solution plus complète avec une adoption plus large.
Notre MVP était composé de producteurs qui publiaient des noms complets de tâches (FQN) et des arguments marinés à Kafka pendant que nos consommateurs lisaient ces messages, importaient les tâches du FQN et les exécutaient de manière synchrone avec les arguments spécifiés.
Figure 1: L’architecture de produit minimal Viable (MVP) que nous avons décidé de construire comprenait un état intermédiaire où nous publierions des tâches mutuellement exclusives à la fois sur l’héritage (lignes pointillées rouges) et sur les nouveaux systèmes (lignes continues vertes), avant l’état final où nous arrêterions de publier des tâches sur RabbitMQ.
Choix de conception pour une adoption transparente par les développeurs
Parfois, l’adoption par les développeurs est un plus grand défi que le développement. Nous avons facilité la tâche en implémentant un wrapper pour l’annotation @task de Celery qui acheminait dynamiquement les soumissions de tâches vers l’un ou l’autre système en fonction d’indicateurs de fonctionnalité configurables dynamiquement. Maintenant, la même interface pourrait être utilisée pour écrire des tâches pour les deux systèmes. Avec ces décisions en place, les équipes d’ingénierie n’ont dû faire aucun travail supplémentaire pour s’intégrer au nouveau système, sauf à mettre en œuvre un indicateur de fonctionnalité unique.
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 mêmes fonctionnalités que Celery. Celery permet aux utilisateurs de configurer leurs tâches avec des paramètres dans leur annotation de tâche ou lorsqu’ils soumettent leur tâche. Pour nous permettre de lancer plus rapidement, nous avons créé une liste blanche de paramètres compatibles et avons choisi de prendre en charge le plus petit nombre de fonctionnalités nécessaires pour prendre en charge la majorité des tâches.
Figure 2: Nous avons rapidement augmenté le volume de tâches au MVP basé sur Kafka, en commençant par les tâches à faible risque et à faible priorité. Certaines de ces tâches étaient exécutées aux heures creuses, ce qui explique les pointes de la métrique décrite ci-dessus.
Comme on le voit sur la figure 2, avec les deux décisions ci-dessus, nous avons lancé notre MVP après deux semaines de développement et avons atteint une réduction de 80% de la charge de tâche RabbitMQ une semaine après le lancement. Nous avons rapidement réglé notre problème principal des pannes et, au cours du projet, nous avons pris en charge de plus en plus de fonctionnalités ésotériques pour permettre l’exécution des tâches restantes.
Déploiement incrémental, absence de temps d’arrêt
La possibilité de changer de clusters Kafka et de basculer dynamiquement entre RabbitMQ et Kafka sans impact commercial était extrêmement importante pour nous. Cette capacité nous a également aidés dans diverses 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 indicateurs de fonctionnalité dynamiques à la fois au niveau de la soumission des messages et du côté de la consommation des messages. Le coût d’être pleinement dynamique ici était de maintenir notre flotte de travailleurs à double capacité. La moitié de cette flotte était consacrée à RabbitMQ et le reste à Kafka. Faire fonctionner le parc de travailleurs à double capacité pesait définitivement sur notre infrastructure. À un moment donné, nous avons même créé un tout nouveau cluster Kubernetes pour héberger tous nos travailleurs.
Au cours de la phase initiale de développement, cette flexibilité nous a bien servis. Une fois que nous avions plus confiance en notre nouveau système, nous avons cherché des moyens de réduire la charge sur notre infrastructure, tels que l’exécution de plusieurs processus consommateurs par machine de travail. Au fur et à mesure que nous avons abordé divers sujets, nous avons pu commencer à réduire le nombre de travailleurs pour RabbitMQ tout en maintenant une petite capacité de réserve.
Aucune solution n’est parfaite, itérer au besoin
Avec notre MVP en production, nous avions la marge nécessaire pour itérer et polir notre produit. Nous avons classé chaque fonctionnalité de Céleri manquante 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 seulement quelques tâches n’ont pas été implémentées dans notre solution personnalisée. Au lieu de cela, nous avons réécrit ces tâches pour ne pas utiliser cette fonctionnalité spécifique. Avec cette stratégie, nous avons finalement déplacé toutes les tâches du Céleri.
L’utilisation de Kafka a également introduit de nouveaux problèmes nécessitant notre attention :
- Blocage de la tête de ligne qui a entraîné des retards de traitement des tâches
- Les déploiements ont déclenché un rééquilibrage des partitions qui a également entraîné des retards
Problème de blocage de la tête de ligne de Kafka
Les sujets de Kafka sont partitionnés de telle sorte qu’un seul consommateur (par groupe de consommateurs) lit les messages pour les partitions qui lui sont attribuées dans l’ordre dans lequel ils sont arrivés. Si un message dans une seule partition prend trop de temps à être traité, il bloquera la consommation de tous les messages derrière lui 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 dans une partition en cas de retard.
Figure 3: Dans le problème de blocage en tête de ligne de Kafka, un message lent dans une partition (en rouge) empêche le traitement de tous les messages derrière celle-ci. D’autres partitions continueraient à être traitées comme prévu.
Bien que le parallélisme soit fondamentalement un problème Python, les concepts de cette solution sont également applicables à d’autres langages. Notre solution, illustrée à la figure 4 ci-dessous, consistait à héberger un processus Kafka-consommateur et plusieurs processus d’exécution de tâches par travailleur. Le processus Kafka-consumer est responsable de la récupération des messages de Kafka et de leur placement dans une file d’attente locale 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 de tâche sera bloqué par le message lent. Le seuil limite également le nombre de messages en vol dans la file d’attente locale (qui peut se perdre en cas de crash du système).
Figure 4 :Notre travailleur Kafka non bloquant se compose d’une file d’attente de messages locale et de deux types de processus: un processus kafka-consommateur et plusieurs processus task-executor. Alors qu’un consommateur kafka peut lire à partir de plusieurs partitions, pour plus de simplicité, nous n’en décrirons qu’une seule. Ce diagramme montre qu’un message à traitement lent (en rouge) ne bloque qu’un seul exécuteur de tâches jusqu’à sa fin, tandis que d’autres messages derrière lui dans la partition continuent d’être traités par d’autres exécuteurs de tâches.
La disruptivité des déploiements
Nous déployons notre application Django plusieurs fois par jour. Un inconvénient de notre solution que nous avons remarqué est qu’un déploiement déclenche un rééquilibrage des affectations de partition dans Kafka. Malgré l’utilisation d’un groupe de consommateurs différent par sujet pour limiter la portée du rééquilibrage, les déploiements ont tout de même provoqué un ralentissement momentané du traitement des messages car la consommation de tâches a dû s’arrêter lors du rééquilibrage. Les ralentissements peuvent être acceptables dans la plupart des cas lorsque nous effectuons des versions planifiées, mais peuvent être catastrophiques lorsque, par exemple, nous effectuons une version d’urgence pour corriger un bogue. La conséquence serait l’introduction d’un ralentissement du traitement en cascade.
Les versions plus récentes de Kafka et de ses clients prennent en charge le rééquilibrage coopératif incrémental, ce qui réduirait massivement l’impact opérationnel d’un rééquilibrage. La mise à niveau de nos clients pour soutenir ce type de rééquilibrage serait notre solution de choix à l’avenir. Malheureusement, le rééquilibrage coopératif incrémental n’est pas encore pris en charge chez notre client Kafka choisi.
Key wins
Avec la conclusion de ce projet, nous avons réalisé des améliorations significatives en termes de disponibilité, d’évolutivité, d’observabilité et de décentralisation. Ces victoires ont été cruciales pour assurer la croissance continue de notre entreprise.
Plus de pannes répétées
Nous avons arrêté les pannes répétées presque dès que nous avons commencé à déployer cette approche Kafka personnalisée. Les pannes entraînaient une expérience utilisateur extrêmement médiocre.
- En implémentant seulement un petit sous-ensemble des fonctionnalités de Céleri les plus utilisées dans notre MVP, nous avons pu expédier le code de travail en production en deux semaines.
- Avec le MVP en place, nous avons pu réduire considérablement la charge sur RabbitMQ et Celery alors que nous continuions à durcir notre solution et à implémenter de nouvelles fonctionnalités.
Le traitement des tâches n’était 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 créer plus de métriques à presque tous les niveaux. Chaque file d’attente, chaque travailleur et chaque 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 énorme victoire non seulement au sens de la production, mais aussi en termes de productivité des développeurs.
Décentralisation opérationnelle
Grâce aux améliorations de l’observabilité, nous avons pu modéliser nos alertes en modules Terraform et attribuer explicitement des propriétaires à chaque sujet et, implicitement, à plus de 900 tâches.
Un guide d’exploitation 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 globales de gestion de cluster Kafka, selon les besoins. Les opérations quotidiennes sont en libre-service et le soutien de notre équipe d’infrastructure est rarement nécessaire.
Conclusion
Pour résumer, nous avons atteint le plafond de notre capacité à mettre à l’échelle RabbitMQ et avons dû chercher des alternatives. L’alternative que nous avons choisie était une solution personnalisée basée sur Kafka. Bien qu’il y ait quelques inconvénients à utiliser Kafka, nous avons trouvé un certain nombre de solutions de contournement, décrites ci-dessus.
Lorsque les flux de travail critiques dépendent fortement du traitement des tâches asynchrones, il est de la plus haute importance de garantir l’évolutivité. Lorsque vous rencontrez des problèmes similaires, n’hésitez pas à vous inspirer de notre stratégie, qui nous a accordé 80% du résultat avec 20% de l’effort. Cette stratégie, dans le cas général, est une approche tactique pour atténuer rapidement les problèmes de fiabilité et gagner du temps cruellement nécessaire pour une solution plus robuste et stratégique.
Remerciements
Les auteurs tiennent à remercier Clement Fang, Corry Haines, Danial Asif, Jay Weinstein, Luigi Tagliamonte, Matthew Anger, Shaohua Zhou et Yun-Yu Chen d’avoir contribué à ce projet.
Photo de tian kuan sur Unsplash