JavaRush /Курсы /Docker for Spring /CPU limits и время о...

CPU limits и время ответа

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

1. CPU vs память: сервис медленнее

С memory мы уже увидели жёсткий budget: сервис может уткнуться во внутренний OutOfMemoryError или вообще словить OOMKilled. CPU ведёт себя иначе. Здесь контейнер чаще не умирает, а начинает жить медленнее.

CPU limits — это тема, в которой мозг разработчика (особенно начинающего) обожает делать неправильные ассоциации. С памятью всё выглядит «логично»: не хватило — упало. А с CPU так не работает. Когда процессу «не дают достаточно процессорного времени», он обычно не умирает, он просто начинает жить медленнее — как разработчик на митингах в понедельник утром.

Представьте два мира. В мире памяти у вас есть «ёмкость»: как будто у вас склад, и если склад переполнен — всё, дверь не закрывается, начинается паника. В мире CPU у вас есть «скорость обслуживания»: как будто у вас одна касса в супермаркете. Если покупателей много — касса не ломается. Она просто делает очередь. И люди в очереди начинают раздражаться, а касса продолжает героически пикать штрих‑кодами.

Для Spring Boot сервиса это означает очень практичную вещь: при CPU‑ограничении контейнер чаще всего остаётся Up, логи выглядят нормально, JVM не жалуется «мне плохо», но весь сервис начинает проявлять симптомы замедления. Это может быть дольше старт контекста Spring, дольше прогрев JIT, медленнее выполнение «тяжёлых» запросов и в целом более высокая задержка (latency).

Чтобы закрепить ментальную модель, удобно держать под рукой маленькую таблицу. Она не про «все случаи жизни», а про типичный опыт разработчика, который вчера запускал сервис без ограничений, а сегодня включил лимиты и удивился.

Ограничение Что ограничиваем Типичный внешний симптом Что вы чувствуете руками
--memory «Сколько памяти контейнеру можно съесть» Может быть аварийное завершение, резкие проблемы при старте/нагрузке «Ой, всё» (иногда буквально)
--cpus «Сколько процессорного времени контейнеру дают» Контейнер живёт, но всё медленнее «Почему всё тормозит, хотя ошибок нет?»

Важный вывод из этой секции простой: CPU‑проблемы надо искать не по факту «контейнер упал/не упал», а по факту «как быстро сервис стартует и отвечает».

2. Docker CPU limits простыми словами: --cpus как потолок

Очень хочется думать про CPU как про «количество ядер», но Docker даёт более точную и более полезную модель: лимит — это потолок по доступному процессорному времени. Самый понятный флаг для новичка (и честный рабочий baseline в курсе) — --cpus. Он задаёт верхнюю границу того, сколько CPU контейнер может использовать.

Например, если у вас на ноутбуке 8 ядер, а вы запускаете контейнер так:

# Ограничиваем контейнер примерно «одним ядром по времени»
docker run --rm --cpus=1.0 docker-java-catalog-service

то контейнеру «разрешено» потреблять примерно как одно ядро по мощности. Не обязательно, что он будет физически работать только на одном конкретном ядре — планировщик ОС может перекидывать выполнение между ядрами, но суммарно вы получаете «одну единицу CPU».

Если вы зададите:

# Половина «ядра по времени»: контейнер жив, но часто становится заметно «вязким»
docker run --rm --cpus=0.5 docker-java-catalog-service

то контейнеру дадут примерно половину «ядра по времени». И это уже тот режим, где вы часто очень наглядно ощущаете: сервис жив, но вязкий. Удобно понимать это так: 0.5 CPU — как будто вы наняли пол‑разработчика для поддержки продакшна. Формально человек есть. Практически — вы скоро начнёте говорить «а можно нам хотя бы одного целого?».

Тут важно не сделать вторую популярную ошибку: считать, что --cpus — это «оптимизация» или «ускорение». Нет. Это именно ограничитель. Он нужен, когда вы хотите воспроизводимо проверить поведение сервиса в условиях дефицита ресурсов, или когда вы делите ресурсы между несколькими контейнерами и не хотите, чтобы один «съел всё».

Для интуиции можно держать ещё одну маленькую табличку:

--cpus Что это примерно означает Как это ощущается локально
0.5 половина ядра по времени старт дольше, тяжёлые операции «думают»
1.0 одно ядро сервис обычно живёт нормально, но без «запаса на всё»
2.0 два ядра комфортнее для параллельной работы и тяжёлых действий

Да, у Docker есть и другие флаги (--cpuset-cpus, --cpu-shares и так далее), но методически в нашем курсе мы держим фокус: сначала вы учитесь мыслить простым потолком (--cpus), а не размазывать внимание по десяти похожим настройкам.

3. Что тормозит Spring Boot под CPU limits

Когда вы впервые запускаете Spring Boot сервис под CPU limit, обычно возникает ощущение: «Да у меня же обычный CRUD‑сервис, почему он вообще может тормозить от CPU?» И вот здесь полезно вспомнить, что Spring Boot — это не один метод main(), это довольно длинная цепочка работы JVM и фреймворка. Просто обычно она «успевает быстро» и вы не обращаете внимания.

На старте Boot‑приложение активно делает CPU‑работу: загружает классы, строит контекст, сканирует classpath, создаёт бины, иногда поднимает встроенный веб‑сервер, формирует прокси, прогревает часть логики. Плюс, JVM параллельно занимается своими делами: JIT‑компиляцией, обслуживанием потоков, сборкой мусора. Если CPU становится меньше, вся эта «стартовая работа» не исчезает — она просто растягивается во времени.

Теперь про обработку запросов. В нашем Container-Ready Catalog Service есть «лёгкие» пути (например, GET /actuator/health или простой read endpoint) и потенциально «тяжёлые» (экспорт, сложные преобразования, сериализация больших ответов, любые вычисления). Под CPU limits очень характерно, что лёгкий endpoint деградирует умеренно, а тяжёлый — заметно сильнее. Это не магия и не «рандом Docker», это просто физика: где CPU используется активно, там ограничение чувствуется сильнее.

Чтобы увидеть разницу без переизобретения бенчмарков, полезно иметь крошечный, нарочито «CPU‑тяжёлый» кусочек кода. Не для бизнеса, а для диагностики. Например, простая сумма квадратов — классический учебный «пожиратель CPU», который легко объяснить даже тем, кто не любит математику.

package com.example.catalog.ops.service;

public final class CpuWork {

  public static long sumSquares(int n) {
    // Чистая CPU-нагрузка: цикл без I/O, без ожиданий, без блокировок.
    long sum = 0;

    // Чем больше n — тем дольше будет выполняться метод (удобно для наблюдений под CPU limits).
    for (int i = 0; i < n; i++) sum += (long) i * i;

    // Возвращаем «результат», чтобы JIT/оптимизации не выкинули вычисление как «ненужное».
    return sum;
  }
}

Обратите внимание на две вещи. Во‑первых, это чистая CPU‑работа: почти нет I/O, почти нет ожиданий, только цикл. Во‑вторых, если вы увеличиваете n, время выполнения растёт линейно (в целом), и это очень удобно для наблюдений.

Теперь добавим маленький «операционный» endpoint (в духе курса у нас есть пакет ops, где такие вещи живут нормально). Он может возвращать результат вычисления и заодно показать, что запрос действительно был обработан.

package com.example.catalog.ops.web;

import com.example.catalog.ops.service.CpuWork;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CpuOpsController {

  @GetMapping("/api/ops/cpu/sum-squares")
  public long sumSquares(@RequestParam(defaultValue = "1000000") int n) {
    // Важно: endpoint учебный и намеренно «CPU-bound».
    // Он помогает отделить «CPU стало мало» от «сломалась база/сеть/конфигурация».
    return CpuWork.sumSquares(n);
  }
}

Да, это «искусственный» endpoint. И да, в продакшн вы его не понесёте (если не хотите, чтобы ваш сервис майнил крипту по запросу). Но как учебный прибор он прекрасен: он помогает отделить «сервис стал медленным из-за CPU» от «сервис стал медленным из-за базы/сети/конфигурации».

Именно этот endpoint дальше и остаётся нашим CPU-зондом: один и тот же запрос, одно и то же вычисление, разные --cpus, сравниваем latency.

И вот здесь появляется то самое практическое различие, которое стоит запомнить словами: под CPU limit контейнер чаще всего живёт, но «пропускная способность» сервиса падает. Если у вас один поток работы (один запрос), он просто выполняется дольше. Если запросов много, они начинают выстраиваться в очередь, и среднее время ответа растёт ещё сильнее.

4. availableProcessors() и «сколько процессоров видит JVM»

После первого запуска под --cpus у многих появляется вопрос: «А JVM вообще понимает, что ей урезали CPU?» В современных версиях Java JVM действительно стала container-aware и пытается учитывать ограничения, которые задаёт контейнерная среда. Но есть тонкость: CPU лимит может быть дробным (0.5), а availableProcessors() — целое число. Поэтому нельзя ожидать, что он честно вернёт «0.5 процессора». Так не бывает: процессор — штука дискретная в API.

Тем не менее availableProcessors() — отличный быстрый сигнал: если у вас стояло --cpus=2.0, а JVM почему-то видит 8, это повод насторожиться и хотя бы понять, что именно происходит. А если вы ограничили до 1 CPU, и JVM видит 1 — вы хотя бы знаете, что часть настроек и библиотек, которые опираются на это значение, будут вести себя соответствующе.

Мини‑пример «какой мир видит JVM»:

// Быстрый «сигнал реальности»: что JVM считает доступным по CPU
int cpus = Runtime.getRuntime().availableProcessors();

// Это целое число: дробные лимиты (например, 0.5) сюда напрямую не «влезают»
System.out.println("cpus=" + cpus); // cpus=1

В нашем проекте полезно выводить это на старте приложения. Не как «оптимизацию», а как элемент операционной ясности: вы запускаете один и тот же image в разных режимах, и логи должны помогать понять, в каком режиме он сейчас живёт. Если maxHeapMb уже логируется на старте, отдельный config-class для CPU здесь не нужен. Достаточно взять int cpus = Runtime.getRuntime().availableProcessors() и добавить это значение в тот же RuntimeSnapshotConfig, рядом с теми сигналами, которые вы там уже печатаете.

Так в одной стартовой строке собираются обе рамки дня: сколько памяти JVM готова взять в heap и сколько CPU она видит. Почему это полезно именно в Docker-контексте? Потому что там очень легко «потерять реальность». Вы думаете, что запустили контейнер с ограничением CPU, а на самом деле запускаете без него. Или думаете, что у вас один режим, а по факту другой. Один небольшой лог-сигнал экономит много времени и убирает гадание.

И ещё один важный нюанс для начинающих: availableProcessors() — это не «сколько у меня реально мощности», а «сколько процессоров/ядер JVM считает доступными». Даже если он вернёт 1 при --cpus=0.5, сервис всё равно будет ощущать себя как «пол‑ядра по времени». Поэтому этот сигнал полезен, но он не заменяет наблюдение времени ответа и общего поведения.

5. Наблюдение CPU limits: latency и docker stats

Когда дело доходит до диагностики CPU, самый вредный подход — ждать «красивой ошибки». Её может не быть. Гораздо практичнее научиться читать три простых сигнала вместе: как долго сервис стартует, как долго он отвечает на простой запрос и что показывает контейнерная статистика по CPU. Это не глубокий performance engineering, а обычная инженерная гигиена вокруг контейнера.

Начнём с контейнерной стороны. Команда docker stats даёт «живую» телеметрию по ресурсам контейнера. Под CPU limit там часто видно, что контейнер упирается в потолок: CPU% стабильно близок к тому, что разрешено лимитом. Это не точная наука (цифры сглаживаются, зависят от платформы), но как симптом — отлично работает.

# Смотрим «живую» статистику: CPU%, память, I/O
docker stats catalog-service

Теперь со стороны приложения. Если вы добавили endpoint вроде /api/ops/cpu/sum-squares, можно (хотя бы на уровне здравого смысла) сравнивать его поведение при разных --cpus. И даже без специальных инструментов измерения вы часто увидите: «на 2 CPU отдаёт ответ почти сразу, на 0.5 CPU — ощутимо думает».

Если хочется добавить совсем маленький кусочек наблюдаемости прямо в код (без метрического зоопарка), можно замерять длительность CPU‑метода и писать её в лог. Для учебного проекта это иногда полезнее, чем обсуждать абстрактные миллисекунды: студент видит, что один и тот же метод под разным CPU лимитом занимает разное время.

import org.springframework.util.StopWatch;

public long timedSumSquares(int n) {
  // Простой ручной замер: нам важно увидеть «стало дольше» под меньшим --cpus
  StopWatch sw = new StopWatch("cpu");

  // Отмечаем, какую именно работу измеряем
  sw.start("sumSquares");
  long result = CpuWork.sumSquares(n);
  sw.stop();

  // Печатаем красивый отчёт (в учебных целях — вполне ок)
  System.out.println(sw.prettyPrint()); // prints task time

  return result;
}

Да, это «ручной» лог‑замер, и да, в проде вы бы делали это иначе (метрики, трассировка и т.д.). Но в учебной лекции нам нужна понятная связь «ограничил CPU → стало дольше», а не идеальная observability‑платформа.

И наконец, самое важное: при CPU limits вы часто видите картину «сервис жив, но медленный». Это отдельный класс проблем. Его нельзя лечить как «сломалась конфигурация» или «сломалась сеть», потому что там обычно есть явная ошибка. Здесь ошибки нет — просто ресурса меньше, чем вы привыкли.

6. Типичные ошибки при работе с CPU limits

CPU limits легко превратить в источник мистики, если относиться к ним как к «ещё одной странной настройке Docker». На самом деле это довольно честная физика: меньше процессорного времени — меньше скорости выполнения. Ошибки чаще возникают не из-за сложности темы, а из-за неправильных ожиданий и попыток диагностировать CPU‑ограничение теми же методами, что и падение приложения.

Ошибка №1: ждать, что сервис “упадёт”, если CPU мало.
Ограничение CPU обычно не приводит к мгновенному завершению контейнера. Контейнер продолжает работать, просто медленнее. Если вы смотрите только на docker ps и видите Up, легко сделать ложный вывод «значит, всё хорошо». В реальности проблема уже есть — просто она в latency, а не в статусе.

Ошибка №2: проверять только один “лёгкий” endpoint и успокаиваться.
/actuator/health (и другие лёгкие запросы) действительно могут оставаться быстрыми даже при жёстком CPU лимите. Это не значит, что сервис “не чувствует” ограничения. CPU‑лимит особенно проявляется на CPU‑bound участках: сериализация больших ответов, экспорт, любые вычисления. Если смотреть только на лёгкий запрос, вы пропускаете главную часть картины.

Ошибка №3: игнорировать availableProcessors() и удивляться странному поведению параллельных вычислений.
Часть Java‑механик и библиотек принимает решения, опираясь на то, сколько процессоров «видно». Параллельные стримы, общий ForkJoinPool, некоторые внутренние пулы — всё это может вести себя иначе при разных значениях. Если вы не логируете и не проверяете этот сигнал, вы теряете важную подсказку о том, почему приложение “перестало быть параллельным”.

Ошибка №4: пытаться лечить CPU‑ограничение настройками памяти.
После лекций про память возникает соблазн: «всё плохо → давайте менять -Xmx». Но CPU‑ограничение лечится не heap‑настройками, а пониманием, что вы сами ограничили скорость выполнения. Память и CPU — разные оси. Да, они могут влиять друг на друга косвенно (например, GC тоже потребляет CPU), но начинать надо с правильной классификации проблемы, а не с привычного набора флагов.

Ошибка №5: менять сразу всё и потом не понимать, что именно помогло (или не помогло).
Очень частая инженерная ловушка: вы одновременно ставите --cpus=0.5, меняете JAVA_TOOL_OPTIONS, добавляете новые логи, включаете другой профиль и тестируете на другом endpoint. Потом сервис стал «то ли лучше, то ли хуже», но вы уже не знаете почему. При работе с limits лучше держать дисциплину: меняем один параметр — смотрим эффект — фиксируем наблюдение — двигаемся дальше.

1
Задача
Docker for Spring, 23 уровень, 2 лекция
Недоступна
CPU-heavy endpoint и измерение времени ответа
CPU-heavy endpoint и измерение времени ответа
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ