Sprawa klastra, który nie chciał rosnąć
Krótka historia detektywistyczna o jednym GPU, czterech duchach i liczbie, której nikt nie sprawdzał od miesięcy.
┌────────────────────────────────────────────────────────────┐ │ TABLICA ŚLEDCZA │ ├────────────────────────────────────────────────────────────┤ │ │ │ [ jeden pod, Pending, 6 godzin ] │ │ │ │ │ ┌─────────┴─────────┐ │ │ ▼ ▼ │ │ ┌───────────┐ ┌───────────┐ │ │ │PODEJRZANY │ │PODEJRZANY │ │ │ │ „flaga, │ │ „w AWS │ │ │ │ którą ktoś│ │ skończyły │ │ │ │ zgrepował”│ │ się GPU” │ │ │ └─────┬─────┘ └─────┬─────┘ │ │ │ niewinna │ niewinny │ │ ▼ ▼ │ │ ┌───────────────────────┐ │ │ │ FAKTYCZNY SPRAWCA │ │ │ │ limit: 1 GPU │ │ │ │ użycie: 4 GPU │ │ │ │ fizyczne GPU: 1 │ │ │ └───────────────────────┘ │ └────────────────────────────────────────────────────────────┘
Zgłoszenie
Na deweloperskim klastrze Kubernetesa Firmy monitor przeszedł w stan krytyczny:
CRITICAL: the autoscaler has reported unschedulable pods continuously for 15 minutes. Autoskaler widzi pody, których nie może umieścić, i nie rozwiązuje tego, dodając węzły.
Piętnaście minut to był próg alertu. Faktyczna liczba, kiedy ktoś spojrzał, wynosiła sześć godzin. Jeden pod. Pending. Bez pętli restartów, bez błędów — po prostu cierpliwie czekał, podczas gdy autoskaler, którego jedynym zadaniem jest dodawanie pojemności, nie robił nic.
Alert usłużnie wymieniał czterech podejrzanych w kolejności prawdopodobieństwa, kończąc uspokajającą uwagą, że tylko ostatni to faktyczny problem po stronie AWS. Inżynier, który podjął zgłoszenie, zdążył już puścić grep po repozytorium konfiguracji i znalazł własnego kandydata:
$ grep -rs "do-not-disrupt" . ./utils/<a media service>/values.yaml: karpenter.sh/do-not-disrupt: "true" ./apps/<a worker service>/values.yaml: karpenter.sh/do-not-disrupt: "true" ./apps/<a backend service>/values.yaml: karpenter.sh/do-not-disrupt: "true"
Trzy obciążenia oznaczone „nie przeszkadzać”. Zablokowany klaster. Zgrabna teoria, z tych, które o 9 rano wydają się słuszne.
Była błędna. Ale zapamiętaj ją — była błędna w ciekawy sposób.
Rozdział 1 — Zapytaj podejrzanego wprost
Zanim zacznie się teoretyzować, był dużo tańszy ruch: autoskaler pisze logi i od sześciu godzin co dwie sekundy pisał to samo.
To nie była wskazówka. To było podpisane przyznanie się do winy:
could not schedule pod NodePool=gpu
error: no instance type has enough resources, ... nodepool In [default],
instance-type In [<general-purpose types>], resources={"cpu":"1600m",
"memory":"5020Mi","nvidia.com/gpu":"1", ...};
all available instance types exceed limits for nodepool (NodePool=gpu)
Dwie osobne odmowy w jednej linii, dlatego chwilę trwało, zanim przeczytano to poprawnie.
Pula węzłów ogólnego przeznaczenia mówiła: nie pomogę, żaden z moich typów maszyn nie ma GPU. Uczciwie.
Pula węzłów GPU mówiła coś dużo dziwniejszego: all available instance types exceed limits. W przekładzie: już przekroczyłam własny budżet zasobów. Nie uruchomię niczego. Nigdy.
To od razu zabiło teorię o AWS — metryka nieudanych uruchomień u dostawcy chmury nie miała żadnych punktów danych. Amazona w ogóle nie poproszono o maszynę. Zabiło to też teorię o do-not-disrupt, bo ta flaga decyduje, czy autoskaler może usunąć istniejący węzeł. Nie ma żadnego głosu w sprawie tworzenia nowego.
Pytanie ładnie się więc wyostrzyło: dlaczego pula węzłów z dokładnie jednym węzłem uważa, że przekroczyła budżet?
Rozdział 2 — Jedno GPU, cztery duchy
Konfiguracja puli była na tyle krótka, że dało się ją przeczytać na jednym oddechu:
limits: cpu: "8" memory: 32Gi nvidia.com/gpu: "1"
A bieżący status puli raportował użycie:
nodes: 1 cpu: 4 memory: ~15Gi nvidia.com/gpu: 4 ← ?
Jeden węzeł. Jedna fizyczna karta graficzna. Cztery GPU zmierzonego użycia, przy limicie jeden.
Wyjaśnienie leżało dwa katalogi dalej, w konfiguracji sterownika urządzeń GPU:
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 4
Time-slicing. Całkowicie rozsądna technika: jedno fizyczne GPU jest ogłaszane Kubernetesowi jako cztery logiczne, żeby cztery skromne obciążenia mogły korzystać z niego na zmianę, zamiast każde żądać całej karty. Zespół włączył to celowo i działało dokładnie zgodnie z projektem.
Oto pułapka. Autoskaler nie liczy sprzętu fizycznego. Liczy to, co węzły ogłaszają. W chwili włączenia time-slicingu ten jeden węzeł zaczął deklarować cztery GPU — a limit puli równy jeden zmienił się z „limitu pozwalającego na jeden węzeł” w „ścianę trwale przekroczoną czterokrotnie”.
CO MIELI NA MYŚLI LUDZIE CO ZMIERZYŁ AUTOSKALER ┌───────────────────────────┐ ┌───────────────────────────┐ │ limit: 1 GPU │ │ limit: 1 GPU │ │ = „pozwól na 1 węzeł GPU”│ vs. │ użycie: 4 GPU │ │ │ │ = „400% ponad budżet, │ │ [ gpu-node-1 ] ✓ │ │ odmawiaj wszystkiego” │ └───────────────────────────┘ └───────────────────────────┘
I żądło: to nie był przypadek. Komentarz w repozytorium mówił to wprost — limit zaimplementowano z wykorzystaniem właśnie tego zachowania przy przekroczeniu, celowo, żeby utrzymać drogą flotę GPU na dokładnie jednej maszynie. Ktoś doskonale zrozumiał mechanizm i zbudował z niego klatkę.
Tylko klatkę bez drzwi. Pula nigdy nie mogła urosnąć — ani przy uzasadnionej potrzebie, ani w sytuacji awaryjnej, ani nigdy — a nic w systemie nie odróżniało „celowo ograniczone” od „naprawdę brak pojemności”. Każdy dashboard pojemności dla tej puli od tygodni pokazywał 400% wyczerpania, co oznaczało, że po cichu przestał być jakimkolwiek sygnałem.
Rozdział 3 — Miejsce, którego nie było
To wyjaśniało, dlaczego nie pojawił się nowy węzeł. Nie wyjaśniało bardziej podstawowego pytania: po co ten pod w ogóle go potrzebował? Węzeł GPU już działał, z dwoma z czterech slotów GPU wolnymi.
Węzeł był maszyną z 4 CPU i około 3,92 rdzenia do użycia. Na papierze:
renderer symulacji cyfrowej ... 1500m (zadeklarowane) replika workera #1 ... 1000m demony systemowe ... 500m ──────────────────────────────────────── zarezerwowane 3000m wolne 920m ← „mnóstwo miejsca”
Oczekujący pod chciał 1000m. Zabrakło mu osiemdziesięciu milirdzeni. Osiemdziesięciu. Błąd zaokrąglenia od sześciogodzinnej awarii.
Ale ciekawa nie była liczba 920, tylko to, co renderer faktycznie robił. Zadeklarował 1500m. Zmierzony przez 24 godziny zużywał płasko i niezmiennie 3,5 rdzenia.
Węzeł nie był zarezerwowany w 77%. Był w 92% faktycznie używany. Scheduler starannie liczył na liczbach, które nie miały żadnego związku z rzeczywistością, i upakował obciążenie na maszynie, na której właściwie nic nie zostało.
Nikt nie kłamał. Requesty w Kubernetesie to deklaracja — obietnica dotycząca przyszłego zachowania — a absolutnie nic w systemie nigdy nie wraca, żeby sprawdzić, czy obietnicy dotrzymano.
Rozdział 4 — Dwa złe zakręty i korekta
Trzy rzeczy prawie wysłały to śledztwo do rowu. Wszystkie trzy warto wyznać.
Zły zakręt pierwszy: wyciek pamięci, którego nie było. Wykres pamięci renderera dał kształt, który każdy inżynier rozpoznaje natychmiast — czysty liniowy wzrost z 4,4 GB do 5,8 GB w piętnaście godzin, potem urwisko. Podręcznikowy wyciek, podręcznikowy restart. Tyle że pod miał zero restartów, a dwie inne metryki były płaskie jak stół: working set przesunął się o 80 MB w ciągu tych piętnastu godzin, a RSS prawie się nie ruszył. Wzrost to był page cache, a urwisko to jądro odzyskujące go pod presją. Metryka, która wyglądała jak dymiący pistolet, po prostu liczy coś, czego większość ludzi się po niej nie spodziewa.
Zły zakręt drugi: autoskaler, który wyglądał na obłąkanego. Widok podsumowania na platformie obserwowalności pokazywał poziomy autoskaler obciążenia na memory: 29781770240% przy celu 75%. Dwadzieścia dziewięć miliardów procent. To czyta się jak katastrofalnie zepsuty komponent i bardzo kusiło, żeby na tym skończyć i zgłosić błąd. Przeczytanie surowego obiektu pokazało pamięć na 69% przy celu 75%, normalne skalowanie, pełne zdrowie. Podsumowanie wyświetlało liczbę bajtów jako procent. Wartość CPU w tej samej linii była całkowicie sensowna, i właśnie to czyniło tę od pamięci przekonującą.
Korekta: arytmetyka to nie pomiar. Proponowaną poprawką było zmniejszenie requestu CPU oczekującego obciążenia, żeby się zmieściło. Pierwsza liczba — 700m — wzięła się prosto z arytmetyki: niech się zmieści, z odrobiną zapasu. Potem ktoś zadał oczywiste pytanie, którego nikt jeszcze nie zadał: ile ten pod faktycznie zużywa?
Dziewięć milirdzeni. W bezczynności, stale. Ze szczytem około 300m przy starcie.
Rezerwował 1000m — ponad sto razy więcej niż faktyczny apetyt — na najbardziej obleganej maszynie w klastrze. Arytmetyczna odpowiedź 700m nie była zła, tylko niepotrzebnie ciasna bez żadnej korzyści. Zmierzona odpowiedź, 500m, była równie bezpieczna i oddawała węzłowi 400m.
Rozdział 5 — Niewinny podejrzany był winny czegoś innego
Co prowadzi nas z powrotem do tego grep.
Flaga do-not-disrupt nie miała nic wspólnego z awarią. Ale przy odhaczaniu jej z listy ktoś porównał flagę w pliku konfiguracji z adnotacjami na działającym podzie.
Nie było jej tam.
Chart oferował dwa podobnie wyglądające miejsca na adnotacje: jedno dołącza je do obiektu deployment, drugie do podów, które on tworzy. Flaga siedziała w pierwszym. Renderowała się poprawnie. Pojawiała się w deploymencie. Przechodziła przegląd bez uwag, zapewne więcej niż raz.
A autoskaler nigdy jej nie widział, bo czyta tę flagę wyłącznie na podach. Przez cały czas życia tej usługi obciążenie, które wszyscy uważali za chronione, było w pełni podatne na zakłócenia i nic nigdy o tym nie wspomniało.
X DEKORACJA OK NOŚNE ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │ deployment: │ │ deployment: │ │ annotations: │ │ podAnnotations: │ │ do-not-disrupt: "true" │ │ do-not-disrupt: "true" │ │ │ │ │ │ # trafia na Deployment. │ │ # trafia na Pod, jedyne miejsce, │ │ # Autoskaler tam nie zagląda. │ │ # gdzie autoskaler faktycznie │ │ # Bez błędu. Bez ostrzeżenia. │ │ # ją czyta. │ └──────────────────────────────────┘ └──────────────────────────────────┘
Dwie siostrzane usługi w tym samym repo miały to we właściwym miejscu. Ta nie. Obie konfiguracje są poprawne; obie się renderują; żadna nie zgłasza błędu. Jedynym sposobem, żeby wiedzieć, którą masz, jest spojrzeć na działający pod.
Rozdział 6 — Sprawa zamknięta, tak jakby
Wypuszczona poprawka była mała: zmniejszyć rezerwację CPU workera z 1000m do 500m, żeby obie repliki naprawdę zmieściły się na istniejącym węźle, i przenieść adnotację tam, gdzie coś ją czyta. Obie repliki wstały. Pod, który przez sześć godzin był w Pending, wystartował w kilka sekund.
Byłoby satysfakcjonująco na tym skończyć. Ale uczciwa wersja ma żądło na końcu i to żądło jest najbardziej przydatną częścią całej historii.
Prawdziwa wada nadal tam jest. Renderer deklaruje 1500m, a używa 3500m. Ta różnica pozwoliła, żeby to się stało, i pozwoli, żeby stało się znowu.
I nie da się jej naprawić osobno. Wpisz uczciwą liczbę, a budżet stanie się 3500 + 500×2 + 500 wobec dostępnych 3920 — obciążenia, które właśnie udało się umieścić, staną się nieumieszczalne i dokładnie ten sam alert odpali ponownie. Poprawienie zaniżonego requestu na nasyconej maszynie nie naprawia awarii; przenosi ją. To w ogóle nie jest edycja konfiguracji. To decyzja, czy kupić kolejną maszynę.
Była jeszcze jedna rzecz warta sprawdzenia, zanim się kogokolwiek obwini: czy to stałe spalanie 3,5 rdzenia w ogóle było uzasadnione? Wyglądało bardzo podejrzanie — martwo płaskie przez całą noc, o 3 w nocy, bez żadnych użytkowników w pobliżu środowiska deweloperskiego. Klasyczne zachowanie zawieszonego procesu.
Nie było. Przeczytanie faktycznego polecenia startowego kontenera ujawniło silnik gier 3D działający z załadowaną sceną, obok rezydentnej usługi przetwarzania assetów. Główna pętla silnika gier renderuje bez przerwy, niezależnie od tego, czy patrzy choć jeden człowiek. Nie ma stanu bezczynności, do którego mogłaby wrócić. Obciążenie zachowywało się dokładnie zgodnie z projektem — po prostu nigdy nie powiedziało schedulerowi prawdy o tym, ile ten projekt kosztuje.
Podsumowanie — Wskazówki dla kolegów śledczych
- Zapytaj komponent wprost, zanim zaczniesz przechodzić runbook. Autoskaler od sześciu godzin dwa razy na sekundę logował dokładny powód. Cztery uszeregowane hipotezy z runbooka były wolniejszą drogą do tej samej odpowiedzi — a najwyżej oceniony podejrzany akurat okazał się trafny, co byłoby czystym szczęściem, gdyby log tego nie potwierdził.
- Wszystko, co zawyża liczbę zasobów, zawyża liczbę, wobec której autoskaler egzekwuje limity. Time-slicing GPU, wirtualne CPU, nadmiarowo przydzielone extended resources — scheduler liczy to, co węzeł ogłasza, a nie to, co fizycznie zainstalowano. Sprawdź limity po włączeniu któregokolwiek z nich.
- Jeśli ograniczasz coś, czyniąc to trwale przekroczonym, oślepiasz też monitor, który to obserwuje. „Celowo ograniczone” i „naprawdę wyczerpane” stają się nieodróżnialne. Ograniczaj mechanizmem, który nadal pozwala odróżnić jedno od drugiego.
- Requesty zasobów to obietnica, której nikt nie audytuje. Pod żądający jednej trzeciej tego, czego używa, psuje każdą decyzję schedulera na swoim węźle, po cichu i na zawsze. Kiedy coś nie mieści się na węźle, który „ma miejsce”, przede wszystkim porównaj deklarowane z faktycznym.
- Nie poprawisz jednego nieuczciwego requestu w izolacji na pełnej maszynie. Uczciwa liczba wyrzuci sąsiadów. Zmierz, zdecyduj, czy kupić pojemność, czy zmniejszyć zużycie, a potem wpisz liczbę.
- Metryka wyglądająca na wyciek może liczyć page cache. Potwierdź na working set i RSS, zanim ruszysz na polowanie na wyciek. A kiedy dashboard monitoringu pokazuje liczbę w rodzaju dwudziestu dziewięciu miliardów procent, najpierw podejrzewaj dashboard, a dopiero potem system.
- Mierz, zanim dobierzesz rozmiar. Pierwsza poprawka wzięła się z arytmetyki i dawała się obronić. Zmierzona odpowiedź była lepsza, a jej zdobycie zajęło dziewięćdziesiąt sekund.
- Wszystko, co konsumuje kontroler, weryfikuj na żywym obiekcie. Nie w pliku konfiguracji, nie w wyrenderowanym szablonie, nie w zasobie nadrzędnym. Poprawne ustawienie w złym miejscu renderuje się idealnie, przechodzi przegląd bez uwag i nie robi absolutnie nic.
PRZEDTEM POTEM
┌────────────────────────────┐ ┌────────────────────────────┐
│ renderer .......... 1500m │ │ renderer .......... 1500m │
│ worker #1 ......... 1000m │ │ worker #1 ......... 500m │
│ worker #2 ...... PENDING │ ═══════► │ worker #2 ......... 500m │
│ demony ............ 500m │ │ demony ............ 500m │
│ │ │ │
│ pula doda węzeł? NIE │ │ wszystko się mieści. │
│ (limit 1, użycie 4) │ │ pula nadal nie urośnie. │
└────────────────────────────┘ └────────────────────────────┘
prawdziwa poprawka nadal otwarta ────┘