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:
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:
noisy neighbor
Czym jest CPU overcommit
Załóżmy node:
16 CPU
Mamy cztery workloady:
A request = 2 CPU
B request = 2 CPU
C request = 2 CPU
D request = 2 CPU
Scheduler widzi:
SUM(requests) = 8 CPU
czyli tylko połowę capacity node'a.
Ale każdy workload może realnie burstować do:
8 CPU
jeżeli nie posiada limitu.
Teoretyczny maksymalny demand:
4 × 8 CPU = 32 CPU
na maszynie:
16 CPU
To właśnie overcommit.
Nie przydzieliliśmy fizycznie:
32 CPU
Po prostu zakładamy:
nie wszyscy będą potrzebowali maksimum jednocześnie
Overcommit sam w sobie nie jest problemem
Załóżmy:
A uses 6 CPU
B uses 1 CPU
C uses 0.5 CPU
D uses 1 CPU
Łącznie:
8.5 CPU
na node:
16 CPU
Wszystko działa dobrze.
Workload A używa znacznie więcej niż jego:
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ć:
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:
8 CPU
Demand:
A = 8
B = 8
C = 8
D = 8
SUM = 32 CPU
Capacity:
16 CPU
Mamy:
demand = 2 × capacity
Nie da się wykonać całej pracy jednocześnie.
Taski zaczynają czekać w runqueue.
Pojawia się:
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:
Pod A request = 1 CPU
Pod B request = 4 CPU
Oba są bez limitu i oba chcą wykorzystać:
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:
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ć:
mniejszy udział w CPU,
kiedy node jest przeciążony
Noisy neighbor
Załóżmy node:
8 CPU
Działają na nim dwa workloady.
API
request = 4 CPU
normal usage = 3 CPU
Batch
request = 500m
normal usage = 200m
W normalnej sytuacji:
API = 3 CPU
Batch = 0.2 CPU
Nie ma problemu.
Teraz batch uruchamia intensywne przetwarzanie i chce:
8 CPU
Łączny demand:
API wants 4 CPU
Batch wants 8 CPU
SUM = 12 CPU
na node z:
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:
CPU usage = 3 CPU
Normalnie:
CPU usage = 3 CPU
W czasie problemu:
CPU usage = 3 CPU
Na pierwszy rzut oka nic się nie zmieniło.
Ale API może teraz chcieć:
5 CPU
i dostawać tylko:
3 CPU
Brakujące execution time materializuje się jako:
runnable waiting
Czyli:
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:
aplikacja sama zwolniła
Zaniżone requesty prowadzą do agresywnego bin packingu
Załóżmy node:
16 CPU
Mamy 16 Podów.
Każdy realnie potrzebuje podczas normalnego ruchu:
1 CPU
ale deklaruje:
request = 250m
Scheduler widzi:
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:
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:
request nie opisuje realnego zapotrzebowania
Zawyżone requesty mają przeciwny koszt
Możemy też przesadzić w drugą stronę.
Workload realnie używa:
500m
ale ma:
request = 4 CPU
Dziesięć takich workloadów:
real usage = 5 CPU
requests = 40 CPU
Scheduler potrzebuje znacznie większej liczby node'ów, niż wynikałoby z realnego usage.
Dostajemy:
słaby bin packing
niski utilization
wyższy koszt
Dlatego prawidłowy request nie jest:
jak najmniejszy
ani:
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:
API:
request = 4 CPU
brak limitu
Batch:
request = 1 CPU
brak limitu
Node:
8 CPU
Normalnie:
API = 3 CPU
Batch = 1 CPU
Pozostają:
4 CPU idle
Batch może wykorzystać tę capacity.
To dobre.
Problem zaczyna się, kiedy:
API wants = 6 CPU
Batch wants = 8 CPU
Oba workloady próbują burstować.
Demand:
14 CPU
Capacity:
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:
throughput workload
vs
latency-sensitive workload
Czy CPU limit rozwiązuje noisy neighbor?
Może.
Załóżmy:
Batch:
request = 1 CPU
limit = 2 CPU
Teraz batch nie może wykorzystać:
8 CPU
nawet jeśli chce.
Maksymalny bandwidth:
2 CPU
Chroni to pozostałe workloady przed bardzo agresywnym burstem batcha.
Ale płacimy za to inną właściwością:
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:
node = 16 CPU
Workloady normalnie potrzebują:
8 CPU
ale jeden batch potrafi wykorzystać dodatkowe:
8 CPU
Bez limitu:
normal:
8 CPU workload
8 CPU idle
batch active:
16 CPU used
Świetne wykorzystanie hardware.
Z limitem batcha:
2 CPU
podczas batch processing możemy mieć:
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ść:
dobry overcommit = 2:1
Wszystko zależy od charakterystyki workloadów.
Jeżeli masz 100 serwisów, które:
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:
Black Friday
cron o 00:00
restart po deployu
cache invalidation
nagły spike trafficu
ich bursty są skorelowane.
Wtedy założenie:
nie wszyscy potrzebują CPU jednocześnie
przestaje działać.
Correlated bursts
To szczególnie ważny przypadek.
Załóżmy dziesięć workloadów.
Każdy:
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:
request enters frontend
|
v
service A
|
+--> B
+--> C
+--> D
wzrost ruchu może jednocześnie zwiększyć demand całego grafu usług.
Wtedy:
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:
latency aplikacji rośnie
ale:
aplikacja nie jest throttlowana
Jednocześnie:
CPU PSI rośnie
oraz na node:
inne workloady mocno zwiększają CPU usage
To sygnał:
problem może być współdzielony,
nie lokalny dla jednego Poda
Najważniejsze jest porównanie zachowania:
workload
z:
resztą node'a
Jeżeli problem jednego Poda pokrywa się czasowo z dużym burstem innego, mamy bardzo mocny trop.
Model mentalny
Overcommit:
SUM(possible CPU demand)
>
physical CPU capacity
nie oznacza jeszcze problemu.
Problem zaczyna się wtedy, gdy jednocześnie:
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:
CPU request
nie jest tylko liczbą potrzebną do przejścia admission schedulera.
Wpływa na:
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.