Przeniesienie Tindera do Kubernetes

Napisane przez: Chris O'Brien, kierownik ds. Inżynierii | Chris Thomas, kierownik ds. Inżynierii | Jinyong Lee, starszy inżynier oprogramowania | Pod redakcją: Cooper Jackson, inżynier oprogramowania

Dlaczego

Prawie dwa lata temu Tinder postanowił przenieść platformę na Kubernetes. Kubernetes dał nam możliwość poprowadzenia Tinder Engineering w kierunku konteneryzacji i obsługi za pomocą dotyku poprzez niezmienne wdrożenie. Kompilacja aplikacji, wdrożenie i infrastruktura byłyby zdefiniowane jako kod.

Chcieliśmy również zmierzyć się z wyzwaniami skali i stabilności. Kiedy skalowanie stało się krytyczne, często cierpieliśmy przez kilka minut oczekiwania na pojawienie się nowych instancji EC2. Pomysł, aby kontenery planowały i obsługiwały ruch w ciągu kilku sekund, a nie minut, było dla nas atrakcyjne.

To nie było łatwe. Podczas migracji na początku 2019 r. Osiągnęliśmy masę krytyczną w naszym klastrze Kubernetes i zaczęliśmy napotykać różne wyzwania związane z natężeniem ruchu, wielkością klastra i DNS. Rozwiązaliśmy ciekawe wyzwania związane z migracją 200 usług i uruchomiliśmy klaster Kubernetes w skali obejmującej 1000 węzłów, 15 000 kapsułek i 48 000 działających kontenerów.

W jaki sposób

Od stycznia 2018 r. Przechodziliśmy przez różne etapy migracji. Zaczęliśmy od konteneryzacji wszystkich naszych usług i wdrożenia ich w szeregu środowisk pomostowych hostowanych przez Kubernetes. Od października zaczęliśmy metodycznie przenosić wszystkie nasze dotychczasowe usługi do Kubernetes. Do marca następnego roku zakończyliśmy migrację, a platforma Tinder działa teraz wyłącznie na Kubernetes.

Budowanie obrazów dla Kubernetes

Istnieje ponad 30 repozytoriów kodu źródłowego dla mikrousług, które działają w klastrze Kubernetes. Kod w tych repozytoriach jest napisany w różnych językach (np. Node.js, Java, Scala, Go) z wieloma środowiskami wykonawczymi dla tego samego języka.

System kompilacji jest zaprojektowany do działania w pełni konfigurowalnym „kontekście kompilacji” dla każdej mikrousługi, który zazwyczaj składa się z pliku Dockerfile i serii poleceń powłoki. Chociaż ich treść można w pełni dostosowywać, wszystkie te konteksty kompilacji są zapisywane zgodnie ze standardowym formatem. Standaryzacja kontekstów kompilacji pozwala jednemu systemowi kompilacji obsługiwać wszystkie mikrousługi.

Rysunek 1–1 Standaryzowany proces kompilacji za pośrednictwem kontenera programu budującego

Aby osiągnąć maksymalną spójność między środowiskami wykonawczymi, ten sam proces kompilacji jest używany podczas fazy programowania i testowania. To nałożyło wyjątkowe wyzwanie, gdy musieliśmy znaleźć sposób, aby zagwarantować spójne środowisko kompilacji na całej platformie. W rezultacie wszystkie procesy kompilacji są wykonywane w specjalnym kontenerze „Builder”.

Wdrożenie kontenera Builder wymagało szeregu zaawansowanych technik Docker. Ten kontener Konstruktora dziedziczy lokalny identyfikator użytkownika i tajne dane (np. Klucz SSH, poświadczenia AWS itp.) Wymagane do uzyskania dostępu do prywatnych repozytoriów Tinder. Montuje lokalne katalogi zawierające kod źródłowy, aby w naturalny sposób przechowywać artefakty kompilacji. Takie podejście poprawia wydajność, ponieważ eliminuje kopiowanie wbudowanych artefaktów między kontenerem Builder a maszyną hosta. Zapisane artefakty kompilacji są ponownie używane następnym razem bez dalszej konfiguracji.

W przypadku niektórych usług musieliśmy utworzyć inny kontener w Konstruktorze, aby dopasować środowisko kompilacji do środowiska wykonawczego (np. Instalacja biblioteki bcrypt Node.js generuje artefakty binarne specyficzne dla platformy). Wymagania dotyczące czasu kompilacji mogą się różnić w zależności od usługi, a ostateczny plik Docker jest tworzony w locie.

Architektura i migracja klastra Kubernetes

Rozmiar klastra

Zdecydowaliśmy się użyć kube-aws do automatycznego udostępniania klastrów w instancjach Amazon EC2. Na początku działaliśmy w jednej ogólnej puli węzłów. Szybko zidentyfikowaliśmy potrzebę rozdzielenia obciążeń na różne rozmiary i typy instancji, aby lepiej wykorzystać zasoby. Powodem było to, że uruchomienie mniejszej liczby mocno gwintowanych strąków przyniosło nam bardziej przewidywalne wyniki wydajności niż umożliwienie im współistnienia z większą liczbą strąków jednowątkowych.

Ustaliliśmy:

  • m5.4xlarge do monitorowania (Prometheus)
  • c5.4xlarge dla obciążenia Node.js (obciążenie jednowątkowe)
  • c5.2xlarge dla Java i Go (obciążenie wielowątkowe)
  • c5.4 duży dla płaszczyzny sterowania (3 węzły)

Migracja

Jednym z kroków przygotowawczych do migracji z naszej starszej infrastruktury do Kubernetes była zmiana istniejącej komunikacji między usługami w celu wskazania nowych elastycznych modułów równoważenia obciążenia (ELB), które zostały utworzone w określonej podsieci wirtualnej chmury prywatnej (VPC). Ta podsieć została wpatrzona w VPC Kubernetes. To pozwoliło nam na szczegółową migrację modułów bez względu na konkretne porządkowanie zależności usług.

Te punkty końcowe zostały utworzone przy użyciu ważonych zestawów rekordów DNS, które miały CNAME wskazujące na każdy nowy ELB. Aby zmienić, dodaliśmy nowy rekord, wskazujący na nową usługę ELB Kubernetes, o wadze 0. Następnie ustawiliśmy Time To Live (TTL) na zestawie rekordów na 0. Stare i nowe wagi zostały następnie powoli dostosowane do ostatecznie otrzyma 100% na nowym serwerze. Po zakończeniu okresu przełączania TTL ustawiono na coś bardziej rozsądnego.

Nasze moduły Java honorowały niski poziom TTL DNS, ale nasze aplikacje Node nie. Jeden z naszych inżynierów przepisał część kodu puli połączeń, aby umieścić go w menedżerze, który odświeżałby pule co 60 lat. To działało dla nas bardzo dobrze, bez zauważalnego spadku wydajności.

Nauki

Limity sieci szkieletowej

We wczesnych godzinach rannych 8 stycznia 2019 r. Platforma Tindera uległa ciągłemu wyłączeniu. W odpowiedzi na niezwiązany ze sobą wzrost opóźnienia platformy wcześniej tego ranka, liczba strąków i węzłów została przeskalowana w klastrze. Spowodowało to wyczerpanie pamięci podręcznej ARP na wszystkich naszych węzłach.

Istnieją trzy wartości Linux związane z pamięcią podręczną ARP:

Kredyt

gc_thresh3 to twardy cap. Jeśli otrzymujesz wpisy dziennika „przepełnienie tabeli sąsiada”, oznacza to, że nawet po synchronicznym usuwaniu pamięci (GC) pamięci podręcznej ARP, nie było wystarczającej ilości miejsca do zapisania pozycji sąsiada. W takim przypadku jądro po prostu całkowicie upuszcza pakiet.

Używamy Flanela jako naszej sieci w Kubernetes. Pakiety są przekazywane przez VXLAN. VXLAN to schemat nakładki warstwy 2 w sieci warstwy 3. Wykorzystuje enkapsulację adresu MAC w adresie użytkownika (MAC-in-UDP), aby zapewnić sposób na rozszerzenie segmentów sieci warstwy 2. Protokół transportu przez fizyczną sieć centrum danych to IP plus UDP.

Rysunek 2–1 Schemat flanelowy (kredyt)

Rysunek 2–2 Pakiet VXLAN (kredyt)

Każdy węzeł roboczy Kubernetes przydziela własną / 24 wirtualną przestrzeń adresową z większego / 9 bloku. Dla każdego węzła skutkuje to 1 pozycją tablicy tras, 1 pozycją tablicy ARP (na interfejsie flannel.1) i 1 pozycją bazy danych przesyłania dalej (FDB). Są one dodawane przy pierwszym uruchomieniu węzła roboczego lub po wykryciu każdego nowego węzła.

Ponadto komunikacja między węzłami (lub między urządzeniami) ostatecznie przepływa przez interfejs eth0 (przedstawiony na powyższym schemacie Flanela). Spowoduje to dodatkowy wpis w tabeli ARP dla każdego odpowiedniego źródła i miejsca docelowego węzła.

W naszym środowisku ten rodzaj komunikacji jest bardzo powszechny. Dla naszych obiektów usług Kubernetes tworzony jest ELB, a Kubernetes rejestruje każdy węzeł w ELB. ELB nie rozpoznaje zasobnika, a wybrany węzeł może nie być ostatecznym miejscem docelowym pakietu. Dzieje się tak, ponieważ gdy węzeł odbiera pakiet od ELB, ocenia swoje reguły iptables dla usługi i losowo wybiera zasobnik w innym węźle.

W momencie awarii w klastrze było 605 węzłów. Z powodów opisanych powyżej wystarczyło to, aby przyćmić domyślną wartość gc_thresh3. Kiedy to nastąpi, nie tylko upuszczane są pakiety, ale brakuje całej Flaneli / 24s wirtualnej przestrzeni adresowej w tablicy ARP. Komunikacja typu węzeł do zasobnika i wyszukiwanie DNS kończy się niepowodzeniem. (DNS jest hostowany w klastrze, co zostanie wyjaśnione bardziej szczegółowo w dalszej części tego artykułu).

Aby rozwiązać problem, wartości gc_thresh1, gc_thresh2 i gc_thresh3 są podnoszone i należy ponownie uruchomić Flanelę, aby ponownie zarejestrować brakujące sieci.

Nieoczekiwanie uruchomiona usługa DNS w skali

Aby dostosować się do naszej migracji, mocno wykorzystaliśmy DNS, aby ułatwić kształtowanie ruchu i przyrostowe przełączanie z dotychczasowych do Kubernetes dla naszych usług. Ustawiamy stosunkowo niskie wartości TTL na powiązanych zestawach Record53 RecordSets. Gdy uruchomiliśmy naszą starszą infrastrukturę na instancjach EC2, nasza konfiguracja resolvera wskazała na DNS Amazon. Uznaliśmy to za coś oczywistego, a koszt stosunkowo niskiego TTL dla naszych usług i usług Amazon (np. DynamoDB) w dużej mierze nie został zauważony.

W miarę jak wprowadzaliśmy coraz więcej usług do Kubernetes, znaleźliśmy się w usłudze DNS, która odpowiadała na 250 000 żądań na sekundę. W naszych aplikacjach napotkaliśmy sporadyczne i wpływowe limity czasu wyszukiwania DNS. Stało się tak pomimo wyczerpujących wysiłków związanych z dostrajaniem i przejścia dostawcy DNS na wdrożenie CoreDNS, które kiedyś osiągnęło szczyt w wysokości 1000 strąków zużywających 120 rdzeni.

Badając inne możliwe przyczyny i rozwiązania, znaleźliśmy artykuł opisujący warunki wyścigu wpływające na netfilter struktury filtrowania pakietów w systemie Linux. Przekroczenia limitów DNS, które widzieliśmy, wraz z rosnącym licznikiem insert_failed w interfejsie Flanela, dostosowane do ustaleń tego artykułu.

Problem występuje podczas tłumaczenia źródłowego i docelowego adresu sieciowego (SNAT i DNAT) i późniejszego wstawiania do tabeli conntrack. Jednym z rozwiązań omówionych wewnętrznie i zaproponowanych przez społeczność było przeniesienie DNS do samego węzła roboczego. W tym przypadku:

  • SNAT nie jest konieczny, ponieważ ruch pozostaje lokalnie w węźle. Nie musi być przesyłany przez interfejs eth0.
  • DNAT nie jest konieczny, ponieważ docelowy adres IP jest lokalny dla węzła, a nie losowo wybrany moduł pod regułami iptables.

Postanowiliśmy pójść naprzód dzięki temu podejściu. CoreDNS został wdrożony jako DaemonSet w Kubernetes i wstrzyknęliśmy lokalny serwer DNS węzła do pliku resolv.conf każdego zasobnika, konfigurując flagę polecenia kubelet --uster-dns. To obejście było skuteczne w przypadku przekroczenia limitu czasu DNS.

Jednak nadal widzimy porzucone pakiety i przyrost licznika insert_failed interfejsu Flannel. Utrzyma się to nawet po powyższym obejściu, ponieważ unikaliśmy tylko SNAT i / lub DNAT dla ruchu DNS. Wyścig nadal będzie występował dla innych rodzajów ruchu. Na szczęście większość naszych pakietów to TCP, a gdy wystąpi taki warunek, pakiety zostaną pomyślnie ponownie przesłane. Długoterminowa poprawka dla wszystkich rodzajów ruchu to kwestia, o której wciąż dyskutujemy.

Korzystanie z wysłannika w celu lepszego równoważenia obciążenia

Podczas migracji naszych usług zaplecza do Kubernetes zaczęliśmy cierpieć z powodu niezrównoważonego obciążenia między zasobami. Odkryliśmy, że dzięki HTTP Keepalive połączenia ELB utknęły w pierwszych gotowych zasobnikach każdego kroczącego wdrożenia, więc większość ruchu przepływała przez niewielki procent dostępnych zasobników. Jednym z pierwszych działań, jakie próbowaliśmy, było zastosowanie 100% MaxSurge w nowych wdrożeniach dla najgorszych przestępców. W przypadku niektórych większych wdrożeń był to marginalnie skuteczny i niezrównoważony długoterminowo.

Kolejnym ograniczeniem, które zastosowaliśmy, było sztuczne zawyżenie zapotrzebowania na zasoby na usługi o kluczowym znaczeniu, aby kolokowane strąki miały więcej miejsca nad innymi ciężkimi strąkami. Nie będzie to również możliwe do utrzymania na dłuższą metę z powodu marnotrawstwa zasobów, a nasze aplikacje Node były jednowątkowe, a zatem skutecznie ograniczone do 1 rdzenia. Jedynym jasnym rozwiązaniem było zastosowanie lepszego równoważenia obciążenia.

Wewnętrznie szukaliśmy oceny wysłannika. To dało nam szansę na wdrożenie go w bardzo ograniczony sposób i czerpanie natychmiastowych korzyści. Envoy to wysokowydajny serwer proxy Layer 7 o otwartym kodzie źródłowym, zaprojektowany dla dużych architektur zorientowanych na usługi. Jest w stanie wdrożyć zaawansowane techniki równoważenia obciążenia, w tym automatyczne próby, przerywanie obwodu i globalne ograniczanie prędkości.

Konfiguracja, którą wymyśliliśmy, miała mieć wózek boczny wysłannika obok każdego zasobnika, który miał jedną trasę i klaster, aby trafić do lokalnego portu kontenerowego. Aby zminimalizować potencjalne kaskadowanie i zachować mały promień wybuchu, wykorzystaliśmy flotę kapsuł wysłannika z przodu proxy, po jednym rozmieszczeniu w każdej strefie dostępności (AZ) dla każdej usługi. Uderzyły one w mechanizm wykrywania małych usług, który jeden z naszych inżynierów połączył, który po prostu zwrócił listę strąków w każdym AZ dla danej usługi.

Następnie wysłannicy usługi korzystali z mechanizmu wykrywania usług z jednym klastrem i trasą nadrzędną. Skonfigurowaliśmy rozsądne limity czasu, zwiększyliśmy wszystkie ustawienia wyłącznika, a następnie wprowadziliśmy minimalną konfigurację ponownych prób, aby pomóc w przejściowych awariach i płynnym wdrażaniu. Każda z tych usług wysłannika frontowego została połączona z ELB TCP. Nawet jeśli podtrzymywalność z naszej głównej przedniej warstwy proxy została przypięta do niektórych kapsuł wysłannika, byli znacznie lepiej w stanie obsłużyć ładunek i byli skonfigurowani tak, aby balansować za pomocą żądania najmniejszego żądania do zaplecza.

W przypadku wdrożeń wykorzystaliśmy hak PreStop zarówno w aplikacji, jak i na wózku bocznym. Ten haczyk, zwany sprawdzaniem stanu bocznego, kończy się niepowodzeniem administratora, wraz z krótkim snem, aby dać trochę czasu na zakończenie i wyczerpanie połączeń w locie.

Jednym z powodów, dla których mogliśmy się tak szybko przenieść, były bogate wskaźniki, które mogliśmy łatwo zintegrować z naszą normalną konfiguracją Prometheus. To pozwoliło nam dokładnie zobaczyć, co się dzieje, gdy powtarzaliśmy ustawienia konfiguracji i ograniczaliśmy ruch.

Wyniki były natychmiastowe i oczywiste. Zaczęliśmy od najbardziej niezrównoważonych usług i w tym momencie uruchomiliśmy je przed dwunastoma najważniejszymi usługami w naszym klastrze. W tym roku planujemy przejść do pełnej sieci usług, z bardziej zaawansowanym wykrywaniem usług, przerywaniem obwodu, wykrywaniem wartości odstających, ograniczaniem prędkości i śledzeniem.

Rysunek 3–1 Zbieżność procesora jednej usługi podczas przełączania na wysłannika

Wynik końcowy

Dzięki tym wnioskom i dodatkowym badaniom opracowaliśmy silny wewnętrzny zespół ds. Infrastruktury, który doskonale zna się na projektowaniu, wdrażaniu i obsłudze dużych klastrów Kubernetes. Cała organizacja inżynierska Tindera ma teraz wiedzę i doświadczenie na temat tworzenia kontenerów i wdrażania swoich aplikacji na Kubernetes.

W naszej starszej infrastrukturze, gdy wymagana była dodatkowa skala, często cierpieliśmy przez kilka minut oczekiwania na pojawienie się nowych instancji EC2. Kontenery planują teraz i obsługują ruch w ciągu kilku sekund, a nie minut. Planowanie wielu kontenerów w jednym wystąpieniu EC2 zapewnia również lepszą gęstość poziomą. W rezultacie prognozujemy znaczne oszczędności na EC2 w 2019 r. W porównaniu z rokiem poprzednim.

Trwało to prawie dwa lata, ale zakończyliśmy migrację w marcu 2019 r. Platforma Tinder działa wyłącznie w klastrze Kubernetes składającym się z 200 usług, 1000 węzłów, 15 000 kapsułek i 48 000 działających kontenerów. Infrastruktura nie jest już zadaniem zarezerwowanym dla naszych zespołów operacyjnych. Zamiast tego inżynierowie w całej organizacji ponoszą tę odpowiedzialność i mają kontrolę nad tym, jak ich aplikacje są budowane i wdrażane przy użyciu wszystkiego jako kodu.