Konrad Kowalski (rootsher)Principal Platform & Reliability Architect001000100110110101001000010010111110101110111100

Anatomia problemu CPU: od zaniżonego requestu do p99 latency

data
kategoria
Capacity & Performance
także w
Containers · Observability
czytanie
4 min / 746 słów

Załóżmy prosty serwis HTTP działający w Kubernetes.

Konfiguracja:

yaml
resources:
  requests:
    cpu: 500m

Brak CPU limitu.

Aplikacja zwykle zużywa:

text
300-400m

więc request 500m wygląda rozsądnie.

p99 latency:

text
35 ms

Przez większość dnia wszystko działa poprawnie.

Potem przychodzi peak traffic.

CPU usage Poda rośnie do:

text
900m

a p99 nagle skacze:

text
35 ms -> 180 ms

Pierwsza reakcja:

text
CPU nadal nie wygląda dramatycznie.
Nie ma limitu.
Dlaczego aplikacja zwolniła?

Żeby odpowiedzieć, trzeba przejść całą drogę od schedulera Kubernetes do schedulera Linuksa.


Krok 1: scheduler uwierzył requestowi

Node ma:

text
16 CPU

Na node działa 20 Podów.

Ich suma requestów:

text
10 CPU

Dla kube-schedulera sytuacja wygląda zdrowo:

text
requested capacity = 10 CPU
node capacity      = 16 CPU

Nasz serwis deklaruje:

text
500m

więc scheduler traktuje każdą jego replikę jako workload potrzebujący pół CPU.

Problem w tym, że przy peak traffic realny demand jednej repliki nie wynosi już:

text
500m

tylko na przykład:

text
1.5 CPU

Request nie ogranicza workloadu, więc aplikacja może próbować użyć więcej.

Ale scheduler podczas placementu nie wiedział, że kilka workloadów jednocześnie będzie potrzebować znacznie więcej niż deklarowały.


Krok 2: realny demand przekracza capacity

W czasie normalnego ruchu node wygląda tak:

text
capacity = 16 CPU
demand   = 8 CPU

Dużo wolnej mocy.

Podczas peak:

text
API pods      -> 10 CPU
batch jobs    -> 4 CPU
inne serwisy  -> 7 CPU

Łącznie:

text
demand = 21 CPU

na:

text
capacity = 16 CPU

Brakuje:

text
5 CPU

Linux nie może wykonać 21 CPU pracy równolegle na 16 logical CPUs.

Nie istnieje żadna ukryta rezerwa.

Część tasków musi czekać.


Krok 3: nie ma throttlingu

Nasz Pod nie posiada:

yaml
limits:
  cpu:

czyli nie ma skończonego CPU bandwidth limitu.

W cgroup v2 będzie to odpowiadać sytuacji typu:

text
cpu.max = max ...

Sprawdzamy cpu.stat.

Nie obserwujemy rosnącego:

text
nr_throttled

Wniosek:

text
kernel nie zatrzymuje aplikacji
z powodu wyczerpania quota

To ważne.

Gdybyśmy w tym momencie automatycznie zwiększyli CPU limit, niczego byśmy nie naprawili.

Limitu przecież nie ma.

Problem znajduje się gdzie indziej.


Krok 4: task staje się runnable

Przychodzi request HTTP.

Worker aplikacji dostaje pracę:

text
request
   |
   v
thread becomes runnable

Normalnie scheduler bardzo szybko daje mu CPU:

text
runnable
   |
   v
running

Załóżmy, że obsługa requestu potrzebuje łącznie:

text
6 ms CPU time

Na zdrowym node wykonanie może wyglądać tak:

text
0 ms             6 ms
|-----------------|
      running

Latency CPU-related:

text
~6 ms

Podczas contention sytuacja wygląda inaczej.


Krok 5: runnable nie oznacza running

Node ma dużo konkurujących tasków.

Nasz thread staje się runnable:

text
t = 0 ms

ale scheduler nie daje mu CPU od razu.

Dostajemy:

text
0 ms       8 ms
|-----------|
 runnable
 waiting

Potem thread wykonuje:

text
8 ms -> 10 ms

czyli:

text
2 ms CPU time

Następuje kolejne przełączenie.

Thread ponownie czeka:

text
10 ms -> 17 ms

Potem wykonuje kolejną porcję pracy.

Całość może wyglądać tak:

text
0------8--10-------17----21
| WAIT |RUN| WAIT  | RUN |

CPU time aplikacji:

text
6 ms

Wall-clock time:

text
21 ms

Kod aplikacji nie stał się trzy razy droższy.

Po prostu wykonanie tych samych 6 ms CPU zostało rozciągnięte przez scheduler wait.


Krok 6: CPU usage nie pokazuje brakującego czasu

To najważniejszy moment całego incydentu.

Monitoring pokazuje:

text
Pod CPU usage = 900m

Można pomyśleć:

text
przecież nawet nie używa całego CPU

Ale usage mówi:

text
ile CPU time workload faktycznie dostał

Nie mówi:

text
ile CPU workload chciał dostać

Jeżeli aplikacja chciała średnio:

text
1.5 CPU

ale przez contention otrzymywała:

text
0.9 CPU

metryka usage pokaże:

text
0.9 CPU

Brakujące:

text
0.6 CPU

nie pojawi się jako usage.

To niewykonana praca czekająca na scheduler.


Krok 7: PSI pokazuje pressure

Patrzymy na CPU PSI.

Przed incydentem:

text
some avg10=1.20

Podczas problemu:

text
some avg10=34.00

To zupełnie inna informacja niż:

text
CPU usage = 900m

PSI mówi nam, że przez znaczną część czasu runnable work doświadcza CPU stall.

Czyli:

text
aplikacja chce się wykonywać
+
scheduler nie może od razu dać jej CPU

Mamy już mocny dowód na:

text
CPU contention

Krok 8: runqueue latency potwierdza mechanizm

Jeżeli potrzebujemy dokładniejszego potwierdzenia, patrzymy na moment:

text
sched_wakeup

oraz:

text
sched_switch

Przykład:

text
thread runnable:
14:00:00.100

thread running:
14:00:00.108

Scheduler delay:

text
8 ms

Dla requestu HTTP, którego własna praca CPU wynosi kilka milisekund, dodatkowe:

text
5-10 ms

oczekiwania przy wielu kolejnych wakeupach może dramatycznie zwiększyć końcową latency.


Dlaczego szczególnie rośnie p99

Nie każdy request trafia na identyczną sytuację.

Jeden request może dostać CPU niemal natychmiast:

text
scheduler wait = 0.2 ms

Inny:

text
scheduler wait = 3 ms

Jeszcze inny trafia w najgorszy moment:

text
scheduler wait = 15 ms

Średnia latency może więc wzrosnąć umiarkowanie.

Ale tail:

text
p95
p99
p99.9

zaczyna rosnąć dużo szybciej.

Przykład:

text
before:

p50 = 15 ms
p95 = 25 ms
p99 = 35 ms

podczas contention:

text
p50 = 22 ms
p95 = 80 ms
p99 = 180 ms

To typowy charakter problemów kolejkowych.

Im bardziej przeciążony jest zasób, tym bardziej nierówny staje się czas oczekiwania poszczególnych requestów.


Gdzie w tym wszystkim był request CPU

Wracamy do początku:

yaml
requests:
  cpu: 500m

Request nie spowodował bezpośrednio:

text
p99 = 180 ms

Ale wpłynął na dwa istotne elementy systemu.

Placement

Scheduler policzył workload jako:

text
0.5 CPU

Jeżeli realny steady-state albo peak demand był znacznie większy, node mógł zostać zapełniony bardziej agresywnie, niż sugerowała rzeczywista charakterystyka workloadów.

CPU weight

Pod contention request wpływa również na względną wagę workloadu.

Zaniżony request może więc oznaczać:

text
agresywniejszy placement
+
słabszą pozycję podczas konkurencji

To nie jest gwarancja problemu.

Ale oba efekty działają w niekorzystnym kierunku, kiedy CPU zaczyna brakować.


Noisy neighbor może uruchomić cały incydent

Załóżmy, że API samo się nie zmieniło.

Problem zaczyna się o 14:00, ponieważ na tym samym node batch workload przechodzi z:

text
200m

do:

text
6 CPU

Batch nie ma limitu.

Ma wolne prawo próbować wykorzystać dostępne CPU.

Przed 14:00:

text
node demand < capacity

Po 14:00:

text
node demand > capacity

API zaczyna czekać w runqueue.

Z jego punktu widzenia:

text
kod ten sam
traffic podobny
CPU usage podobne
p99 eksploduje

Problem nie znajduje się wyłącznie „wewnątrz Poda”.

Znajduje się we współdzielonym zasobie.


Jak wygląda pełna diagnoza

Mamy symptom:

text
p99 latency ↑

Sprawdzamy usage:

text
CPU usage = 900m

Nie wystarcza do diagnozy.

Sprawdzamy limit:

text
brak

Sprawdzamy throttling:

text
nr_throttled nie rośnie

Czyli:

text
to nie quota

Sprawdzamy PSI:

text
CPU pressure ↑

Czyli:

text
runnable work czeka

Sprawdzamy node:

text
inne workloady burstują

Sprawdzamy scheduler latency:

text
runnable -> running delay ↑

Diagnoza:

text
CPU contention
spowodowany przeciążeniem wspólnego node'a

Nie:

text
CPU limit jest za niski

Nie:

text
aplikacja nagle zużywa więcej instrukcji

Nie:

text
900m CPU to za dużo

Tylko konkretny mechanizm.


Cała ścieżka

Incydent można sprowadzić do:

text
zaniżone / agresywne requests
        |
        v
gęsty bin packing
        |
        v
równoczesny burst workloadów
        |
        v
CPU demand > CPU capacity
        |
        v
więcej runnable tasks niż dostępnego CPU
        |
        v
runqueue
        |
        v
scheduler latency
        |
        v
request execution rozciągnięte w czasie
        |
        v
p99 latency ↑

A obserwowalność tej samej ścieżki:

text
CPU usage
    |
    | nie pokazuje całego demand
    v
cpu.stat
    |
    | brak throttlingu
    v
CPU PSI
    |
    | pressure rośnie
    v
scheduler / runqueue latency
    |
    v
potwierdzone CPU contention

To jest najważniejszy wniosek całej serii:

text
"problem z CPU"

nie jest jedną rzeczą.

Może oznaczać:

text
za mało CPU capacity
zły request
CPU throttling
CPU contention
noisy neighbor
zły placement na hardware

Dlatego debugowanie CPU zaczyna się nie od pytania:

text
ile procent CPU widzę?

ale od:

text
czy workload dostał CPU wtedy,
kiedy chciał wykonać pracę?

Jeżeli nie - kolejne pytanie brzmi:

text
co dokładnie mu w tym przeszkodziło?

I dopiero wtedy mamy diagnozę.