Przyp. tłum.: ta pouczająca historia Omio — europejskiego agregatora podróży — prowadzi czytelników od podstawowej teorii do fascynujących praktycznych szczegółów w konfiguracji Kubernetes. Zapoznanie się z takimi przypadkami pomaga nie tylko poszerzyć horyzonty, ale także zapobiegać niecodziennym problemom.

Czy zdarzyło ci się, że aplikacja "zatrzymywała się" w miejscu, przestawała odpowiadać na zapytania o stan (health check'i) i nie mogłeś zrozumieć powodu takiego zachowania? Jednym z możliwych wyjaśnień jest limit kwot na zasoby CPU. O tym będzie mowa w tym artykule.
TL;DR:
Zdecydowanie zalecamy rezygnację z limitów CPU w Kubernetes (lub wyłączenie kwot CFS w Kubelet), jeśli używana jest wersja jądra Linux z błędem w kwotach CFS. W jądrze jest poważny i błąd, który prowadzi do nadmiernego throttlingu i opóźnień..
W Omio cała infrastruktura jest zarządzana przez Kubernetes.Wszystkie nasze stateful- i stateless-obciążenia działają wyłącznie na Kubernetes (używamy Google Kubernetes Engine). W ciągu ostatnich sześciu miesięcy zaczęliśmy obserwować losowe przycinanie. Aplikacje zawieszają się lub przestają odpowiadać na health check'i, tracą łączność z siecią itd. Takie zachowanie długo nas frustrowało, aż w końcu postanowiliśmy poważnie zająć się problemem.
Streszczenie artykułu:
- Kilka słów o kontenerach i Kubernetes;
- Jak zrealizowane są żądania i limity CPU;
- Jak działa limit CPU w środowiskach z wieloma rdzeniami;
- Jak śledzić throttling CPU;
- Rozwiązanie problemu i jego niuanse.
Kilka słów o kontenerach i Kubernetes
Kubernetes jest w zasadzie współczesnym standardem w świecie infrastruktury. Jego głównym zadaniem jest orkiestracja kontenerów.
Kontenery
W przeszłości musieliśmy tworzyć artefakty takie jak Java JAR'y/WAR'y, Python Egg'i lub pliki wykonywalne do późniejszego uruchomienia na serwerach. Jednak, aby sprawić, że działały, trzeba było wykonać dodatkową pracę: zainstalować środowisko uruchomieniowe (Java/Python), umieścić niezbędne pliki w odpowiednich miejscach, zapewnić zgodność z konkretną wersją systemu operacyjnego itd. Innymi słowy, trzeba było zwracać szczególną uwagę na zarządzanie konfiguracjami (co często było przyczyną konfliktów między programistami a administratorami systemów).
Kontenery wszystko zmieniły. Obecnie artefaktem jest obraz kontenera. Można go przedstawić jako rozszerzony plik wykonywalny, zawierający nie tylko program, ale także pełne środowisko uruchomieniowe (Java/Python/…) oraz niezbędne pliki/pakiety, wstępnie zainstalowane i gotowe do uruchomienia. Kontenery można wdrażać i uruchamiać na różnych serwerach bez dodatkowych działań.
Co więcej, kontenery działają w swoim własnym piaskownicowym środowisku. Mają swój własny wirtualny adapter sieciowy, system plików z ograniczonym dostępem, swoją hierarchię procesów, swoje ograniczenia na CPU i pamięć itd. Wszystko to realizowane jest dzięki specjalnemu podsystemowi jądra Linux - namespaces (przestrzenie nazw).
Kubernetes
Jak już wspomniano, Kubernetes to orkiestrator kontenerów. Działa to w następujący sposób: dostarczasz mu pulę maszyn, a następnie mówisz: "Hej, Kubernetes, uruchom dziesięć instancji mojego kontenera z 2 procesorami i 3 GB pamięci na każdą, i utrzymuj je w działaniu!". Kubernetes zajmie się resztą. Znajdzie wolne zasoby, uruchomi kontenery i będzie je restartować w razie potrzeby, zaktualizuje je przy zmianie wersji itd. W zasadzie Kubernetes pozwala abstrahować od sprzętu i sprawia, że różnorodność systemów jest gotowa do wdrażania i działania aplikacji.

Kubernetes z punktu widzenia zwykłego użytkownika
Co to są requesty i limity w Kubernetes
Ok, zrozumieliśmy kontenery i Kubernetes. Wiemy również, że kilka kontenerów może znajdować się na jednej maszynie.
Można przeprowadzić analogię do mieszkania komunalnego. Bierze się przestronny lokal (maszyny/węzły) i wynajmuje go kilku lokatorom (kontenerom). Kubernetes pełni rolę pośrednika. Pojawia się pytanie, jak powstrzymać lokatorów przed konfliktami? Co jeśli jeden z nich, powiedzmy, zdecyduje się zająć łazienkę na pół dnia?
Właśnie tutaj w grę wchodzą requesty i limity. CPU Request jest potrzebny wyłącznie do planowania. To coś w rodzaju „listy życzeń” kontenera i jest używane do dobierania najbardziej odpowiedniego węzła. Równocześnie CPU Limit można porównać z umową najmu - gdy już dobierzemy węzeł dla kontenera, to nie może przekroczyć ustalonych limitów. I tutaj pojawia się problem…
Jak są realizowane requesty i limity w Kubernetes
Kubernetes wykorzystuje wbudowany w jądro mechanizm throttlingu (opóźnienia) do realizacji limitów CPU. Jeśli aplikacja przekracza limit, włącza się throttling (tzn. otrzymuje mniej cykli CPU). Requesty i limity dla pamięci są zorganizowane inaczej, więc łatwiej je wykryć. Wystarczy sprawdzić ostatni status restartu poda: czy nie jest on "OOMKilled". Z throttlingiem CPU sprawy nie są tak proste, ponieważ K8s udostępnia jedynie metryki użytkowania, a nie cgroups.
CPU Request

Jak jest realizowany CPU request
Dla uproszczenia rozważmy proces na przykładzie maszyny z 4-rdzeniowym CPU.
K8s używa mechanizmu kontrolnych grup (cgroups) do zarządzania przydziałem zasobów (pamięci i CPU). Dla niego dostępny jest hierarchiczny model: potomstwo dziedziczy limity grupy rodzica. Szczegóły przydziału są przechowywane w wirtualnym systemie plików (/sys/fs/cgroup). W przypadku procesora to /sys/fs/cgroup/cpu,cpuacct/*.
K8s używa pliku cpu.share do przydziału zasobów CPU. W naszym przypadku główna kontrolna grupa otrzymuje 4096 udziałów zasobów CPU — 100% dostępnej mocy procesora (1 rdzeń = 1024; to wartość stała). Główna grupa przydziela zasoby proporcjonalnie w zależności od udziałów potomków, zapisanych w cpu.share, a te z kolei postępują analogicznie z własnymi potomkami itd. W typowym węźle Kubernetes główna kontrolna grupa ma trzech potomków: system.slice, user.slice i kubepods. Dwie pierwsze podgrupy są używane do przydziału zasobów między krytycznymi obciążeniami systemowymi a aplikacjami użytkowników spoza K8s. Ostatnia — kubepods — jest tworzona przez Kubernetes do przydziału zasobów między podami.
Na schemacie powyżej widać, że pierwsza i druga podgrupa otrzymały po 1024 udziale, przy czym podgrupie kubepod przydzielono 4096 udziałów. Jak to możliwe: przecież głównej grupie dostępne są tylko 4096 udziały, a suma udziałów jej potomków znacznie przekracza tę liczbę (6144)? Chodzi o to, że wartość ta ma logiczny sens, dlatego planista Linuxa (CFS) używa jej do proporcjonalnego przydziału zasobów CPU. W naszym przypadku pierwsze dwie grupy otrzymują po 680 rzeczywistych udziałów (16,6% z 4096), a kubepod otrzymuje pozostałe 2736 udziały. W przypadku bezczynności pierwsze dwie grupy nie będą używać przydzielonych zasobów.
Na szczęście w planie umieszczono mechanizm, który pozwala uniknąć straty nieużywanych zasobów CPU. Przekazuje on "bezczynne" moce do globalnej puli, z której są one rozdzielane grupom, które potrzebują dodatkowych mocy procesora (przekaz odbywa się partiami, aby uniknąć strat związanych z zaokrąglaniem). Podobna metoda jest stosowana także w przypadku wszystkich potomków potomków.
Ten mechanizm zapewnia sprawiedliwy podział mocy procesora i dba o to, aby żaden proces nie "kradł" zasobów od innych.
Limit CPU
Chociaż konfiguracje limitów i żądań w K8s wyglądają podobnie, ich realizacja jest zasadniczo różna: to najbardziej myląca i najmniej udokumentowana część.
K8s wykorzystuje do realizacji limitów. Ich ustawienia określane są w plikach cfs_period_us i cfs_quota_us w katalogu cgroup (tam również znajdują się pliki cpu.share).
W przeciwieństwie do cpu.share, kwota opiera się na okresie czasu, a nie na dostępnej mocy procesora. cfs_period_us określa długość okresu (epoki) — zawsze 100000 μs (100 ms). W K8s istnieje możliwość zmiany tej wartości, jednak obecnie jest ona dostępna tylko w wersji alfa. Planista wykorzystuje epokę do ponownego uruchamiania wykorzystanych kwot. Drugi plik, cfs_quota_us, określa dostępny czas (kwotę) w każdej epoce. Zauważ, że jest ona również podana w mikrosekundach. Kwota może przekraczać długość epoki; innymi słowy, może być większa niż 100 ms.
Przyjrzyjmy się dwóm scenariuszom na maszynach z 16 rdzeniami (najpopularniejszy typ komputerów u nas w Omio):

Scenariusz 1: 2 wątki i limit 200 ms. Bez throttlingu

Scenariusz 2: 10 wątków i limit 200 ms. Throttling zaczyna się po 20 ms, dostęp do zasobów CPU zostaje wznowiony po dodatkowych 80 ms
Załóżmy, że ustawiliście limit CPU na 2 rdzenie; Kubernetes przetłumaczy tę wartość na 200 ms. Oznacza to, że kontener może wykorzystać maksymalnie 200 ms czasu procesora bez throttlingu.
I tu zaczyna się najciekawsze. Jak wspomniano powyżej, dostępna kwota wynosi 200 ms. Jeśli równocześnie działa dziesięć wątków na maszynie z 12 rdzeniami (patrz ilustracja do scenariusza 2), podczas gdy wszystkie inne pod’y będą w bezczynności, kwota zostanie wyczerpana zaledwie po 20 ms (ponieważ 10 * 20 ms = 200 ms), a wszystkie wątki tego pod’a "utkną" (throttle) na następne 80 ms. Sytuację pogarsza już wspomniany , przez który występuje nadmierne throttling i kontener nie może wykorzystać nawet dostępnej kwoty.
Jak ocenić throttling w pod’ach?
Wystarczy wejść do poda i wykonać cat /sys/fs/cgroup/cpu/cpu.stat.
-
nr_periods— całkowita liczba okresów planera; -
nr_throttled— liczba okresów throttlingu w składzienr_periods; -
throttled_time— łączny czas throttlingu w nanosekundach.

Co tak naprawdę się dzieje?
W rezultacie uzyskujemy wysoki throttling w wszystkich aplikacjach. Czasami jest on półtora raza silniejszy niż przewidywane!
Prowadzi to do różnych błędów — awarii kontroli gotowości (readiness), zawieszania się kontenerów, przerywania połączeń sieciowych, timeoutów w wywołaniach serwisowych. Ostatecznie objawia się to zwiększoną latencją i wzrostem liczby błędów.
Rozwiązanie i konsekwencje
Tu wszystko jest proste. Zrezygnowaliśmy z limitów CPU i zajęliśmy się aktualizacją jądra systemu operacyjnego w klastrach do najnowszej wersji, w której błąd został naprawiony. Liczba błędów (HTTP 5xx) w naszych usługach natychmiast znacznie spadła:
Błędy HTTP 5xx

Błędy HTTP 5xx jednego krytycznie ważnego serwisu
Czas odpowiedzi p95

Opóźnienie zapytań krytycznie ważnego serwisu, 95. percentyl
Koszty eksploatacji

Liczba wydanych godzin egzemplarzowych
Gdzie jest haczyk?
Jak wspomniano na początku artykułu:
Można porównać to do mieszkania komunalnego… Kubernetes działa jako agent nieruchomości. Ale jak powstrzymać najemców przed konfliktami między sobą? Co jeśli jeden z nich, powiedzmy, postanowi zająć łazienkę na pół dnia?
Oto w czym tkwi haczyk. Jeden nieodpowiedzialny kontener może wchłonąć wszystkie dostępne zasoby procesora w maszynie. Jeśli masz solidny stos aplikacji (na przykład odpowiednio skonfigurowane JVM, Go, Node VM), to nie jest problem: można pracować w takich warunkach przez długi czas. Ale jeśli aplikacje są źle zoptymalizowane lub w ogóle nie są zoptymalizowane (FROM java:latest), sytuacja może wymknąć się spod kontroli. W Omio mamy zautomatyzowane podstawowe pliki Dockerfile z odpowiednimi ustawieniami domyślnymi dla głównych języków, więc taki problem nie wystąpił.
Zalecamy monitorowanie metryk (użycie, nasycenie i błędy), opóźnienia API i częstość występowania błędów. Upewnij się, że wyniki spełniają oczekiwania.
Linki
Taka jest nasza historia. Następujące materiały bardzo pomogły zrozumieć, co się dzieje:
- ;
- ;
- ;
- ;
- — szukaj „cpu throttling”.
Raporty o błędach Kubernetes:
- ;
- ;
- .
Czy napotkałeś podobne problemy w swojej praktyce lub masz doświadczenie związane z ograniczaniem w konteneryzowanych środowiskach produkcyjnych? Podziel się swoją historią w komentarzach!
P.S. od tłumacza
Przeczytaj także na naszym blogu:
- «»;
- «»;
- «».
Źródło: habr.com
