[Uwaga] Ten artykuł został pierwotnie przygotowany w języku angielskim i został przetłumaczony na język polski.
Ile pamięci systemowej może zużyć Java na maszynie (wirtualnej lub nie)? To zależy. Algorytm, który określa tę ilość, nie jest prosty i czasem zmienia się wraz z wersjami Javy. Ile pamięci powinna zużywać Java na typowym serwerze? To również zależy. Mogą być tam uruchomione inne aplikacje, takie jak baza danych, lub inne usługi.
Ile pamięci powinna zużywać Java w kontenerze? W tym przypadku odpowiedź jest prosta. Całą dostępną! Jeśli uruchomisz pod z memory limitem 1 GB i zużyjesz tylko 256 MB, to marnotrawstwo.
Domyślne limity są bardzo małe, więc zawsze powinieneś ustawiać memory limits w swojej aplikacji Java, gdy uruchamiasz ją w kontenerze.
Jednak ustawienie rozmiarów pamięci tak, aby limity kontenera były wykorzystane w pełni, nie jest takie łatwe. System skonsumuje trochę pamięci, część zostanie użyta do przechowywania danych klas (class data), część na stosy wątków (threads stacks), a dopiero reszta powinna zostać przeznaczona na heap.
Jako Java Developer nie wiesz dokładnie, ile pamięci jest wymagane dla każdego z celów, a nawet jeśli to przetestujesz i użyjesz otrzymanej wartości, może się ona zmienić w pewnych okolicznościach – na przykład przy zmianie obrazu bazowego kontenera, wersji Javy, GC lub po prostu, gdy aplikacja spawnuje dodatkowe wątki pod dużym obciążeniem. Poniżej znajduje się kilka wskazówek do własnych obliczeń.
System i dane klas prawdopodobnie zużyją zaledwie kilkadziesiąt megabajtów. Rezerwacja 50 MB na ten cel powinna być w porządku.
Ilość pamięci zużywanej przez wątki będzie zależeć od tego, ile wątków masz w swojej aplikacji. Wartość alokowanej pamięci może wynosić od „kilku” MB dla usług przetwarzania kolejek w backendzie, do nawet setek dla usługi HTTP REST uzyskującej dostęp do bazy danych lub dwóch. Domyślny stack size jest różny, ale zazwyczaj ustawiony na 1 MB, więc zużycie pamięci może wynosić od „kilku” MB do „stu lub więcej” MB. Jeśli chcesz wiedzieć, ilu wątków używa Twoja aplikacja, uruchom ją w trybie debug, wykonaj kilka operacji i zatrzymaj JVM. Następnie użyj otrzymanej wartości pomnożonej przez własny współczynnik bezpieczeństwa (np. 2), aby oszacować wymaganą pamięć.
Heap size to miejsce, w którym przechowywana jest większość danych Twojej aplikacji w pamięci. Jest to wartość, nad którą masz realną kontrolę i prawdopodobnie ta, która ma największe znaczenie. Można ją skonfigurować na dwa sposoby:
W większości przypadków możesz jednak po prostu użyć magicznego zakresu 75-80% całkowitej dostępnej pamięci RAM jako rozmiaru pamięci heap. Dla mniejszego kontenera, np. 256-512 MB, powinno to być prawdopodobnie 75%, a im większy kontener, tym większy procent może być wykorzystany przez heap, aż do około 80% (choć dla niektórych naprawdę dużych podów może to być nawet więcej).
TL;DR: ustaw swój initial heap size na 75-80% pamięci dostępnej w kontenerze.
Właściwe ustawienie limitu CPU kontenera jest dość trudne. Podobnie jak w przypadku pamięci, istnieją sposoby na ręczne dostrojenie zużycia procesora – tym razem jednak nie ma rozwiązania, które byłoby poprawne w 90% przypadków. Zależy to od konkretnego przypadku użycia. Istnieje również pewna wiedza ekspercka, którą powinieneś posiadać, aby podejmować właściwe decyzje.
Nawet jeśli Twoja aplikacja jest tylko jednowątkowym procesorem wsadowym (batch processor), w praktyce zawsze będzie używać więcej niż jednego wątku. W większości przypadków będziesz mieć dziesiątki wątków: wątki garbage collection, wątki ForkJoinPool, wątki puli połączeń DB (DB connection pool), wątki HTTP handler, wątki spring cron i tak dalej.
Liczba wątków używanych przez każdą bibliotekę jest inna, może mieć wartości domyślne i może różnić się w zależności od parametrów sprzętowych. Zawsze jednak można ją skonfigurować ręcznie.
Jeśli skalujesz swoją usługę REST do wielu małych podów, sensowne jest ograniczenie wątków roboczych HTTP (HTTP worker threads), ponieważ posiadanie ich zbyt wielu spowoduje jedynie błędy zamiast opóźnień. Nie musisz mieć w puli więcej połączeń DB, niż jest możliwych użytkowników połączeń DB, takich jak wątki robocze HTTP.
TL;DR: Twoja aplikacja używa wielu wątków i wiele z tych wątków spędza dużo czasu, właściwie nie używając CPU.
Wiesz już, że masz wiele wątków w swojej aplikacji Java. Ale dlaczego ma to znaczenie? Cóż, jest to ważne ze względu na to, jak CPU jest faktycznie alokowane dla poda w Kubernetes.
Wyobraź sobie, że ustawiasz limit na 1000m dla swojej aplikacji. Co to naprawdę oznacza? Czy dokładnie jeden rdzeń CPU jest alokowany do Twojego kontenera na cały okres jego życia? Nie. To nie jest takie proste.
Limit CPU dla kontenera określa, ile całkowitej mocy obliczeniowej Twoja aplikacja może zużyć w ciągu każdej sekundy (przeczytaj więcej w dokumentacji Kubernetes). Na przykład, jeśli masz 5 wątków, z których każdy wykonuje operacje CPU-intensive, i ustawisz limit na 1000m, spowoduje to, że Twoja aplikacja będzie używać 5 procesorów przez pierwsze 200 ms każdej sekundy, a następnie będzie czekać przez 800 ms.
Liczba procesorów nie jest jawnie ograniczona – mierzone i ograniczane (throttled) w każdej sekundzie jest całkowite zużycie mocy obliczeniowej. Szczerze mówiąc, nazwa wybrana dla tego parametru w k8s jest naprawdę myląca. Powinna nazywać się mocą obliczeniową na sekundę, CPUps lub coś w tym rodzaju.
Jeśli Twoja aplikacja obsługuje żądania REST i zabraknie jej CPU, wprowadzi to opóźnienie do każdego żądania obsługiwanego w tej sekundzie, równe ilości brakującego czasu CPU dla tej sekundy. To samo dotyczy Twojej aplikacji obsługującej zdarzenia – przetwarzanie każdego eventu potrwa dłużej, ponieważ aplikacja będzie musiała czekać na kolejną sekundę, aby kontynuować pracę.
TL;DR: Jeśli Twój container CPU limit jest zbyt niski, Twoja aplikacja będzie niezwykle powolna pod dużym obciążeniem. Twoje zobowiązanie SLA dotyczące czasu obsługi żądań prawdopodobnie zostanie naruszone.
Jeśli Twoja aplikacja głównie czeka na wyniki zapytań do bazy danych, mało prawdopodobne jest, abyś osiągnął limit. Z drugiej strony, jeśli nigdy go nie osiągasz, czy jest sens ustawiać go na nierozsądnie niskim poziomie? I tak prawdopodobnie skalujesz swój klaster Kubernetes do rzeczywistego zużycia, a nie do teoretycznych limitów. Ryzyko ustawienia limitu wyższego niż oczekiwana liczba używanych zasobów jest niewielkie, ponieważ procesor i tak zostanie ograniczony przez hardware Twojego node’a.
Ile procesorów widzi Java, gdy działa w kontenerze? Jak zawsze, to zależy. Jeśli wszystko działa poprawnie i używasz w miarę nowoczesnej wersji Javy (jeśli nie, koniecznie przeczytaj mój przewodnik po migracji z Javy 8 do Javy 17), widzi ona ceil(containerCpuLimit). Dla 500m będzie to 1 CPU, dla 1000m – 1 CPU, dla 1500m – 2 CPU.
Wartość ta jest dostępna w runtime poprzez metodę Runtime.availableProcessors(). Jest ona odczytywana przez wiele bibliotek przy określaniu poziomu równoległości (parallelism level). Java’s ForkJoinPool używa jej do określenia core pool size, podobnie jak Spring dla swojego ThreadPoolExecutor.
Ustaliliśmy więc, że widoczna liczba procesorów jest zazwyczaj niska i że Java w kontenerze może faktycznie używać więcej procesorów jednocześnie, jeśli zajdzie taka potrzeba. Low parallelism może w rzeczywistości wprowadzić niepotrzebne dławienie obliczeniowe (computing throttling) dla skoku przetwarzania w krótkim czasie. Jeśli masz mało żądań, ale obsługujesz je algorytmem opartym na fork/join, będziesz czekać dłużej niż to konieczne. Pamiętaj, że w tym kontenerze nie działa nic innego.
Aby uniknąć niepotrzebnego dławienia, zwiększ liczbę wątków, których używa Twoja aplikacja, powyżej domyślnej, małej liczby.
TL;DR: Możesz zrobić jedną lub obie z tych rzeczy:
Z którego garbage collectora korzysta Twoja aplikacja? Prawdopodobnie nie wiesz. Java wybiera go za Ciebie, a więc – to zależy.
Z mojego doświadczenia wynika, że większość kontenerów Java działa z limitem około 1 GB RAM i 2 CPU. Przy takiej konfiguracji domyślnym GC będzie… Serial GC. Co, zazwyczaj, nie jest optymalnym wyborem.
Serial GC jest dobry tylko dla małych aplikacji typu small heap / single-core. Jak wspomnieliśmy, każda aplikacja Java wdrożona na k8s jest w rzeczywistości aplikacją multicore i zawsze działa w niej wiele wątków. Nie powinieneś wybierać Serial GC, chyba że naprawdę wiesz, co robisz.
W większości przypadków powinieneś wybrać Parallel GC dla mniejszych heapów. Wprowadza on niewielki narzut i jest wielowątkowy; jego pauzy powinny być ogólnie krótsze niż w przypadku Serial GC. Wstrzymuje on całą maszynę JVM na czas trwania garbage collection, a pauzy wydłużają się wraz z większą przestrzenią heapu. Włączenie Parallel GC można wykonać za pomocą flagi -XX:+UseParallelGC.
Przy heapie rzędu 2-4 GB możesz rozważyć przejście na G1 (JDK<17) / Z (JDK 17+). Oba te collectory mają krótsze pauzy niż Parallel GC. Z GC jest szczególnie interesującym wyborem ze względu na bardzo krótkie pauzy (powinny być krótsze niż 1 ms). Jednak przy krótszych pauzach pojawia się większy CPU overhead, więc więcej limitu CPU zostanie zużyte przez sam GC. Możesz włączyć garbage collectory G1 lub Z, określając odpowiednio flagę -XX:+UseG1GC lub -XX:+UseZGC.
TL;DR: Użyj Parallel GC dla swojej typowej skonteneryzowanej aplikacji Java z 1 GB RAM i 2 CPU.
Uruchamiając aplikację Java w kontenerze, upewnij się, że pozwalasz jej w pełni wykorzystać przydzielone zasoby. Określ heap size, przemyśl i przetestuj CPU usage oraz ustaw GC jawnie.
Co jeśli po prostu uruchomisz aplikację za pomocą java -jar myapp.jar? Będzie działać, ale nie będzie działać dobrze. Prawdopodobnie zużyje tylko 25% dostępnej pamięci na heap, użyje niedoskonałego Serial GC i prawdopodobnie nie będzie efektywnie wykorzystywać CPU. Nie chcesz tego, prawda?
TL;DR: Użyj następującego polecenia podczas uruchamiania Javy w kontenerze:
java -XX:MaxRAMPercentage=75 -XX:+UseParallelGC -XX:ActiveProcessorCount=<2x twójLimitCpu> myapp.jar
Będzie ono lepsze niż ustawienia domyślne w 99% przypadków.