1. Дві межі: пам’ять контейнера і пам’ять JVM
З некоректною конфігурацією, проблемним хостом або некоректним профілем усе доволі чесно: сервіс або не стартує, або прямо пише, що не так. З лімітами ресурсів хитріше. Той самий образ і той самий конфіг можуть поводитися по-різному просто тому, що контейнеру дали інший бюджет пам’яті або CPU. Тому спочатку розведемо ці дві осі, а почнемо з пам’яті: тут головна плутанина майже завжди крутиться навколо --memory і -Xmx.
Коли Java-розробник уперше чує «memory limit контейнера», мозок намагається спростити все до: «А, це просто як -Xmx, тільки задається Dockerʼом». І ось тут на нас чекає перша пастка дня. Насправді у нас дві різні межі: зовнішня межа контейнера і внутрішня організація пам’яті JVM. Якщо переплутати їхні ролі, ви лікуватимете не причину, а симптоми.
Контейнерний ліміт пам’яті — це те, що Docker (точніше, runtime) дозволяє всьому процесу всередині контейнера. JVM же в межах цього бюджету намагається розподілити пам’ять за своїми потребами: heap (де живуть Java-об’єкти), службові області, стеки потоків, буфери тощо. Тому --memory і -Xmx відповідають на різні запитання, навіть якщо обидва «про пам’ять».
Щоб було зовсім наочно, уявімо це як матрьошку:
flowchart TB A["Ліміт пам’яті контейнера Docker
(--memory)"] --> B["Процес Java (JVM)"] B --> C["Heap (об’єкти Java)
-Xmx"] B --> D["Інша пам’ять
(службова пам’ять JVM, стеки, буфери тощо)"]
Сенс діаграми простий: heap — це частина пам’яті процесу, а контейнер обмежує весь процес цілком.
2. --memory у Docker: що обмежує
Коли ми говоримо «поставимо ліміт контейнеру за пам’яттю», ми не робимо JVM послугу й не «налаштовуємо Java». Ми говоримо Dockerʼу: «Ось цьому контейнеру не можна споживати більше, ніж N мегабайт». Це зовнішнє обмеження, воно не залежить від того, чи є у вас там Spring Boot, чи калькулятор на Bash (так, люди пишуть і таке).
У найпростішому вигляді ліміт задається під час запуску контейнера. У Docker є прапорець --memory (коротка форма -m). Приклад для нашого сервісу:
# Запускаємо контейнер із лімітом пам’яті для всього процесу всередині нього
docker run --rm --name catalog-service \
--memory=512m \
-p 8080:8080 \
docker-java-catalog-service
Тут важливо зрозуміти одну річ: --memory=512m не означає «heap буде 512m». Він означає: «контейнеру не можна вийти за приблизно 512 мегабайт сумарного споживання пам’яті». А далі вже починається життя: JVM всередині контейнера намагатиметься вкластися в цей бюджет. Якщо вкладеться — чудово. Якщо ні — середовище має право (і технічну можливість) завершити процес примусово так, що ви навіть не встигнете ввічливо сказати «вибачте».
Якщо ліміт не задано, це не означає «безмежно». Це означає «у межах того, що доступно системі або VM, де працює Docker». На Docker Desktop (macOS/Windows) ще є ліміт пам’яті самої віртуалки Docker Desktop, тож «безліміт» там теж доволі умовний. Але для нас сьогодні головна думка: `--memory` — це зовнішня стеля для всього процесу.
3. -Xmx: максимум heap
Тепер повернімося до звичного світу JVM. Прапорець -Xmx задає максимальний розмір heap. Heap — це місце, де живуть ваші звичайні Java-об’єкти: рядки, списки, DTO, entity, мапи, усе те, що ви створюєте через new (або опосередковано через фреймворки, які теж люблять new, але без вашого відома).
Для швидкої перевірки «який у мене максимальний heap просто зараз» можна подивитися значення Runtime.getRuntime().maxMemory(). У Java це якраз максимально доступний heap, який JVM готова використовувати (у байтах). Мініприклад:
// maxMemory() повертає значення в байтах — переводимо в мегабайти для зручності читання
long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// Це саме "стеля heap", а не ліміт контейнера
System.out.println("maxHeapMb=" + maxHeapMb); // maxHeapMb=256 (приклад)
Це значення дуже корисне, але важливо не переоцінити його зміст. Воно показує внутрішню межу heap, а не «скільки пам’яті у контейнера». JVM може мати maxHeapMb=256, а контейнер при цьому може мати ліміт 384 МБ або 1 ГБ — це різні рівні.
Чому не можна вважати, що heap — це вся пам’ять? Тому що JVM — це не лише heap. Їй потрібно ще дещо для життя. Поки достатньо запам’ятати просте правило: heap — важлива, але не єдина частина пам’яті Java-процесу.
4. Бюджет і запас: --memory vs -Xmx
Тепер зберімо головну ідею лекції в одну зрозумілу картинку. У вас є зовнішній бюджет контейнера, а всередині нього JVM має розмістити heap і все інше. Якщо ви поставите -Xmx майже рівним контейнерному ліміту, ви залишите JVM без запасу. А JVM, як і будь-який живий організм, без запасу починає нервувати, а потім падає. Іноді — дуже різко.
Давайте порівняємо два обмеження в таблиці, щоб прибрати плутанину:
| Обмеження | Де задається | Що обмежує | Типова помилка новачка |
|---|---|---|---|
| --memory=512m | Docker runtime (docker run, Compose) | Усю пам’ять контейнера, тобто пам’ять процесу загалом | Думати, що це «розмір heap» |
| -Xmx256m | JVM (через args/env) | Лише heap, де живуть Java-об’єкти | Думати, що цього достатньо, щоб не впіймати проблеми з пам’яттю |
Тепер про практичний запас. Уявіть, що контейнер — це квартира з жорстким обмеженням площі, а heap — ваш робочий стіл. Можна поставити стіл впритул до стін так, що дверцята шафи не відкриються. Формально стіл умістився. Фактично жити незручно: ви почнете чіплятися щоразу, коли спробуєте пройти. У JVM «чіплятися» зазвичай означає неприємні сценарії з пам’яттю та нестабільність.
Робоче правило на рівні здорового глузду для Junior звучить так: спочатку вибираємо memory limit контейнера як загальний бюджет, потім вирішуємо, яку частину бюджету віддаємо heap, і залишаємо запас на решту життя процесу. Поки не розкладаємо цю решту життя по деталях. Нам зараз важливо побачити сам принцип: дві межі, зовнішній бюджет і внутрішній розподіл.
5. Логуємо maxHeapMb на старті
Теорія стає набагато спокійнішою, коли з’являється спостережуваний сигнал просто в логах нашого застосунку. Ми не хочемо гадати, який heap у JVM; ми хочемо побачити це під час старту контейнера так само природно, як звикли бачити активні профілі й порт.
У Container-Ready Catalog Service ми можемо додати маленький операційний маячок у пакет ops: під час старту застосунку виводити максимальний heap у мегабайтах. Це не тюнінг, не магія, а просто зрозуміла діагностика, яка допомагає пов’язати JAVA_TOOL_OPTIONS і поведінку JVM. Цю невелику конфігурацію далі зручно розширювати новими сигналами, а не заводити під кожну метрику окремий startup-class.
Приклад конфігурації, яку легко тримати в проєкті (і ще легше потім видалити, якщо треба), виглядає так:
package com.example.catalog.ops;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Конфігураційний клас Spring: реєструємо інфраструктурний бін
public class RuntimeSnapshotConfig {
private static final Logger log = LoggerFactory.getLogger(RuntimeSnapshotConfig.class);
@Bean
ApplicationRunner logRuntimeSnapshot() {
// ApplicationRunner виконається один раз під час старту застосунку
return args -> {
// maxMemory() — це стеля heap, яку JVM готова виділити (значення в байтах)
long maxHeapMb = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// Логуємо метрику як "операційний маячок", щоб бачити ефект від -Xmx у середовищі виконання
log.info("Знімок середовища виконання: maxHeapMb={}", maxHeapMb);
};
}
}
Тут важливе одне. Ми логуємо лише те, що JVM чесно повідомляє про heap. Ми не намагаємося в цій лекції витягти ліміт контейнера зсередини Java — це окремий світ (cgroups, системні файли тощо), і нам зараз важливіше не ускладнювати. Контейнерний ліміт ми читаємо ззовні, командами Docker, а внутрішню межу heap — із Java.
Після цього у нас з’являється чудова зв’язка: запускаємо контейнер, дивимося docker logs — бачимо maxHeapMb. Змінюємо JAVA_TOOL_OPTIONS — бачимо, як змінюється maxHeapMb. І все це без перескладання image. Краса. Майже як у казці, тільки без єдинорогів і з логами.
6. Передаємо -Xmx через JAVA_TOOL_OPTIONS
Тепер важливий практичний трюк, який одночасно простий і дуже «контейнерний» за духом. Ми не хочемо перескладати образ лише тому, що захотіли змінити heap. Ба більше, це суперечить нашій базовій ідеї курсу: той самий образ, інша конфігурація середовища виконання.
Для JVM є стандартний механізм: змінна середовища JAVA_TOOL_OPTIONS. Якщо вона задана, JVM під час старту автоматично підхоплює аргументи з неї. Тобто ми можемо запускати один і той самий image, але змінювати heap так:
# Обмежуємо контейнер за пам’яттю і окремо задаємо межу heap для JVM
docker run --rm --name catalog-service \
--memory=512m \
-e JAVA_TOOL_OPTIONS="-Xmx256m" \
-p 8080:8080 \
docker-java-catalog-service
Що тут відбувається по рівнях і чому це важливо тримати в голові?
Контейнерний ліміт --memory=512m задає зовнішній бюджет. Змінна середовища JAVA_TOOL_OPTIONS="-Xmx256m" задає внутрішню межу heap. Це не конкуруючі налаштування, а налаштування різних рівнів. Саме тому сьогодні ми так наполегливо розводимо їх за ролями.
І так, маленьке побутове попередження: лапки та пробіли. У Unix-like shell усе доволі звично. У PowerShell і CMD на Windows можуть бути нюанси екранування. У межах курсу ми не йдемо в OS-specific трек, але якщо у вас раптом «не застосувався -Xmx», першим ділом перевірте, що змінна середовища справді дійшла до контейнера (через docker inspect), а рядок не розвалився через лапки.
7. Стартовий -Xmx: безпечна точка
Зараз хочеться отримати «чарівну формулу». Наприклад: «ставте heap рівно 70 % від ліміту, і буде щастя». Я розумію бажання. Воно дуже людське. Але універсальної цифри немає: занадто сильно залежить від того, що робить застосунок, скільки потоків, які бібліотеки підключені та які буфери використовуються.
Тим не менш стартова точка все одно потрібна. Робоче правило на цьому рівні просте: не ставити -Xmx впритул до ліміту контейнера. Якщо контейнеру дали --memory=384m, почати з -Xmx256m зазвичай набагато безпечніше, ніж із -Xmx384m.
Сенс не в магічній цифрі 256. Сенс у запасі. Щойно в бюджет потрапляють metaspace, стеки потоків і буфери, швидко стає видно, чому «віддати весь ліміт heap» — погана ідея. Якщо хочеться зовсім побутової картинки: контейнерний ліміт — це весь гаманець, а -Xmx — лише одна стаття витрат. Віддати весь гаманець одній статті легко, жити так потім — складно.
Для першого запуску цього правила достатньо. Точні діапазони з’являються лише тоді, коли ви враховуєте не тільки heap, а й решту пам’яті процесу.
8. Перевіряємо ліміт контейнера
Дуже типовий сценарій у реальності виглядає так: людина пише команду з --memory, потім дивиться на застосунок і каже: «Ну, начебто працює». А потім з’ясовується, що ліміт не застосувався (або застосувався не туди), і висновки про поведінку JVM зроблені на око.
Щоб цього уникнути, корисно тримати в голові два швидкі способи перевірити ліміт зовні, не лізучи в нетрі. Перший спосіб — docker stats, він показує споживання пам’яті й ліміт поруч, в одному місці. Приклад:
docker stats catalog-service
У виводі буде колонка на кшталт MEM USAGE / LIMIT. Якщо ви задали --memory=512m, ви повинні побачити ліміт близько 512 MiB (формат залежить від платформи, але сенс той самий). Це простий спосіб за 10 с зрозуміти: «Я точно запустив контейнер так, як думаю?»
Другий спосіб — docker inspect. Він дає вам «правду в JSON». Для навчальної швидкості можна витягнути лише шматок стану:
# Витягуємо ліміт пам’яті контейнера з HostConfig (значення буде в байтах)
docker inspect --format '{{json .HostConfig.Memory}}' catalog-service
Тут важливо не заплутатися в одиницях. Docker зберігає багато значень у байтах. Якщо ви побачили число, схоже на 536870912, не лякайтеся: це якраз 512 * 1024 * 1024. Тобто ваш ліміт справді записано.
І ось коли у вас є обидва сигнали — docker stats/inspect ззовні й maxHeapMb у логах усередині — ви отримуєте те, заради чого ми це робимо: спостережувану систему, а не набір здогадок.
9. Типові помилки під час роботи з --memory і -Xmx
Помилка № 1: ототожнювати --memory із -Xmx.
Це найчастіша плутанина: «Я поставив контейнеру 512m, отже heap теж 512m». Ні. --memory обмежує весь процес цілком, а -Xmx — лише heap. Як уникнути: завжди тримати в голові дві межі й перевіряти їх двома способами — ліміт через Docker, heap через maxMemory().
Помилка № 2: ставити -Xmx майже впритул до ліміту контейнера.
Комбінація на кшталт --memory=512m і -Xmx512m — це майже гарантований спосіб улаштувати собі нестабільне життя, особливо на Spring Boot-сервісі, де, окрім ваших об’єктів, є ще інфраструктурний код, класи, потоки та багато іншого. Як уникнути: залишати помітний запас, особливо на малих лімітах.
Помилка № 3: змінювати одночасно ліміт контейнера й налаштування JVM — і намагатися зрозуміти, що саме допомогло.
Якщо ви одночасно змінили --memory, -Xmx, профіль Spring Boot і ще «трохи» версію образу, ви потім не зможете чесно відповісти, що саме вплинуло. Як уникнути: змінювати один шар за раз. Сьогодні — лише ліміти середовища виконання та параметри JVM, образ і код тримаємо незмінними.
Помилка № 4: дивитися тільки на Java-логи і ігнорувати команду запуску контейнера.
Іноді в логах видно «все добре», а контейнерний ліміт насправді не застосувався, або застосувався інший. Як уникнути: після запуску контейнера швидко перевіряти docker stats або docker inspect, щоб не будувати висновки на хибній передумові.
Помилка № 5: лікувати будь-який симптом, пов’язаний із пам’яттю, збільшенням heap.
Це рефлекс Java-розробника: «не вистачило пам’яті — додамо heap». Але якщо проблема в тому, що контейнерний бюджет малий, то збільшення heap може лише пришвидшити зіткнення з лімітом. Як уникнути: спочатку зіставити зовнішній бюджет (--memory) і внутрішню межу heap (-Xmx), і лише потім ухвалювати рішення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ