1. Єдиний сценарій замість «шаманства»
Якщо ви колись дебажили «у мене локально іноді гальмує», ви знаєте, як легко скотитися в містицизм: змінити три параметри, пересобрати образ, перезапустити Docker Desktop, випити кави й оголосити, що допомогла саме кава. Єдиний сценарій потрібен саме для того, щоб не втратити причинно-наслідковий звʼязок і змінювати по одному фактору за раз.
Головна думка проста й навіть трохи нудна, а в інфраструктурі нудне часто означає «надійне»: один і той самий образ Docker має поводитися по-різному лише через ліміти середовища виконання та runtime-конфігурацію. На цьому етапі сервіс уже має всі потрібні сигнали: стартовий Runtime snapshot із maxHeapMb і cpus, /actuator/health, docker stats, docker inspect та обчислювально важку кінцеву точку /api/ops/cpu/sum-squares. Нічого нового збирати не треба.
Беремо один і той самий docker-java-catalog-service, проганяємо його через baseline, memory-pressure і CPU-pressure та кожного разу читаємо стан у фіксованому порядку: спочатку стартові логи, потім health, далі docker stats, потім docker inspect. Саме так і будується дерево рішень для обмежень ресурсів: memory-pressure частіше закінчується OutOfMemoryError або OOMKilled, а CPU-pressure — зростанням затримки.
Ми не переписуємо доменну логіку, не робимо окремої docker-гілки коду й не збираємо новий образ під кожен експеримент. Ми запускаємо один і той самий docker-java-catalog-service і читаємо його стан у фіксованому порядку: спочатку стартові логи, потім health, далі docker stats, потім docker inspect і лише після цього робимо висновок, що саме сталося.
2. Runtime snapshot у стартових логах
Сервіс може бути ідеальним, але якщо він не каже нам, які межі бачить, ми змушені гадати. Тому на цьому етапі стартові логи вже друкують Runtime snapshot: там є щонайменше maxHeapMb і cpus, а якщо ви розширювали snapshot для memory-path, то поруч може бути й usedNonHeapMb. Для memory-сценаріїв це повʼязує JAVA_TOOL_OPTIONS з реальним максимумом heap, а для CPU-сценаріїв одразу показує, чи JVM помітила обмеження за процесорами.
Цього мінімуму достатньо, щоб baseline і обидва режими тиску на ресурси порівнювалися за одними й тими самими сигналами. Нам тут не потрібен ще один логер запуску. Потрібен один зрозумілий snapshot, який читається однаково під час кожного запуску контейнера.
Для CPU-сценарію в нас уже є окремий зонд — /api/ops/cpu/sum-squares. Він потрібен не як «ще один бізнес-endpoint», а як передбачуваний обчислювально важкий запит. Завдяки йому можна змінювати лише --cpus і спостерігати, як зростає затримка, не змішуючи ефект із мережею, базою чи випадковими особливостями доменних запитів. Одного такого ops-зонда для CPU-сценарію більш ніж достатньо.
3. Baseline: запуск без limits
Перш ніж обмежувати ресурси, потрібно зрозуміти, як сервіс поводиться «в нормі». Це як із температурою: якщо ви не знаєте, що таке «36,6» для вашого організму, то «37,2» перетворюється на привід для філософських суперечок. Baseline-запуск — це не марнування часу, а точка відліку, без якої порівняння будуть беззмістовними.
Припустімо, що образ уже зібрано. Запускаємо контейнер так, щоб він не видалявся автоматично: це важливо, тому що якщо ми потім будемо розслідувати збій, нам стане у пригоді docker inspect. Тому на час лабораторії краще не ставити --rm.
# Запускаємо без `--rm`, щоб за потреби провести післяаварійний аналіз через `docker inspect`.
docker run -d --name catalog-service -p 8080:8080 docker-java-catalog-service
Тепер чесна перевірка, що сервіс живий, — це не просто наявність контейнера в docker ps, а кілька простих спостережень. Ми перевіряємо health і один доменний endpoint (каталог).
curl -s http://localhost:8080/actuator/health
# {"status":"UP", ...}
curl -s http://localhost:8080/api/catalog/items | head
# [ ... елементи каталогу ... ]
А тепер те, заради чого ми додавали runtime snapshot: читаємо стартові логи й знаходимо там рядок Runtime snapshot. В ідеальному світі цей рядок знаходиться без зусиль, а не ховається серед 200 рядків автоконфігурації.
docker logs catalog-service | grep "Runtime snapshot"
# Runtime snapshot: maxHeapMb=..., ..., cpus=...
І останній елемент baseline — не для краси, а щоб ми звикли дивитися на реальне споживання ресурсів. Для цього достатньо docker stats.
docker stats catalog-service
На baseline ви зазвичай побачите спокійне споживання памʼяті й невелике навантаження на CPU. Запамʼятайте або навіть запишіть порядок величин: скільки памʼяті сервіс займає в простої після старту й наскільки стрибає CPU під час запитів. Це ваш «нормальний пульс» перед тим, як ми почнемо саджати сервіс на дієту.
Коли baseline зафіксовано, контейнер можна зупинити й видалити, щоб наступні прогони були чистими й без конфліктів імен.
docker stop catalog-service
docker rm catalog-service
4. Memory: ліміт контейнера і -Xmx
Тепер робимо те, заради чого весь рівень і було задумано: запускаємо той самий образ, але із зовнішнім memory limit і внутрішнім heap limit. Важливо розуміти: ми не граємося «в -Xmx» у вакуумі. Ми завжди дивимося на звʼязку «межа контейнера» + «межа heap», тому що саме так Java живе в Docker.
Для прикладу візьмемо розумний «навчальний» бюджет: контейнеру дамо 384 MB, heap обмежимо 256 MB. Це виглядає безпечніше, ніж «256 і 256», тому що залишає запас на metaspace, потоки, direct buffers і решту радощів JVM.
# `--memory` обмежує пам’ять контейнера зовні.
# `JAVA_TOOL_OPTIONS` дає змогу задати JVM-параметри без перескладання образу.
docker run -d --name catalog-service -p 8080:8080 \
--memory=384m \
-e JAVA_TOOL_OPTIONS="-Xmx256m" \
docker-java-catalog-service
Далі ми не стрибаємо по командах, як по кнопках ліфта, а йдемо за одним і тим самим сценарієм спостереження. Спочатку дивимося стартові логи й переконуємося, що сервіс справді стартував і що він побачив саме той heap, який ми йому задали.
docker logs catalog-service | grep "Runtime snapshot"
# Runtime snapshot: maxHeapMb=256, ..., cpus=...
Потім перевіряємо health. Якщо health зелений, це означає не «все ідеально», а «сервіс принаймні здатний відповідати й не падає на старті».
curl -s http://localhost:8080/actuator/health
# {"status":"UP", ...}
Потім відкриваємо docker stats і дивимося на Memory. Тут корисно не шукати магічні цифри, а ловити відчуття: якщо контейнер уже на старті близький до ліміту, то будь-який додатковий тиск — експорт, більше даних, кілька паралельних запитів — може штовхнути його в стіну.
docker stats catalog-service
І ось тут зʼявляється важлива думка дня: погане налаштування memory sizing може виглядати двома різними способами. В одному випадку Java падає сама, і ви бачите в логах щось на кшталт OutOfMemoryError. В іншому випадку контейнер ніби просто помер, логів немає або вони обриваються на півслові, і ви починаєте підозрювати, що «Docker знову щось утнув». У цьому місці ми не підозрюємо, а перевіряємо.
Якщо контейнер несподівано зупинився, спочатку переконуємося, що він справді не живий:
docker ps -a --filter "name=catalog-service"
# ... Exited (...) catalog-service
Потім читаємо state через docker inspect. Для післяаварійного аналізу нам важливі три речі: чи був OOMKill, який exit code і коли контейнер завершив роботу.
# `.State.OOMKilled=true` — ключова ознака того, що процес убили зовні через memory limit.
docker inspect --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Status={{.State.Status}}' catalog-service
# OOMKilled=true ExitCode=137 Status=exited
Якщо OOMKilled=true, це означає: «його вбили зовні через ліміт памʼяті». Тут можна скільки завгодно шукати stacktrace в логах — його може й не бути, бо процес не встиг нічого красиво написати. Саме тому inspect у цьому сценарії важливіший за здогадки.
Щоб додатково перевірити, що ми взагалі правильно задали memory limit, а не лише думаємо, що задали, можна подивитися конфігурацію контейнера. Там ліміти будуть у байтах — це нормально, просто така реальність.
docker inspect --format 'MemoryBytes={{.HostConfig.Memory}}' catalog-service
# MemoryBytes=402653184 (це 384 * 1024 * 1024)
Коли ви побачили цю картину хоча б один раз, зʼявляється досить практичне правило: якщо контейнер помер без зрозумілих Java-логів, насамперед дивіться OOMKilled і ліміти, а вже потім сперечайтеся з колегами про GC і про те, чи треба нам переписати все на Kotlin.
Після експерименту не забуваємо привести стенд у чистий стан:
docker rm -f catalog-service
5. CPU-сценарій: --cpus і latency
Ліміти CPU — це підступна історія, тому що вони рідко ламають сервіс красиво. Скоріше вони перетворюють швидкий сервіс на повільний, а повільний — на «чому все так довго, але помилок же немає». Тому в CPU-сценарії ми спостерігаємо не лише статус контейнера та health, а й швидкість відповідей, особливо для обчислювально важкої кінцевої точки.
Запустімо контейнер з обмеженням CPU. Для чистоти експерименту залишимо памʼять достатньо комфортною і поставимо передбачуваний heap, щоб не змішувати «CPU-проблему» та «memory-проблему».
docker run -d --name catalog-service -p 8080:8080 \
--cpus=0.5 \
--memory=512m \
-e JAVA_TOOL_OPTIONS="-Xmx256m" \
docker-java-catalog-service
Перше, що ми перевіряємо, — сервіс узагалі стартував і живий.
curl -s http://localhost:8080/actuator/health
# {"status":"UP", ...}
Тепер має сенс порівняти «легкий шлях» і «важкий шлях». Легким буде, наприклад, той самий health. Важким — наша навчальна CPU кінцева точка або інший відомий важкий сценарій, якщо він у вас уже є.
Щоб побачити ефект не на око, а цифрами, зручно використовувати curl з виведенням часу відповіді:
curl -o /dev/null -s -w "health time_total=%{time_total}\n" \
http://localhost:8080/actuator/health
# health time_total=0.010
curl -o /dev/null -s -w "cpu time_total=%{time_total}\n" \
"http://localhost:8080/api/ops/cpu/sum-squares?n=5000000"
# cpu time_total=1.800
Цифри у вас будуть іншими, і це нормально. Тут важливо не впіймати рівно 1,8 секунди, а побачити принцип: під CPU limit важка кінцева точка деградує сильніше, ніж легка, а контейнер при цьому не зобовʼязаний падати. Він живий, але повільніший.
Далі знову вмикаємо docker stats і дивимося на CPU. Якщо ви навантажуєте обчислювально важку кінцеву точку, то побачите, що використання CPU впирається в стелю. Це як із людиною, яка йде з рюкзаком: вона йде, але швидше не може.
docker stats catalog-service
І ще одна приємна перевірка — та сама строка Runtime snapshot. Під обмеженням CPU JVM може «бачити» іншу кількість процесорів. Це залежить від середовища й налаштувань, але в будь-якому разі корисно мати цей сигнал у логах, щоб потім не сперечатися: «А чому пул потоків почав поводитися інакше?».
docker logs catalog-service | grep "Runtime snapshot"
# Runtime snapshot: maxHeapMb=256, ..., cpus=1
Після прогону чистимо контейнер, щоб наступний експеримент знову починався з нуля.
docker rm -f catalog-service
6. Порядок діагностики: логи → health → stats → inspect
Найцінніше тут — не конкретні числа «384m і 256m», а стійкий порядок читання сигналів. Щойно ви його фіксуєте, діагностика перестає бути панікою й перетворюється на ремесло: спочатку дивимося, що застосунок сам про себе пише, потім перевіряємо зовнішній контракт здоровʼя, далі дивимося фактичне споживання ресурсів і лише потім читаємо посмертний стан контейнера.
Ось так цей сценарій можна тримати в голові, і так, у вигляді схеми він запамʼятовується краще, ніж у вигляді «ну там спочатку логи, потім…»:
flowchart TD A["Запуск контейнера з лімітами середовища виконання"] --> B["Логи старту: профілі, maxHeapMb, cpus"] B --> C["/actuator/health: сервіс відповідає?"] C --> D["docker stats: CPU і памʼять під навантаженням"] D --> E["docker inspect: OOMKilled, ExitCode, Status"] E --> F["Висновок: живий / повільний / зупинений середовищем"]
Щоб зробити це максимально прикладним, корисно тримати маленьку таблицю «який інструмент відповідає на яке питання». Це не список команд на зубок, а спосіб не стріляти з гармати по горобцях.
| Сигнал | Чим читаємо | На яке питання відповідає |
|---|---|---|
| Старт і внутрішні рамки JVM | docker logs | Сервіс стартував? Які профілі? Який max heap? Скільки CPU бачить JVM? |
| Готовність відповідати | GET /actuator/health | Сервіс справді приймає HTTP-запити, а не просто «процес живий» |
| Фактичне споживання ресурсів | docker stats | Скільки памʼяті й CPU споживає контейнер просто зараз, а не «в теорії» |
| Підсумок після збою | docker inspect | Контейнер упав сам чи його вбили? OOMKilled? який ExitCode? |
І ось тут відбувається магія в хорошому сенсі, тобто не магія, а дисципліна: ви перестаєте робити висновок за одним симптомом. «Health зелений» більше не означає «все добре», тому що ви бачите, що CPU впирається, а важка кінцева точка стала у 10 разів повільнішою. «Логів немає» більше не означає «Docker зламався», тому що ви бачите OOMKilled=true.
7. Типові помилки під час діагностики обмежень ресурсів
У цій темі помилки особливо неприємні тим, що вони виглядають як «ну я ж усе зробив правильно, а воно все одно дивно». Насправді майже завжди проблема не в тому, що Docker або JVM «капризують», а в тому, що ми самі ламаємо експеримент: змінюємо кілька факторів одночасно, втрачаємо контейнер для inspect або читаємо лише одне джерело сигналів. Нижче — найчастіші граблі, на які натрапляють навіть уважні люди.
Помилка №1: запускати експерименти з --rm, а потім намагатися зробити післяаварійний аналіз.
--rm чудовий, коли ви впевнені, що контейнер або відпрацює штатно, або вам не важливі сліди. Але в сценарії OOMKilled ви отримаєте класичну ситуацію: контейнер зник, а ви хочете подивитися .State.OOMKilled. Психологічно це дуже схоже на те, ніби контейнер зник без сліду, але насправді це ви самі його утилізували. Для лабораторії з лімітами краще запускати без --rm і видаляти контейнер вручну після аналізу.
Помилка №2: поставити -Xmx майже рівним --memory і дивуватися, що сервіс помирає.
Новачку здається логічним: «контейнеру дали 512 MB, отже heap 512 MB — ідеально». JVM так не вважає. Їй потрібне місце на metaspace, на стек потоків, на нативні буфери, на внутрішні структури. Тому безпечна стратегія — це завжди запас, і інколи доволі відчутний. Якщо ви залишаєте 5–10 MB на все інше, це вже не запас, а знущання.
Помилка №3: змінювати одночасно й ліміти, і код, і образ, і конфіги.
Так дуже легко отримати «ніби допомогло», але не зрозуміти чому. Якщо ви одночасно зменшили --memory, змінили JAVA_TOOL_OPTIONS, пересклали образ і ще ввімкнули інший профіль, ви потім не зможете пояснити, що саме вплинуло. У сценаріях із обмеженнями ресурсів краще бути нудною людиною: один параметр — одна гіпотеза — одна перевірка.
Помилка №4: читати лише логи й ігнорувати docker inspect.
Під час звичайного збою застосунку логи справді є головним джерелом правди: stacktrace, причина, місце. Але OOMKilled — це зовнішній убивця, і логів може не бути. У цьому випадку inspect — не додаткова команда, а ключ до розгадки. Якщо контейнер помер несподівано, а в логах тиша, OOMKilled потрібно перевіряти майже рефлекторно.
Помилка №5: у CPU-сценарії чекати crash і не дивитися на затримку.
CPU limits найчастіше проявляються як «все стало повільно», а не як «все впало». Якщо ви дивитеся лише docker ps і радієте, що контейнер живий, ви пропускаєте головний ефект. У CPU-лабораторії корисніше порівнювати часи відповіді легкої та важкої кінцевої точки й паралельно дивитися docker stats, ніж чекати червоної помилки в логах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ