środa, 15 czerwca 2022

Analiza błędów OutOfMemory w Linuksie

   Każdy administrator Linuxa, prędzej czy później zobaczy w logach błąd mówiący o tym, że system Linuks wyczerpał całą dostępną pamięć. Nie każdy jednak wie co dokładnie oznacza zapisana w logach informacja, a co za tym idzie nie koniecznie będzie wiedzieć co faktycznie błąd spowodowało i jak przeciwdziałać podobnym zdarzeniom w przyszłości.

  Problem omówimy sobie na dosyć ciekawym przypadku, który wystąpił niedawno na jednym z serwerów pracujących pod kontrolą CentOS7 (wersja jądra 3.10) oraz procesorze w architekturze x86_64.

  Tyle tytułem wstępu, teraz przejdźmy do objawów. Wspomniany wcześniej serwer jest maszyną fizyczną na której są uruchomione maszyny wirtualne KVM, zarządzane przy pomocy libvirt. Dosyć standardowa konfiguracja, bez żadnych wodotrysków. Od jakiegoś czasu serwer zalicza regularnie błędy typu OutOfMemory, zabijając przy okazji jedną z maszyn wirtualnych.

  Tymczasem zebrane statystyki świadczą o tym, że system posiada dostępną pamięć. Zsumowanie pamięci przydzielonej maszynom wirtualnym, również pokazuje spory zapas. Przeanalizujmy zatem po kolei zapisany log, zaczynając od pierwszych linii :

# dmesg -T
...
[Wed Jun 15 06:37:51 2022] python invoked oom-killer: gfp_mask=0x3000d0, order=2, oom_score_adj=0
[Wed Jun 15 06:37:51 2022] python cpuset=/ mems_allowed=0-1
[Wed Jun 15 06:37:51 2022] CPU: 9 PID: 24121 Comm: python Tainted: G I ------------ 3.10.0-1160.2.2.el7.x86_64 #1
...
 Po kolei co my tu mamy:
  • Błąd został spowodowany przez proces python-a pracujący na procesorze numer 9

  • order=2 - Proces próbował zaalokować 16kB pamięci, w jednym ciągłym bloku. Parametr "order" podaje żądaną liczbę stron pamięci. Przy czym nie jest to liczba podana bezpośrednio, a jako potęga liczby dwa.

    Czyli w naszym wypadku mamy liczbę stron pamięci wynoszącą 2^2=4. Teraz mnożymy to przez rozmiar normalnej strony pamięci 4*4096 = 16kB. Gdybyśmy mieli order=0, wtedy byłaby to próba alokacji 1 strony pamięci, ponieważ 2^0=1

  •  gfp_mask=0x3000d0 - flagi GFP, które możemy zdekodować poprzez zerknięcie w plik źródłowy jądra include/linux/gfp.h. W naszym wypadku flagi te mówią, że mamy do czynienia z normalną alokacją pamięci (brak ustawionych flag DMA, HIGHMEM czy DMA32)

  • cpuset=/ mems_allowed=0-1 - informacja o tym czy proces został przypisany do jakiegoś konkretnego procesora lub z których "węzłów" pamięci może korzystać.

    Tutaj widać, żę mamy dwa węzły (mems_allowed=0-1), co za tym idzie serwer musi obsługiwać mechanizm NUMA. W wielkim skrócie chodzi o to, że wybrane procesory są fizycznie połączone tylko z częścią pamięci operacyjnej. Robione jest to po to by zapewnić jak najkrótsze czasy dostępu, czyli dużo krótsze niż w przypadku gdyby wszystkie procesory korzystały z wspólnej szyny danych.

  Na początek możemy sprawdzić jak są przypisane procesory oraz pamięć do poszczególnych węzłówv NUMA, np.:

# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22
node 0 size: 32237 MB
node 0 free: 1554 MB
node 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23
node 1 size: 32012 MB
node 1 free: 4897 MB
node distances:
node 0 1
0: 10 20
1: 20 10

Ponieważ wiem, że proces korzystał z procesora numer 9, wiem również że pamięć alokował w pierwszym węźle NUMA, czyli tam gdzie w tym momencie mamy dostępne niecałe 5GB pamięci. Już teraz można z zauważyć, że w naszym przypadku możliwe jest wystąpienie błędu OOM, nawet gdy system raportuje aż połowę wolnej pamięci ;)

  Pamięć w systemie jest podzielona nie tylko ze względu na węzły NUMA. Dodatkowo mamy jeszcze podział na tak zwane strefy (ang. zone). Generalnie chodzi o to, że starsze urządzenia 16-bitowe czy 32-bitowe (wpięte np. w szynę PCI lub ISA),  nie potrafią odwoływać się do całej dostępnej pamięci. W przypadku urządzeń, które mogą zaadresować pamięć z zakresu 0-16MB (szyna ISA) mamy strefę DMA. Urządzenia 32-bitowe, adersujące pamięć w przedziale 0-4GB, powinny wykorzystywać strefę DMA32. O strefach DMA i DMA32, które korzystają z "niskich" adresów, mówimy że jest to pamięć niska (ang. lowmem). Pamięć która nie będzie wykorzystywana do komunikacji z urządzeniami, powinna być alokowana w strefie Normal lub Highmem. Procesory 64-bit nie posiadają strefy Highmem, natomiast procesory 32-bit posiadają zarówno Normal (16-896MB) jaki i Highmem (>896MB).

  System próbuje alokować pamięć w kolejności od Normal, następnie DMA32 i na końcu w DMA. Czyli zasadniczo najpierw wybiera tą przestrzeń gdzie pamięci jest najwięcej, aż dochodzi do strefy DMA która jest najmniejsza.

  Sprawdźmy teraz ile faktycznie było wolnej pamięci w chwili wystąpienia problemu. Najwięcej powie nam fragment cytowany poniżej, który podaje ile i jakiej wielkości bloki były dostępne w poszczególnych węzłach NUMA oraz ich strefach :

...
[Wed Jun 15 06:37:51 2022] Node 0 Normal: 622118*4kB (UEM) 287275*8kB (UEM) 161*16kB (UM) 36*32kB (M) 30*64kB (M) 1*128kB (M) 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB = 4792448kB
[Wed Jun 15 06:37:51 2022] Node 1 DMA: 0*4kB 0*8kB 1*16kB (U) 0*32kB 2*64kB (U) 1*128kB (U) 1*256kB (U) 0*512kB 1*1024kB (U) 1*2048kB (M) 3*4096kB (M) = 15888kB
[Wed Jun 15 06:37:51 2022] Node 1 DMA32: 22393*4kB (UEM) 4279*8kB (UEM) 44*16kB (UEM) 4*32kB (U) 0*64kB 0*128kB 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB = 124636kB
[Wed Jun 15 06:37:51 2022] Node 1 Normal: 12930*4kB (U) 0*8kB 0*16kB 0*32kB 0*64kB 0*128kB 0*256kB 0*512kB 0*1024kB 0*2048kB 0*4096kB = 51720kB
...

  Możemy zobaczyć, że pomimo wydawałoby się sporej ilości dostępnej pamięci, jest ona dosyć pofragmentowana i składa się głównie z pojedynczych bloków o rozmiarze jednej strony (4kB). Widzimy, że w pierwszym węźle (Node 1 Normal), brak było wolnych bloków o rozmiarze 16kB, więc alokacja w tej strefie nie mogła się powieść, pomimo dostępnej pamięci. Mamy również blisko 4.7GB wolnej pamięci w węźle zerowym, ale co z tego skoro proces działa na procesorze numer 9, który nie może skorzystać z tej pamięci.

Zagadka rozwiązana? Trochę tak, ale nie mogę się zatrzymać w tym miejscu, ponieważ do pełnego zrozumienia brakuje nam jeszcze kilku istotnych informacji.

  Przejdzmy więc teraz do sytuacji czysto hipotetycznej. Gdyby w strefie Normal było zwyczajnie zbyt mało wolnej pamieci do alokacji 16kB, wtedy jądro starałoby się wykorzystać pozostałe strefy - DMA lub DMA32, gdzie widać odpowiednio 1 i 44 bloki po 16kB. Pytanie jest więc takie: która strefa i na jakiej podstawie zostałaby wybrana w takiej sytuacji?

  Istotne jest, że pewna część pamięci musi pozostać zachowana na potrzeby jądra, a dodatkowo chcemy również unikać alokacji pamięci "niskiej" (ang. lowmem), ponieważ jest ona wykorzystywana do komunikacji z urządzeniami poprzez kanały DMA. Jak wielką część pamięci niskiej chronimy, możemy po części sterować przy pomocy parametru vm.lowmem_reserve_ratio:

# cat /proc/sys/vm/lowmem_reserve_ratio
256 256 32

Powyższe ustawienia mówią, że rezerwujemy dodatkowo 1/256 rozmiaru strefy DMA i DMA32, oraz 1/32 rozmiaru Normal.

Upraszczając to wszystko, jądro podejmie próbę zaalokowania pamięci w danej strefie w momencie gdy spełniony jest warunek:

low_watermark + lowmem_reserve[2] < free_pages - n_pages

gdzie wszystkie wartości podane są w stronach pamięci i oznaczają:

  • low_watermak - pamięć która musi pozostać wolna dla poprawnego działania systemu.

    W momencie gdy liczba wolnej pamięci spadnie poniżej tej wartości, uruchamiany jest proces odzyskujący strony pamięci, np. wyrzucający je na przestrzeń swap lub przenoszący do innych stref. Jeżeli nie uda się zwolnić wystarczająco dużo miejsca, uruchamiany jest tzw. oom-killer który wybiera a następnie kończy jakiś proces w systemie. Zazwyczaj jest to proces trzymający najwięcej pamięci

    Proces odzyskujący strony jest wstrzymywany gdy ilość wolnej pamięci przekroczy tzw. high watermark

  • lowmem_reserve[2] - dodatkowy bufor chroniący pamięć "niską", sterowany przez vm.lowmem_reserve_ratio.

    Indeks wynosi dwa, ponieważ w naszym przypadku pamięć powinna być zaalokowana w zonie Normal. Definicję stref oraz ich indeks można sprawdzić kodzie źródłowym jądra, podglądając definicję zone_type w pliku include/linux/mmzone.h

  • free_pages - liczba wolnych stron pamięci

  • n_pages - liczba stron które proces chce zaalokować

  Spójrzmy teraz jak wyglądała sytuacja w kolejnych strefach, istotne dane zostały wyróżnione:

...
[Wed Jun 15 06:37:51 2022] Node 1 DMA free:15888kB min:20kB low:24kB high:28kB active_anon:0kB inactive_anon:0kB active_file:0kB inactive_file:0kB unevictable:0kB isolated(anon):0kB isolated(file):0kB present:15988kB managed:15904kB mlocked:0kB dirty:0kB writeback:0kB mapped:0kB shmem:0kB slab_reclaimable:0kB slab_unreclaimable:16kB kernel_stack:0kB pagetables:0kB unstable:0kB bounce:0kB free_pcp:0kB local_pcp:0kB free_cma:0kB writeback_tmp:0kB pages_scanned:0 all_unreclaimable? yes
[Wed Jun 15 06:37:51 2022] lowmem_reserve[]: 0 3071 31997 31997
[Wed Jun 15 06:37:51 2022] Node 1 DMA32 free:124624kB min:4304kB low:5380kB high:6456kB active_anon:1441480kB inactive_anon:1384568kB active_file:0kB inactive_file:0kB unevictable:212kB isolated(anon):32kB isolated(file):0kB present:3378660kB managed:3145156kB mlocked:212kB dirty:0kB writeback:0kB mapped:200kB shmem:589376kB slab_reclaimable:163356kB slab_unreclaimable:23908kB kernel_stack:2176kB pagetables:2940kB unstable:0kB bounce:0kB free_pcp:0kB local_pcp:0kB free_cma:0kB writeback_tmp:0kB pages_scanned:0 all_unreclaimable? yes
[Wed Jun 15 06:37:51 2022] lowmem_reserve[]: 0 0 28925 28925
...

Liczymy teraz równanie dla strefy DMA32, od razu w kilobajtach:

5380 + (28925*4096)/1024 < 124624 -16

121080 < 124608

Jak widać warunek jest spełniony. Teraz policzmy to samo dla strefy DMA, w kilobajtach:

24 + (31997*4096)/1024 < 15888 - 16

128012 > 15872

W tym wypadku warunek już nie jest spełniony.

 Pamiętając, że kolejność w jakiej podejmowane są próby to Normal, DMA32 i na końcu DMA, nowa pamięć zostałaby przydzielona w strefie DMA32 - nawet gdyby było wolne miejsce w DMA.

  Na końcu wrócmy się jeszcze do naszej prawdziwej sytuacji. Co można zrobić by zbalansować wykorzystanie poszczególnych węzłów NUMA i tym samym zminimalizować ryzyko wystąpienia błędów OOM?

  Można uruchomić serwis numad, który przypisuje procesy do jednego węzła NUMA (co poprawia również wydajność) oraz potrafi przenosić pamięć już uruchomionych procesów pomiędzy węzłami. Poniżej sytuacja przed uruchomieniem numad:

# numastat -c qemu-kvm

Per-node process memory usage (in MBs)
PID Node 0 Node 1 Total
--------------- ------ ------ -----
26346 (qemu-kvm) 2504 5567 8070
26541 (qemu-kvm) 7771 8482 16254
30627 (qemu-kvm) 4410 9973 14383
31855 (qemu-kvm) 3385 105 3490
31903 (qemu-kvm) 3737 55 3792
31952 (qemu-kvm) 2446 287 2733
--------------- ------ ------ -----
Total 24253 24469 48722

Oraz po uruchomieniu numad, gdzie widać że poszczególne procesy maszyn wirtualnych korzystają z pojedynczych węzłów:

# numastat -c qemu-kvm

Per-node process memory usage (in MBs)
PID Node 0 Node 1 Total
--------------- ------ ------ -----
26346 (qemu-kvm) 12 8059 8070
26541 (qemu-kvm) 1 16258 16259
31855 (qemu-kvm) 6 3484 3490
31903 (qemu-kvm) 3792 0 3792
31952 (qemu-kvm) 2383 350 2733
32737 (qemu-kvm) 16430 1 16431
--------------- ------ ------ -----
Total 22624 28152 50776