Le déménagement de Tinder à Kubernetes

Rédigé par: Chris O'Brien, directeur de l'ingénierie | Chris Thomas, directeur de l'ingénierie | Jinyong Lee, ingénieur logiciel principal | Sous la direction de: Cooper Jackson, ingénieur logiciel

Pourquoi

Il y a près de deux ans, Tinder a décidé de déplacer sa plateforme vers Kubernetes. Kubernetes nous a donné l'opportunité de conduire Tinder Engineering vers la conteneurisation et le fonctionnement sans contact grâce à un déploiement immuable. La construction, le déploiement et l'infrastructure de l'application seraient définis comme du code.

Nous cherchions également à relever les défis d'échelle et de stabilité. Lorsque la mise à l'échelle est devenue critique, nous avons souvent souffert de plusieurs minutes d'attente pour la mise en ligne de nouvelles instances EC2. L'idée que les conteneurs planifient et desservent le trafic en quelques secondes plutôt qu'en quelques minutes nous a séduit.

Ce n'était pas facile. Au cours de notre migration au début de 2019, nous avons atteint une masse critique au sein de notre cluster Kubernetes et avons commencé à rencontrer divers défis en raison du volume de trafic, de la taille du cluster et du DNS. Nous avons relevé des défis intéressants pour migrer 200 services et exécuter un cluster Kubernetes à l'échelle totalisant 1 000 nœuds, 15 000 pods et 48 000 conteneurs en cours d'exécution.

Comment

À partir de janvier 2018, nous avons franchi différentes étapes de l'effort de migration. Nous avons commencé par conteneuriser tous nos services et les déployer sur une série d'environnements de stades hébergés par Kubernetes. Début octobre, nous avons commencé à déplacer méthodiquement tous nos services hérités vers Kubernetes. En mars de l'année suivante, nous avons finalisé notre migration et la plateforme Tinder fonctionne désormais exclusivement sur Kubernetes.

Création d'images pour Kubernetes

Il existe plus de 30 référentiels de code source pour les microservices qui s'exécutent dans le cluster Kubernetes. Le code de ces référentiels est écrit dans différentes langues (par exemple, Node.js, Java, Scala, Go) avec plusieurs environnements d'exécution pour la même langue.

Le système de build est conçu pour fonctionner sur un «contexte de build» entièrement personnalisable pour chaque microservice, qui se compose généralement d'un Dockerfile et d'une série de commandes shell. Bien que leur contenu soit entièrement personnalisable, ces contextes de construction sont tous écrits en suivant un format standardisé. La standardisation des contextes de génération permet à un système de génération unique de gérer tous les microservices.

Figure 1–1 Processus de construction standardisé via le conteneur Builder

Afin d'atteindre la cohérence maximale entre les environnements d'exécution, le même processus de génération est utilisé pendant la phase de développement et de test. Cela a imposé un défi unique lorsque nous devions trouver un moyen de garantir un environnement de construction cohérent sur la plate-forme. Par conséquent, tous les processus de construction sont exécutés dans un conteneur spécial «Builder».

L'implémentation du conteneur Builder a nécessité un certain nombre de techniques Docker avancées. Ce conteneur Builder hérite de l'ID utilisateur local et des secrets (par exemple, clé SSH, informations d'identification AWS, etc.) comme requis pour accéder aux référentiels privés Tinder. Il monte des répertoires locaux contenant le code source pour avoir un moyen naturel de stocker les artefacts de construction. Cette approche améliore les performances, car elle élimine la copie des artefacts construits entre le conteneur Builder et la machine hôte. Les artefacts de build stockés sont réutilisés la prochaine fois sans configuration supplémentaire.

Pour certains services, nous devions créer un autre conteneur dans le générateur pour faire correspondre l'environnement de compilation avec l'environnement d'exécution (par exemple, l'installation de la bibliothèque bcrypt de Node.js génère des artefacts binaires spécifiques à la plate-forme). Les exigences de temps de compilation peuvent différer selon les services et le Dockerfile final est composé à la volée.

Architecture et migration du cluster Kubernetes

Dimensionnement de cluster

Nous avons décidé d'utiliser kube-aws pour le provisionnement automatisé des clusters sur les instances Amazon EC2. Au début, nous exécutions tout dans un pool de nœuds général. Nous avons rapidement identifié la nécessité de séparer les charges de travail en différentes tailles et types d'instances, afin de mieux utiliser les ressources. Le raisonnement était que le fait d'exécuter moins de pods fortement filetés ensemble a donné des résultats de performance plus prévisibles que de les laisser coexister avec un plus grand nombre de pods à un seul thread.

Nous nous sommes installés sur:

  • m5.4xlarge pour la surveillance (Prometheus)
  • c5.4xlarge pour la charge de travail Node.js (charge de travail monothread)
  • c5.2xlarge pour Java et Go (charge de travail multithread)
  • c5.4xlarge pour le plan de contrôle (3 nœuds)

Migration

L'une des étapes de préparation de la migration de notre infrastructure héritée vers Kubernetes a été de modifier la communication de service à service existante pour pointer vers de nouveaux Elastic Load Balancers (ELB) créés dans un sous-réseau VPC (Virtual Private Cloud) spécifique. Ce sous-réseau a été appairé au VPC Kubernetes. Cela nous a permis de migrer de manière granulaire les modules sans tenir compte de la commande spécifique des dépendances de service.

Ces points de terminaison ont été créés à l'aide d'ensembles d'enregistrements DNS pondérés dont un CNAME pointait vers chaque nouvel ELB. Pour le basculement, nous avons ajouté un nouvel enregistrement, pointant vers le nouveau service ELB du service Kubernetes, avec un poids de 0. Nous avons ensuite défini le Time To Live (TTL) sur l'enregistrement défini sur 0. L'ancien et le nouveau poids ont ensuite été lentement ajustés à finir avec 100% sur le nouveau serveur. Une fois la transition terminée, le TTL a été réglé sur quelque chose de plus raisonnable.

Nos modules Java ont respecté le TTL DNS faible, mais pas nos applications Node. L'un de nos ingénieurs a réécrit une partie du code du pool de connexions pour l'envelopper dans un gestionnaire qui actualiserait les pools toutes les 60s. Cela a très bien fonctionné pour nous, sans performance appréciable.

Apprentissage

Limites de la structure du réseau

Tôt le matin du 8 janvier 2019, la plateforme de Tinder a subi une panne persistante. En réponse à une augmentation non liée de la latence de la plateforme plus tôt dans la matinée, le nombre de pods et de nœuds a été mis à l'échelle sur le cluster. Cela a entraîné l'épuisement du cache ARP sur tous nos nœuds.

Il existe trois valeurs Linux pertinentes pour le cache ARP:

Crédit

gc_thresh3 est un plafond rigide. Si vous obtenez des entrées de journal de «débordement de table voisine», cela indique que même après un garbage collection synchrone (GC) du cache ARP, il n'y avait pas assez de place pour stocker l'entrée voisine. Dans ce cas, le noyau supprime entièrement le paquet.

Nous utilisons Flannel comme structure de réseau à Kubernetes. Les paquets sont transmis via VXLAN. VXLAN est un schéma de superposition de couche 2 sur un réseau de couche 3. Il utilise l'encapsulation MAC-in-User Datagram Protocol (MAC-in-UDP) pour fournir un moyen d'étendre les segments de réseau de couche 2. Le protocole de transport sur le réseau du centre de données physique est IP plus UDP.

Figure 2–1 Diagramme de flanelle (crédit)

Figure 2–2 Paquet VXLAN (crédit)

Chaque nœud de travail Kubernetes alloue son propre / 24 d'espace d'adressage virtuel à partir d'un bloc plus grand / 9. Pour chaque nœud, cela se traduit par 1 entrée de table de routage, 1 entrée de table ARP (sur l'interface flannel.1) et 1 entrée de base de données de transfert (FDB). Ceux-ci sont ajoutés lors du premier lancement du nœud de travail ou lors de la découverte de chaque nouveau nœud.

De plus, la communication nœud à pod (ou pod à pod) passe finalement par l'interface eth0 (illustrée dans le diagramme de flanelle ci-dessus). Cela se traduira par une entrée supplémentaire dans la table ARP pour chaque source de nœud et destination de nœud correspondantes.

Dans notre environnement, ce type de communication est très courant. Pour nos objets de service Kubernetes, un ELB est créé et Kubernetes enregistre chaque nœud avec l'ELB. L'ELB n'est pas compatible avec le pod et le nœud sélectionné peut ne pas être la destination finale du paquet. En effet, lorsque le nœud reçoit le paquet de l'ELB, il évalue ses règles iptables pour le service et sélectionne au hasard un pod sur un autre nœud.

Au moment de la panne, il y avait au total 605 nœuds dans le cluster. Pour les raisons décrites ci-dessus, cela a suffi pour éclipser la valeur par défaut gc_thresh3. Une fois que cela se produit, non seulement les paquets sont supprimés, mais des Flannel / 24 entiers d'espace d'adressage virtuel manquent dans la table ARP. La communication nœud à pod et les recherches DNS échouent. (DNS est hébergé au sein du cluster, comme cela sera expliqué plus en détail plus loin dans cet article.)

Pour résoudre, les valeurs gc_thresh1, gc_thresh2 et gc_thresh3 sont augmentées et Flannel doit être redémarré pour réenregistrer les réseaux manquants.

Exécution inattendue du DNS à l'échelle

Pour s'adapter à notre migration, nous avons largement exploité le DNS pour faciliter la mise en forme du trafic et la transition incrémentielle de l'héritage vers Kubernetes pour nos services. Nous avons défini des valeurs TTL relativement faibles sur les jeux d'enregistrements Route53 associés. Lorsque nous avons exécuté notre infrastructure héritée sur des instances EC2, notre configuration de résolveur pointait vers le DNS d'Amazon. Nous avons pris cela pour acquis et le coût d'une TTL relativement faible pour nos services et les services d'Amazon (par exemple DynamoDB) est passé largement inaperçu.

Alors que nous intégrions de plus en plus de services à Kubernetes, nous nous sommes retrouvés à exécuter un service DNS qui répondait à 250 000 demandes par seconde. Nous rencontrions des délais d'attente de recherche DNS intermittents et percutants dans nos applications. Cela s'est produit malgré un effort de réglage exhaustif et un changement de fournisseur DNS vers un déploiement CoreDNS qui, à un moment donné, a culminé à 1 000 pods consommant 120 cœurs.

En recherchant d'autres causes et solutions possibles, nous avons trouvé un article décrivant une condition de concurrence critique affectant le netfilter du framework de filtrage des paquets Linux. Les délais d'attente DNS que nous avons vus, ainsi qu'un compteur incrémentiel insert_failed sur l'interface Flannel, alignés avec les résultats de l'article.

Le problème se produit lors de la traduction d'adresses réseau source et de destination (SNAT et DNAT) et lors de l'insertion ultérieure dans la table conntrack. Une solution de contournement discutée en interne et proposée par la communauté était de déplacer le DNS sur le nœud de travail lui-même. Dans ce cas:

  • SNAT n'est pas nécessaire, car le trafic reste localement sur le nœud. Il n'a pas besoin d'être transmis via l'interface eth0.
  • DNAT n'est pas nécessaire car l'IP de destination est locale au nœud et non un pod sélectionné au hasard selon les règles iptables.

Nous avons décidé d'aller de l'avant avec cette approche. CoreDNS a été déployé en tant que DaemonSet dans Kubernetes et nous avons injecté le serveur DNS local du nœud dans le resolv.conf de chaque pod en configurant l'indicateur de commande kubelet - cluster-dns. La solution de contournement était efficace pour les délais d'attente DNS.

Cependant, nous voyons toujours des paquets abandonnés et l'incrément de compteur insert_failed de l'interface Flannel. Cela persistera même après la solution de contournement ci-dessus car nous avons uniquement évité SNAT et / ou DNAT pour le trafic DNS. La condition de concurrence continue de se produire pour d'autres types de trafic. Heureusement, la plupart de nos paquets sont TCP et lorsque la condition se produit, les paquets seront retransmis avec succès. Une solution à long terme pour tous les types de trafic est quelque chose dont nous discutons encore.

Utilisation d'Envoy pour obtenir un meilleur équilibrage de charge

Alors que nous migrions nos services backend vers Kubernetes, nous avons commencé à souffrir d'une charge déséquilibrée entre les pods. Nous avons découvert qu'en raison de HTTP Keepalive, les connexions ELB se collaient aux premiers pods prêts de chaque déploiement continu, de sorte que la plupart du trafic passait par un petit pourcentage des pods disponibles. L'une des premières mesures d'atténuation que nous avons essayées a été d'utiliser un 100% MaxSurge sur les nouveaux déploiements pour les pires délinquants. Cela a été marginalement efficace et non durable à long terme avec certains des déploiements les plus importants.

Une autre mesure d'atténuation que nous avons utilisée consistait à gonfler artificiellement les demandes de ressources sur les services essentiels afin que les pods colocalisés aient plus d'espace libre aux côtés d'autres pods lourds. Cela n'allait pas non plus être tenable à long terme en raison du gaspillage des ressources et nos applications Node étaient à un seul thread et donc efficacement plafonnées à 1 cœur. La seule solution claire était d'utiliser un meilleur équilibrage de charge.

Nous cherchions en interne à évaluer Envoy. Cela nous a permis de le déployer de manière très limitée et d'en tirer des avantages immédiats. Envoy est un proxy Layer 7 open source hautes performances conçu pour les grandes architectures orientées services. Il est capable de mettre en œuvre des techniques avancées d'équilibrage de charge, notamment des tentatives automatiques, des coupures de circuit et une limitation de débit globale.

La configuration que nous avons proposée était d'avoir un side-car Envoy à côté de chaque pod qui avait une route et un cluster pour atteindre le port de conteneur local. Pour minimiser les cascades potentielles et conserver un petit rayon de souffle, nous avons utilisé une flotte de pods Envoy à proxy frontal, un déploiement dans chaque zone de disponibilité (AZ) pour chaque service. Ceux-ci ont atteint un petit mécanisme de découverte de service mis en place par l'un de nos ingénieurs qui a simplement renvoyé une liste de pods dans chaque AZ pour un service donné.

Les envoyés frontaux de service ont ensuite utilisé ce mécanisme de découverte de service avec un cluster et une route en amont. Nous avons configuré des délais raisonnables, augmenté tous les paramètres du disjoncteur, puis mis en place une configuration de relance minimale pour aider aux pannes transitoires et aux déploiements en douceur. Nous avons associé chacun de ces services Envoy frontaux à un ELB TCP. Même si le Keepalive de notre couche principale de proxy avant a été épinglé sur certains pods Envoy, ils étaient beaucoup mieux en mesure de gérer la charge et ont été configurés pour s'équilibrer via le moins_demande au backend.

Pour les déploiements, nous avons utilisé un crochet preStop à la fois sur l'application et sur le module side-car. Ce crochet appelé le point de terminaison d'administration d'échec du contrôle de santé du sidecar, avec un petit sommeil, pour donner un peu de temps pour permettre aux connexions en vol de se terminer et de s'écouler.

L'une des raisons pour lesquelles nous avons pu évoluer si rapidement est due aux métriques riches que nous avons pu intégrer facilement avec notre configuration Prometheus normale. Cela nous a permis de voir exactement ce qui se passait alors que nous itérions sur les paramètres de configuration et réduisions le trafic.

Les résultats ont été immédiats et évidents. Nous avons commencé avec les services les plus déséquilibrés et, à ce stade, nous l'avons devant douze des services les plus importants de notre cluster. Cette année, nous prévoyons de passer à un maillage à service complet, avec une découverte de service plus avancée, une coupure de circuit, une détection des valeurs aberrantes, une limitation de débit et un traçage.

Figure 3–1 Convergence CPU d'un service lors du passage à l'envoyé

Le résultat final

Grâce à ces apprentissages et à des recherches supplémentaires, nous avons développé une solide équipe d'infrastructure interne très familiarisée avec la conception, le déploiement et l'exploitation de grands clusters Kubernetes. L'ensemble de l'organisation d'ingénierie de Tinder possède désormais des connaissances et une expérience sur la façon de conteneuriser et de déployer leurs applications sur Kubernetes.

Sur notre infrastructure héritée, lorsqu'une extension supplémentaire était nécessaire, nous avons souvent souffert plusieurs minutes d'attente pour la mise en ligne de nouvelles instances EC2. Les conteneurs planifient et traitent désormais le trafic en quelques secondes au lieu de quelques minutes. La planification de plusieurs conteneurs sur une seule instance EC2 améliore également la densité horizontale. En conséquence, nous prévoyons des économies de coûts substantielles sur EC2 en 2019 par rapport à l'année précédente.

Cela a pris près de deux ans, mais nous avons finalisé notre migration en mars 2019. La plateforme Tinder fonctionne exclusivement sur un cluster Kubernetes composé de 200 services, 1 000 nœuds, 15 000 pods et 48 000 conteneurs en cours d'exécution. L'infrastructure n'est plus une tâche réservée à nos équipes opérationnelles. Au lieu de cela, les ingénieurs de toute l'organisation partagent cette responsabilité et contrôlent la façon dont leurs applications sont construites et déployées avec tout sous forme de code.