JavaRush /Курсы /Docker for Spring /Resource-limits для Catalog Service

Resource-limits для Catalog Service

Docker for Spring
23 уровень , 4 лекция
Открыта

1. Единый сценарий вместо «шаманства»

Если вы когда-нибудь дебажили «у меня локально иногда тормозит», вы знаете, насколько легко уйти в мистику: поменять три параметра, пересобрать образ, перезапустить Docker Desktop, выпить кофе, и объявить, что помогло именно кофе. Единый сценарий нужен ровно затем, чтобы не потерять причинно‑следственную связь и менять по одному фактору за раз.

Главная мысль простая и даже немного скучная (а скучное в инфраструктуре часто означает «надёжное»): один и тот же Docker image должен вести себя по‑разному только из‑за runtime limits и runtime конфигурации. К этому месту у сервиса уже есть все нужные сигналы: стартовый Runtime snapshot с maxHeapMb и cpus, /actuator/health, docker stats, docker inspect и CPU-bound endpoint /api/ops/cpu/sum-squares. Ничего нового собирать не надо.

Берём один и тот же docker-java-catalog-service, прогоняем его через baseline, memory-pressure и CPU-pressure и каждый раз читаем состояние в фиксированном порядке: сначала стартовые логи, затем health, затем docker stats, затем docker inspect. Именно так и собирается decision tree resource-limits: memory-pressure чаще заканчивается OutOfMemoryError или OOMKilled, CPU-pressure — ростом latency.

Мы не переписываем доменную логику, не делаем «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 и оба режима давления на ресурсы сравнивались по одним и тем же сигналам. Нам здесь не нужен ещё один startup-logger. Нужен один понятный snapshot, который читается одинаково при каждом запуске контейнера.

Для CPU-пути у нас уже есть отдельный зонд — /api/ops/cpu/sum-squares. Он нужен не как «ещё один бизнес-endpoint», а как предсказуемый CPU-bound запрос. Благодаря ему можно менять только --cpus и смотреть, как плывёт latency, не смешивая эффект с сетью, базой или случайными особенностями доменных запросов. Одного такого ops-зонда для CPU-сценария более чем достаточно.

3. Baseline: запуск без limits

Перед тем как ограничивать ресурсы, нужно понять, как сервис ведёт себя «в норме». Это как с температурой: если вы не знаете, что такое «36.6» для вашего организма, то «37.2» превращается в повод для философских споров. Baseline-запуск — это не трата времени, это точка отсчёта, без которой сравнения будут бессмысленными.

Предположим, что image уже собран. Запускаем контейнер так, чтобы он не удалялся автоматически: это важно, потому что если мы потом будем расследовать падение, нам пригодится docker inspect. Поэтому на время лаборатории лучше не ставить --rm.

# Запускаем без `--rm`, чтобы при необходимости сделать post-mortem через `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. Для post-mortem нам важны три вещи: был ли 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 limits — это коварная история, потому что она редко «ломает» сервис красиво. Скорее она превращает быстрый сервис в медленный, а медленный — в «почему всё так долго, но ошибок же нет». Поэтому в CPU-сценарии мы наблюдаем не только статус контейнера и health, но и скорость ответов, особенно для вычислительно тяжёлого endpoint’а.

Запустим контейнер с ограничением 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 endpoint (или другой известный тяжёлый сценарий, если он у вас уже есть).

Чтобы увидеть эффект не «на глаз», а цифрой, удобно использовать 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 тяжёлый endpoint деградирует сильнее, чем лёгкий, а контейнер при этом не обязан падать. Он жив, но медленнее.

Дальше снова включаем docker stats и смотрим на CPU. Если вы долбите CPU-heavy endpoint, вы увидите, что CPU usage упирается в потолок. Это как с человеком, который идёт с рюкзаком: он идёт, но быстрее не может.

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["Запуск контейнера с runtime limits"] --> B["Startup logs: profiles, maxHeapMb, cpus"]
  B --> C["/actuator/health: сервис отвечает?"]
  C --> D["docker stats: CPU и Memory под нагрузкой"]
  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 упирается, а тяжёлый endpoint стал в 10 раз медленнее. «Логов нет» больше не означает «Docker сломался», потому что вы видите OOMKilled=true.

7. Типичные ошибки при resource-limits диагностике

В этой теме ошибки особенно неприятны тем, что они выглядят как «ну я же всё сделал правильно, а оно всё равно странно». На самом деле почти всегда проблема не в том, что Docker или JVM «капризничают», а в том, что мы сами ломаем эксперимент: меняем несколько факторов одновременно, теряем контейнер для inspect, или читаем только один источник сигналов. Ниже — самые частые грабли, на которые наступают даже аккуратные люди.

Ошибка №1: запускать эксперименты с --rm, а потом пытаться сделать post-mortem.
--rm прекрасен, когда вы уверены, что контейнер либо отработает штатно, либо вам не важны следы. Но в сценарии OOMKilled вы получите классическую ситуацию: контейнер исчез, а вы хотите посмотреть .State.OOMKilled. Психологически это очень похоже на «труп украли», но на самом деле это вы сами его утилизировали. Для лаборатории по лимитам лучше запускать без --rm и удалять контейнер вручную после анализа.

Ошибка №2: поставить -Xmx почти равным --memory и удивляться, что сервис умирает.
Новичку кажется логичным: «контейнеру дали 512 MB, значит heap 512 MB — идеально». JVM так не считает. Ей нужно место на metaspace, на стек потоков, на нативные буферы, на внутренние структуры. Поэтому safe-стратегия — это всегда запас, и иногда довольно ощутимый. Если вы оставляете 510 MB на всё остальное, это уже не запас, а издевательство.

Ошибка №3: менять одновременно и лимиты, и код, и образ, и конфиги.
Так очень легко получить «вроде помогло», но не понять почему. Если вы одновременно уменьшили --memory, поменяли JAVA_TOOL_OPTIONS, пересобрали образ и ещё включили другой профиль — вы потом не сможете объяснить, что именно повлияло. В resource-limits сценариях лучше быть скучным человеком: один параметр — одна гипотеза — одна проверка.

Ошибка №4: читать только логи и игнорировать docker inspect.
При обычном падении приложения логи действительно главный источник правды: stacktrace, причина, место. Но OOMKilled — это внешний убийца, и логов может не быть. В этом случае inspect — не «дополнительная команда», а ключ к разгадке. Если контейнер умер неожиданно, а в логах тишина, OOMKilled нужно проверять почти рефлекторно.

Ошибка №5: в CPU-сценарии ждать crash и не смотреть на latency.
CPU limits чаще всего проявляются как «всё стало медленно», а не как «всё упало». Если вы смотрите только docker ps и радуетесь, что контейнер жив, вы пропускаете главный эффект. В CPU-лаборатории полезнее сравнивать времена ответов лёгкого и тяжёлого endpoint’а и параллельно смотреть docker stats, чем ждать красной ошибки в логах.

1
Задача
Docker for Spring, 23 уровень, 4 лекция
Недоступна
Единый отчёт по baseline и memory limit
Единый отчёт по baseline и memory limit
1
Задача
Docker for Spring, 23 уровень, 4 лекция
Недоступна
Единый отчёт по normal CPU и limited CPU
Единый отчёт по normal CPU и limited CPU
1
Опрос
Docker Limits, 23 уровень, 4 лекция
Недоступен
Docker Limits
Память, CPU и JVM
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ