Sprawa węzłów, które nie mogły usiedzieć w miejscu

Krótka historia detektywistyczna ze świata autoskalowania Kubernetesa.

 ┌────────────────────────────────────────────────────────────┐
 │                     TABLICA ŚLEDCZA                        │
 ├────────────────────────────────────────────────────────────┤
 │                                                            │
 │   [ node-a ]        [ node-b ]        [ node-c ]           │
 │    wiek: 74d         wiek: 10m         wiek: 3m            │
 │       |                  |                  |              │
 │       |                  '--------.---------'              │
 │       |                           |                        │
 │       |                     ┌─────▼─────┐                  │
 │       |                     │PODEJRZANY │                  │
 │       |                     │consolidate│                  │
 │       |                     │ After: 1m │                  │
 │       |                     └─────┬─────┘                  │
 │       |                           |                        │
 │       |                     ┌─────▼─────┐                  │
 │       '-------------------->│   MOTYW   │                  │
 │                             │ $0.23/h   │                  │
 │                             │„oszczędn.”│                  │
 │                             └───────────┘                  │
 └────────────────────────────────────────────────────────────┘

Zgłoszenie

Zaczęło się od mglistego, niepokojącego zgłoszenia od zespołu platformowego Firmy:

„Coś jest nie tak z naszymi węzłami. Ciągle... znikają. I pojawiają się z powrotem. I znowu znikają.”

Żadne alarmy nie wyły. Nikt nie ogłosił awarii. Ale coś było nie tak — ten rodzaj lekkiego dyskomfortu, któremu doświadczeni inżynierowie uczą się ufać. Pody były przeplanowywane dużo częściej, niż powinny. Tu i ówdzie drobne zakłócenia. Nic nie leżało, ale nic nie wydawało się też stabilne.

Podejrzany: Karpenter, autoskaler odpowiedzialny za tworzenie i usuwanie węzłów w klastrze Kubernetesa Projektu na podstawie bieżącego zapotrzebowania.

Przezwisko, które zespół nadał objawowi, mówiło wszystko: node flapping, czyli trzepoczące węzły.

Rozdział 1 — Oględziny miejsca zbrodni

Zanim cokolwiek ruszyć, pierwszym krokiem było przeczytanie konfiguracji w obecnym stanie — bez założeń, tylko fakty na papierze.

Dwa pliki opowiadały dwie połowy historii:

W regułach zakłóceń (disruption) ukryte było ustawienie, które wyglądało niemal zbyt prosto, żeby budzić podejrzenia:

consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 1m

W przekładzie na zwykły język: „Jeśli węzeł wygląda na niedociążony, odczekaj tylko minutę, a potem zacznij go rozbierać i przepakowywać jego pody gdzie indziej”.

Jedna minuta. Ledwie tyle, żeby świeżo uruchomiony węzeł złapał oddech, nie mówiąc o osiągnięciu stabilnego, reprezentatywnego poziomu wykorzystania.

To był trop. Ale trop to nie dowód. Czas iść tam, gdzie są prawdziwe dowody: do samego klastra.

     PLIK MÓWIŁ...                          KLASTER MÓWIŁ...
  ┌───────────────────────────┐          ┌───────────────────────────┐
  │ consolidationPolicy:      │          │ $ kubectl get nodes       │
  │   WhenEmptyOrUnderutilized│   vs.    │                           │
  │ consolidateAfter: 1m      │ ───────► │ NAME     AGE              │
  │                           │          │ node-a   74d              │
  │ „wygląda dość niewinnie”  │          │ node-b   10m              │
  │                           │          │ node-c    3m              │
  │                           │          │ node-d   50s   <-- ???    │
  └───────────────────────────┘          └───────────────────────────┘

Rozdział 2 — Śledzenie tropu na żywo

Z dostępem do klastra tylko do odczytu śledztwo przeszło od teorii do obserwacji.

Pierwszy trop: wiek węzłów.

NAME              AGE
node-a            74d
node-b            10m
node-c            3m
node-d            50s

Niektóre węzły spokojnie działały od miesięcy. Inne były ledwie na tyle stare, żeby mieć nazwę. To nie jest zdrowy wzorzec autoskalowania — to drzwi obrotowe.

Drugi trop: logi samego autoskalera. I tu historia zmieniła się z „podejrzanej” w „sprawę oczywistą”.

Oś czasu w logach czytała się niemal jak przyznanie się do winy:

  1. 08:07 — zupełnie nowy węzeł kończy start i dołącza do klastra.
  2. 08:08 — inny węzeł, ledwie 4-minutowy, zostaje usunięty jako „niedociążony”.
  3. 08:09 — autoskaler decyduje, że kolejny węzeł też trzeba zakłócić — dla prognozowanej oszczędności 0,23 USD na godzinę.
  4. W ciągu kilku sekund zamówiony zostaje węzeł zastępczy, uruchomiony i dołączony.
  5. Powtórz. Co jedną, dwie minuty.

Trzeci trop, ten, który uczynił sprawę osobistą: zdarzenia klastra pokazywały mniej więcej tuzin podów na cykl — agentów monitoringu, workerów backendu, procesy w tle — eksmitowanych z powodem Underutilized, raz za razem, co kilka minut. Jedno obciążenie w szczególności, stanowa usługa przypominająca bazę danych, oparta na trwałym dysku, ciągle nie mogła się czysto przeplanować, bo jej wolumen był przypięty do konkretnej strefy dostępności, a rotacja wysyłała ją na węzły, które do niej nie pasowały.

Obraz był teraz kompletny: autoskaler nie reagował na prawdziwe obciążenie. Gonił własny ogon — konsolidował, eksmitował, odbudowywał i od razu rewidował własną decyzję, dla groszowych oszczędności.

              ┌─────────────────────────────┐
              │   1. Startuje nowy węzeł    │
              └──────────────┬──────────────┘
                              │
                              ▼
              ┌─────────────────────────────┐
        ┌────►│   2. Czekaj... minutę       │
        │     └──────────────┬──────────────┘
        │                    ▼
        │     ┌─────────────────────────────┐
        │     │ 3. „Niedociążony!”          │
        │     │    węzeł do usunięcia       │
        │     └──────────────┬──────────────┘
        │                    ▼
        │     ┌─────────────────────────────┐
        │     │ 4. Pody eksmitowane,        │
        │     │    węzeł usunięty           │
        │     └──────────────┬──────────────┘
        │                    ▼
        │     ┌─────────────────────────────┐
        └─────┤ 5. Zamówiony zastępca...    │
              │    wróć do kroku 1          │
              └─────────────────────────────┘

Rozdział 3 — Pierwsza „poprawka”, która nią nie była

Żeby przerwać pętlę, zaproponowano dwie zmiany:

  1. Dać węzłom odetchnąć — podnieść consolidateAfter z 1 minuty do 10 minut, żeby Karpenter reagował tylko na utrzymujące się niedociążenie, a nie na chwilowy spadek.
  2. Chronić wrażliwe obciążenie — oznaczyć stanową, przypiętą do strefy usługę jako „nie przeszkadzać”, żeby autoskaler nigdy nie wybierał jej węzła do usunięcia.

Druga zmiana została zastosowana — mała adnotacja dodana do konfiguracji obciążenia, na wzór podobnych adnotacji w innych miejscach tego samego repozytorium.

Wyglądała dobrze. Przeszła przegląd bez uwag. Została nawet scalona.

I nie zrobiła nic.

Wyłapała to osoba odpowiedzialna za to obciążenie: adnotację umieszczono o jeden poziom za płytko w drzewie konfiguracji. Okazuje się, że „wygląda spójnie z sąsiednimi plikami” to nie to samo co „zgadza się z tym, co faktycznie czyta szablon chartu Helm”. Dwa podobnie wyglądające obciążenia (usługa webowa i stanowa baza danych) używały dwóch różnych zagnieżdżonych ścieżek dla dokładnie tego samego ustawienia — i dopiero przeczytanie źródłowego szablonu chartu pokazało, która obowiązuje tutaj.

Lekcja odnotowana: w razie wątpliwości czytaj szablon, a nie tylko sąsiednie przykłady. Poprawkę skorygowano, zweryfikowano linia po linii ze źródłem chartu i za drugim razem wypuszczono prawidłowo.

   X ŹLE (po cichu ignorowane)                  OK DOBRZE (to czyta chart)
  ┌────────────────────────────────────────────┐  ┌────────────────────────────────────────────┐
  │                                            │  │ statefulset:                               │
  │ podAnnotations:                            │  │   podAnnotations:                          │
  │   karpenter.sh/do-not-disrupt: "true"      │  │     karpenter.sh/do-not-disrupt: "true"    │
  │                                            │  │                                            │
  │ # najwyższy poziom — szablon StatefulSet   │  │ # zagnieżdżone pod `statefulset:` —        │
  │ # chartu nigdy nie czyta tego klucza       │  │ # dokładnie tego oczekuje template.yaml    │
  └────────────────────────────────────────────┘  └────────────────────────────────────────────┘

Rozdział 4 — Sprawa zamknięta (z dodatkowym znaleziskiem)

Z obiema poprawkami na produkcji klaster był obserwowany przez kolejny czas. Wyniki mówiły same za siebie:

W bonusie śledztwo wyciągnęło na wierzch drugi, niezwiązany, ale długo trwający kłopot: jeden z potoków wdrożeniowych stale oznaczał stanowe obciążenie jako „out of sync” — kosmetyczny fałszywy alarm, bo Kubernetes zawsze raportuje puste pole znacznika czasu, którego konfiguracja źródłowa nigdy nie zawierała. To znana przypadłość, a poprawka na nią istniała już gdzie indziej w tym samym projekcie — tylko nie została jeszcze przeniesiona do tego potoku. Dziesięć minut porównywania zamknęło i tę lukę.

Podsumowanie — Wskazówki dla kolegów śledczych

Jeśli używasz autoskalowania obciążeń w Kubernetesie (Karpentera lub innego), kilka wniosków zdobytych z trudem:

  1. Bardzo krótkie okno „consolidate after” to pozorna oszczędność. Kilka centów na godzinę nie jest warte destabilizowania obciążeń co parę minut. Daj autoskalerowi czas na zaobserwowanie utrzymującego się niedociążenia, zanim zacznie działać.
  2. Obciążenia stanowe i przypięte do strefy potrzebują jawnej ochrony. Jeśli storage poda nie może po prostu przenieść się na dowolny węzeł, powiedz autoskalerowi, żeby zostawił jego węzeł w spokoju — nie licz, że ogólna polityka zakłóceń „sama się domyśli”.
  3. Czytaj żywy stan klastra, nie tylko konfigurację. Konfiguracja mówi o zamiarze. Logi i zdarzenia mówią, co faktycznie się dzieje. Dymiący pistolet w tej historii (0,23 USD/h oszczędności za chaos w całym klastrze) pojawił się tylko w logach.
  4. Nie ufaj „wygląda jak plik obok”. Skopiowanie wzorca z podobnie wyglądającej konfiguracji to świetny punkt startu i fatalne ostateczne źródło prawdy. Kiedy ustawienie zdaje się nie działać, idź prosto do szablonu albo dokumentacji, które go faktycznie używają.
  5. Skoro już tam jesteś, rozejrzyj się. Niektóre z najlepszych poprawek to nie te, których szukałeś — tylko te, o które potknąłeś się po drodze.

   PRZEDTEM: drzwi obrotowe              POTEM: cichy, stabilny klaster
  ┌───────────────────────────┐         ┌───────────────────────────┐
  │ node-a ............   50s │         │ node-a ............   74d │
  │ node-b ............    3m │  ═════► │ node-b ............   74d │
  │ node-c ............   10m │         │ node-c ............   62d │
  │ node-d ............    2m │         │ node-d ............   41d │
  │  rotacja co 1-2 minuty    │         │   nudnie stabilnie        │
  └───────────────────────────┘         └───────────────────────────┘