Загрузка 20%, а очередь на CPU росла: нашли лимит в cgroup
Дашборд честно показывал среднюю загрузку CPU в районе 20%. При этом время ответа API росло, воркеры не успевали разгребать очередь задач, а на алертах постоянно горело «processing lag». Разработчики разводили руками: сервер же не нагружен, откуда тормоза? Разгадка нашлась не в top, а в файле cpu.stat внутри cgroup контейнера — там счётчик nr_throttled рос быстрее, чем кто-либо успевал это заметить.
Содержание
- Что видели: низкая загрузка CPU и растущая очередь ответов
- Отбрасываем очевидное: диск, сеть, сосед по гипервизору
- Проверяем cgroup: throttled time вместо простоя
- Как работает CFS quota и почему усреднённые проценты обманывают
- Что изменили: лимиты, период quota и размер пула горутин
- Как не наступить на те же грабли: чек-лист и мониторинг throttling
Что видели: низкая загрузка CPU и растущая очередь ответов
Сервис — сборщик отчётов на Go, десяток горутин на воркер, запущен в Docker-контейнере на выделенном сервере с 8 ядрами. Контейнеру выставлен лимит --cpus="2". Раз в несколько минут прилетала пачка задач — parsing, агрегация, запись в базу — и именно в эти моменты очередь начинала расти.
Первый взгляд на метрики ничего не подсказывал:
docker statsна хосте — CPU% контейнера болтается в районе 15-25%;htopна хосте — суммарная загрузка всех 8 ядер и того ниже;- в Grafana по хосту (node_exporter) — та же картина, полка на 20%;
- при этом очередь задач (Prometheus-метрика
queue_depth) устойчиво растёт весь пиковый интервал, а p99 времени обработки задачи улетает за разумные пределы.
Классическая ловушка: если бы CPU реально был узким местом, загрузка стремилась бы к 100%. А тут — 20%, и мимо. Первая реакция команды — «дело не в CPU», и следующие два дня искали совсем в другом месте.
Отдельно стоит сказать про сам процент загрузки: он усредняется по времени, и именно это усреднение прячет проблему. Если хотите разобраться, что на самом деле означает это число и чем load average отличается от %CPU, у нас есть отдельный разбор — load average: что это число значит на самом деле. Здесь важно другое: усреднённый процент по хосту не показывает, что происходит внутри одного конкретного контейнера на масштабе долей секунды.
Отбрасываем очевидное: диск, сеть, сосед по гипервизору
По порядку проверили обычных подозреваемых.
Диск. iostat -x 1 во время всплеска — %util не выше 10%, await в норме. NVMe справлялся с записью отчётов без вопросов. Отбросили.
Сеть. ss -s не показывал накопления соединений, tcpdump на порту базы данных не выявил ретрансмитов, latency до постгреса в том же дата-центре стабильно держалась на одном уровне. Отбросили.
Сосед по гипервизору. Сервер — выделенный, но команда на всякий случай проверила steal time через mpstat -P ALL 1: столбец %steal был нулевым. Если бы дело было в перегруженном хосте виртуализации, чужие процессы отъедали бы такты именно через steal time — у нас об этом отдельная статья, steal time: как понять, что сосед ест ваш CPU. В этом случае steal был ни при чём: сервер выделенный, гипервизора с чужими соседями просто не было в цепочке.
GC и блокировки в базе. Сервис на Go, не JVM — паузы GC исключили сразу через GODEBUG=gctrace=1, паузы были в пределах единиц миллисекунд. В Postgres смотрели pg_stat_activity и pg_locks — блокирующих запросов не было, воркеры не ждали друг друга на уровне SQL.
Пул соединений к базе. Проверили размер пула (pgxpool) — свободные соединения были, воркеры не стояли в очереди за коннектом. Тоже мимо.
К концу второго дня список гипотез закончился, а очередь всё так же росла каждый раз, когда прилетала пачка задач. Именно тогда кто-то из команды предложил посмотреть не на хост целиком, а на то, что видит сам контейнер изнутри — на уровне cgroup.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПроверяем cgroup: throttled time вместо простоя
На сервере cgroup v1 (Ubuntu 22.04, Docker 24.x без принудительного перехода на unified hierarchy). Путь к статистике CPU контейнера:
# найти cgroup контейнера
docker inspect --format '{{.Id}}' report-worker
# посмотреть статистику CPU
cat /sys/fs/cgroup/cpu/docker/<container_id>/cpu.stat
Вывод:
nr_periods 128040
nr_throttled 41732
throttled_time 986543210123
Вот она, недостающая часть картины. nr_periods — сколько всего было учётных периодов CFS (по умолчанию период — 100 мс, cpu.cfs_period_us = 100000). nr_throttled — в скольких из этих периодов контейнер упирался в лимит и принудительно останавливался до конца периода. throttled_time — суммарное время в наносекундах, которое процессы контейнера провели «замороженными» ядром, несмотря на то, что физические ядра сервера в этот момент простаивали.
Доля throttled-периодов оказалась заметной — больше трети всех периодов заканчивались принудительной остановкой. Именно эти паузы и превращались в рост очереди: горутины не «тормозили» сами по себе, их синхронно останавливал планировщик ядра, а как только квота на новый период выделялась заново — они срывались с места все разом, забивая CPU до предела на пару миллисекунд и тут же снова упираясь в лимит.
Это прямо противоречило показаниям docker stats и htop: там процент считается как отношение фактически использованного времени CPU к времени наблюдения — обычно с усреднением в 1-15 секунд. А throttling происходит на масштабе 100 мс. Даже если контейнер 60 мс из каждых 100 мс простаивает под троттлингом, а оставшиеся 40 мс использует хоть все 2 ядра лимита на полную — усреднённый по секундам процент всё равно нарисует скромную цифру, потому что делит короткие вспышки полной загрузки на длинные окна простоя.
Если в целом непонятно, как cgroup вообще ограничивает контейнер и откуда берутся эти лимиты — стоит сначала прочитать про механику на пальцах: как cgroups ограничивают контейнер.
Как работает CFS quota и почему усреднённые проценты обманывают
Суть механизма CFS bandwidth control такая:
- Ядру задаются два числа на cgroup —
cpu.cfs_period_us(длина периода, обычно 100000 мкс = 100 мс) иcpu.cfs_quota_us(сколько микросекунд суммарного процессорного времени по всем ядрам можно потратить за один период). - Лимит
--cpus="2"в Docker транслируется примерно вcfs_quota_us = 200000при периоде 100000 — то есть 2 «ядро-периода» на 100 мс. - Если у приложения одновременно активны, скажем, 6 горутин на 6 разных ядрах, за 100 мс периода оно способно израсходовать квоту в 200 мс суммарного времени всего за первые ~33 мс реального времени — и на оставшиеся ~67 мс периода планировщик просто не даёт этим потокам исполняться, независимо от того, свободны физические ядра или нет.
- С точки зрения приложения это выглядит как случайные микрозависания на десятки миллисекунд, которые точно совпадают по частоте с периодом cgroup.
Ключевая ловушка именно в разнице масштабов: throttling считается ядром за интервалы в районе 100 мс, а мониторинг ресурсов обычно усредняет метрики за секунды и минуты. Пачка коротких, но частых пауз в сумме даёт заметную деградацию латентности, но почти не двигает средний процент загрузки — он остаётся низким, потому что делится на длинное окно.
Отдельно стоит проговорить: если у сервиса всего один поток на задачу и лимит выставлен «с запасом», троттлинга обычно не видно вообще — проблема проявляется именно у многопоточных/многогорутинных нагрузок с короткими всплесками параллелизма, которые пытаются использовать больше ядер, чем разрешено квотой, за очень короткое время. Это ровно наш случай: воркер запускал горутины на все доступные логические ядра сервера при получении пачки задач, не зная, что видимых ядер физически 8, а разрешённых cgroup — только эквивалент двух.
Тем, кто настраивает лимиты CPU для контейнеров и виртуалок впервые, полезно посмотреть на это шире — на сравнение вариантов ограничения и типичные ошибки: Docker: ресурсы, лимиты CPU и памяти.
Что изменили: лимиты, период quota и размер пула горутин
Правки внесли в три слоя, а не в один — потому что проблема была на стыке конфигурации cgroup и архитектуры приложения.
1. Пересчитали реальный лимит CPU. Вместо --cpus="2" выставили лимит, соответствующий фактической пиковой потребности сервиса с небольшим запасом, а не «сколько не жалко». Для этого несколько дней собирали метрику container_cpu_cfs_throttled_periods_total (через cAdvisor/node-exporter) рядом с реальным пиковым параллелизмом задач и подобрали квоту, при которой throttling в пиковые минуты практически исчезает.
# docker-compose.yml — было
services:
report-worker:
deploy:
resources:
limits:
cpus: "2"
# стало — лимит поднят, плюс явный запас под всплески
services:
report-worker:
deploy:
resources:
limits:
cpus: "4"
reservations:
cpus: "2"
2. Ограничили параллелизм внутри приложения под фактическую квоту. Раньше сервис при получении пачки задач запускал горутины «на все ядра, которые видит runtime.NumCPU()» — а NumCPU() внутри контейнера с cgroup v1 без дополнительных настроек видит физические ядра хоста, а не квоту cgroup. Добавили явное ограничение пула воркеров через семафор на уровне приложения, привязанное к выделенной квоте, а не к количеству ядер хоста:
// ограничиваем реальный параллелизм задач размером квоты,
// а не физическим числом ядер хоста
workerPool := make(chan struct{}, allocatedCPUQuota)
Это устранило сам механизм резких скачков — сервис перестал пытаться выжать из cgroup больше, чем ему разрешено, короткими рывками, и стал распределять нагрузку внутри периода ровнее.
3. Добавили алерт именно на throttling, а не только на процент CPU. Метрика %CPU контейнера осталась в дашбордах, но перестала быть единственным сигналом — рядом завели алерт на долю throttled-периодов:
# Prometheus alert (пример правила, пороги подбираются под вашу нагрузку)
- alert: ContainerCPUThrottlingHigh
expr: rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m]) > 0.15
for: 5m
labels:
severity: warning
После этих трёх правок очередь задач перестала расти во время пиковых пачек — задержка обработки выровнялась, и nr_throttled в cpu.stat практически перестал увеличиваться в пиковые интервалы.
Как не наступить на те же грабли: чек-лист и мониторинг throttling
Что стоит проверять заранее, не дожидаясь инцидента:
| Симптом | Что проверить | Где смотреть |
|---|---|---|
Низкий %CPU, но растёт латентность/очередь | cpu.stat cgroup контейнера: nr_throttled, throttled_time | /sys/fs/cgroup/cpu/.../cpu.stat (v1) или cpu.stat в unified hierarchy (v2) |
| Подозрение на «соседа» на виртуалке | %steal в mpstat | см. отдельную статью про steal time |
| Приложение видит «не те» ядра | runtime.NumCPU() / аналог в вашем рантайме | сверить с реальным --cpus / cpu.max |
| Периодичность тормозов совпадает с всплесками нагрузки | сопоставить nr_throttled по времени с логами пиков | Grafana, наложение графиков |
Практические рекомендации:
- Никогда не судите о CPU-узком месте только по усреднённому проценту загрузки — на масштабе секунд и минут он гарантированно сглаживает короткие троттлинг-паузы CFS, которые считаются по 100-миллисекундным периодам.
- Если стек — cAdvisor + Prometheus + Grafana, метрики
container_cpu_cfs_throttled_periods_totalиcontainer_cpu_cfs_periods_totalуже собираются в большинстве конфигураций «из коробки» — их достаточно вывести на дашборд и завести алерт по отношению, а не изобретать собственный сборcpu.stat. - Для многопоточных/многогорутинных сервисов внутри контейнеров с жёстким CPU-лимитом закладывайте параллелизм приложения от выделенной квоты, а не от числа ядер хоста — иначе рантайм будет честно пытаться утилизировать все видимые ядра и упираться в троттлинг на каждом всплеске.
- На cgroup v2 механика та же по смыслу (единый файл
cpu.maxвместо парыcfs_quota_us/cfs_period_us), но путь и формат отличаются — при миграции с v1 на v2 стоит один раз вручную свериться, что мониторинг throttling продолжает собирать метрики из нового расположения, а не тихо перестаёт находить файл. - Если лимит CPU выставлен «на глаз» при первой настройке сервера и с тех пор не пересматривался — это первый кандидат на ревизию при любой жалобе на «необъяснимые» тормоза с низкой загрузкой.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему top и docker stats не показывают throttling напрямую?
Потому что оба инструмента считают процент использованного CPU-времени за окно наблюдения (обычно секунды), а throttling CFS происходит на масштабе одного периода — по умолчанию 100 мс. Короткие паузы внутри периода растворяются в усреднении и не видны как отдельная метрика без обращения к cpu.stat.
Разве лимит CPU в Docker не должен просто равномерно замедлять процесс, а не «морозить» его рывками?
Нет, если используется механизм CFS bandwidth (quota/period, актуален по умолчанию и в 2026 году для контейнеров на большинстве дистрибутивов). Процесс работает на полной скорости, пока не израсходует выделенную квоту за период, а затем принудительно останавливается до начала следующего периода — отсюда рывки, а не плавное замедление.
Достаточно ли просто увеличить лимит CPU, чтобы проблема больше не повторилась?
Это первый и часто самый быстрый шаг, но не единственный: если приложение продолжает пытаться параллелить работу на количество ядер, превышающее выделенную квоту, при следующем росте нагрузки та же картина повторится на новом уровне. Ограничение параллелизма приложения под фактическую квоту снижает риск повторения при будущих всплесках.
Как посмотреть throttling, если используется cgroup v2, а не v1?
Путь и формат файла отличаются — в unified hierarchy статистика лежит в cpu.stat внутри /sys/fs/cgroup/<путь_к_cgroup>/, а квота и период объединены в один файл cpu.max в формате "<quota> <period>". Поля nr_periods, nr_throttled, throttled_usec (имя счётчика времени в v2 отличается от v1) читаются оттуда же.
Могла ли похожая проблема возникнуть без Docker, просто на голом сервере с systemd?
Да — если процесс запущен в systemd-слайсе с явным CPUQuota=, ядро использует тот же механизм CFS bandwidth control на уровне cgroup, созданной systemd, и проверять нужно точно так же: cpu.stat соответствующего слайса, а не только top.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →