Achievers entwickelte ein Framework für Lasttests, damit unsere Engineering-Teams ein besseres Verständnis der Plattform-Performance unter plötzlicher Last erlangen konnten. Dies führte zu einer Verbesserung der gesamten Performance, dem Vierfachen an Traffic-Throughput und einer effektiveren Skalierung von Clustern. In diesem Blog werden die Ziele beleuchtet, die auf Grundlage der anfänglichen Baseline-Ergebnisse gesetzt wurden, und wir erläutern, wie wir Bottlenecks behoben, auf die wir während der Testphase stießen. Sobald wir wussten, wie der Status der Plattform aktuell aussah, machten wir uns daran, diese Baseline zu übertreffen.

Die Baseline als Ausgangspunkt

Beim Monitoring und Reporting wurden folgende Metriken berücksichtigt.

  • Throughput: Zahl der Anfragen, die ein System pro Sekunde bewältigen kann.
  • Fehler: Zahl der Anfragen, die während des Testzeitraums fehlschlugen.
  • Latenz: Die Zeitspanne zwischen dem Versand einer Anfrage und ihrer Verarbeitung.
  • Skalierbarkeit: Die Fähigkeit einer Plattform, zunehmender Auslastung ohne wesentliche Performance-Einbußen standzuhalten.

Für die obigen Metriken setzten wir Ziele fest, damit eine effektive Messung eventueller Systemverbesserungen möglich war.

Diagramm

Ergebnisorientierte Zielsetzungen zur Bewertung der Lasttests

Zum Messen der Testergebnisse und Nachverfolgen der Erwartungen erstellten wir in New Relic Observability-Dashboards. Über diese Dashboards konnte das Team rasch Bottlenecks und andere Probleme ermitteln, die während der Tests auftraten.

Nach Einsicht in die Dashboards wurde klar, dass die Plattform-Performance durch verschiedene Engpässe eingeschränkt wurde.

Optimierung des Throughput

Istio Load Balancing

gRPC hält TCP-Sessions möglichst lange offen, um den Throughput zu maximieren und den Overhead zu minimieren. Aber solche langen Sessions machen das Load Balancing zu einer komplexen Angelegenheit. Vor allem in autoskalierten Kubernetes-Umgebungen ist das problematisch. Bei zunehmender Last werden zwar neue Pods hinzugefügt, allerdings bleibt der Client weiterhin mit den alten gRPC-Pods verbunden, sodass es zu einer ungleichmäßigen Lastverteilung kommt.

Nachstehend sehen Sie ein Beispiel, in dem sich gRPC-Traffic auf dem Weg zu einem Deployment befindet und kein Lastausgleich stattfindet.

Diagramm

New Relic Dashboard zeigt ungleichmäßige Verteilung der Anfragen pro Sekunde (RPS)

Wie Sie sehen, gehen zu jedem Pod sporadische Anfragen, ein Pod erhält allerdings gar keinen Traffic. Istio teilt die Verbindungsinformationen zwischen Envoy-Proxys und hilft so, die Anfragen gleichmäßiger zu verteilen. Die Envoy-Proxys teilen die Anzahl der empfangenen Anfragen; Istio erkennt, welcher Pod Kapazität hat und sich daher besser zur Verarbeitung einer Anfrage eignet.

Envoy

Durch Konfigurieren des Load Balancing in der DestinationRule zur Verwendung von LEAST_REQUEST anstelle des standardmäßigen Round-Robin-Algorithmus wurde das Problem des unausgewogenen Traffic behoben.

Code

DestinationRule mit LEAST_REQUEST

Nach der Implementierung der neuen loadBalancer-Konfiguration zeigen die Ergebnisse eine gleichmäßige Verteilung der Anfragen über die Pods hinweg sowie eine signifikante Throughput-Steigerung in allen Services.

Diagramm

Gleichmäßig auf Pods verteilte Anfragen

Fehler

Clientseitig

Nicht alle Engpässe finden sich auf dem Server. Auch beim Lasttest-Client kann es bei der Lastgenerierung zu Bottlenecks kommen, was für eine hohe Fehlerquote sorgen kann. Zwar reichten CPU und Speicher aus, um die Tests durchzuführen, aber in unserem Netzwerk traten bald ein paar Einschränkungen zutage.

Bei Google-Cloud-VMs ohne externe IP-Adresse wird der ausgehende Traffic über eine Cloud NAT geleitet. Bei der Durchführung eines Lasttests aus einem anderen GCP-Projekt heraus schnellte die Anzahl der OUT_OF_RESOURCES-Fehler aufgrund des ausgehenden Traffics während der Testphase in die Höhe.

Diagramm

Hohe Fehlerquote bei ausgehenden Verbindungen während des Lasttests

Das Team fügte zusätzliche IP-Adressen hinzu und erhöhte die „für eine Zieladresse zulässigen Ports“ zur Google Cloud NAT. Wie in der Abbildung unten ersichtlich, waren die Ressourcen zunächst maximal ausgelastet (flache Linien), bevor wir das Problem am Ende des Diagramms beheben konnten.

Diagramm

Flatlines zeigen Ressourcenüberlastung an; ganz rechts ist das Problem behoben

Eine langfristige Lösung wäre der komplette Umstieg auf IPV6, um ausgehende Verbindungsbeschränkungen aufzuheben. Für 2024 planen wir, das gesamte Netzwerk bei Achievers auf IPV6 umzustellen.

Latenz

Istio-Konfiguration

Da der gesamte Cluster-Traffic durch den Istio-Envoy-Proxy verläuft, sahen wir dort eine Möglichkeit, die Latenz zu verringern. Wir fanden zwar keine Transaktionen, die zu lange in Envoy verblieben, aber wir wollten sehen, ob wir Istio eventuell durch ein paar Konfigurationsänderungen verbessern konnten. Istio-Concurrency oder -Parallelität bezeichnet die Anzahl der Worker-Threads, die auf dem Envoy-Sidecar-Proxy laufen. Die Standardeinstellung ist 2. Wird sie in 0 geändert, werden alle Kerne des Rechners auf Grundlage von CPU-Requests und -Limits verwendet – zur Steuerung werden diese Werte genutzt, wobei die Limits Vorrang vor den Requests haben.

Ergebnisse der Concurrency-Prüfung:

4 Worker => 4434,11/s + p(95) = 114,67 ms

6 Worker => 4230,42/s + p(95) = 98,3 ms

2 Worker => 4519,10/s + p(95) = 100,75 ms

In unserem Fall schien die Istio-Concurrency keine Auswirkungen auf die Performance zu haben. Es war dennoch sinnvoll zu prüfen, ob sich daraus für uns Vorteile ergeben würden, auch wenn dies letztendlich nicht der Fall war.

Codeverbesserungen

Freigegebener Code kann oft zu dienstübergreifenden Problemen führen. Bei uns verursachte beispielsweise ein Bug in unserem Datenbank-Konnektor zusätzliche Latenz in den ausgehenden Verbindungen.

Tabelle

Zusätzliche Datenbankaufrufe sorgten für unnötige Latenz: Rot zeigt einen SLO-Verstoß (in Millisekunden)

Dank New Relic Tracing kam rasch ein Muster ans Licht. Im Bild oben sieht man, dass die Latenzresultate auf denjenigen Endpunkten hoch waren, auf denen diese langsamen Datenbankaufrufe stattfanden. Zum Wechsel auf das für die Abfrage notwendige Schema wurden Datenbanksperren verwendet. Die Handhabung dieser Sperren wiederum kostet Zeit. Zur Behebung des Problems wurde der Aufruf zum Schemawechsel entfernt; stattdessen wird der Name des Schemas an die Abfrage angehängt.

Nach der Optimierung der Bibliothek verbesserte sich die durchschnittliche Antwortzeit insgesamt.

Tabelle

Nach der Verbesserung der Datenbankaufrufe ging die durchschnittliche Antwortzeit deutlich zurück (in Millisekunden gemessen)

Ein weiteres Codeproblem kam an einem alten Endpunkt in unserem Monolith zutage. Die Performance dort war miserabel, die Erledigung von Anfragen dauerte oft mehr als 10 Sekunden.

Endpunkt

Ein langsamer Endpunkt hatte Bearbeitungszeiten von mehr als 10 Sekunden

Durch Upgraden des Endpunkts und Optimieren der Datenbankabfragen konnten wir dies auf Millisekunden reduzieren.

Diagramm

Nach latenzverringernden Verbesserungen am Code

Caching

l

Kubernetes-Pod mit unzureichendem Arbeitsspeicher

Deutliche Cache-Verbesserungen und verkürzte Antwortzeiten konnten wir durch das Verschieben einiger Bilddateien zum CDN erzielen. Ein paar Bottlenecks wurden in Diensten mit intensiver Nutzung eines Redis-Cache lokalisiert. Zwar sollten Systeme nicht von Redis abhängig sein, es kann allerdings die Performance von Anwendungen deutlich verbessern.

Aus Ressourcendiagrammen in New Relic wurde ersichtlich, dass Redis in einigen kritischen Namespaces nicht genug Ressourcen hatte. Die zwei violetten Linien übersteigen das Kubernetes-Limit, was auf Speicherüberlastung und Out-of-Memory(OOM)-Fehler hindeutet.

l

New Relic Dashboard zeigt hohe Speichernutzung auf Redis-Mastern während der Lasttests

Das Problem ließ sich durch einen raschen PR zur Steigerung der Anfragen von Redis Kubernetes beheben. Dies erwies sich insgesamt als nützlich, um zu testen, wie groß die Performance-Einbußen bei stark gecachten Services waren, wenn Redis-Ressourcen ausgelastet waren.

Skalierung

Bei Knoten handelt es sich um die verwalteten virtuellen Maschinen, auf denen ein Kubernetes-Cluster die Workloads ausführt. Virtuelle Maschinen mögen kurzlebig sein, dennoch müssen Sie sicherstellen, dass Ihr Cluster einwandfrei funktioniert und angemessen skaliert. Nachdem Sie grundlegende Verbesserungen wie die Justierung von Knoten-CPU, Speicher und Datenträger vorgenommen haben, sollten Sie sich einige der nachstehenden Maßnahmen ansehen, die für eine effektive Skalierung Ihrer Infrastruktur sorgen.

Beschränkungen bei IP-Adressen:

Cluster werden mit IP-Bereichen erstellt, und oft werden diese nie wieder geprüft. Schon kurz nach Testbeginn bemerkten wir, dass unser Cluster angesichts der Anzahl der darin enthaltenen Worker-Knoten bald an seine Grenzen stoßen könnte. Kubernetes verteilt das Pod-Netzwerk auf das Worker-Knotennetzwerk und beschränkt so die Anzahl der Worker in Ihren Knotenpools. Je nach dem Wert für max_pods_per_node ist die Knotenerstellung an die Anzahl der Adressen im Pod-Adressenbereich gebunden.

s

Beispiel: Skalierungslimit während eines Lasttests in einem Kubernetes-Cluster als Flatline erkennbar

Wenn man beispielsweise die Standard-Höchstzahl der Pods auf 110 setzt und den sekundären IP-Adressbereich für Pods auf /21, weist Kubernetes den Knoten im Cluster einen CIDR-Bereich von /24 zu. Dadurch werden maximal 2(24-21) = 23 = 8 Knoten im Cluster zugelassen.

Die Beschränkung der IP-Adressen kann dazu führen, dass die Knoten Ihres Clusters nicht mehr skaliert werden können und die Workloads gedrosselt werden oder sogar aufgrund mangelnder Ressourcen abstürzen. Das konnten wir leicht beheben, da wir nur einige der Pods und Workern zugewiesenen IP-Bereiche anpassen mussten. Google hat zusätzlich „sekundäre IP-Bereiche“ eingeführt, die für Teams hilfreich sein können, die ihre Cluster neu erstellen müssen, um dieses Problem zu beheben.

TopologySpreadConstraints:

Ein intensiv genutztes Kubernetes-Deployment muss eventuell über verschiedene Worker-Knoten verteilt werden, damit die Ressourcen nicht erschöpft werden. Durch Verwendung von topologySpreadConstraints können Pods über die Fehlerdomains des Clusters wie Regionen, Zonen, Hosts und andere benutzerdefinierte Topologien verteilt werden.

Dieses Feature verwenden wir oft für unsere kritischen Services, um eine effektive Skalierung unserer Kubernetes-Cluster sicherzustellen. Ist Unsatisfiable als DoNotSchedule festgelegt, kann der Cluster zur Skalierung gezwungen werden, wenn mehr als zwei Pods über alle Worker-Knoten verteilt sind.

Verteilung

Indem wir die Skalierung des Clusters für kritische Services zulassen, stellen wir sicher, dass CPU und Speicher auch bei Trafficspitzen ausreichen. Durch Justieren dieses Werts zusammen mit durchschnittlichen Skalierungswerten war die Clusterskalierung um das Zehnfache effektiver.

Fazit

Nachdem alle größeren Bottlenecks behoben waren, sahen wir uns unsere ursprünglichen Ziele wieder an. Das Resultat konnte sich sehen lassen!

Diagramm

In allen Fällen erreichte das Team unsere Ziele oder übertraf sie sogar. Die Tatsache, dass nach Abschluss des ersten Tests keine unserer festgelegten Latenz- oder Fehler-SLOs beeinträchtigt waren, war eine große Erleichterung. Durch die Verbesserung des Throughputs um das Vierfache waren wir in der Lage, den Failover-Traffic in einer einzigen Region während eines Ausfalls sicher zu bewältigen. Insgesamt konnten wir die Cluster-Skalierung zudem durch Einführung angemessen dimensionierter Ressourcen und mithilfe von Load Balancing für gRPC-Anfragen durch Istio verbessern.

Natürlich gibt es immer Verbesserungsmöglichkeiten, aber wir freuen uns, dass die Achievers-Plattform jetzt über ein flexibles Framework verfügt, mit dem wir kontinuierliche Lasttests und Performance-Messungen durchführen können.