„Kubernetes zwiększył opóźnienia 10 razy”: kto jest za to winien?

Notatka. przeł.: Artykuł ten, napisany przez Galo Navarro, który zajmuje stanowisko głównego inżyniera oprogramowania w europejskiej firmie Adevinta, jest fascynującym i pouczającym „badaniem” w dziedzinie operacji infrastrukturalnych. Jego oryginalny tytuł został nieco rozszerzony w tłumaczeniu z powodu, który autor wyjaśnia na samym początku.

„Kubernetes zwiększył opóźnienia 10 razy”: kto jest za to winien?

Notatka od autora: Wygląda jak ten post przyciągnięty znacznie więcej uwagi, niż oczekiwano. Wciąż spotykam się z gniewnymi komentarzami, że tytuł artykułu jest mylący i że niektórzy czytelnicy są zasmuceni. Rozumiem przyczyny tego, co się dzieje, dlatego pomimo ryzyka zrujnowania całej intrygi, chcę od razu powiedzieć, o czym jest ten artykuł. Ciekawą rzeczą, którą zauważyłem, gdy zespoły migrują do Kubernetes, jest to, że ilekroć pojawia się problem (taki jak zwiększone opóźnienie po migracji), pierwszą rzeczą, którą obwinia się, jest Kubernetes, ale potem okazuje się, że orkiestrator tak naprawdę nie powinien tego robić winić. W tym artykule opisano jeden taki przypadek. Jego nazwa powtarza wykrzyknik jednego z naszych programistów (później przekonasz się, że Kubernetes nie ma z tym nic wspólnego). Nie znajdziesz tutaj żadnych zaskakujących rewelacji na temat Kubernetesa, ale możesz spodziewać się kilku dobrych lekcji na temat złożonych systemów.

Kilka tygodni temu mój zespół przeprowadzał migrację pojedynczej mikrousługi na platformę podstawową, która obejmowała CI/CD, środowisko wykonawcze oparte na Kubernetes, metryki i inne gadżety. Posunięcie to miało charakter próbny: planowaliśmy na nim się oprzeć i w ciągu najbliższych miesięcy przenieść około 150 kolejnych usług. Wszyscy odpowiadają za działanie jednych z największych platform internetowych w Hiszpanii (Infojobs, Fotocasa itp.).

Po wdrożeniu aplikacji na Kubernetesie i przekierowaniu na nią części ruchu czekała nas niepokojąca niespodzianka. Opóźnienie (czas oczekiwania) żądań w Kubernetesie było 10 razy więcej niż w EC2. Generalnie trzeba było albo znaleźć rozwiązanie tego problemu, albo zrezygnować z migracji mikroserwisu (i ewentualnie całego projektu).

Dlaczego opóźnienie jest o wiele większe w Kubernetesie niż w EC2?

Aby znaleźć wąskie gardło, zebraliśmy metryki z całej ścieżki żądania. Nasza architektura jest prosta: brama API (Zuul) przekazuje żądania do instancji mikrousług w EC2 lub Kubernetes. W Kubernetesie używamy NGINX Ingress Controller, a backendy to zwykłe obiekty typu Rozlokowanie z aplikacją JVM na platformie Spring.

                                  EC2
                            +---------------+
                            |  +---------+  |
                            |  |         |  |
                       +-------> BACKEND |  |
                       |    |  |         |  |
                       |    |  +---------+  |                   
                       |    +---------------+
             +------+  |
Public       |      |  |
      -------> ZUUL +--+
traffic      |      |  |              Kubernetes
             +------+  |    +-----------------------------+
                       |    |  +-------+      +---------+ |
                       |    |  |       |  xx  |         | |
                       +-------> NGINX +------> BACKEND | |
                            |  |       |  xx  |         | |
                            |  +-------+      +---------+ |
                            +-----------------------------+

Problem wydawał się być związany z początkowym opóźnieniem w backendie (obszar problemu oznaczyłem na wykresie jako „xx”). Na EC2 odpowiedź aplikacji trwała około 20 ms. W Kubernetesie opóźnienie wzrosło do 100-200 ms.

Szybko odrzuciliśmy prawdopodobne podejrzenia związane ze zmianą środowiska wykonawczego. Wersja JVM pozostaje taka sama. Problemy z konteneryzacją również nie miały z tym nic wspólnego: aplikacja działała już pomyślnie w kontenerach na EC2. Ładowanie? Zaobserwowaliśmy jednak duże opóźnienia nawet przy 1 żądaniu na sekundę. Można również pominąć przerwy na wywóz śmieci.

Jeden z naszych administratorów Kubernetes zastanawiał się, czy aplikacja ma zależności zewnętrzne, ponieważ zapytania DNS powodowały w przeszłości podobne problemy.

Hipoteza 1: Rozpoznawanie nazw DNS

W przypadku każdego żądania nasza aplikacja uzyskuje dostęp do instancji AWS Elasticsearch od jednego do trzech razy w domenie takiej jak elastic.spain.adevinta.com. Wewnątrz naszych kontenerów jest skorupa, dzięki czemu możemy sprawdzić, czy wyszukiwanie domeny faktycznie zajmuje dużo czasu.

Zapytania DNS z kontenera:

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

Podobne żądania z jednej z instancji EC2, w której działa aplikacja:

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

Biorąc pod uwagę, że wyszukiwanie trwało około 30 ms, stało się jasne, że rozpoznawanie DNS podczas uzyskiwania dostępu do Elasticsearch rzeczywiście przyczyniało się do wzrostu opóźnień.

Było to jednak dziwne z dwóch powodów:

  1. Mamy już mnóstwo aplikacji Kubernetes, które wchodzą w interakcję z zasobami AWS bez dużych opóźnień. Niezależnie od przyczyny, dotyczy ona konkretnie tej sprawy.
  2. Wiemy, że JVM buforuje DNS w pamięci. Na naszych obrazach wpisana jest wartość TTL $JAVA_HOME/jre/lib/security/java.security i ustaw na 10 sekund: networkaddress.cache.ttl = 10. Innymi słowy, maszyna JVM powinna buforować wszystkie zapytania DNS przez 10 sekund.

Aby potwierdzić pierwszą hipotezę, postanowiliśmy na jakiś czas zaprzestać wywoływania DNS i sprawdzić, czy problem zniknął. Na początek postanowiliśmy przekonfigurować aplikację tak, aby komunikowała się z Elasticsearch bezpośrednio po adresie IP, a nie poprzez nazwę domeny. Wymagałoby to zmian w kodzie i nowego wdrożenia, dlatego po prostu zmapowaliśmy domenę na jej adres IP w /etc/hosts:

34.55.5.111 elastic.spain.adevinta.com

Teraz kontener otrzymał adres IP niemal natychmiast. Spowodowało to pewną poprawę, ale byliśmy tylko nieznacznie bliżej oczekiwanych poziomów opóźnień. Chociaż rozpoznawanie DNS trwało długo, prawdziwy powód wciąż nam umykał.

Diagnostyka przez sieć

Postanowiliśmy przeanalizować ruch z kontenera za pomocą tcpdumpaby zobaczyć co dokładnie dzieje się w sieci:

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

Następnie wysłaliśmy kilka żądań i pobraliśmy ich przechwycenie (kubectl cp my-service:/capture.pcap capture.pcap) do dalszej analizy w Wireshark.

W zapytaniach DNS nie było nic podejrzanego (z wyjątkiem jednej małej rzeczy, o której powiem później). Jednak w sposobie, w jaki nasz serwis obsługiwał każde żądanie, występowały pewne dziwactwa. Poniżej znajduje się zrzut ekranu przedstawiający przyjęcie żądania przed rozpoczęciem odpowiedzi:

„Kubernetes zwiększył opóźnienia 10 razy”: kto jest za to winien?

Numery pakietów podane są w pierwszej kolumnie. Dla przejrzystości oznaczyłem kolorami różne przepływy TCP.

Zielony strumień rozpoczynający się od pakietu 328 pokazuje, jak klient (172.17.22.150) nawiązał połączenie TCP z kontenerem (172.17.36.147). Po wstępnym uścisku dłoni (328-330) przyniesiono paczkę 331 HTTP GET /v1/.. — przychodzące zapytanie do naszego serwisu. Cały proces trwał 1 ms.

Szary strumień (z pakietu 339) pokazuje, że nasza usługa wysłała żądanie HTTP do instancji Elasticsearch (nie ma uzgadniania TCP, ponieważ korzysta ona z istniejącego połączenia). Zajęło to 18 ms.

Na razie wszystko jest w porządku, a czasy mniej więcej odpowiadają oczekiwanym opóźnieniom (20-30 ms mierzone od klienta).

Jednak niebieska sekcja zajmuje 86 ms. Co się w nim dzieje? W przypadku pakietu 333 nasza usługa wysłała żądanie HTTP GET do /latest/meta-data/iam/security-credentials, a zaraz po nim, za pośrednictwem tego samego połączenia TCP, kolejne żądanie GET do /latest/meta-data/iam/security-credentials/arn:...

Odkryliśmy, że powtarza się to przy każdym żądaniu w trakcie śledzenia. Rozpoznawanie DNS rzeczywiście jest w naszych kontenerach nieco wolniejsze (wyjaśnienie tego zjawiska jest dość ciekawe, ale zostawię je na osobny artykuł). Okazało się, że przyczyną dużych opóźnień były wywołania usługi AWS Instance Metadata przy każdym żądaniu.

Hipoteza 2: niepotrzebne wywołania AWS

Obydwa punkty końcowe należą do API metadanych instancji AWS. Nasz mikroserwis korzysta z tej usługi podczas działania Elasticsearch. Obydwa wywołania są częścią podstawowego procesu autoryzacji. Punkt końcowy, do którego uzyskuje się dostęp w ramach pierwszego żądania, wystawia rolę uprawnień powiązaną z instancją.

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

Drugie żądanie pyta drugi punkt końcowy o tymczasowe uprawnienia dla tej instancji:

/ # 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"
}

Klient może z nich korzystać przez krótki okres czasu i musi okresowo zdobywać nowe certyfikaty (zanim zostaną Expiration). Model jest prosty: AWS często zmienia klucze tymczasowe ze względów bezpieczeństwa, ale klienci mogą przechowywać je w pamięci podręcznej na kilka minut, aby zrekompensować spadek wydajności związany z uzyskaniem nowych certyfikatów.

AWS Java SDK powinien przejąć odpowiedzialność za organizację tego procesu, ale z jakiegoś powodu tak się nie dzieje.

Po przeszukaniu problemów w GitHubie natrafiliśmy na problem #1921. Pomogła nam określić kierunek, w którym należy dalej „kopać”.

Zestaw AWS SDK aktualizuje certyfikaty, gdy wystąpi jeden z następujących warunków:

  • Data wygaśnięcia (Expiration) Popaść w EXPIRATION_THRESHOLD, zakodowany na stałe do 15 minut.
  • Od ostatniej próby odnowienia certyfikatów minęło więcej czasu REFRESH_THRESHOLD, zakodowany na stałe przez 60 minut.

Aby zobaczyć rzeczywistą datę wygaśnięcia otrzymanych przez nas certyfikatów, uruchomiliśmy powyższe polecenia cURL zarówno z kontenera, jak i instancji EC2. Okres ważności certyfikatu otrzymanego z kontenera okazał się znacznie krótszy: dokładnie 15 minut.

Teraz wszystko stało się jasne: na pierwsze żądanie nasz serwis otrzymał tymczasowe certyfikaty. Ponieważ nie były one ważne dłużej niż 15 minut, zestaw AWS SDK zdecydowałby się je zaktualizować na kolejne żądanie. I tak było z każdym żądaniem.

Dlaczego okres ważności certyfikatów uległ skróceniu?

Metadane instancji AWS zaprojektowano do współpracy z instancjami EC2, a nie Kubernetes. Z drugiej strony nie chcieliśmy zmieniać interfejsu aplikacji. Do tego użyliśmy KIAM - narzędzie, które za pomocą agentów na każdym węźle Kubernetes pozwala użytkownikom (inżynierom wdrażającym aplikacje w klastrze) przypisywać role IAM do kontenerów w podach tak, jakby były instancjami EC2. KIAM przechwytuje wywołania usługi AWS Instance Metadata i przetwarza je ze swojej pamięci podręcznej, otrzymawszy je wcześniej z AWS. Z punktu widzenia aplikacji nic się nie zmienia.

KIAM dostarcza krótkoterminowe certyfikaty do strąków. Ma to sens, biorąc pod uwagę, że średnia długość życia kapsuły jest krótsza niż instancji EC2. Domyślny okres ważności certyfikatów równe tym samym 15 minutom.

W rezultacie, jeśli nałożysz na siebie obie wartości domyślne, pojawi się problem. Każdy certyfikat dostarczony do aplikacji wygasa po 15 minutach. Jednak pakiet AWS Java SDK wymusza odnowienie każdego certyfikatu, któremu do daty wygaśnięcia pozostało mniej niż 15 minut.

W rezultacie tymczasowy certyfikat jest zmuszony odnawiać się przy każdym żądaniu, co wiąże się z kilkoma wywołaniami do API AWS i powoduje znaczny wzrost opóźnień. W AWS Java SDK znaleźliśmy żądanie funkcji, który wspomina o podobnym problemie.

Rozwiązanie okazało się proste. Po prostu przekonfigurowaliśmy KIAM, aby żądał certyfikatów o dłuższym okresie ważności. Kiedy to nastąpiło, żądania zaczęły napływać bez udziału usługi AWS Metadata, a opóźnienia spadły do ​​jeszcze niższego poziomu niż w EC2.

odkrycia

Z naszych doświadczeń z migracjami wynika, że ​​jednym z najczęstszych źródeł problemów nie są błędy w Kubernetesie, ani w innych elementach platformy. Nie naprawia również żadnych podstawowych błędów w mikrousługach, które przenosimy. Problemy często pojawiają się po prostu dlatego, że łączymy ze sobą różne elementy.

Mieszamy złożone systemy, które nigdy wcześniej ze sobą nie współdziałały, oczekując, że razem utworzą jeden, większy system. Niestety, im więcej elementów, tym więcej miejsca na błędy, tym wyższa entropia.

W naszym przypadku duże opóźnienia nie były wynikiem błędów czy złych decyzji w Kubernetesie, KIAM, AWS Java SDK czy naszym mikroserwisie. Było to wynikiem połączenia dwóch niezależnych ustawień domyślnych: jednego w KIAM, drugiego w AWS Java SDK. Rozważane osobno oba parametry mają sens: aktywna polityka odnawiania certyfikatów w AWS Java SDK i krótki okres ważności certyfikatów w KAIM. Ale kiedy je połączyć, wyniki stają się nieprzewidywalne. Dwa niezależne i logiczne rozwiązania nie muszą mieć sensu połączone.

PS od tłumacza

Więcej o architekturze narzędzia KIAM do integracji AWS IAM z Kubernetesem możesz dowiedzieć się na stronie ten artykuł od jego twórców.

Przeczytaj także na naszym blogu:

Źródło: www.habr.com

Dodaj komentarz