Tinders Umzug nach Kubernetes

Geschrieben von: Chris O'Brien, technischer Leiter | Chris Thomas, technischer Leiter | Jinyong Lee, Senior Software Engineer | Herausgegeben von: Cooper Jackson, Software Engineer

Warum

Vor fast zwei Jahren beschloss Tinder, seine Plattform nach Kubernetes zu verlegen. Kubernetes bot uns die Gelegenheit, Tinder Engineering durch unveränderliche Bereitstellung in Richtung Containerisierung und Low-Touch-Betrieb voranzutreiben. Anwendungserstellung, -bereitstellung und -infrastruktur werden als Code definiert.

Wir wollten auch die Herausforderungen in Bezug auf Größe und Stabilität angehen. Wenn die Skalierung kritisch wurde, mussten wir oft mehrere Minuten warten, bis neue EC2-Instanzen online gingen. Die Idee, Container innerhalb von Sekunden und nicht innerhalb von Minuten zu planen und den Verkehr zu bedienen, hat uns angesprochen.

Es war nicht einfach. Während unserer Migration Anfang 2019 erreichten wir eine kritische Masse innerhalb unseres Kubernetes-Clusters und stießen aufgrund des Verkehrsaufkommens, der Clustergröße und des DNS auf verschiedene Herausforderungen. Wir haben interessante Herausforderungen gelöst, um 200 Dienste zu migrieren und einen Kubernetes-Cluster mit einer Größe von insgesamt 1.000 Knoten, 15.000 Pods und 48.000 laufenden Containern auszuführen.

Wie

Ab Januar 2018 haben wir uns durch verschiedene Phasen der Migrationsbemühungen gearbeitet. Wir haben zunächst alle unsere Services containerisiert und in einer Reihe von von Kubernetes gehosteten Staging-Umgebungen bereitgestellt. Anfang Oktober haben wir begonnen, alle unsere Legacy-Services methodisch auf Kubernetes zu verlagern. Im März des folgenden Jahres haben wir unsere Migration abgeschlossen und die Tinder-Plattform läuft jetzt ausschließlich auf Kubernetes.

Erstellen von Bildern für Kubernetes

Es gibt mehr als 30 Quellcode-Repositorys für die Microservices, die im Kubernetes-Cluster ausgeführt werden. Der Code in diesen Repositorys ist in verschiedenen Sprachen (z. B. Node.js, Java, Scala, Go) mit mehreren Laufzeitumgebungen für dieselbe Sprache geschrieben.

Das Build-System arbeitet mit einem vollständig anpassbaren „Build-Kontext“ für jeden Microservice, der normalerweise aus einer Docker-Datei und einer Reihe von Shell-Befehlen besteht. Während ihre Inhalte vollständig anpassbar sind, werden diese Build-Kontexte alle in einem standardisierten Format geschrieben. Die Standardisierung der Build-Kontexte ermöglicht es einem einzelnen Build-System, alle Microservices zu verwalten.

Abbildung 1–1 Standardisierter Erstellungsprozess über den Builder-Container

Um die maximale Konsistenz zwischen Laufzeitumgebungen zu erreichen, wird während der Entwicklungs- und Testphase der gleiche Erstellungsprozess verwendet. Dies war eine einzigartige Herausforderung, als wir einen Weg finden mussten, um eine konsistente Build-Umgebung auf der gesamten Plattform zu gewährleisten. Infolgedessen werden alle Erstellungsprozesse in einem speziellen "Builder" -Container ausgeführt.

Die Implementierung des Builder-Containers erforderte eine Reihe fortgeschrittener Docker-Techniken. Dieser Builder-Container erbt die lokale Benutzer-ID und Geheimnisse (z. B. SSH-Schlüssel, AWS-Anmeldeinformationen usw.), die für den Zugriff auf private Tinder-Repositorys erforderlich sind. Es stellt lokale Verzeichnisse bereit, die den Quellcode enthalten, um Build-Artefakte auf natürliche Weise zu speichern. Dieser Ansatz verbessert die Leistung, da das Kopieren von erstellten Artefakten zwischen dem Builder-Container und dem Host-Computer vermieden wird. Gespeicherte Build-Artefakte werden beim nächsten Mal ohne weitere Konfiguration wiederverwendet.

Für bestimmte Dienste mussten wir im Builder einen weiteren Container erstellen, um die Umgebung zur Kompilierungszeit mit der Laufzeitumgebung abzugleichen (z. B. werden durch die Installation der bcrypt-Bibliothek von Node.js plattformspezifische binäre Artefakte generiert). Die Anforderungen an die Kompilierungszeit können zwischen den Diensten unterschiedlich sein, und die endgültige Docker-Datei wird im laufenden Betrieb erstellt.

Kubernetes Cluster Architektur und Migration

Clustergröße

Wir haben uns für die Verwendung von kube-aws für die automatisierte Clusterbereitstellung auf Amazon EC2-Instanzen entschieden. Schon früh haben wir alles in einem allgemeinen Knotenpool ausgeführt. Wir haben schnell erkannt, dass Workloads in verschiedene Größen und Arten von Instanzen unterteilt werden müssen, um die Ressourcen besser nutzen zu können. Der Grund dafür war, dass das gleichzeitige Ausführen von weniger Pods mit starkem Gewinde zu besser vorhersehbaren Leistungsergebnissen führte, als wenn sie mit einer größeren Anzahl von Pods mit einem Gewinde koexistieren konnten.

Wir haben uns entschieden für:

  • m5.4xlarge für die Überwachung (Prometheus)
  • c5.4xlarge für Node.js Workload (Single-Threaded-Workload)
  • c5.2xlarge für Java und Go (Multithread-Workload)
  • c5.4xlarge für die Steuerebene (3 Knoten)

Migration

Einer der Vorbereitungsschritte für die Migration von unserer Legacy-Infrastruktur zu Kubernetes bestand darin, die vorhandene Service-zu-Service-Kommunikation so zu ändern, dass auf neue ELBs (Elastic Load Balancers) verwiesen wird, die in einem bestimmten VPC-Subnetz (Virtual Private Cloud) erstellt wurden. Dieses Subnetz wurde auf die Kubernetes VPC übertragen. Dies ermöglichte es uns, Module ohne Berücksichtigung der spezifischen Reihenfolge für Serviceabhängigkeiten granular zu migrieren.

Diese Endpunkte wurden mit gewichteten DNS-Datensatzgruppen erstellt, deren CNAME auf jede neue ELB verweist. Zum Umschneiden haben wir einen neuen Datensatz hinzugefügt, der auf den neuen Kubernetes-Dienst ELB mit einer Gewichtung von 0 verweist. Anschließend haben wir die TTL (Time To Live) für den Datensatz auf 0 gesetzt. Die alten und neuen Gewichte wurden dann langsam angepasst am Ende mit 100% auf dem neuen Server. Nachdem die Umstellung abgeschlossen war, wurde die TTL auf etwas Vernünftigeres eingestellt.

Unsere Java-Module haben eine niedrige DNS-TTL berücksichtigt, unsere Node-Anwendungen jedoch nicht. Einer unserer Ingenieure hat einen Teil des Verbindungspoolcodes neu geschrieben, um ihn in einen Manager zu packen, der die Pools alle 60 Sekunden aktualisiert. Dies funktionierte sehr gut für uns ohne nennenswerten Leistungseinbruch.

Lernen

Netzwerkstrukturbeschränkungen

In den frühen Morgenstunden des 8. Januar 2019 erlitt Tinders Plattform einen anhaltenden Ausfall. Als Reaktion auf einen nicht damit verbundenen Anstieg der Plattformlatenz am frühen Morgen wurden die Anzahl der Pods und Knoten im Cluster skaliert. Dies führte zu einer Erschöpfung des ARP-Cache auf allen unseren Knoten.

Es gibt drei Linux-Werte, die für den ARP-Cache relevant sind:

Anerkennung

gc_thresh3 ist eine harte Kappe. Wenn Sie Protokolleinträge für "Nachbartabellenüberlauf" erhalten, bedeutet dies, dass selbst nach einer synchronen Garbage Collection (GC) des ARP-Cache nicht genügend Platz zum Speichern des Nachbareintrags vorhanden war. In diesem Fall verwirft der Kernel das Paket einfach vollständig.

Wir verwenden Flanell als Netzwerkstruktur in Kubernetes. Pakete werden über VXLAN weitergeleitet. VXLAN ist ein Layer 2-Overlay-Schema über ein Layer 3-Netzwerk. Es verwendet die MAC-in-UDP-Kapselung (MAC Address-in-User Datagram Protocol), um ein Mittel zum Erweitern von Layer 2-Netzwerksegmenten bereitzustellen. Das Transportprotokoll über das physische Rechenzentrumsnetzwerk ist IP plus UDP.

Abbildung 2–1 Flanelldiagramm (Gutschrift)

Abbildung 2–2 VXLAN-Paket (Gutschrift)

Jeder Kubernetes-Worker-Knoten weist aus einem größeren / 9-Block seinen eigenen / 24 virtuellen Adressraum zu. Dies führt für jeden Knoten zu 1 Routentabelleneintrag, 1 ARP-Tabelleneintrag (auf der Flanell-1-Schnittstelle) und 1 FDB-Eintrag (Forwarding Database). Diese werden hinzugefügt, wenn der Worker-Knoten zum ersten Mal gestartet wird oder wenn jeder neue Knoten erkannt wird.

Darüber hinaus fließt die Kommunikation von Knoten zu Pod (oder von Pod zu Pod) letztendlich über die eth0-Schnittstelle (siehe Flanell-Diagramm oben). Dies führt zu einem zusätzlichen Eintrag in der ARP-Tabelle für jede entsprechende Knotenquelle und jedes Knotenziel.

In unserer Umgebung ist diese Art der Kommunikation sehr verbreitet. Für unsere Kubernetes-Serviceobjekte wird eine ELB erstellt und Kubernetes registriert jeden Knoten bei der ELB. Die ELB ist nicht pod-fähig und der ausgewählte Knoten ist möglicherweise nicht das endgültige Ziel des Pakets. Dies liegt daran, dass der Knoten, wenn er das Paket von der ELB empfängt, seine iptables-Regeln für den Dienst auswertet und zufällig einen Pod auf einem anderen Knoten auswählt.

Zum Zeitpunkt des Ausfalls befanden sich insgesamt 605 Knoten im Cluster. Aus den oben genannten Gründen war dies ausreichend, um den Standardwert gc_thresh3 in den Schatten zu stellen. Sobald dies geschieht, werden nicht nur Pakete verworfen, sondern es fehlen auch ganze Flannel / 24s des virtuellen Adressraums in der ARP-Tabelle. Die Kommunikation zwischen Knoten und Pod und die DNS-Suche schlagen fehl. (DNS wird im Cluster gehostet, wie später in diesem Artikel näher erläutert wird.)

Zum Auflösen werden die Werte gc_thresh1, gc_thresh2 und gc_thresh3 ausgelöst und Flannel muss neu gestartet werden, um fehlende Netzwerke neu zu registrieren.

Unerwartetes Ausführen von DNS im Maßstab

Um unserer Migration gerecht zu werden, haben wir DNS stark genutzt, um die Gestaltung des Datenverkehrs und die schrittweise Umstellung von Legacy auf Kubernetes für unsere Dienste zu erleichtern. Wir setzen relativ niedrige TTL-Werte für die zugehörigen Route53 RecordSets. Als wir unsere Legacy-Infrastruktur auf EC2-Instanzen ausführten, zeigte unsere Resolver-Konfiguration auf das DNS von Amazon. Wir haben dies als selbstverständlich angesehen und die Kosten für eine relativ niedrige TTL für unsere Dienste und die Dienste von Amazon (z. B. DynamoDB) blieben weitgehend unbemerkt.

Als wir immer mehr Dienste in Kubernetes integriert haben, haben wir einen DNS-Dienst ausgeführt, der 250.000 Anfragen pro Sekunde beantwortet. In unseren Anwendungen traten zeitweise und wirkungsvolle DNS-Suchzeitlimits auf. Dies geschah trotz eines umfassenden Optimierungsaufwands und eines Wechsels eines DNS-Anbieters zu einer CoreDNS-Bereitstellung, die zu einem Zeitpunkt einen Höchststand von 1.000 Pods mit 120 Kernen erreichte.

Bei der Untersuchung anderer möglicher Ursachen und Lösungen haben wir einen Artikel gefunden, der eine Race-Bedingung beschreibt, die sich auf den Netfilter des Linux-Paketfilter-Frameworks auswirkt. Die DNS-Zeitüberschreitungen, die wir gesehen haben, sowie ein inkrementierender Zähler für insert_failed auf der Flanellschnittstelle stimmten mit den Ergebnissen des Artikels überein.

Das Problem tritt während der Quell- und Zielnetzwerk-Adressübersetzung (SNAT und DNAT) und dem anschließenden Einfügen in die Conntrack-Tabelle auf. Eine intern diskutierte und von der Community vorgeschlagene Problemumgehung bestand darin, DNS auf den Worker-Knoten selbst zu verschieben. In diesem Fall:

  • SNAT ist nicht erforderlich, da der Datenverkehr lokal auf dem Knoten verbleibt. Es muss nicht über die eth0-Schnittstelle übertragen werden.
  • DNAT ist nicht erforderlich, da die Ziel-IP lokal für den Knoten ist und kein zufällig ausgewählter Pod gemäß iptables-Regeln.

Wir haben uns entschlossen, diesen Ansatz voranzutreiben. CoreDNS wurde als DaemonSet in Kubernetes bereitgestellt, und wir haben den lokalen DNS-Server des Knotens in die resolv.conf jedes Pods eingefügt, indem wir das Befehlsflag kubelet - cluster-dns konfiguriert haben. Die Problemumgehung war für DNS-Zeitüberschreitungen wirksam.

Wir sehen jedoch immer noch verworfene Pakete und das Zählerinkrement insert_failed der Flanellschnittstelle. Dies bleibt auch nach der oben genannten Problemumgehung bestehen, da SNAT und / oder DNAT nur für den DNS-Verkehr vermieden wurden. Die Rennbedingung tritt weiterhin für andere Verkehrstypen auf. Glücklicherweise sind die meisten unserer Pakete TCP und wenn die Bedingung eintritt, werden Pakete erfolgreich erneut übertragen. Eine langfristige Lösung für alle Arten von Verkehr ist etwas, über das wir noch diskutieren.

Verwenden von Envoy, um einen besseren Lastausgleich zu erzielen

Als wir unsere Backend-Services auf Kubernetes migrierten, litten wir unter einer unausgeglichenen Auslastung der Pods. Wir haben festgestellt, dass ELB-Verbindungen aufgrund von HTTP Keepalive an den ersten fertigen Pods jeder fortlaufenden Bereitstellung hängen bleiben, sodass der größte Teil des Datenverkehrs über einen kleinen Prozentsatz der verfügbaren Pods fließt. Eine der ersten Abhilfemaßnahmen, die wir versucht haben, war die Verwendung eines 100% MaxSurge für neue Bereitstellungen für die schlimmsten Straftäter. Dies war bei einigen der größeren Bereitstellungen nur geringfügig wirksam und auf lange Sicht nicht nachhaltig.

Eine weitere Abschwächung, die wir verwendet haben, bestand darin, Ressourcenanforderungen für kritische Dienste künstlich zu erhöhen, damit kolokalisierte Pods neben anderen schweren Pods mehr Headroom haben. Dies war aufgrund von Ressourcenverschwendung auch auf lange Sicht nicht haltbar, und unsere Knotenanwendungen waren Single-Threaded-Anwendungen und somit effektiv auf einen Kern begrenzt. Die einzig klare Lösung bestand darin, einen besseren Lastausgleich zu verwenden.

Wir hatten intern versucht, Envoy zu evaluieren. Dies bot uns die Möglichkeit, es nur in sehr begrenztem Umfang einzusetzen und sofortige Vorteile zu erzielen. Envoy ist ein Open-Source-Hochleistungs-Layer-7-Proxy für große serviceorientierte Architekturen. Es ist in der Lage, fortschrittliche Lastausgleichstechniken zu implementieren, einschließlich automatischer Wiederholungsversuche, Unterbrechung des Stromkreises und globaler Ratenbegrenzung.

Die Konfiguration, die wir uns ausgedacht hatten, bestand darin, neben jedem Pod einen Envoy-Beiwagen zu haben, der eine Route und einen Cluster hatte, um den lokalen Containerhafen zu erreichen. Um mögliche Kaskadierungen zu minimieren und einen kleinen Explosionsradius beizubehalten, haben wir eine Flotte von Front-Proxy-Envoy-Pods verwendet, eine Bereitstellung in jeder Verfügbarkeitszone (AZ) für jeden Dienst. Diese trafen auf einen kleinen Mechanismus zur Erkennung von Diensten, den einer unserer Ingenieure zusammengestellt hatte und der einfach eine Liste der Pods in jedem AZ für einen bestimmten Dienst zurückgab.

Die Service-Front-Envoys verwendeten dann diesen Service-Erkennungsmechanismus mit einem Upstream-Cluster und einer Route. Wir haben angemessene Zeitüberschreitungen konfiguriert, alle Einstellungen des Leistungsschalters verbessert und dann eine minimale Wiederholungskonfiguration vorgenommen, um bei vorübergehenden Fehlern und reibungslosen Bereitstellungen zu helfen. Wir haben jeden dieser Front-Envoy-Dienste mit einem TCP-ELB ausgestattet. Selbst wenn das Keepalive von unserer Haupt-Front-Proxy-Schicht auf bestimmten Envoy-Pods fixiert wurde, waren sie viel besser in der Lage, die Last zu bewältigen, und wurden so konfiguriert, dass sie über Least_Request an das Backend ausgeglichen werden.

Für Bereitstellungen haben wir einen PreStop-Hook sowohl für die Anwendung als auch für den Sidecar-Pod verwendet. Dieser Haken, der als Sidepoint Health Check Fail Admin-Endpunkt bezeichnet wird, sowie ein kleiner Schlaf, geben etwas Zeit, damit die Verbindungen während des Flugs abgeschlossen und entleert werden können.

Ein Grund, warum wir uns so schnell bewegen konnten, waren die umfangreichen Metriken, die wir problemlos in unser normales Prometheus-Setup integrieren konnten. Auf diese Weise konnten wir genau sehen, was passierte, als wir die Konfigurationseinstellungen iterierten und den Datenverkehr reduzierten.

Die Ergebnisse waren sofort und offensichtlich. Wir haben mit den unausgewogensten Diensten begonnen und haben sie zu diesem Zeitpunkt vor zwölf der wichtigsten Dienste in unserem Cluster ausgeführt. In diesem Jahr planen wir die Umstellung auf ein Full-Service-Netz mit fortschrittlicherer Serviceerkennung, Schaltkreisunterbrechung, Ausreißererkennung, Ratenbegrenzung und Nachverfolgung.

Abbildung 3–1 CPU-Konvergenz eines Dienstes während der Umstellung auf den Gesandten

Das Endergebnis

Durch diese Erkenntnisse und zusätzliche Forschung haben wir ein starkes internes Infrastruktur-Team aufgebaut, das mit dem Entwerfen, Bereitstellen und Betreiben großer Kubernetes-Cluster bestens vertraut ist. Die gesamte technische Organisation von Tinder verfügt nun über Kenntnisse und Erfahrungen in Bezug auf die Containerisierung und Bereitstellung ihrer Anwendungen auf Kubernetes.

In unserer Legacy-Infrastruktur mussten wir oft mehrere Minuten warten, bis neue EC2-Instanzen online gingen, wenn zusätzliche Skalierung erforderlich war. Container planen und bedienen den Datenverkehr jetzt innerhalb von Sekunden und nicht mehr innerhalb von Minuten. Das Planen mehrerer Container auf einer einzelnen EC2-Instanz bietet auch eine verbesserte horizontale Dichte. Infolgedessen erwarten wir für 2019 erhebliche Kosteneinsparungen bei EC2 im Vergleich zum Vorjahr.

Es hat fast zwei Jahre gedauert, aber wir haben unsere Migration im März 2019 abgeschlossen. Die Tinder-Plattform wird ausschließlich auf einem Kubernetes-Cluster ausgeführt, der aus 200 Diensten, 1.000 Knoten, 15.000 Pods und 48.000 laufenden Containern besteht. Infrastruktur ist keine Aufgabe mehr, die unseren Betriebsteams vorbehalten ist. Stattdessen teilen Ingenieure im gesamten Unternehmen diese Verantwortung und haben die Kontrolle darüber, wie ihre Anwendungen mit allem als Code erstellt und bereitgestellt werden.