„Kubernetes hat die Latenz um das Zehnfache erhöht“: Wer ist schuld daran?

Anmerkung des Übersetzers.: Dieser Artikel, verfasst von Galo Navarro, der die Position des Principal Software Engineer bei der europäischen Firma Adevinta innehat, ist eine faszinierende und lehrreiche "Untersuchung" im Bereich des Infrastrukturmanagements. Der ursprüngliche Titel wurde in der Übersetzung leicht ergänzt, aus einem Grund, den der Autor zu Beginn erklärt.

„Kubernetes hat die Latenz um das Zehnfache erhöht“: Wer ist schuld daran?

Hinweis des Autors: Es scheint, dass diese Veröffentlichung aufmerksamkeit erregt hat viel mehr, als erwartet. Ich erhalte nach wie vor wütende Kommentare darüber, dass der Titel des Artikels irreführend sei und dass einige Leser enttäuscht sind. Ich verstehe die Gründe für das Geschehen, deshalb möchte ich, trotz des Risikos, die gesamte Spannung zu verderben, gleich zu Beginn erklären, worum es in diesem Artikel geht. Bei der Umstellung auf Kubernetes beobachte ich eine interessante Sache: Jedes Mal, wenn ein Problem auftritt (zum Beispiel steigende Latenzen nach der Migration), wird zuerst Kubernetes beschuldigt, doch später stellt sich heraus, dass der Orchestrator eigentlich nicht schuld ist. Dieser Artikel handelt von einem solchen Fall. Sein Titel wiederholt den Ausruf eines unserer Entwickler (später werden Sie feststellen, dass Kubernetes hier mit Sicherheit nicht betroffen ist). Sie werden in ihm keine unerwarteten Enthüllungen über Kubernetes finden, aber Sie können mit ein paar guten Lektionen über komplexe Systeme rechnen.

Vor ein paar Wochen beschäftigte sich mein Team mit der Migration eines Mikroservices auf die Hauptplattform, die CI/CD, eine auf Kubernetes basierende Arbeitsumgebung, Metriken und andere nützliche Funktionen umfasst. Der Umzug war als Test gedacht: Wir planten, ihn als Grundlage zu nehmen und in den kommenden Monaten etwa 150 weitere Dienste zu migrieren. Alle sind für den Betrieb einiger der größten Onlineplattformen in Spanien (Infojobs, Fotocasa usw.) verantwortlich.

Nachdem wir die Anwendung in Kubernetes bereitgestellt und einen Teil des Datenverkehrs darauf umgeleitet hatten, wurden wir mit einer besorgniserregenden Überraschung konfrontiert. Die Latenz (latency) von Anfragen in Kubernetes war zehnmal höher als in EC2. Insgesamt war es notwendig, entweder eine Lösung für dieses Problem zu finden oder die Migration des Mikroservices (und möglicherweise des gesamten Projekts) aufzugeben.

Warum ist die Latenz in Kubernetes so viel höher als in EC2?

Um den Flaschenhals zu finden, haben wir Metriken auf dem gesamten Weg der Anfrage gesammelt. Unsere Architektur ist einfach: Der API-Gateway (Zuul) leitet Anfragen an Instanzen des Mikroservices in EC2 oder Kubernetes weiter. In Kubernetes verwenden wir den NGINX Ingress Controller, und die Backends sind gewöhnliche Objekte vom Typ Deployment mit einer JVM-Anwendung auf der Spring-Plattform.

                                  EC2
                            +---------------+
                            |  +---------+  |
                            |  |         |  |
                       +-------> BACKEND |  |
                       |    |  |         |  |
                       |    |  +---------+  |                   
                       |    +---------------+
             +------+  |
Öffentlich  |      |  |
      -------> ZUUL +--+
Traffic     |      |  |              Kubernetes
             +------+  |    +-----------------------------+
                       |    |  +-------+      +---------+ |
                       |    |  |       |  xx  |         | |
                       +-------> NGINX +------> BACKEND | |
                            |  |       |  xx  |         | |
                            |  +-------+      +---------+ |
                            +-----------------------------+

Es schien, als liege das Problem an der Verzögerung in der anfänglichen Phase des Backend-Betriebs (ich habe den problematischen Bereich in der Grafik als „xx“ markiert). In EC2 betrug die Antwortzeit der Anwendung etwa 20 ms. In Kubernetes stieg die Verzögerung auf 100–200 ms.

Wir haben schnell potenzielle Verdächtige, die mit dem Wechsel der Ausführungsumgebung zusammenhängen, ausgeschlossen. Die JVM-Version blieb gleich. Containerisierungsprobleme waren ebenfalls nicht schuld: Die Anwendung lief bereits erfolgreich in Containern in EC2. Überlastung? Aber wir beobachteten hohe Verzögerungen selbst bei 1 Anfrage pro Sekunde. Pausen aufgrund der Müllsammlung konnten ebenfalls vernachlässigt werden.

Einer unserer Kubernetes-Administratoren fragte, ob die Anwendung externe Abhängigkeiten habe, da in der Vergangenheit DNS-Anfragen ähnliche Probleme verursacht hatten.

Hypothese 1: DNS-Namensauflösung

Bei jeder Anfrage kontaktiert unsere Anwendung ein bis drei Mal eine AWS Elasticsearch-Instanz in einer Domain wie elastic.spain.adevinta.com. Innerhalb der Container haben wir eine Shell, daher können wir überprüfen, ob die Domänensuche tatsächlich lange dauert.

DNS-Anfragen aus dem Container:

[root@be-851c76f696-alf8z \/]# while true; do dig "elastic.spain.adevinta.com" | grep time; sleep 2; done
;; Abfragezeit: 22 ms
;; Abfragezeit: 22 ms
;; Abfragezeit: 29 ms
;; Abfragezeit: 21 ms
;; Abfragezeit: 28 ms
;; Abfragezeit: 43 ms
;; Abfragezeit: 39 ms

Ähnliche Anfragen von einer der EC2-Instanzen, auf der die Anwendung läuft:

bash-4.4# while true; do dig "elastic.spain.adevinta.com" | grep time; sleep 2; done
;; Abfragezeit: 77 ms
;; Abfragezeit: 0 ms
;; Abfragezeit: 0 ms
;; Abfragezeit: 0 ms
;; Abfragezeit: 0 ms

Da die Suche etwa 30 ms dauert, wurde klar, dass die DNS-Auflösung beim Zugriff auf Elasticsearch tatsächlich zur Erhöhung der Verzögerung beiträgt.

Das war jedoch aus zwei Gründen seltsam:

  1. Wir haben bereits viele Anwendungen in Kubernetes, die mit AWS-Ressourcen interagieren, aber nicht unter hohen Latenzen leiden. Egal aus welchem Grund, es bezieht sich speziell auf diesen Fall.
  2. Wir wissen, dass die JVM die DNS-Caching im Speicher durchführt. In unseren Images ist der TTL-Wert festgelegt auf $JAVA_HOME/jre/lib/security/java.security und auf 10 Sekunden eingestellt: networkaddress.cache.ttl = 10. Mit anderen Worten, die JVM sollte alle DNS-Anfragen für 10 Sekunden cachen.

Um die erste Hypothese zu bestätigen, haben wir beschlossen, vorübergehend auf DNS-Anfragen zu verzichten und zu sehen, ob das Problem verschwindet. Zunächst haben wir das Anwendung umkonfiguriert, damit sie direkt über die IP-Adresse mit Elasticsearch kommuniziert, anstatt über den Domainnamen. Das hätte Codeänderungen und ein neues Deployment erfordert, daher haben wir einfach die Domain mit ihrer IP-Adresse in /etc/hosts:

34.55.5.111 elastic.spain.adevinta.com

verknüpft. Nun erhielt der Container die IP fast sofort. Das führte zu einer gewissen Verbesserung, aber wir kamen nur leicht näher an das erwartete Latenniveau. Obwohl die DNS-Auflösung viel Zeit in Anspruch nahm, entging uns immer noch die wahre Ursache.

Netzwerkdiagnose

Wir haben uns entschieden, den Datenverkehr aus dem Container mit tcpdumpzu analysieren, um zu verfolgen, was genau im Netzwerk passiert:

[root@be-851c76f696-alf8z /]# tcpdump -leni any -w capture.pcap

Dann haben wir einige Anfragen gesendet und ihre Capture-Datei heruntergeladen (kubectl cp my-service:/capture.pcap capture.pcap) zur weiteren Analyse in Wireshark.

Bei den DNS-Anfragen gab es nichts Verdächtiges (außer einer Kleinigkeit, die ich später erklären werde). Aber es gab bestimmte Merkwürdigkeiten in der Art und Weise, wie unser Service jede Anfrage verarbeitet hat. Unten sehen Sie einen Screenshot des Captures, der die Annahme der Anfrage vor Beginn der Antwort zeigt:

„Kubernetes hat die Latenz um das Zehnfache erhöht“: Wer ist schuld daran?

Die Paketnummern sind in der ersten Spalte aufgeführt. Zur Klarheit habe ich verschiedene TCP-Ströme farblich hervorgehoben.

Der grüne Stream, der mit dem 328. Paket beginnt, zeigt, wie der Client (172.17.22.150) eine TCP-Verbindung zum Container (172.17.36.147) hergestellt hat. Nach dem anfänglichen Handshake (328-330) brachte Paket 331 HTTP GET /v1/.. – die eingehende Anfrage an unseren Service. Der gesamte Prozess dauerte 1 ms.

Der graue Stream (ab Paket 339) zeigt, dass unser Service eine HTTP-Anfrage an die Elasticsearch-Instanz gesendet hat (das TCP-Handschlag fehlt, da eine bestehende Verbindung verwendet wird). Das dauerte 18 ms.

Bis jetzt scheint alles in Ordnung zu sein, und die Zeiten entsprechen ungefähr den erwarteten Latenzen (20-30 ms bei Messungen vom Client).

Die blaue Sektion benötigt jedoch 86 ms. Was passiert dort? Mit Paket 333 hat unser Service eine HTTP GET-Anfrage an /latest/meta-data/iam/security-credentials, und direkt danach, über die gleiche TCP-Verbindung, eine weitere GET-Anfrage an /latest/meta-data/iam/security-credentials/arn:...

Wir haben festgestellt, dass dies bei jeder Anfrage in der gesamten Trace wiederholt wird. Die DNS-Auflösung ist in unseren Containern tatsächlich etwas langsamer (die Erklärung für dieses Phänomen ist sehr interessant, aber ich werde sie für einen separaten Artikel aufbewahren). Es stellte sich heraus, dass die Ursache für die großen Verzögerungen die Anfragen an den AWS Instance Metadata Service bei jeder Anfrage sind.

Hypothese 2: überflüssige Anfragen an AWS

Beide Endpoints gehören zu AWS Instance Metadata API. Unser Mikrodienst verwendet diesen Service während der Arbeit mit Elasticsearch. Beide Aufrufe sind Teil des grundlegenden Autorisierungsprozesses. Der Endpoint, auf den bei der ersten Anfrage zugegriffen wird, gibt die mit der Instanz verbundene IAM-Rolle zurück.

/ # curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
arn:aws:iam::<account_id>:role/some_role

Die zweite Anfrage greift auf den zweiten Endpoint zu, um zeitlich begrenzte Berechtigungen für diese Instanz zu erhalten:

/ # curl http://169.254.169.254/latest/meta-data/iam/security-credentials/arn:aws:iam::<account_id>:role/some_role`
{
    "Code" : "Success",
    "LastUpdated" : "2012-04-26T16:39:16Z",
    "Type" : "AWS-HMAC",
    "AccessKeyId" : "ASIAIOSFODNN7EXAMPLE",
    "SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
    "Token" : "token",
    "Expiration" : "2017-05-17T15:09:54Z"
}

Der Kunde kann sie für einen kurzen Zeitraum nutzen und muss regelmäßig neue Zertifikate erhalten (bis zu ihrer Expiration). Das Modell ist einfach: AWS führt häufige Rotationen temporärer Schlüssel aus Sicherheitsgründen durch, aber die Kunden können sie für einige Minuten cachen, um die Leistungseinbußen durch das Abrufen neuer Zertifikate auszugleichen.

Das AWS Java SDK sollte die Verantwortung für die Organisation dieses Prozesses übernehmen, jedoch geschieht das aus irgendeinem Grund nicht.

Bei der Durchsuchung der Issues auf GitHub sind wir auf das Problem gestoßen #1921. Es hat uns geholfen, die Richtung zu bestimmen, in die wir weiter "graben" sollten.

Das AWS SDK aktualisiert die Zertifikate, wenn eine der folgenden Bedingungen eintritt:

  • Das Ablaufdatum (Expiration) fällt in den EXPIRATION_THRESHOLD, der im Code fest auf 15 Minuten eingestellt ist.
  • Es ist seit dem letzten Versuch, die Zertifikate zu aktualisieren, mehr Zeit vergangen als REFRESH_THRESHOLD, der auf 60 Minuten "hardcodiert" ist.

Um die tatsächliche Restlaufzeit der von uns erhaltenen Zertifikate zu überprüfen, haben wir die oben angegebenen cURL-Befehle aus dem Container und von der EC2-Instanz aus ausgeführt. Die Gültigkeitsdauer des aus dem Container erhaltenen Zertifikats war deutlich kürzer: genau 15 Minuten.

Jetzt ist alles klar: Für die erste Anfrage erhielt unser Service temporäre Zertifikate. Da ihre Gültigkeitsdauer 15 Minuten nicht überschritt, entschied sich das AWS SDK bei der folgenden Anfrage, diese zu aktualisieren. Und das geschah bei jeder Anfrage.

Warum ist die Gültigkeitsdauer der Zertifikate kürzer geworden?

Der AWS Instance Metadata Service ist für die Arbeit mit EC2-Instanzen und nicht mit Kubernetes vorgesehen. Auf der anderen Seite wollten wir das Anwendungsinterface nicht ändern. Dafür haben wir KIAM verwendet, ein Tool, das es Benutzern (Ingenieuren, die Anwendungen im Cluster bereitstellen) mittels Agenten auf jedem Kubernetes-Knoten ermöglicht, IAM-Rollen in Pods so zuzuweisen, als wären sie EC2-Instanzen. KIAM fängt Anfragen an den AWS Instance Metadata Service ab und verarbeitet sie aus seinem Cache, der zuvor von AWS erhalten wurde. Aus Sicht der Anwendung ändert sich nichts.

KIAM liefert kurzfristige Zertifikate an die Pods. Dies ist sinnvoll, da die durchschnittliche Lebensdauer eines Pods kürzer ist als die eines EC2-Instanz. Standardmäßig beträgt die Gültigkeitsdauer der Zertifikate 15 Minuten.

Wenn man also beide Standardwerte übereinanderlegt, ergibt sich ein Problem. Jedes Zertifikat, das an die Anwendung ausgegeben wird, läuft nach 15 Minuten ab. Zur gleichen Zeit zwingt das AWS Java SDK ein Update für jedes Zertifikat, dessen Gültigkeitsdauer weniger als 15 Minuten beträgt.

Infolgedessen wird das temporäre Zertifikat mit jeder Anfrage zwangsweise aktualisiert, was zu mehreren API-Aufrufen an AWS führt und die Latenz erheblich erhöht. In dem AWS Java SDK haben wir Feature-Anfrageeine ähnliche Problematik entdeckt.

Die Lösung war einfach. Wir haben KIAM einfach so konfiguriert, dass es Zertifikate mit einer längeren Gültigkeitsdauer anfragt. Sobald dies geschah, verliefen die Anfragen ohne Beteiligung des AWS Metadata Services und die Latenz sank sogar auf ein niedrigeres Niveau als in EC2.

Das DBMS Tarantool ist ein attraktives, zukunftsträchtiges Produkt zur Erstellung von hochbelasteten Anwendungen.

Basierend auf unseren Erfahrungen mit Migrationen lässt sich sagen, dass eine der häufigsten Quellen für Probleme nicht Fehler in Kubernetes oder anderen Plattformkomponenten sind. Sie sind auch nicht mit fundamentalen Mängeln in den Mikrodiensten verbunden, die wir migrieren. Probleme entstehen oft einfach, weil wir verschiedene Elemente zusammenführen.

Wir kombinieren komplexe Systeme, die zuvor nie miteinander interagiert haben, in der Erwartung, dass sie zusammen ein größeres, einheitliches System bilden. Leider gilt: Je mehr Elemente, desto größer der Raum für Fehler und desto höher die Entropie.

In unserem Fall war die hohe Latenz kein Ergebnis von Fehlern oder schlechten Entscheidungen in Kubernetes, KIAM, AWS Java SDK oder unserem Microservice. Sie war das Ergebnis der Kombination zweier unabhängiger, standardmäßig festgelegter Parameter: einer in KIAM und der andere im AWS Java SDK. Für sich genommen sind beide Parameter sinnvoll: sowohl die aktive Zertifikatsaktualisierungspolitik im AWS Java SDK als auch die kurze Gültigkeitsdauer der Zertifikate in KIAM. Aber wenn man sie zusammenführt, werden die Ergebnisse unvorhersehbar. Zwei unabhängige und logische Entscheidungen müssen bei einer Kombination nicht zwingend sinnvoll sein.

P.S. vom Übersetzer

Weitere Informationen zur Architektur des KIAM-Tools zur Integration von AWS IAM mit Kubernetes finden Sie bei diesem Artikel seinen Entwicklern.

In unserem Blog lesen Sie auch:

Quelle: habr.com

60GB SSD 8Gb DDR4