JavaRush /Курси /Docker for Spring /Обмеження ресурсів для Catalog Service

Обмеження ресурсів для Catalog Service

Docker for Spring
Рівень 23 , Лекція 4
Відкрита

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, на стек потоків, на нативні буфери, на внутрішні структури. Тому безпечна стратегія — це завжди запас, і інколи доволі відчутний. Якщо ви залишаєте 510 MB на все інше, це вже не запас, а знущання.

Помилка №3: змінювати одночасно й ліміти, і код, і образ, і конфіги.
Так дуже легко отримати «ніби допомогло», але не зрозуміти чому. Якщо ви одночасно зменшили --memory, змінили JAVA_TOOL_OPTIONS, пересклали образ і ще ввімкнули інший профіль, ви потім не зможете пояснити, що саме вплинуло. У сценаріях із обмеженнями ресурсів краще бути нудною людиною: один параметр — одна гіпотеза — одна перевірка.

Помилка №4: читати лише логи й ігнорувати docker inspect.
Під час звичайного збою застосунку логи справді є головним джерелом правди: stacktrace, причина, місце. Але OOMKilled — це зовнішній убивця, і логів може не бути. У цьому випадку inspect — не додаткова команда, а ключ до розгадки. Якщо контейнер помер несподівано, а в логах тиша, OOMKilled потрібно перевіряти майже рефлекторно.

Помилка №5: у CPU-сценарії чекати crash і не дивитися на затримку.
CPU limits найчастіше проявляються як «все стало повільно», а не як «все впало». Якщо ви дивитеся лише docker ps і радієте, що контейнер живий, ви пропускаєте головний ефект. У CPU-лабораторії корисніше порівнювати часи відповіді легкої та важкої кінцевої точки й паралельно дивитися docker stats, ніж чекати червоної помилки в логах.

1
Опитування
Обмеження Docker, рівень 23, лекція 4
Недоступний
Обмеження Docker
Пам’ять, CPU і JVM
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ