Tłumaczenie artykułu:
Ten artykuł wydał mi się dość interesujący, a ponieważ Envoy jest najczęściej używany jako część „istio” lub po prostu jako „ingress controller” w Kubernetes, większość osób nie ma z nim takiego samego bezpośredniego kontaktu jak na przykład z typowymi instalacjami Nginx lub Haproxy. Jednak jeśli coś się psuje, dobrze byłoby rozumieć, jak to działa od środka. Starałem się przetłumaczyć jak najwięcej tekstu na polski, w tym specjalne słowa, a dla tych, którym trudno na to patrzeć, zostawiłem oryginały w nawiasach. Witajcie pod katem.
Dokumentacja techniczna na niskim poziomie dotycząca bazy kodowej Envoy jest obecnie dość uboga. Aby to naprawić, planuję stworzyć serię artykułów na blogu dotyczących różnych podsystemów Envoy. Ponieważ to pierwszy artykuł, dajcie mi znać, co o tym myślicie i co mogłoby Was interesować w następnych artykułach.
Jednym z najczęściej zadawanych pytań technicznych dotyczących Envoy jest prośba o niskopoziomowy opis modelu wątków (threading model). W tym wpisie opiszę, jak Envoy mapuje połączenia na wątki oraz przedstawiam system lokalnego przechowywania wątków (Thread Local Storage), który jest stosowany wewnętrznie, aby uczynić kod bardziej równoległym i wydajnym.
Przegląd wątków (Threading overview)
![[Перевод] Model wątków Envoy (Envoy threading model)](/wp-content/uploads/2019/04/c5028463db1eb5b07ccb1a3af1f64a03.jpeg)
Envoy używa trzech różnych typów wątków:
- Główny (Main): Ten wątek zarządza uruchamianiem i końcem procesu, całym przetwarzaniem API XDS (xDiscovery Service), w tym DNS, sprawdzaniem stanu (health checking), ogólnym zarządzaniem klastrem i procesem działania usługi (runtime), resetowaniem statystyk, administracją i zarządzaniem ogólnym procesami — sygnały Linux, gorące ponowne uruchomienie (hot restart) itd. Wszystko, co dzieje się w tym wątku, jest asynchroniczne i "nieblokujące". Ogólnie rzecz biorąc, główny wątek koordynuje wszystkie krytyczne procesy funkcjonalności, do których nie jest potrzebna duża moc obliczeniowa. To pozwala na pisanie większości kodu kontrolnego tak, jakby działał w trybie jednowątkowym.
- Wątek roboczy (Worker): Domyślnie Envoy tworzy wątek roboczy dla każdego wątku sprzętowego w systemie, co można kontrolować za pomocą opcji
--concurrency. Każdy wątek roboczy uruchamia „nienablockowany” cykl zdarzeń, który odpowiedzialny jest za nasłuchiwanie każdego nasłuchiwacza. W momencie pisania artykułu (29 lipca 2017 r.) nie ma segmentacji nasłuchiwacza, przyjmowania nowych połączeń, tworzenia instancji stosu filtrów dla połączenia oraz obsługi wszystkich operacji wejścia-wyjścia (IO) przez czas istnienia połączenia. Dzięki temu większość kodu przetwarzającego połączenia można pisać tak, jakby był jednowątkowy. - Zapis do pliku (File flusher): Każdy plik, który pisze Envoy, głównie logi dostępu, obecnie ma niezależny blokujący wątek. Dzieje się tak, ponieważ zapis do plików pamięci podręcznej pliku systemowego, nawet przy użyciu
O_NONBLOCKczasami może się blokować. Kiedy wątki robocze muszą zapisać do pliku, dane faktycznie przenoszone są do bufora w pamięci, skąd ostatecznie są zapisywane przez wątek file flush. To jedna z części kodu, w której technicznie wszystkie wątki robocze (worker threads) mogą blokować tę samą blokadę (lock), próbując wypełnić bufor pamięci.
Zarządzanie połączeniami (Connection handling)
Jak wspomniano wcześniej, wszystkie wątki robocze nasłuchują wszystkich nasłuchiwaczy (listeners) bez jakiegokolwiek segmentowania. W ten sposób rdzeń jest wykorzystywany do efektywnego przydzielania odebranych gniazd do wątków roboczych. Nowoczesne rdzenie są w tym bardzo efektywne, wykorzystując takie funkcje, jak zwiększenie priorytetu wejścia-wyjścia (IO), aby próbować wypełnić wątek pracą, zanim zaczną korzystać z innych wątków nasłuchujących to samo gniazdo, a także unikają cyklicznej blokady (Spinlock) w celu przetworzenia każdego żądania.
Gdy połączenie zostanie przyjęte przez wątek roboczy (worker thread), nigdy go nie opuszcza. Całe dalsze przetwarzanie połączenia jest w całości realizowane w wątku roboczym (worker thread), w tym wszelkie zachowania przesyłania (forwarding behavior).
To ma kilka istotnych konsekwencji:
- Wszystkie pule połączeń w Envoy dotyczą wątku roboczego. W związku z tym, chociaż pule połączeń HTTP/2 nawiązują tylko jedno połączenie z każdym nadrzędnym hostem na raz, w przypadku czterech wątków roboczych będą cztery połączenia HTTP/2 do nadrzędnego hosta w stanie ustalonym.
- Powód, dla którego Envoy działa w ten sposób, polega na tym, że utrzymując wszystko w jednym wątku roboczym, prawie cały kod może być napisany bez blokad i jak gdyby był jednowątkowy. Ten projekt upraszcza pisanie dużej ilości kodu i niesamowicie dobrze skaluje się dla praktycznie nieograniczonej liczby wątków roboczych.
- Jednak jednym z kluczowych wniosków jest to, że jeśli chodzi o efektywność, pula pamięci i połączeń jest w rzeczywistości bardzo ważna do skonfigurowania.
--concurrency. Zbytnia liczba wątków roboczych prowadzi do utraty pamięci, zwiększonej liczby nieaktywnych połączeń oraz obniżenia prędkości dostępu do puli połączeń. W Lyft nasze kontenery envoy sidecar działają z bardzo niskim poziomem równoległości, tak aby wydajność była zbliżona do usług, obok których się znajdują. Uruchamiamy Envoy jako proxy brzegowe (edge) tylko przy maksymalnej równoległości (concurrency).
Co oznacza tryb nieblokujący (What non-blocking means)
Termin „nieblokujący” był wielokrotnie używany podczas dyskusji na temat tego, jak działają wątki główny i roboczy. Cały kod napisany jest z założeniem, że nic nigdy nie zostanie zablokowane. Jednakże, to nie do końca prawda (co nie do końca jest prawdą?).
Envoy stosuje kilka długotrwałych blokad procesu:
- Jak już wspomniano, podczas rejestrowania dzienników dostępu wszystkie wątki robocze otrzymują jednakową blokadę przed zapisaniem dziennika w pamięci. Czas utrzymywania blokady powinien być bardzo niski, ale możliwe jest, że ta blokada będzie kwestionowana przy wysokiej równoległości i dużej przepustowości.
- Envoy wykorzystuje bardzo zaawansowany system do przetwarzania statystyk, który jest lokalny dla wątku. To będzie temat osobnego wpisu. Niemniej jednak, wspomnę krótko, że w ramach lokalnego przetwarzania statystyk wątku czasami wymagana jest blokada dla centralnego „magazynu statystyk”. Ta blokada nie powinna być mogła być kiedykolwiek potrzebna.
- Główny wątek okresowo wymaga koordynacji ze wszystkimi wątkami roboczymi. Odbywa się to poprzez „publikację” z głównego wątku do wątków roboczych, a czasami także z wątków roboczych z powrotem do głównego wątku. Wymaga to blokady, aby opublikowana wiadomość mogła być umieszczona w kolejce do późniejszej dostawy. Te blokady nigdy nie powinny być przedmiotem poważnej konkurencji, ale mogą być technicznie blokowane.
- Gdy Envoy zapisuje dziennik w systemowym strumieniu błędów (standard error), otrzymuje blokadę całego procesu. Ogólnie rzecz biorąc, lokalne prowadzenie dziennika przez Envoy uważa się za słabe pod względem wydajności, dlatego nie przykłada się do jego ulepszania zbyt dużej wagi.
- Istnieje kilka innych przypadkowych blokad, ale żadna z nich nie jest krytyczna dla wydajności i nigdy nie powinna być poddawana kwestionowaniu.
Lokalne przechowywanie wątków (Thread local storage)
Ze względu na sposób, w jaki Envoy oddziela obowiązki głównego wątku od obowiązków wątków roboczych, istnieje wymaganie, aby skomplikowane przetwarzanie mogło być realizowane w głównym wątku, a następnie dostarczane do każdego wątku roboczego z wysokim poziomem równoległości. W tej sekcji opisano system Envoy Thread Local Storage (TLS) na wysokim poziomie. W następnej sekcji przedstawię, jak wykorzystuje się go do zarządzania klastrem.
![[Перевод] Model wątków Envoy (Envoy threading model)](/wp-content/uploads/2019/04/a47af1e8eb1f55a4d7609b3ff3ee9ce1.jpeg)
Jak już opisano, główny wątek zajmuje się praktycznie wszystkimi funkcjami zarządzania i funkcjonalnością płaszczyzny kontrolnej w procesie Envoy. Płaszczyzna kontrolna jest tutaj nieco przeciążona, ale gdy rozważamy ją w ramach samego procesu Envoy i porównujemy z przesyłaniem realizowanym przez wątki robocze, wydaje się to sensowne. Zasadniczo główny wątek wykonuje pewne zadania, a następnie musi aktualizować każdy wątek roboczy zgodnie z wynikami tej pracy, przy czym wątek roboczy nie musi blokować dostępu przy każdym dostępnie.
System TLS (Thread Local Storage) w Envoy działa w następujący sposób:
- Kod wykonywany w głównym wątku może przydzielić slot TLS dla całego procesu. Chociaż jest to zrozumiałe, w praktyce jest to indeks w wektorze, który zapewnia dostęp O(1).
- Główny wątek może ustawić dowolne dane w swoim slocie. Gdy to się stanie, dane są publikowane w każdym wątku roboczym jako zwykłe zdarzenie cyklu zdarzeń.
- Wątki robocze mogą odczytywać ze swojego slotu TLS i pobierać jakiekolwiek lokalne dane wątków, które są tam dostępne.
Chociaż jest to bardzo prosta i niesamowicie potężna paradygma, która jest bardzo podobna do koncepcji blokady RCU (Read-Copy-Update), to zasadniczo wątki robocze nigdy nie widzą żadnych zmian danych w slotach TLS podczas realizacji pracy. Zmiany odbywają się tylko w okresie bezczynności między zdarzeniami roboczymi.
Envoy wykorzystuje to na dwa różne sposoby:
- Przechowując różne dane w każdym wątku roboczym, dostęp do tych danych odbywa się bez jakiejkolwiek blokady.
- Zachowując wskaźnik ogólny na dane globalne w trybie ‚tylko do odczytu’ w każdym wątku roboczym. W ten sposób każdy wątek roboczy ma licznik odniesień do danych, który nie może być zmniejszany podczas wykonywania pracy. Dopiero kiedy wszyscy pracownicy się uspokoją i załadują nowe wspólne dane, stare dane zostaną zniszczone. Jest to identyczne z RCU.
Wątek aktualizacji klastra (Cluster update threading)
W tej sekcji opiszę, jak TLS (Thread local storage) jest używane do zarządzania klastrem. Zarządzanie klastrem obejmuje obsługę API xDS i/lub DNS, a także sprawdzanie stanu (health checking).
![[Перевод] Model wątków Envoy (Envoy threading model)](/wp-content/uploads/2019/04/238f5718729e1f12f8f0d6507f16abe8.jpeg)
Zarządzanie wątkami klastra obejmuje następujące komponenty i etapy:
- Menadżer klastra to komponent wewnątrz Envoy, który zarządza wszystkimi znanymi upstreamami klastra, interfejsem API CDS (Cluster Discovery Service), interfejsami API SDS (Secret Discovery Service) i EDS (Endpoint Discovery Service), DNS oraz aktywnymi zewnętrznymi sprawdzeniami stanu (health checking). Odpowiada za stworzenie «ostatecznie spójnego» (eventually consistent) widoku każdego upstreama klastra, który obejmuje wykryte hosty oraz stan ich dostępności (health status).
- Środek sprawdzania dostępności (health checker) wykonuje aktywne sprawdzenie stanu i zgłasza zmiany w stanie do menadżera klastra.
- CDS (Cluster Discovery Service) / SDS (Secret Discovery Service) / EDS (Endpoint Discovery Service) / DNS są wykorzystywane do ustalania przynależności do klastra. Zmiana stanu jest przekazywana do menadżera klastra.
- Każdy wątek roboczy nieustannie wykonuje cykl przetwarzania zdarzeń.
- Gdy menadżer klastra stwierdzi, że stan klastra uległ zmianie, tworzy nowy migawkowy stan klastra, dostępny tylko do odczytu, i przesyła go do każdego wątku roboczego.
- W trakcie następnego okresu bezczynności, strumień roboczy zaktualizuje zrzut w przypisanym slocie TLS.
- Podczas zdarzenia wejścia/wyjścia, które ma zdefiniować hosta do rozkładu obciążenia, rozkładacz obciążenia zażąda slotu TLS (przechowywanie lokalne wątków) w celu uzyskania informacji o hoście. Nie wymaga to blokad. Należy również zauważyć, że TLS może również inicjować zdarzenia podczas aktualizacji, dzięki czemu podsystemy rozkładu obciążenia i inne komponenty mogą przeliczać pamięci podręczne, struktury danych itp. To wykracza poza zakres tego postu, ale jest wykorzystywane w różnych miejscach w kodzie.
Dzięki opisanej wyżej procedurze, Envoy może obsługiwać każde żądanie bez żadnych blokad (poza wymienionymi wcześniej). Oprócz złożoności samego kodu TLS, większość kodu nie musi rozumieć, jak działa wielowątkowość, i może być napisana w trybie jednowątkowym. Ułatwia to pisanie większej części kodu, co przekłada się na doskonałą wydajność.
Inne podsystemy wykorzystujące TLS
TLS (przechowywanie lokalne wątków) oraz RCU (Read Copy Update) są szeroko stosowane w Envoy.
Przykłady użycia:
- Mechanizm zmiany funkcjonalności w trakcie działania: Aktualna lista aktywnych funkcji jest obliczana w głównym wątku. Następnie każdemu wątkowi roboczemu udostępniany jest zrzut tylko do odczytu z użyciem semantyki RCU.
- Zamiana tabel tras: dla tabel tras dostarczanych przez RDS (Route Discovery Service), tabele tras są tworzone w głównym wątku. Zrzut tylko do odczytu będzie następnie udostępniany każdemu wątkowi roboczemu z użyciem semantyki RCU (Read Copy Update). Umożliwia to efektywne atomowe zmiany w tabelach tras.
- Buforowanie nagłówków HTTP: Jak się okazuje, obliczenie nagłówka HTTP dla każdego żądania (przy wydajności ~25K+ RPS na rdzeń) jest dość kosztowne. Envoy centralnie oblicza nagłówek co około pół sekundy i udostępnia go każdemu pracownikowi przez TLS i RCU.
Istnieją także inne przypadki, jednak wcześniejsze przykłady powinny zapewnić dobre zrozumienie, do czego używa się TLS.
Znane pułapki wydajności
Chociaż ogólnie Envoy działa wystarczająco dobrze, istnieje kilka znanych obszarów, które wymagają uwagi, gdy używa się go przy bardzo wysokim poziomie równoległości i przepustowości:
- Jak opisano w tym artykule, obecnie wszystkie wątki robocze blokują się podczas zapisywania do bufora pamięci dziennika dostępu. Przy wysokiej równoległości i dużej przepustowości konieczne będzie pakowanie dzienników dostępu dla każdego wątku roboczego kosztem nieuporządkowanej dostawy podczas zapisu do pliku końcowego. Alternatywnie, można tworzyć oddzielny dziennik dostępu dla każdego wątku roboczego.
- Chociaż statystyki są bardzo dobrze zoptymalizowane, przy bardzo wysokiej równoległości i przepustowości prawdopodobnie wystąpi rywalizacja atomowa na statystykach indywidualnych. Rozwiązaniem tego problemu są liczniki dla jednego wątku roboczego z okresowym resetowaniem centralnych liczników. To zostanie omówione w następnym poście.
- Obecna architektura nie będzie działać efektywnie, jeśli Envoy zostanie wdrożony w scenariuszu, w którym istnieje mało połączeń wymagających znacznych zasobów do przetwarzania. Nie ma gwarancji, że połączenia będą równomiernie rozłożone pomiędzy wątkami roboczymi. Można to rozwiązać poprzez wdrożenie równoważenia połączeń roboczych, które umożliwi wymianę połączeń pomiędzy wątkami roboczymi.
Wnioski (Conclusion)
Model wątków Envoy został zaprojektowany w celu zapewnienia prostoty programowania i masowego równoległego przetwarzania, przy potencjalnie rozrzutnym wykorzystaniu pamięci i połączeń, jeśli nie są one odpowiednio skonfigurowane. Model ten pozwala na bardzo dobrą wydajność przy dużej liczbie wątków i przepustowości.
Jak wspomniałem krótko na Twitterze, projekt może również działać na pełnoprawnym stosie sieciowym w trybie użytkownika, takim jak DPDK (Data Plane Development Kit), co może sprawić, że standardowe serwery będą mogły obsługiwać miliony żądań na sekundę przy pełnej obsłudze L7. Bardzo ciekawie będzie zobaczyć, co zostanie zbudowane w ciągu najbliższych kilku lat.
Jeszcze jeden szybki komentarz: wielokrotnie pytano mnie, dlaczego wybraliśmy C++ dla Envoy. Powód nadal tkwi w tym, że jest to jedyny powszechnie używany język przemysłowy, na którym można zbudować architekturę opisaną w tym poście. C++ nie jest zdecydowanie odpowiedni dla wszystkich lub nawet wielu projektów, ale w określonych przypadkach użycia nadal pozostaje jedynym narzędziem do wykonania tej pracy.
Linki do kodu
Linki do plików z interfejsami i implementacjami nagłówków, omawianymi w tym poście:
Źródło: habr.com
