Debugowanie wycieków pamięci w aplikacjach Node.js
Opublikowany: 2022-03-11Kiedyś jeździłem audi z silnikiem V8 twin-turbo, a jego osiągi były niesamowite. Jechałem z prędkością około 140 mil na godzinę autostradą IL-80 w pobliżu Chicago o 3 nad ranem, kiedy nikogo nie było na drodze. Od tego czasu termin „V8” kojarzy mi się z wysoką wydajnością.
Chociaż silnik V8 Audi jest bardzo mocny, nadal jesteś ograniczony pojemnością zbiornika gazu. To samo dotyczy Google V8 – silnika JavaScript stojącego za Node.js. Jego wydajność jest niesamowita i jest wiele powodów, dla których Node.js działa dobrze w wielu przypadkach, ale zawsze jesteś ograniczony przez rozmiar sterty. Gdy potrzebujesz przetworzyć więcej żądań w swojej aplikacji Node.js, masz dwie możliwości: skalowanie w pionie lub w poziomie. Skalowanie w poziomie oznacza, że musisz uruchamiać więcej współbieżnych instancji aplikacji. Kiedy zrobisz to dobrze, będziesz w stanie obsłużyć więcej żądań. Skalowanie w pionie oznacza, że musisz poprawić wykorzystanie pamięci i wydajność aplikacji lub zwiększyć zasoby dostępne dla instancji aplikacji.
Niedawno zostałem poproszony o pracę nad aplikacją Node.js dla jednego z moich klientów Toptal, aby naprawić problem z wyciekiem pamięci. Aplikacja, serwer API, miała być w stanie przetwarzać setki tysięcy żądań na minutę. Oryginalna aplikacja zajmowała prawie 600 MB pamięci RAM, dlatego postanowiliśmy wziąć gorące punkty końcowe API i ponownie je zaimplementować. Koszty ogólne stają się bardzo drogie, gdy trzeba obsłużyć wiele żądań.
Dla nowego API wybraliśmy restify z natywnym sterownikiem MongoDB i Kue dla zadań w tle. Brzmi jak bardzo lekki stos, prawda? Nie do końca. Podczas szczytowego obciążenia nowa instancja aplikacji może zużywać do 270 MB pamięci RAM. Dlatego moje marzenie o posiadaniu dwóch instancji aplikacji na 1X Heroku Dyno zniknęło.
Debugowanie wycieku pamięci Node.js Arsenał
Memwatch
Jeśli szukasz „jak znaleźć wyciek w węźle”, pierwszym narzędziem, które prawdopodobnie znajdziesz, jest memwatch . Oryginalny pakiet został porzucony dawno temu i nie jest już utrzymywany. Jednak możesz łatwo znaleźć nowsze wersje na liście widełek GitHub dla repozytorium. Ten moduł jest przydatny, ponieważ może emitować zdarzenia wycieku, jeśli widzi, jak sterta rośnie w ciągu 5 kolejnych wyrzucania elementów bezużytecznych.
Zrzut stosu
Świetne narzędzie, które pozwala programistom Node.js robić zrzuty stosu i później je sprawdzać za pomocą Narzędzi dla programistów Chrome.
Inspektor węzłów
Jeszcze bardziej użyteczna alternatywa dla sterty, ponieważ pozwala na łączenie się z uruchomioną aplikacją, robienie zrzutu sterty, a nawet debugowanie i ponowne kompilowanie go w locie.
Biorąc „inspektora węzłów” na spin
Niestety nie będziesz mógł połączyć się z aplikacjami produkcyjnymi działającymi na Heroku, ponieważ nie pozwala to na wysyłanie sygnałów do uruchomionych procesów. Jednak Heroku nie jest jedyną platformą hostingową.
Aby doświadczyć node-inspectora w działaniu, napiszemy prostą aplikację Node.js używając restify i umieścimy w niej małe źródło wycieku pamięci. Wszystkie eksperymenty tutaj zostały wykonane przy użyciu Node.js v0.12.7, który został skompilowany z V8 v3.28.71.19.
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
Aplikacja tutaj jest bardzo prosta i ma bardzo oczywisty wyciek. Zadania tablicy rosłyby w czasie życia aplikacji, powodując jej spowolnienie i ostatecznie awarię. Problem polega na tym, że przeciekamy nie tylko zamknięcie, ale także całe obiekty żądania.
GC w V8 wykorzystuje strategię zatrzymania świata, co oznacza, że im więcej obiektów masz w pamięci, tym dłużej zajmie zebranie śmieci. Na logu poniżej wyraźnie widać, że na początku życia aplikacji zebranie śmieci zajęłoby średnio 20ms, ale kilkaset tysięcy żądań później zajmuje to około 230ms. Osoby, które próbują uzyskać dostęp do naszej aplikacji, musiałyby teraz czekać 230 ms dłużej z powodu GC. Widać również, że GC jest wywoływany co kilka sekund, co oznacza, że co kilka sekund użytkownicy mieliby problemy z dostępem do naszej aplikacji. A opóźnienie będzie rosło, aż do awarii aplikacji.
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
Te wiersze dziennika są drukowane, gdy aplikacja Node.js jest uruchamiana z flagą –trace_gc :
node --trace_gc app.js
Załóżmy, że uruchomiliśmy już naszą aplikację Node.js z tą flagą. Przed połączeniem aplikacji z node-inspectorem musimy wysłać do niej sygnał SIGUSR1 do uruchomionego procesu. Jeśli uruchamiasz Node.js w klastrze, upewnij się, że łączysz się z jednym z procesów podrzędnych.
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
W ten sposób wprowadzamy aplikację Node.js (a dokładniej V8) w tryb debugowania. W tym trybie aplikacja automatycznie otwiera port 5858 z protokołem debugowania V8.
Naszym następnym krokiem jest uruchomienie inspektora węzłów, który połączy się z interfejsem debugowania uruchomionej aplikacji i otworzy inny interfejs sieciowy na porcie 8080.
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
W przypadku, gdy aplikacja działa w środowisku produkcyjnym i masz zaporę ogniową, możemy tunelować zdalny port 8080 do hosta lokalnego:
ssh -L 8080:localhost:8080 [email protected]
Teraz możesz otworzyć przeglądarkę internetową Chrome i uzyskać pełny dostęp do narzędzi programistycznych Chrome dołączonych do zdalnej aplikacji produkcyjnej. Niestety, narzędzia programistyczne Chrome nie będą działać w innych przeglądarkach.
Znajdźmy przeciek!
Wycieki pamięci w V8 nie są prawdziwymi wyciekami pamięci, jakie znamy z aplikacji C/C++. W JavaScript zmienne nie znikają w pustce, po prostu zostają „zapomniane”. Naszym celem jest znalezienie tych zapomnianych zmiennych i przypomnienie im, że Zgredek jest wolny.
Wewnątrz Narzędzi dla programistów Chrome mamy dostęp do wielu profili. Szczególnie interesuje nas funkcja Record Heap Allocations , która uruchamia i wykonuje wiele migawek sterty w czasie. Daje nam to jasny wgląd w to, gdzie przeciekają obiekty.
Rozpocznij rejestrowanie alokacji sterty i zasymuluj 50 równoczesnych użytkowników na naszej stronie głównej za pomocą Apache Benchmark.
ab -c 50 -n 1000000 -k http://example.com/
Przed wykonaniem nowych migawek V8 wykonałoby usuwanie śmieci po znakach, więc na pewno wiemy, że w migawce nie ma starych śmieci.
Naprawianie wycieku w locie
Po zebraniu migawek alokacji sterty przez okres 3 minut otrzymujemy coś takiego:
Wyraźnie widać, że na stercie znajduje się kilka gigantycznych tablic, dużo obiektów IncomingMessage, ReadableState, ServerResponse i Domain. Spróbujmy przeanalizować źródło wycieku.
Po wybraniu różnicy sterty na wykresie od 20. do 40. zobaczymy tylko obiekty dodane po 20s od uruchomienia profilera. W ten sposób możesz wykluczyć wszystkie normalne dane.
Zwracając uwagę na to, ile obiektów każdego typu znajduje się w systemie, rozszerzamy filtr z 20s do 1min. Widzimy, że tablice, już dość gigantyczne, wciąż rosną. Pod „(tablica)” widzimy, że istnieje wiele obiektów „(właściwości obiektu)” w równej odległości. Te obiekty są źródłem naszego wycieku pamięci.
Widzimy również, że obiekty „(zamknięcia)” również szybko rosną.
Przydatne może być również przyjrzenie się strunom. Pod listą ciągów znaków znajduje się wiele fraz „Hi Leaky Master”. To też może dać nam jakąś wskazówkę.

W naszym przypadku wiemy, że ciąg „Hi Leaky Master” można było złożyć tylko w ramach trasy „GET /”.
Jeśli otworzysz ścieżkę do elementów ustalających, zobaczysz, że do tego ciągu jest w jakiś sposób odwołanie poprzez req , a następnie tworzony jest kontekst i wszystko to jest dodawane do jakiejś gigantycznej tablicy zamknięć.
W tym momencie wiemy, że mamy jakiś gigantyczny zestaw zamknięć. Przejdźmy i nazwijmy wszystkie nasze zamknięcia w czasie rzeczywistym w zakładce źródła.
Po zakończeniu edycji kodu możemy nacisnąć CTRL+S, aby zapisać i ponownie skompilować kod w locie!
Teraz nagrajmy kolejną migawkę alokacji sterty i zobaczmy, które zamknięcia zajmują pamięć.
Oczywiste jest, że SomeKindOfClojure() to nasz czarny charakter. Teraz widzimy, że domknięcia SomeKindOfClojure() są dodawane do jakiejś tablicy nazwanych zadań w przestrzeni globalnej.
Łatwo zauważyć, że ta tablica jest po prostu bezużyteczna. Możemy to skomentować. Ale jak uwolnić pamięć już zajętą? Bardzo proste, po prostu przypisujemy pustą tablicę do zadań i przy następnym żądaniu zostanie ona nadpisana, a pamięć zostanie zwolniona po kolejnym zdarzeniu GC.
Zgredek jest wolny!
Życie na śmieciach w V8
Sterta V8 jest podzielona na kilka różnych przestrzeni:
- Nowa przestrzeń : Ta przestrzeń jest stosunkowo niewielka i ma rozmiar od 1 MB do 8 MB. Większość obiektów jest tu przydzielona.
- Old Pointer Space : zawiera obiekty, które mogą mieć wskaźniki do innych obiektów. Jeśli obiekt przetrwa wystarczająco długo w Nowej Przestrzeni, zostaje awansowany do Starej Przestrzeni Wskaźnika.
- Przestrzeń starych danych : zawiera tylko surowe dane, takie jak ciągi, liczby w ramkach i tablice nieopakowanych dubli. Obiekty, które wystarczająco długo przetrwały GC w Nowej Przestrzeni, są tu również przenoszone.
- Przestrzeń dużego obiektu : w tej przestrzeni tworzone są obiekty, które są zbyt duże, aby zmieścić się w innych przestrzeniach. Każdy obiekt ma w pamięci swój własny obszar
mmap
'ed - Przestrzeń kodu : zawiera kod zestawu wygenerowany przez kompilator JIT.
- Przestrzeń komórki, przestrzeń komórki właściwości, przestrzeń mapy : ta przestrzeń zawiera
Cell
,PropertyCell
s iMap
. Służy do uproszczenia zbierania śmieci.
Każda przestrzeń składa się ze stron. Strona to region pamięci przydzielony przez system operacyjny za pomocą mmap. Każda strona ma zawsze rozmiar 1 MB, z wyjątkiem stron o dużej przestrzeni obiektu.
V8 ma dwa wbudowane mechanizmy zbierania śmieci: Scavenge, Mark-Sweep i Mark-Compact.
Scavenge to bardzo szybka technika zbierania śmieci i operuje na obiektach w Nowej Przestrzeni . Scavenge to implementacja algorytmu Cheneya. Pomysł jest bardzo prosty, New Space jest podzielona na dwie równe półprzestrzenie: To-Space i From-Space. Scavenge GC występuje, gdy To-Space jest pełne. Po prostu zamienia przestrzenie To i From i kopiuje wszystkie żywe obiekty do To-Space lub promuje je do jednej ze starych przestrzeni, jeśli przeżyły dwa zmiatania, a następnie jest całkowicie usuwane z przestrzeni. Oczyszczanie jest bardzo szybkie, jednak wiąże się z utrzymywaniem podwójnej sterty i ciągłym kopiowaniem obiektów w pamięci. Powodem używania padlinożerców jest to, że większość obiektów umiera młodo.
Mark-Sweep & Mark-Compact to kolejny typ odśmiecacza używanego w V8. Inna nazwa to pełny garbage collector. Zaznacza wszystkie aktywne węzły, a następnie zamiata wszystkie martwe węzły i defragmentuje pamięć.
Wskazówki dotyczące wydajności i debugowania GC
Chociaż w przypadku aplikacji internetowych wysoka wydajność może nie być tak dużym problemem, nadal będziesz chciał uniknąć wycieków za wszelką cenę. Podczas fazy oznaczania w pełnym GC aplikacja jest faktycznie wstrzymana do czasu zakończenia zbierania śmieci. Oznacza to, że im więcej obiektów masz na stercie, tym dłużej zajmie wykonanie GC i tym dłużej użytkownicy będą musieli czekać.
Zawsze nadaj nazwy zamknięciom i funkcjom
Znacznie łatwiej jest sprawdzać ślady stosu i stosy, gdy wszystkie domknięcia i funkcje mają nazwy.
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
Unikaj dużych obiektów w gorących funkcjach
Idealnie byłoby uniknąć dużych obiektów wewnątrz gorących funkcji, tak aby wszystkie dane mieściły się w nowej przestrzeni . Wszystkie operacje związane z procesorem i pamięcią powinny być wykonywane w tle. Unikaj również wyzwalaczy deoptymalizacji dla gorących funkcji, zoptymalizowana gorąca funkcja zużywa mniej pamięci niż niezoptymalizowane.
Gorące funkcje powinny być zoptymalizowane
Gorące funkcje, które działają szybciej, ale również zużywają mniej pamięci, powodują rzadsze uruchamianie GC. V8 udostępnia kilka pomocnych narzędzi do debugowania, które umożliwiają wykrywanie niezoptymalizowanych lub niezoptymalizowanych funkcji.
Unikaj polimorfizmu układów scalonych w gorących funkcjach
Wbudowane pamięci podręczne (IC) są używane do przyspieszenia wykonywania niektórych fragmentów kodu, poprzez buforowanie dostępu do właściwości obiektu obj.key
lub jakiejś prostej funkcji.
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
Kiedy x(a,b) jest uruchamiany po raz pierwszy, V8 tworzy monomorficzny układ scalony. Kiedy wywołujesz x
po raz drugi, V8 wymazuje stary układ scalony i tworzy nowy polimorficzny układ scalony, który obsługuje oba typy argumentów całkowitych i łańcuchowych. Kiedy dzwonisz do IC po raz trzeci, V8 powtarza tę samą procedurę i tworzy kolejny polimorficzny IC poziomu 3.
Istnieje jednak ograniczenie. Gdy poziom IC osiągnie 5 (można to zmienić za pomocą flagi –max_inlining_levels ) funkcja staje się megamorficzna i nie jest już uważana za nadającą się do optymalizacji.
Intuicyjnie zrozumiałe jest, że funkcje monomorficzne działają najszybciej i mają mniejszy rozmiar pamięci.
Nie dodawaj dużych plików do pamięci
Ten jest oczywisty i dobrze znany. Jeśli masz duże pliki do przetworzenia, na przykład duży plik CSV, czytaj go wiersz po wierszu i przetwarzaj w małych porcjach, zamiast wczytywać cały plik do pamięci. Zdarzają się raczej rzadkie przypadki, w których pojedyncza linia pliku csv byłaby większa niż 1mb, co pozwala zmieścić ją w New Space .
Nie blokuj głównego wątku serwera
Jeśli masz jakiś gorący interfejs API, którego przetworzenie zajmuje trochę czasu, na przykład interfejs API do zmiany rozmiaru obrazów, przenieś go do osobnego wątku lub przekształć w zadanie w tle. Operacje intensywnie wykorzystujące procesor blokowałyby główny wątek, zmuszając wszystkich innych klientów do czekania i dalszego wysyłania żądań. Nieprzetworzone dane żądania byłyby układane w pamięci, zmuszając w ten sposób pełne GC do zakończenia dłuższego czasu.
Nie twórz niepotrzebnych danych
Miałem kiedyś dziwne doświadczenie z restify. Jeśli wyślesz kilkaset tysięcy żądań do nieprawidłowego adresu URL, pamięć aplikacji gwałtownie wzrośnie do stu megabajtów, aż w kilka sekund później uruchomi się pełna GC, kiedy wszystko wróci do normy. Okazuje się, że dla każdego nieprawidłowego adresu URL restify generuje nowy obiekt błędu, który zawiera ślady długiego stosu. Wymusiło to alokowanie nowo utworzonych obiektów w przestrzeni dużego obiektu , a nie w nowej przestrzeni .
Dostęp do takich danych może być bardzo pomocny podczas projektowania, ale oczywiście nie jest wymagany w produkcji. Dlatego zasada jest prosta - nie generuj danych, chyba że na pewno ich potrzebujesz.
Poznaj swoje narzędzia
Ostatnią, ale z pewnością nie najmniej ważną, jest znajomość swoich narzędzi. Istnieją różne debuggery, katery wycieków i generatory wykresów użycia. Wszystkie te narzędzia mogą sprawić, że oprogramowanie będzie szybsze i wydajniejsze.
Wniosek
Zrozumienie, jak działa odśmiecanie i optymalizator kodu w V8, jest kluczem do wydajności aplikacji. V8 kompiluje JavaScript do natywnego zestawu iw niektórych przypadkach dobrze napisany kod może osiągnąć wydajność porównywalną z aplikacjami skompilowanymi przez GCC.
A jeśli się zastanawiasz, nowa aplikacja API dla mojego klienta Toptal, chociaż jest miejsce na ulepszenia, działa bardzo dobrze!
Joyent niedawno wydał nową wersję Node.js, która wykorzystuje jedną z najnowszych wersji V8. Niektóre aplikacje napisane dla Node.js v0.12.x mogą nie być zgodne z nową wersją v4.x. Jednak w nowej wersji Node.js aplikacje odczują ogromną poprawę wydajności i wykorzystania pamięci.