Architektura rozkładowania ruchu w Yandex.Cloud

Architektura rozkładowania ruchu w Yandex.Cloud
Cześć, jestem Sergey Ełancew, opracowuję balansator sieciowy w Yandex.Cloud. Wcześniej kierowałem rozwojem L7-balansatora portalu Yandex — koledzy żartują, że cokolwiek bym nie robił, zawsze wychodzi balansator. Opowiem czytelnikom Habr, jak zarządzać obciążeniem na platformie chmurowej, jak widzimy idealne narzędzie do osiągnięcia tego celu i jak zmierzamy do stworzenia tego narzędzia.

Na początek wprowadźmy kilka terminów:

  • VIP (Virtual IP) — adres IP balansatora
  • Serwer, backend, instancja — wirtualna maszyna z uruchomioną aplikacją
  • RIP (Real IP) — adres IP serwera
  • Healthcheck — sprawdzenie gotowości serwera
  • Strefa dostępności, Availability Zone, AZ — izolowana infrastruktura w centrum danych
  • Region — zbiór różnych AZ

Balansatory obciążenia realizują trzy główne zadania: wykonują samą balansowanie, poprawiają odporność na awarie usług i ułatwiają jej skalowanie. Odporność na awarie zapewnia automatyczne zarządzanie ruchem: balansator monitoruje stan aplikacji i wyklucza z balansowania instancje, które nie przeszły testu na żywotność. Skalowanie jest zapewnione przez równomierne rozdzielenie obciążenia pomiędzy instancjami oraz aktualizację listy instancji w locie. Jeśli balansowanie nie będzie wystarczająco równomierne, niektóre instancje otrzymają obciążenie przekraczające ich granice wydajności, co sprawi, że usługa stanie się mniej niezawodna.

Balansatory obciążenia często klasyfikuje się według poziomu protokołu w modelu OSI, na którym pracują. Balansator Chmury działa na poziomie TCP, co odpowiada czwartemu poziomowi, L4.

Przejdźmy do przeglądu architektury balansatora Chmury. Będziemy stopniowo zwiększać poziom szczegółowości. Dzielimy komponenty balansatora na trzy klasy. Klasa config plane odpowiada za interakcję z użytkownikiem i przechowuje docelowy stan systemu. Control plane przechowuje aktualny stan systemu i zarządza systemami z klasy data plane, które odpowiadają bezpośrednio za dostarczanie ruchu od klientów do twoich instancji.

Data plane

Ruch trafiku kierowany jest do kosztownych urządzeń zwanych routerami brzegowymi. Aby zwiększyć odporność na awarie, w jednym centrum danych pracuje jednocześnie kilka takich urządzeń. Następnie ruch trafia do równoważników obciążenia, które dla klientów ogłaszają adres IP anycast we wszystkich AZ przez BGP. 

Architektura rozkładowania ruchu w Yandex.Cloud

Ruch przesyłany jest za pomocą ECMP — to strategia routingu, według której może istnieć kilka równie dobrych tras do celu (w naszym przypadku celem będzie adres IP docelowy) i pakiety mogą być wysyłane po dowolnej z nich. Wspieramy również działanie w kilku strefach dostępności według następującego schematu: ogłaszamy adres w każdej ze stref, ruch trafia do najbliższej i już poza nią nie wychodzi. W dalszej części omówimy dokładniej, co dzieje się z ruchem.

Config plane

 
Kluczowym komponentem config plane jest API, przez które wykonywane są główne operacje z równoważnikami obciążenia: tworzenie, usuwanie, zmiana składu instancji, uzyskiwanie wyników healthchecks itp. Z jednej strony jest to REST API, a z drugiej, w Chmurze bardzo często używamy frameworka gRPC, dlatego "tłumaczymy" REST na gRPC i dalej używamy tylko gRPC. Każde zapytanie prowadzi do stworzenia serii asynchronicznych idempotentnych zadań, które są wykonywane w ogólnym puli pracowników Yandex.Cloud. Zadania są pisane w taki sposób, że mogą być w każdej chwili wstrzymywane, a następnie uruchamiane ponownie. Zapewnia to skalowalność, powtarzalność i rejestrowalność operacji.

Architektura rozkładowania ruchu w Yandex.Cloud

W efekcie zadanie z API wykona zapytanie do kontrolera usługi równoważników obciążenia, który napisany jest w Go. Może on dodawać i usuwać równoważniki obciążenia, zmieniać skład backendów oraz ustawienia. 

Architektura rozkładowania ruchu w Yandex.Cloud

Serwis przechowuje swoje dane w Yandex Database — rozproszonej zarządzanej bazie danych, z której wkrótce będziecie mogli korzystać również wy. W Yandex.Cloud, jak już wspomnieliśmy, opowiadaliśmyobowiązuje koncepcja dog food: jeśli sami korzystamy z naszych usług, to również nasi klienci będą z przyjemnością z nich korzystać. Yandex Database to przykład realizacji tej koncepcji. Przechowujemy w YDB wszystkie nasze dane i nie musimy martwić się o serwisowanie i skalowanie bazy: te problemy zostały rozwiązane za nas, korzystamy z bazy jako usługi.

Wracamy do kontrolera równoważenia obciążenia. Jego zadaniem jest przechowywanie informacji o równoważniku i wysyłanie zadań do sprawdzenia gotowości maszyny wirtualnej do kontrolera healthcheck.

Kontroler healthcheck

Odbiera on zapytania o zmianę reguł sprawdzania, zapisuje je w YDB, rozdziela zadania pomiędzy węzły healthcheck i agreguje wyniki, które następnie są zapisywane w bazie i wysyłane do kontrolera równoważenia obciążenia. Ten z kolei wysyła zapytanie o zmianę składu klastra do data plane na loadbalancer-node, o którym opowiem poniżej.

Architektura rozkładowania ruchu w Yandex.Cloud

Porozmawiajmy bardziej szczegółowo o healthchecks. Można je podzielić na kilka klas. Sprawdzania mają różne kryteria sukcesu. Sprawdzenia TCP muszą nawiązać połączenie w określonym czasie. Sprawdzenia HTTP wymagają zarówno udanego połączenia, jak i uzyskania odpowiedzi z kodem statusu 200.

Również sprawdzania różnią się klasą działania — mogą być aktywne lub pasywne. Pasywne sprawdzania po prostu monitorują, co dzieje się z ruchem, nie podejmując żadnych specjalnych działań. To nie działa zbyt dobrze na L4, ponieważ zależy od logiki protokołów wyższego poziomu: na L4 nie ma informacji o tym, ile czasu zajęła operacja i czy zakończenie połączenia było dobre czy złe. Aktywne sprawdzania wymagają, aby równoważnik wysyłał zapytania do każdego instancji serwera.

Większość równoważników obciążenia wykonuje sprawdzania „żywotności” samodzielnie. W Chmurze zdecydowaliśmy się podzielić te części systemu, aby zwiększyć skalowalność. Takie podejście pozwoli nam zwiększyć liczbę równoważników, zachowując liczbę zapytań healthcheck do usługi. Sprawdzania są wykonywane przez oddzielne węzły healthcheck, na które są shardowane i replikowane cele sprawdzeń. Nie można wykonywać sprawdzeń z jednego hosta, ponieważ może on ulec awarii. W przeciwnym razie nie otrzymamy stanu sprawdzonych instancji. Wykonujemy sprawdzenia dowolnej instancji przynajmniej z trzech węzłów healthcheck. Cele sprawdzeń shardujemy pomiędzy węzłami za pomocą algorytmów konsystentnego haszowania.

Architektura rozkładowania ruchu w Yandex.Cloud

Podział równoważenia i healthcheck może prowadzić do problemów. Jeśli healthcheck node dokonuje zapytań do instancji, omijając równoważnik (który w danym momencie nie obsługuje ruchu), pojawia się dziwna sytuacja: zasób wydaje się być żywy, ale ruch do niego nie dotrze. Problem rozwiązujemy w ten sposób: gwarantujemy przesyłanie ruchu healthcheck przez równoważniki. Innymi słowy, schemat przesyłania pakietów z ruchu klientów i z healthchecks różni się minimalnie: w obu przypadkach pakiety trafią do równoważników, które dostarczą je do docelowych zasobów.

Różnica polega na tym, że klienci wysyłają zapytania do VIP, a healthchecks odnosi się do każdego osobnego RIP. Pojawia się tutaj ciekawy problem: naszym użytkownikom dajemy możliwość tworzenia zasobów w szarych sieciach IP. Załóżmy, że są dwóch różnych właścicieli chmur, którzy ukryli swoje usługi za równoważnikami. Każdy z nich ma zasoby w podsieci 10.0.0.1/24, mając takie same adresy. Konieczne jest rozróżnienie ich w jakiś sposób, i tutaj trzeba zagłębić się w strukturę wirtualnej sieci Yandex.Cloud. Lepiej dowiedzieć się szczegółów w filmie z wydarzenia about:cloud, dla nas ważne jest, że sieć jest warstwowa i zawiera tunele, które można rozróżniać po id podsieci.

Healthcheck nodes łączą się z równoważnikami za pomocą tzw. quasi-adresów IPv6. Quasi-adres to adres IPv6, w którym zakodowany jest adres IPv4 oraz id podsieci użytkownika. Ruch trafia do równoważnika, ten wydobywa z niego adres IPv4 zasobu, zamienia IPv6 na IPv4 i wysyła pakiet do sieci użytkownika.

Ruch zwrotny odbywa się w ten sam sposób: równoważnik widzi, że przeznaczenie to szara sieć z healthcheckerami i przekształca IPv4 w IPv6.

VPP to serce data plane

Równoważnik został zrealizowany w technologii Vector Packet Processing (VPP) — frameworka od Cisco do przetwarzania pakietów ruchu sieciowego. W naszym przypadku framework działa na bibliotece do zarządzania urządzeniami sieciowymi w przestrzeni użytkownika — Data Plane Development Kit (DPDK). To zapewnia wysoką wydajność przetwarzania pakietów: w jądrze występuje znacznie mniej przerwań, nie ma przełączeń kontekstu między przestrzenią jądra a przestrzenią użytkownika. 

VPP idzie jeszcze dalej, wyciskając z systemu jeszcze więcej wydajności dzięki łączeniu pakietów w batche. Wzrost wydajności następuje dzięki agresywnemu wykorzystaniu pamięci podręcznej nowoczesnych procesorów. Używane są zarówno pamięci podręczne danych (pakiety są przetwarzane "wektorem", dane są blisko siebie), jak i pamięci podręczne instrukcji: w VPP przetwarzanie pakietów przebiega zgodnie z grafem, w węzłach którego znajdują się funkcje realizujące jedną czynność.

Na przykład przetwarzanie pakietów IP w VPP przebiega w następującej kolejności: najpierw w węźle analizy następuje parsowanie nagłówków pakietów, a następnie są one wysyłane do węzła, który przekazuje pakiety dalej zgodnie z tabelami routingu.

Trochę hardcore'u. Autorzy VPP nie tolerują kompromisów w wykorzystaniu pamięci podręcznej procesora, dlatego typowy kod przetwarzania wektora pakietów zawiera ręczną wektoryzację: jest pętla przetwarzania, w której rozpatrywana jest sytuacja o typie "mamy cztery pakiety w kolejce", następnie to samo dla dwóch, a potem dla jednego. Często używane są instrukcje prefetch, które ładują dane do pamięci podręcznej, aby przyspieszyć do nich dostęp w kolejnych iteracjach.

n_left_from = frame->n_vectors;
while (n_left_from > 0)
{
    vlib_get_next_frame(vm, node, next_index, to_next, n_left_to_next);
    // ...
    while (n_left_from >= 4 && n_left_to_next >= 2)
    {
        // przetwarzanie wielu pakietów naraz
        u32 next0 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        u32 next1 = SAMPLE_NEXT_INTERFACE_OUTPUT;
        // ...
        /* Prefetch dla następnej iteracji. */
        {
            vlib_buffer_t *p2, *p3;

            p2 = vlib_get_buffer(vm, from[2]);
            p3 = vlib_get_buffer(vm, from[3]);

            vlib_prefetch_buffer_header(p2, LOAD);
            vlib_prefetch_buffer_header(p3, LOAD);

            CLIB_PREFETCH(p2->data, CLIB_CACHE_LINE_BYTES, STORE);
            CLIB_PREFETCH(p3->data, CLIB_CACHE_LINE_BYTES, STORE);
        }
        // właściwe przetwarzanie danych
        /* weryfikacja spekulacyjnych kolejek, być może zmiana bieżącej następnej ramki */
        vlib_validate_buffer_enqueue_x2(vm, node, next_index,
                to_next, n_left_to_next,
                bi0, bi1, next0, next1);
    }

    while (n_left_from > 0 && n_left_to_next > 0)
    {
        // przetwarzanie pakietów po jednym
    }

    // przetworzony batch
    vlib_put_next_frame(vm, node, next_index, n_left_to_next);
}

Zatem Healthchecks zwracają się przez IPv6 do VPP, który przekształca je w IPv4. Zajmuje się tym węzeł grafu, który nazywamy algorytmicznym NAT. Dla ruchu wstecznego (i konwersji z IPv6 na IPv4) znajduje się taki sam węzeł algorytmicznego NAT.

Architektura rozkładowania ruchu w Yandex.Cloud

Ruch bezpośredni od klientów balansownika przechodzi przez węzły grafu, które realizują samą równoważenie obciążenia. 

Architektura rozkładowania ruchu w Yandex.Cloud

Pierwszy węzeł — sticky sessions. W nim przechowywany jest hash od 5-tuple dla zainstalowanych sesji. 5-tuple zawiera adres i port klienta, z którego przesyłane są dane, adres i porty zasobów dostępnych do odbioru ruchu oraz protokół sieciowy. 

Hash z 5-tuple pomaga nam wykonywać mniej obliczeń w kolejnych węzłach haszowania konsystentnego, a także lepiej obsługiwać zmiany listy zasobów za load balancerem. Kiedy do load balancera przychodzi pakiet, dla którego nie ma sesji, jest on wysyłany do węzła haszowania konsystentnego. Tam odbywa się balansowanie za pomocą haszowania konsystentnego: wybieramy zasób z listy dostępnych "żywych" zasobów. Następnie pakiety są wysyłane do węzła NAT, który dokonuje rzeczywistej zamiany adresu docelowego i oblicza sumy kontrolne. Jak widać, przestrzegamy zasad VPP — podobne do podobnego, grupując podobne obliczenia w celu zwiększenia efektywności pamięci podręcznych procesora.

Haszowanie konsystentne

Dlaczego wybraliśmy właśnie jego i co to w ogóle jest? Najpierw rozważmy poprzedni problem — wybór zasobu z listy. 

Architektura rozkładowania ruchu w Yandex.Cloud

Przy haszowaniu niekonsystentnym oblicza się hash z przychodzącego pakietu, a zasób wybiera się z listy na podstawie reszty z dzielenia tego hasha przez liczbę zasobów. Dopóki lista pozostaje niezmienna, taki schemat działa dobrze: zawsze wysyłamy pakiety z tym samym 5-tuple do tego samego instancji. Jeśli jednak jakiś zasób przestaje odpowiadać na healthchecks, to dla znacznej części hashów wybór się zmienia. Klientowi zrywa połączenia TCP: pakiet, który wcześniej trafiał do instancji A, może zacząć trafiać do instancji B, która nie zna sesji dla tego pakietu.

Haszowanie konsystentne rozwiązuje opisany problem. Najłatwiej wyjaśnić tę koncepcję w ten sposób: wyobraź sobie, że masz pierścień, na którym rozdzielasz zasoby według hasha (na przykład, według IP:port). Wybór zasobu to obrót koła pod kątem określonym przez hash pakietu.

Architektura rozkładowania ruchu w Yandex.Cloud

W ten sposób minimalizuje się redystrybucję ruchu przy zmianie składu zasobów. Usunięcie zasobu wpłynie tylko na tę część pierścienia haszowania konsystentnego, na której znajdował się dany zasób. Dodanie zasobu również zmienia rozkład, ale mamy węzeł ze sticky sessions, który pozwala nie przełączać już ustalonych sesji na nowe zasoby.

Przeanalizowaliśmy, co dzieje się z ruchem bezpośrednim między load balancerem a zasobami. Teraz zajmijmy się ruchem zwrotnym. Podąża on za tym samym schematem co ruch zdrowotny — przez algorytmowy NAT, czyli przez NAT 44 dla ruchu klienta oraz NAT 46 dla ruchu healthchecks. Trzymamy się naszej własnej struktury: unifikujemy ruch healthchecks i rzeczywisty ruch użytkowników.

Loadbalancer-node i komponenty w zbiorze

O składzie load balancerów i zasobów w VPP informuje lokalna usługa — loadbalancer-node. Subskrybuje ona strumień zdarzeń z loadbalancer-controller, potrafi konstrukcje różnice między bieżącym stanem VPP a docelowym stanem uzyskanym od kontrolera. Otrzymujemy zamknięty system: zdarzenia z API trafiają do kontrolera load balancera, który zleca kontrolerowi healthcheck zadania do sprawdzenia 'żywotności' zasobów. Ten z kolei zleca zadania healthcheck-node i agreguje wyniki, po czym z powrotem przekazuje je do kontrolera load balancerów. Loadbalancer-node subskrybuje zdarzenia od kontrolera i zmienia stan VPP. W takim systemie każda usługa wie tylko to, co niezbędne o sąsiednich usługach. Liczba powiązań jest ograniczona, a my mamy możliwość niezależnego eksploatowania i skalowania różnych segmentów.

Architektura rozkładowania ruchu w Yandex.Cloud

Jakich pytań udało się uniknąć

Wszystkie nasze usługi w control plane są napisane w Go i odznaczają się dobrymi właściwościami w zakresie skalowalności i niezawodności. W Go jest wiele otwartoźródłowych bibliotek do budowy systemów rozproszonych. Aktywnie używamy GRPC, wszystkie komponenty zawierają otwartoźródłową implementację discovery usług — nasze usługi monitorują swoją wydajność nawzajem, mogą dynamicznie zmieniać swój skład, a my powiązaliśmy to z równoważeniem GRPC. Do metryk również wykorzystujemy rozwiązanie open source. W data plane uzyskaliśmy odpowiednią wydajność i duży zapas zasobów: okazało się bardzo trudno skonstruować stanowisko, na którym można by było spotkać się z wydajnością VPP, a nie karty sieciowej.

Problemy i rozwiązania

Co nie zadziałało zbyt dobrze? W Go zarządzanie pamięcią jest automatyczne, ale wycieki pamięci nadal się zdarzają. Najprostszy sposób na ich rozwiązanie to uruchamianie gorutin i nie zapominanie o ich kończeniu. Wniosek: monitoruj zużycie pamięci programów Go. Często dobrym wskaźnikiem jest liczba gorutin. W tej sytuacji jest też zaleta: w Go łatwo uzyskać dane dotyczące runtime — o zużyciu pamięci, liczbie uruchomionych gorutin i wielu innych parametrach.

Ponadto, Go — być może nie jest najlepszym wyborem do testów funkcjonalnych. Są one dość rozbudowane, a standardowe podejście „uruchom wszystko w CI zbiorowo” nie jest dla nich zbyt odpowiednie. Chodzi o to, że testy funkcjonalne są bardziej wymagające pod względem zasobów, działają z prawdziwymi limitami czasowymi. Z tego powodu testy mogą zakończyć się niepowodzeniem, ponieważ CPU jest zajęte testami jednostkowymi. Wniosek: jeśli to możliwe, wykonuj „ciężkie” testy oddzielnie od testów jednostkowych. 

Architektura mikroserwisowa oparta na zdarzeniach jest bardziej skomplikowana niż monolit: przeszukiwanie logów na dziesiątkach różnych maszyn nie jest zbyt wygodne. Wniosek: jeśli tworzysz mikroserwisy, od razu myśl o śledzeniu.

Nasze plany

Uruchomimy wewnętrzny load balancer, balancer IPv6, dodamy wsparcie dla scenariuszy Kubernetes, będziemy dalej dzielić nasze usługi (obecnie dzielone są tylko healthcheck-node i healthcheck-ctrl), dodamy nowe healthchecki, a także wdrożymy inteligentną agregację sprawdzeń. Rozważamy możliwość uczynienia naszych usług jeszcze bardziej niezależnymi — aby komunikowały się nie bezpośrednio między sobą, lecz za pomocą systemu kolejek. W Chmurze niedawno pojawiła się usługa zgodna z SQS. Yandex Message Queue.

Niedawno miała miejsce publiczna premiera Yandex Load Balancer. Zapoznaj się z dokumentację usługą, zarządzaj load balancerami w dogodny dla siebie sposób i zwiększaj odporność swoich projektów!

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster