Konrad Kowalski (rootsher)Principal Platform & Reliability Architect111011001101011001101010001101100001001010101001

Dlaczego 1 CPU nie oznacza jednego rdzenia

data
kategoria
Containers
także w
Computer Science
czytanie
4 min / 721 słów

W Kubernetes bardzo łatwo zbudować sobie taki model:

yaml
resources:
  requests:
    cpu: "1"
  limits:
    cpu: "1"

czyli:

text
ten kontener działa na jednym rdzeniu

To nie jest poprawne.

1 CPU w Kubernetes opisuje ilość CPU capacity, a nie konkretny procesor.

Jeżeli workload ma limit 1 CPU, kernel ogranicza jego łączny CPU time do odpowiedniego bandwidthu. Nie oznacza to jednak, że wszystkie jego taski muszą wykonywać się na jednym konkretnym logical CPU.

To są dwa różne problemy:

text
ile CPU time mogę zużyć?

oraz:

text
na których CPU mogę się wykonywać?

CPU time i CPU placement to osobne mechanizmy

Załóżmy node:

text
CPU 0
CPU 1
CPU 2
CPU 3
CPU 4
CPU 5
CPU 6
CPU 7

Kontener ma:

text
limit = 1 CPU

Nie oznacza to:

text
container -> CPU 3

Może wyglądać tak:

text
t0: thread A -> CPU 2
t1: thread A -> CPU 5
t2: thread A -> CPU 1
t3: thread A -> CPU 7

Scheduler Linuksa może migrować task pomiędzy logical CPUs.

Limit nadal pozostaje:

text
1 CPU

bo dotyczy sumarycznego CPU time, a nie identyfikatora procesora.


Wielowątkowa aplikacja jeszcze lepiej pokazuje różnicę

Załóżmy:

text
limit = 1 CPU

ale aplikacja ma cztery runnable thready:

text
T1
T2
T3
T4

Kernel może przez pewien czas wykonywać je równolegle:

text
CPU 0 -> T1
CPU 1 -> T2
CPU 2 -> T3
CPU 3 -> T4

Jeżeli wszystkie cztery wykonują się przez:

text
25 ms

to workload zużył:

text
4 × 25 ms
=
100 ms CPU time

Czyli przy quota odpowiadającej 1 CPU i periodzie 100 ms mógł właśnie wykorzystać cały swój budżet.

To pokazuje fundamentalną rzecz:

text
limit = 1 CPU

nie znaczy:

text
maksymalnie jeden thread może być running

Znaczy:

text
łączny CPU bandwidth odpowiada jednemu CPU

Logical CPU

Kiedy Kubernetes mówi:

text
1 CPU

operuje na jednostce odpowiadającej CPU widocznemu dla systemu operacyjnego.

Na typowej maszynie x86 z SMT system może widzieć na przykład:

text
16 physical cores
32 logical CPUs

Linux ponumeruje je mniej więcej:

text
CPU 0
CPU 1
...
CPU 31

Scheduler operuje właśnie na tych logical CPUs.

Dlatego:

text
1 CPU

w Kubernetes nie oznacza automatycznie:

text
jeden fizyczny core

To rozróżnienie stanie się szczególnie istotne przy SMT, ale na tym etapie wystarczy:

text
Kubernetes CPU
≈ logical CPU capacity widoczna dla systemu

Kto więc decyduje, gdzie task się wykonuje?

Linux scheduler.

Jeżeli task może wykonywać się na wielu CPU, scheduler wybiera konkretny procesor.

Może również później przenieść task:

text
CPU 2 -> CPU 6

czyli wykonać CPU migration.

Powodów może być wiele, na przykład próba zbalansowania runnable work pomiędzy CPU.

Z punktu widzenia limitu nic szczególnego się nie wydarzyło.

Task nadal zużywa ten sam wspólny CPU-time budget swojej cgroup.

Zmieniło się tylko:

text
gdzie był wykonywany

CPU affinity

Jeżeli chcemy ograniczyć task do określonych CPU, potrzebujemy osobnego mechanizmu:

text
CPU affinity

Możemy na przykład powiedzieć:

text
task może wykonywać się tylko na CPU 2 i CPU 3

Conceptually:

text
allowed CPUs = {2,3}

Scheduler nadal decyduje:

text
CPU 2 czy CPU 3?

ale nie może przenieść taska na:

text
CPU 0
CPU 1
CPU 4
...

Affinity odpowiada więc na pytanie:

text
gdzie task może być wykonywany?

Nie:

text
ile CPU time może zużyć?

To kolejny niezależny wymiar.


cpuset

Dla grup procesów Linux posiada controller:

text
cpuset

W cgroup v2 możemy spotkać:

text
cpuset.cpus

Na przykład:

text
2-3

oznacza, że taski należące do tej cgroup mają być wykonywane na CPU należących do tego zestawu.

Kernel dokumentuje cpuset.cpus jako listę CPU, które mogą być używane przez taski z danej cgroup; efektywny zestaw jest dodatkowo ograniczany przez hierarchię nadrzędnych cgroups.

Przykład:

text
cpuset.cpus = 4-5

daje:

text
T1 -> CPU 4
T2 -> CPU 5

ale nie:

text
T1 -> CPU 8

To jest faktyczne ograniczenie placementu.


Limit i cpuset mogą działać jednocześnie

Załóżmy cgroup:

text
cpuset.cpus = 4-5

oraz:

text
CPU limit = 1

Mamy więc dwa niezależne ograniczenia.

Placement

text
workload może wykonywać się tylko na:

CPU 4
CPU 5

Bandwidth

text
workload może średnio zużywać
odpowiednik 1 CPU

Czyli dwa thready mogą wykonywać się równolegle:

text
CPU 4 -> T1
CPU 5 -> T2

ale wspólnie nadal konsumują jeden CPU-time budget.

Na przykład:

text
T1 = 50 ms
T2 = 50 ms

daje:

text
100 ms CPU time

Request też nie definiuje placementu

Analogicznie:

yaml
resources:
  requests:
    cpu: "1"

nie oznacza:

text
zarezerwuj dla mnie CPU #7

Jak wiemy z poprzednich artykułów, request odpowiada za:

text
resource accounting kube-schedulera
+
względną wagę CPU przy contention

Nie za lokalizację taska na konkretnym procesorze.

Dlatego zwykły Pod z:

yaml
requests:
  cpu: "1"

może wykonywać swoje taski na różnych CPU node'a.


Kiedy Kubernetes rzeczywiście przydziela konkretne CPU?

Do tego służy CPU Manager kubeleta.

Domyślna polityka nie daje zwykłym Podom dedykowanych CPU.

Natomiast polityka:

text
static

umożliwia przydzielanie wybranym kontenerom exclusive CPUs.

W uproszczeniu, do takiego przydziału kwalifikują się kontenery należące do Podów Guaranteed, które posiadają całkowitoliczbowe CPU requests. Workloady BestEffort, Burstable oraz Guaranteed z fractional CPU requests pozostają we współdzielonej puli CPU.

Przykład:

yaml
resources:
  requests:
    cpu: "2"
  limits:
    cpu: "2"

przy odpowiedniej konfiguracji CPU Managera może skończyć się przypisaniem:

text
CPU 6
CPU 7

dla tego kontenera.

Wtedy faktycznie wchodzimy w świat:

text
CPU placement

a nie tylko:

text
CPU bandwidth

„Exclusive” też wymaga precyzji

Nawet określenie:

text
exclusive CPU

nie znaczy bezwarunkowo:

text
nikt poza moim procesem nigdy nie wykona tam instrukcji

Dokumentacja Kubernetes zaznacza, że ekskluzywność CPU Managera dotyczy innych Podów. Procesy systemowe, takie jak kubelet czy container runtime, nadal mogą wykonywać pracę na takich CPU.

Czyli:

text
exclusive dla workload placement

niekoniecznie oznacza:

text
pełna izolacja CPU od całego systemu operacyjnego

Pełniejsza izolacja procesora jest osobnym problemem.


Fractional CPU i integer CPU

To rozróżnienie jest istotne.

Request:

text
500m

jest fractional.

Nie istnieje coś takiego jak:

text
pół logical CPU

które można fizycznie wydzielić przez cpuset.

Dlatego fractional CPU naturalnie działa jako:

text
time sharing

Przykład:

text
500m

może oznaczać wykonanie taska:

text
na CPU 1
potem CPU 5
potem CPU 2

przy odpowiednim udziale CPU time.

Natomiast:

text
2 CPU

może - przy odpowiedniej polityce CPU Managera - zostać odwzorowane na dwa konkretne logical CPUs.


Trzy osobne pytania

Najlepiej rozdzielić wszystkie poznane mechanizmy na trzy pytania.

Ile deklaruję?

text
requests.cpu

Przykład:

text
500m

Ile maksymalnie mogę zużyć?

text
limits.cpu

Przykład:

text
1 CPU

Gdzie mogę się wykonywać?

text
affinity / cpuset

Przykład:

text
CPU 4-5

To trzy różne konfiguracje.

Możemy więc mieć workload:

text
request = 500m
limit   = 1 CPU
cpuset  = CPU 4-5

co oznacza w uproszczeniu:

text
Kubernetes accounting:
0.5 CPU

CPU bandwidth:
maksymalnie 1 CPU

CPU placement:
tylko CPU 4 i 5

Te wartości nie są ze sobą sprzeczne.

Opisują inne właściwości wykonania.


Model mentalny

Nie myśl:

text
1 CPU
=
jeden konkretny rdzeń

Myśl:

text
CPU request / limit
=
ilość CPU capacity / CPU time

Natomiast:

text
CPU affinity / cpuset
=
zbiór CPU, na których task może się wykonywać

Dlatego:

yaml
limits:
  cpu: "1"

nie przypina procesu do jednego CPU.

A:

text
cpuset.cpus = 4

nie oznacza automatycznie limitu:

text
1 CPU bandwidth

Jedno kontroluje czas wykonania.

Drugie kontroluje miejsce wykonania.

Dopiero gdy świadomie połączymy te mechanizmy, możemy mówić o rzeczywistym przypisaniu workloadu do konkretnych CPU.