Konrad Kowalski (rootsher)Principal Platform & Reliability Architect001010101111111100100100100001000001011111100111

Noisy neighbor i CPU overcommit: kiedy jeden Pod psuje drugi

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

CPU jest zasobem łatwym do współdzielenia.

Jeżeli workload A chwilowo nie korzysta z procesora, workload B może wykorzystać wolną capacity.

To właśnie umożliwia:

text
CPU overcommit

i sprawia, że nie musimy przydzielać każdemu workloadowi fizycznego rdzenia na wyłączność.

Problem pojawia się wtedy, gdy kilka workloadów chce burstować jednocześnie.

Wtedy jeden z nich może zacząć odbierać CPU innym.

To klasyczny:

text
noisy neighbor

Czym jest CPU overcommit

Załóżmy node:

text
16 CPU

Mamy cztery workloady:

text
A request = 2 CPU
B request = 2 CPU
C request = 2 CPU
D request = 2 CPU

Scheduler widzi:

text
SUM(requests) = 8 CPU

czyli tylko połowę capacity node'a.

Ale każdy workload może realnie burstować do:

text
8 CPU

jeżeli nie posiada limitu.

Teoretyczny maksymalny demand:

text
4 × 8 CPU = 32 CPU

na maszynie:

text
16 CPU

To właśnie overcommit.

Nie przydzieliliśmy fizycznie:

text
32 CPU

Po prostu zakładamy:

text
nie wszyscy będą potrzebowali maksimum jednocześnie

Overcommit sam w sobie nie jest problemem

Załóżmy:

text
A uses 6 CPU
B uses 1 CPU
C uses 0.5 CPU
D uses 1 CPU

Łącznie:

text
8.5 CPU

na node:

text
16 CPU

Wszystko działa dobrze.

Workload A używa znacznie więcej niż jego:

text
request = 2 CPU

i to jest całkowicie normalne.

Wykorzystuje po prostu idle capacity pozostawioną przez innych.

Overcommit jest więc mechanizmem pozwalającym zwiększyć wykorzystanie infrastruktury.

Bez niego moglibyśmy mieć:

text
requests = 16 CPU
actual usage = 5 CPU

i połowę maszyny stojącą bezczynnie.


Problem zaczyna się przy równoczesnym peak

Teraz każdy workload nagle potrzebuje:

text
8 CPU

Demand:

text
A = 8
B = 8
C = 8
D = 8

SUM = 32 CPU

Capacity:

text
16 CPU

Mamy:

text
demand = 2 × capacity

Nie da się wykonać całej pracy jednocześnie.

Taski zaczynają czekać w runqueue.

Pojawia się:

text
CPU contention

i właśnie wtedy zaczyna być istotne, jakie CPU weights mają poszczególne workloady.


Request zaczyna działać jak pozycja negocjacyjna

Załóżmy dwa Pody:

text
Pod A request = 1 CPU
Pod B request = 4 CPU

Oba są bez limitu i oba chcą wykorzystać:

text
8 CPU

Dopóki node posiada dużo wolnego CPU, oba mogą dostać więcej niż request.

Ale gdy zaczyna brakować procesora, ich względne weights wynikające z requestów zaczynają mieć znaczenie.

W uproszczeniu:

text
A : B
1 : 4

Pod B ma więc znacznie silniejszą pozycję przy contention.

To oznacza, że zaniżony request nie jest tylko problemem kube-schedulera.

Może również oznaczać:

text
mniejszy udział w CPU,
kiedy node jest przeciążony

Noisy neighbor

Załóżmy node:

text
8 CPU

Działają na nim dwa workloady.

API

text
request = 4 CPU
normal usage = 3 CPU

Batch

text
request = 500m
normal usage = 200m

W normalnej sytuacji:

text
API   = 3 CPU
Batch = 0.2 CPU

Nie ma problemu.

Teraz batch uruchamia intensywne przetwarzanie i chce:

text
8 CPU

Łączny demand:

text
API wants   4 CPU
Batch wants 8 CPU

SUM = 12 CPU

na node z:

text
8 CPU

Batch staje się noisy neighbor.

Nie dlatego, że robi coś „nielegalnego”.

Jeśli nie ma limitu, może próbować wykorzystać całą dostępną capacity.

Problem polega na tym, że jego burst zaczyna wpływać na latency API.


Dlaczego noisy neighbor może być trudny do zauważenia

Patrzymy na API:

text
CPU usage = 3 CPU

Normalnie:

text
CPU usage = 3 CPU

W czasie problemu:

text
CPU usage = 3 CPU

Na pierwszy rzut oka nic się nie zmieniło.

Ale API może teraz chcieć:

text
5 CPU

i dostawać tylko:

text
3 CPU

Brakujące execution time materializuje się jako:

text
runnable waiting

Czyli:

text
PSI rośnie
runqueue latency rośnie
latency aplikacji rośnie

Samo CPU usage aplikacji może nie wzrosnąć.

To dlatego noisy neighbor jest często błędnie diagnozowany jako:

text
aplikacja sama zwolniła

Zaniżone requesty prowadzą do agresywnego bin packingu

Załóżmy node:

text
16 CPU

Mamy 16 Podów.

Każdy realnie potrzebuje podczas normalnego ruchu:

text
1 CPU

ale deklaruje:

text
request = 250m

Scheduler widzi:

text
16 × 0.25
=
4 CPU

Czyli node wygląda na bardzo luźno wypełniony.

Może więc przyjąć kolejne Pody.

Realny demand już wynosi jednak:

text
16 CPU

czyli całą capacity.

Każdy kolejny burst prowadzi bezpośrednio do contention.

W takim systemie problem nie polega na tym, że Linux źle scheduluje.

Problem powstał wcześniej:

text
request nie opisuje realnego zapotrzebowania

Zawyżone requesty mają przeciwny koszt

Możemy też przesadzić w drugą stronę.

Workload realnie używa:

text
500m

ale ma:

text
request = 4 CPU

Dziesięć takich workloadów:

text
real usage = 5 CPU
requests   = 40 CPU

Scheduler potrzebuje znacznie większej liczby node'ów, niż wynikałoby z realnego usage.

Dostajemy:

text
słaby bin packing
niski utilization
wyższy koszt

Dlatego prawidłowy request nie jest:

text
jak najmniejszy

ani:

text
jak największy dla bezpieczeństwa

To próba opisania realnego CPU demand workloadu w sposób sensowny dla konkretnego typu aplikacji.


Batch i latency-sensitive workload na jednym node

To klasyczny przypadek noisy neighbor.

Załóżmy:

text
API:
request = 4 CPU
brak limitu

Batch:
request = 1 CPU
brak limitu

Node:

text
8 CPU

Normalnie:

text
API   = 3 CPU
Batch = 1 CPU

Pozostają:

text
4 CPU idle

Batch może wykorzystać tę capacity.

To dobre.

Problem zaczyna się, kiedy:

text
API wants = 6 CPU
Batch wants = 8 CPU

Oba workloady próbują burstować.

Demand:

text
14 CPU

Capacity:

text
8 CPU

Scheduler musi dzielić procesor.

Batch może nadal wykonywać mnóstwo pracy, ale jednocześnie zwiększać scheduler wait API.

To typowy konflikt:

text
throughput workload
vs
latency-sensitive workload

Czy CPU limit rozwiązuje noisy neighbor?

Może.

Załóżmy:

text
Batch:
request = 1 CPU
limit   = 2 CPU

Teraz batch nie może wykorzystać:

text
8 CPU

nawet jeśli chce.

Maksymalny bandwidth:

text
2 CPU

Chroni to pozostałe workloady przed bardzo agresywnym burstem batcha.

Ale płacimy za to inną właściwością:

text
jeśli node ma 6 CPU idle,
batch nadal może użyć maksymalnie 2 CPU

Czyli limit poprawia izolację kosztem możliwości wykorzystania idle capacity.

To jest trade-off.


Bez limitów wykorzystujemy CPU efektywniej

Załóżmy:

text
node = 16 CPU

Workloady normalnie potrzebują:

text
8 CPU

ale jeden batch potrafi wykorzystać dodatkowe:

text
8 CPU

Bez limitu:

text
normal:
8 CPU workload
8 CPU idle

batch active:
16 CPU used

Świetne wykorzystanie hardware.

Z limitem batcha:

text
2 CPU

podczas batch processing możemy mieć:

text
10 CPU used
6 CPU idle

mimo że aplikacja ma pracę do wykonania.

Limity dają przewidywalność.

Brak limitów daje elastyczność.


CPU overcommit jest więc decyzją capacity-planningową

Nie istnieje uniwersalna wartość:

text
dobry overcommit = 2:1

Wszystko zależy od charakterystyki workloadów.

Jeżeli masz 100 serwisów, które:

text
przez większość czasu używają 10% peak CPU

duży overcommit może działać bardzo dobrze.

Jeżeli wszystkie skalują się od tego samego eventu:

text
Black Friday
cron o 00:00
restart po deployu
cache invalidation
nagły spike trafficu

ich bursty są skorelowane.

Wtedy założenie:

text
nie wszyscy potrzebują CPU jednocześnie

przestaje działać.


Correlated bursts

To szczególnie ważny przypadek.

Załóżmy dziesięć workloadów.

Każdy:

text
average = 500m
peak    = 2 CPU

Jeżeli peaki pojawiają się losowo, średnio możemy dobrze współdzielić capacity.

Ale jeżeli wszystkie reagują na ten sam traffic:

text
request enters frontend
       |
       v
service A
       |
       +--> B
       +--> C
       +--> D

wzrost ruchu może jednocześnie zwiększyć demand całego grafu usług.

Wtedy:

text
peak A
peak B
peak C
peak D

nie są niezależne.

Overcommit oparty tylko na średnim CPU może wyglądać świetnie aż do momentu prawdziwego ruchu produkcyjnego.


Jak rozpoznać noisy neighbor

Typowy obraz:

text
latency aplikacji rośnie

ale:

text
aplikacja nie jest throttlowana

Jednocześnie:

text
CPU PSI rośnie

oraz na node:

text
inne workloady mocno zwiększają CPU usage

To sygnał:

text
problem może być współdzielony,
nie lokalny dla jednego Poda

Najważniejsze jest porównanie zachowania:

text
workload

z:

text
resztą node'a

Jeżeli problem jednego Poda pokrywa się czasowo z dużym burstem innego, mamy bardzo mocny trop.


Model mentalny

Overcommit:

text
SUM(possible CPU demand)
>
physical CPU capacity

nie oznacza jeszcze problemu.

Problem zaczyna się wtedy, gdy jednocześnie:

text
actual runnable demand
>
physical CPU capacity

Wtedy pojawia się contention.

Noisy neighbor to workload, którego zmiana demand powoduje pogorszenie warunków wykonywania innych workloadów współdzielących ten sam zasób.

Najważniejsze:

text
CPU request

nie jest tylko liczbą potrzebną do przejścia admission schedulera.

Wpływa na:

text
placement
bin packing
relative CPU weight
zachowanie pod contention

Zbyt niskie requesty pozwalają bardzo efektywnie wykorzystać klaster - aż do momentu, kiedy realny demand pojawi się jednocześnie.

I wtedy overcommit przestaje być optymalizacją.

Staje się kolejką runnable tasków.