Ustawiłeś nowy limit CPU na działającym już podzie przez in-place resize i czekasz, aż aplikacja w końcu z niego skorzysta. Kubernetes mówi, że limit się zmienił. Metryki węzła to potwierdzają. A JVM w środku kontenera zachowuje się tak, jakby nic się nie stało — nadal odpala tyle samo wątków GC, Runtime.getRuntime().availableProcessors() zwraca starą liczbę, a ForkJoinPool.commonPool() ma rozmiar sprzed zmiany. To nie jest błąd w konfiguracji. To dwa mechanizmy, które nigdy nie miały się ze sobą komunikować po fakcie.
In-place resize obiecuje zmianę CPU bez restartu — i słowo dotrzymuje
In-place pod resizing pozwala zmieniać przydzielone kontenerowi requesty i limity CPU oraz pamięci na działającym już podzie, często bez konieczności restartu kontenera — tak opisuje tę funkcję blog Kubernetes.io. Kubernetes odzwierciedla zmianę w polu status.containerStatuses[*].resources, które pokazuje faktycznie skonfigurowane zasoby na działającym kontenerze. Funkcja trafiła do Kubernetesa jako alpha w wersji 1.27 — zmiana CPU i pamięci bez restartu kontenerów.
Motywacja jest konkretna, nie akademicka: niektóre aplikacje Javy potrzebują znacznie więcej CPU podczas inicjalizacji niż w czasie normalnej pracy, jak zauważa ten sam wpis. Chcesz dać JVM dużo rdzeni na start, potem je oddać, bez zabijania procesu i odbudowywania stanu. Na poziomie API Kubernetesa to działa dokładnie tak, jak sugeruje nazwa: zmiana wchodzi w życie bez restartu.
Problem zaczyna się piętro wyżej — w samej JVM.
JVM czyta cgroup dokładnie raz — przy starcie
JVM traktuje dostępną liczbę procesorów jako wartość obliczoną jednorazowo przy starcie procesu; taki jednorazowy, statyczny odczyt nie jest w stanie uchwycić dynamicznej zmiany limitu w trakcie działania — tak opisuje to zgłoszenie w Java Bug System (OpenJDK). Sam proces nie jest w stanie stwierdzić, ile CPU dostanie, patrząc wyłącznie na wartość CPU Shares przypisaną własnemu procesowi — to dodatkowa niepewność, bo shares to wartość względna wobec innych procesów na hoście, a nie twardy limit.
Mechanicznie wygląda to tak: os::active_processor_count() może zwrócić mniejszą liczbę niż liczba rdzeni fizycznych, bo CgroupSubsystem::active_processor_count() odczytuje limit CPU skonfigurowany w pseudo-systemie plików cgroup. Ten odczyt dzieje się w kodzie natywnym — kod C++ OpenJDK czyta limity cgroups przy starcie procesu, jak opisuje blog Red Hat Developer. Przy włączonym logowaniu kontenerowym widać to wprost: JVM loguje wykrycie cgroups v2 i odczyt ścieżki /sys/fs/cgroup/cpu.max już w fazie inicjalizacji.
Jeżeli nie ustawisz nic ręcznie, implementacja os::initialize_initial_active_processor_count() na Linuksie sięga albo po wywołanie systemowe, albo po pliki cpu.shares/cpu.quota cgroupa — i robi to raz, według wpisu na blogu Brice'a Dutheila. To ustalenie pochodzi z bloga społecznościowego, nie z dokumentacji Oracle, więc traktuj je jako prawdopodobne, a nie oficjalnie potwierdzone. Rezultat jest ten sam: to, co Kubernetes zmienia w locie, JVM widziała tylko raz, w momencie, którego już nie ma.
Co dokładnie zamraża się razem z liczbą rdzeni
To nie jest tylko liczba zwracana przez jedną metodę API. Liczba wątków GC, wątek kompilatora oraz rozmiar fork-join poola są pochodną liczby dostępnych procesorów wykrytej przy starcie — potwierdza to zgłoszenie w OpenJDK Bug System opisujące tę zmianę. Po stronie samego JDK poprawka miała sprawić, że dostępna liczba procesorów będzie ograniczona wyłącznie przez cpuset i maksymalny limit ustawiony w cgroups — czyli że odczyt przy starcie będzie poprawny względem cgroupa. Ale poprawny odczyt przy starcie to nie to samo, co odczyt na bieżąco.
Dla domyślnego kolektora równoległego dochodzi kolejna warstwa: dokumentacja Oracle podaje, że przy liczbie wątków sprzętowych N większej niż 8 kolektor równoległy używa stałego ułamka N jako liczby wątków GC — w przybliżeniu 5/8 dla dużych N, a poniżej 8 używa całego N. Ten ułamek liczony jest raz, od liczby wykrytej na starcie. Jeśli podniesiesz limit CPU z 4 do 16 rdzeni w locie, GC dalej pracuje tak, jakby miał 4.
Dowody z terenu: zgłoszenie o 17.0.5 i pułapka buildpacków
Zgłoszenie na GitHubie w repozytorium Adoptium opisuje sytuację, w której od wersji JDK 17.0.5 aplikacja Javy uruchomiona w kontenerze miała dostęp do wszystkich rdzeni maszyny hosta, mimo ustawionych ograniczeń CPU na poziomie środowiska uruchomieniowego kontenera. To pojedyncze zgłoszenie społecznościowe, nie potwierdzony błąd opisany w dokumentacji — traktuj je jako sygnał dla konkretnej wersji, nie jako regułę dla całego OpenJDK. W tym samym wątku jawne ustawienie -XX:ActiveProcessorCount=1 usuwało problem: efektywna liczba CPU wynosiła 1, a Runtime.getRuntime().availableProcessors() zwracało 1.
Podobna pułapka istnieje po stronie narzędzi buildowych. Skrypt active_processor_count.go z buildpacków Paketo ustawia flagę -XX:ActiveProcessorCount na wartość zwróconą przez funkcję Go runtime.NumCPU() — a ta, według kodu źródłowego projektu, zwraca liczbę procesorów hosta, nie liczbę dostępną w kontenerze. Jeśli obraz zbudowany jest przez ten buildpack, JVM dostaje jawnie ustawioną, ale błędną liczbę procesorów, jeszcze zanim w ogóle dotknie tematu cgroupów.
Co z tym zrobić: ActiveProcessorCount albo restart
Masz dwie realne opcje, nie trzy. Pierwsza: pogódź się z tym, że in-place resize CPU dla kontenera z JVM wymaga restartu procesu, żeby nowa liczba rdzeni dotarła do GC i puli fork-join. To jest bezpieczne i przewidywalne, ale unieważnia sens funkcji „bez restartu” dla tego konkretnego przypadku.
Druga: wymuś liczbę procesorów jawnie flagą -XX:ActiveProcessorCount=<n>, dostępną w wierszu poleceń JVM, i zmieniaj ją razem z resize'em przez zewnętrzny mechanizm, który potem i tak restartuje kontener z nową wartością. Wpis na blogu Mike my bytes rekomenduje jawne ustawienie tej flagi właśnie po to, by ustabilizować zachowanie JVM, gdy pod nie ma ustawionego limitu CPU — przy -XX:ActiveProcessorCount=2 aplikacja zawsze „widzi” 2 CPU, niezależnie od tego, jak duży jest węzeł, na którym akurat wylądowała. To rekomendacja jednego autora, nie oficjalne wytyczne, ale mechanika, którą opisuje, jest zgodna z tym, jak JVM faktycznie odczytuje procesory.
W praktyce oznacza to jedno: in-place pod resize CPU rozwiązuje problem na poziomie Kubernetesa i nie dotyka problemu na poziomie procesu JVM. Jeśli planujesz na nim budować automatyzację skalowania procesów Javy, licz się z restartem kontenera przy każdej zmianie — albo zaakceptuj, że aplikacja przez cały czas życia poda będzie działać z liczbą rdzeni, jaką dostała przy starcie, niezależnie od tego, co pokazuje status.containerStatuses.