Производительность в общей среде: cgroup-троттлинг, steal time и шумные соседи
Знакомая картина. Дашборд показывает утилизацию CPU 30 %, памяти — 45 %, ошибок нет, очередей нет. И при этом p99 пилит: базовые 12 мс, а раз в несколько запросов — 110 мс. Вы снимаете CPU-профиль по рецепту из статьи про https://courses.digitable.life/post/performance/03-cpu-profiling/ — профиль идеально чистый, никакой функции-виновника. Снимаете off-CPU профиль по рецепту из статьи про https://courses.digitable.life/post/performance/07-concurrency-performance/ — потоки просто «не на процессоре», без блокировок и без ввода-вывода. Смотрите GC-логи — паузы 0,4 мс. Всё измерено, и ничего не найдено.
Так выглядит проблема, у которой причина лежит вне процесса. До этой статьи трек молчаливо предполагал, что машина ваша: что если код готов исполняться, он исполняется. В проде это неправда. Ваш процесс живёт внутри cgroup, cgroup — внутри виртуальной машины, виртуальная машина — на физическом хосте с чужими виртуальными машинами, а физический хост делит L3-кэш и шину памяти между всеми ядрами сразу. На каждом из этих слоёв есть механизм, который умеет снять ваш поток с процессора так, что ни один профайлер уровня приложения этого не заметит: снимать нечего, кода на CPU просто не было.
Это последняя статья трека, и она отвечает на вопрос, которым заканчивается большинство реальных расследований производительности: «я всё измерил внутри, и внутри всё хорошо — где ещё смотреть?». Разберём пять слоёв разделения ресурсов, механику каждого, приборы для каждого и цикл right-sizing, который переводит лимиты из категории «поставили наугад при первом деплое» в категорию измеряемых величин.
1. Пять слоёв, на которых у вас отнимают процессор
Начнём с карты. Она нужна, чтобы диагностика была перебором конечного списка, а не блужданием.
Ключевое различие, вокруг которого крутится вся статья: профайлер отвечает на вопрос «что делал мой код», а слои со второго по пятый отвечают на вопрос «почему моего кода не было на CPU». Это два разных вопроса, и они требуют двух разных наборов приборов. Инженер, который знает только первый набор, в общей среде слепнет.
Порядок разбора в статье — снизу вверх по частоте встречаемости: сначала cgroup-троттлинг (самая частая и самая недодиагностированная причина), затем steal и облачные тарифы, затем железные соседи, затем память и PSI.
2. Механика CFS bandwidth control: период, квота, потоки
В Linux ограничение CPU для контейнера реализовано механизмом CFS bandwidth control. Он устроен принципиально иначе, чем интуитивно ожидают: это не «замедлить процессы», а «выдать бюджет на окно времени и остановить всех, когда бюджет кончился».
# cgroup v2 — один файл, два числа: квота и период в микросекундах
cat /sys/fs/cgroup/cpu.max
# 200000 100000 → 200 мс CPU-времени на каждые 100 мс реального времени = лимит 2 CPU
# cgroup v1 — два файла
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us # 200000
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us # 100000
Читается так: за каждое окно длиной $T = 100$ мс группа может суммарно потратить $q = 200$ мс процессорного времени. «Суммарно» — то есть по всем потокам вместе. Ядро ведёт глобальный счётчик на группу и раздаёт из него куски (slice, по умолчанию 5 мс) на локальные очереди каждого CPU. Как только глобальный счётчик обнулился, все задачи группы снимаются с процессоров и не возвращаются до начала следующего периода.
Отсюда следует свойство, ломающее интуицию: чем больше у вас параллельных потоков, тем быстрее кончается бюджет. Если $N$ потоков одновременно готовы работать, квота $q$ израсходуется за $q/N$ реального времени, а оставшиеся $T - q/N$ группа простаивает принудительно.
Подставим типовую аварию. Узел с 16 ядрами, Go-сервис с GOMAXPROCS = 16 (рантайм увидел ядра хоста, а не свой лимит), лимит cpu: 2. Пришла пачка параллельной работы:
$$ \frac{q}{N} = \frac{200\ \text{мс}}{16} = 12{,}5\ \text{мс},\qquad T - \frac{q}{N} = 100 - 12{,}5 = 87{,}5\ \text{мс простоя} $$
Восемьдесят семь миллисекунд, в течение которых процесс жив, здоров, ничего не ждёт и ничего не блокирует — и не исполняется. Именно это добавляется к хвосту как «необъяснимые 90 мс».
просто нет права исполняться K->>G: новый период, бюджет пополнен K-->>A: потоки возвращаются на CPU A-->>U: ответ на 97 мс вместо 9 мс
Три следствия, каждое из которых стоит отдельной строчки в чек-листе:
- Средняя утилизация не опровергает троттлинг. Средняя за минуту 0,6 CPU при лимите 2 — «30 %», а троттлинг происходил в 30 % стомиллисекундных окон. Усреднение по минуте уничтожает как раз тот масштаб времени, на котором работает механизм.
- Троттлинг квантуется периодом. Добавка к задержке всегда меньше $T$ и тяготеет к «пилообразным» значениям в районе десятков миллисекунд при стандартных 100 мс. Увидели в гистограмме задержек второй горб около 50–100 мс — первым делом проверьте
cpu.stat, а не свой код. - Виноват не средний расход, а всплеск. Сервис, который равномерно ест 1,9 CPU при лимите 2, троттлится редко. Сервис, который ест 0,6 CPU, но разворачивает по 16 потоков на запрос, троттлится постоянно.
Историческая мина: истечение локальных слайсов
До ядра 5.4 механизм имел неприятный дефект: 5-миллисекундные слайсы, выданные на локальные очереди CPU и не использованные до конца, «протухали» и возвращались в общий пул не полностью. Приложения с большим числом потоков и короткими всплесками теряли часть квоты и троттлились при утилизации 20–30 % от лимита. Исправление — коммит de53fd7aedb1 «sched/fair: Fix low cpu usage with high throttling by removing expiration of cpu-local slices», вошедшее в 5.4 и забэкпорченное в дистрибутивные ядра. Обсуждение проблемы — легендарный тред kubernetes#67577, который стоит прочитать целиком: это образцовое расследование производительности.
Практический вывод: если вы наблюдаете троттлинг на ядре старше 5.4 (а такие узлы в проде живут дольше, чем хочется), первое действие — uname -r, а не переписывание кода.
3. Как измерить троттлинг честно
Источник истины один — файл cpu.stat внутри cgroup контейнера:
cat /sys/fs/cgroup/cpu.stat
# usage_usec 1284410000
# user_usec 998210000
# system_usec 286200000
# nr_periods 612340 ← сколько 100-мс окон прошло
# nr_throttled 188112 ← в скольких окнах бюджет кончился досрочно
# throttled_usec 9124300000 ← суммарное время принудительного простоя
Правильная метрика — доля троттлящихся периодов, а не абсолютное throttled_usec:
$$ \text{throttle ratio} = \frac{\Delta,\texttt{nr_throttled}}{\Delta,\texttt{nr_periods}} $$
Почему именно так. throttled_usec растёт пропорционально числу потоков (каждый простаивающий поток капает в счётчик), поэтому «10 секунд троттлинга в минуту» ничего не значит без знания параллелизма. А доля периодов — величина, напрямую переводимая в вероятность попадания запроса в паузу.
В Prometheus те же величины приходят из cAdvisor:
# доля троттлящихся периодов по контейнеру
rate(container_cpu_cfs_throttled_periods_total{container!=""}[5m])
/ rate(container_cpu_cfs_periods_total{container!=""}[5m])
# топ-10 самых душимых контейнеров кластера
topk(10,
rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m]))
# реальное потребление против лимита — обе величины нужны рядом
rate(container_cpu_usage_seconds_total{container!=""}[5m])
/ on(namespace,pod,container) kube_pod_container_resource_limits{resource="cpu"}
Ориентиры по порогам, выведенные из практики, а не из документации:
| Доля троттлящихся периодов | Что это значит |
|---|---|
| < 1 % | фон, обычно не виден на p99 |
| 1–5 % | заметная добавка к p99, стоит внести в бэклог |
| 5–25 % | p99 и p999 определяются троттлингом, а не вашим кодом |
| > 25 % | сервис живёт в троттлинге; любая оптимизация кода бессмысленна до правки лимита |
Две ловушки измерения:
kubectl topи большинство дашбордов усредняют по 30–60 с. На этом масштабе всплески в 100 мс невидимы. Если строите свою панель — беритеrate(...[1m])минимум, а лучше смотрите долю периодов, которая всплески не прячет.- Внутри контейнера
nproc,/proc/cpuinfoиos.cpu_count()показывают ядра хоста. Ни один из них не знает про квоту. Число «доступных» ядер надо вычислять изcpu.max, и именно на этом ломаются рантаймы — об этом следующий раздел.
4. Диагностическое дерево: троттлинг, steal, GC или блокировка
У «необъяснимых пауз» есть четыре типовые причины, и они различимы по приборам, не по ощущениям.
CPU-профиль чистый"] --> T{"cpu.stat:
nr_throttled / nr_periods > 1 %?"} T -->|"да"| TH["ТРОТТЛИНГ CGROUP
раздел 5: параллелизм, лимит, всплеск"] T -->|"нет"| ST{"mpstat -P ALL 1:
%steal устойчиво > 2 %?"} ST -->|"да"| STL["ОТНЯЛИ СНАРУЖИ ГОСТЯ
раздел 6: соседи по хосту,
кредиты burstable-инстанса"] ST -->|"нет"| GC{"Паузы совпадают
с событиями GC?"} GC -->|"да"| GCP["ПАУЗЫ СБОРЩИКА
см. статью про память трека"] GC -->|"нет"| PSI{"PSI: cpu.pressure some avg10
или memory.pressure растут?"} PSI -->|"cpu"| RQ["ОЧЕРЕДЬ НА УЗЛЕ
соседи выедают ядра, проверьте requests"] PSI -->|"memory"| RC["RECLAIM ПО memory.high
раздел 8"] PSI -->|"io"| IO["НАСЫЩЕНИЕ ДИСКА
см. статью про ввод-вывод трека"] PSI -->|"всё спокойно"| HW{"perf stat: IPC упал
при неизменной своей нагрузке?"} HW -->|"да"| NN["ШУМНЫЙ СОСЕД ПО ЖЕЛЕЗУ
раздел 7: L3, шина памяти, SMT"] HW -->|"нет"| APP["Причина всё-таки внутри:
вернитесь к off-CPU профилю"]
Сводная таблица различий — её удобно держать в раннбуке:
| Признак | Троттлинг cgroup | Steal time | Пауза GC | Контеншн на блокировке |
|---|---|---|---|---|
| Длительность паузы | < периода, тяготеет к 50–95 мс | размазано, доли процента непрерывно | свой характерный профиль рантайма | зависит от держателя блокировки |
| Утилизация CPU | низкая или средняя | выглядит нормальной | всплеск при сборке | низкая, потоки спят |
| CPU-профиль | пустой | пустой | видны кадры рантайма | пустой, но виден off-CPU |
| Ключевой прибор | cpu.stat |
mpstat, %steal |
GC-логи, /gc |
mutex-профиль, perf lock |
| Корреляция с трафиком | по всплескам параллелизма | не коррелирует | по темпу аллокаций | по конкуренции |
Про GC и блокировки трек уже рассказал в статьях про https://courses.digitable.life/post/performance/04-memory/ и https://courses.digitable.life/post/performance/07-concurrency-performance/; здесь важно только уметь их исключить, чтобы не чинить не то.
5. Пять рычагов против троттлинга, по убыванию отдачи
Рычаг 1: согласовать параллелизм рантайма с квотой
Самый частый и самый дешёвый фикс. Рантайм, который создал 16 потоков под лимит в 2 CPU, гарантированно троттлится, потому что жжёт бюджет в 8 раз быстрее необходимого. Лечится настройкой, а не кодом.
// Go: начиная с 1.25 рантайм сам читает лимит cgroup и выставляет GOMAXPROCS
// по квоте — https://go.dev/doc/go1.25 . Для более старых версий:
import _ "go.uber.org/automaxprocs" // выставит GOMAXPROCS = ceil(quota/period)
// Проверить фактическое значение в рантайме:
// runtime.GOMAXPROCS(0) должно быть близко к лимиту, а не к числу ядер узла
# JVM: контейнеро-осведомлённость включена по умолчанию с JDK 10,
# но при дробном лимите (cpu: 1500m) округление вниз даёт 1 — задайте явно.
java -XX:ActiveProcessorCount=2 -XX:MaxRAMPercentage=70 -jar app.jar
# .NET: рантайм читает квоту сам, но пулы можно зафиксировать
DOTNET_PROCESSOR_COUNT=2 dotnet app.dll
# Node.js: сам однопоточный, но libuv держит пул для fs/dns/zlib/crypto
UV_THREADPOOL_SIZE=2 node server.js
# Python: GIL не спасает от чужого параллелизма — BLAS внутри NumPy
# по умолчанию поднимает поток на каждое ядро ХОСТА и душит весь под
OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2 MKL_NUM_THREADS=2 python app.py
Последняя строчка стоит отдельного внимания: сервис на Python, который «просто считает эмбеддинги», через NumPy поднимает 64 OpenMP-потока под лимитом в 1 CPU и троттлится на 90 % периодов. Симптом — деградация ровно в тех подах, что попали на самые «широкие» узлы кластера.
Рычаг 2: поднять лимит до реального потребления
Арифметика простая, но её надо делать по правильным окнам. Нужен не средний расход за минуту, а перцентиль расхода по окнам, сравнимым с периодом:
# p99 потребления CPU по 100-мс окнам за сутки — то, во что надо целиться лимитом
quantile_over_time(0.99,
rate(container_cpu_usage_seconds_total{pod=~"api-.*"}[100ms])[24h:100ms])
Такой запрос дорог и не всегда доступен по разрешению хранилища; практическая замена — взять rate(...[1m]) p99 и умножить на коэффициент всплеска, измеренный один раз нагрузочным тестом (см. https://courses.digitable.life/post/performance/11-load-testing/). Дальше:
$$ \text{limit} = p_{99}(\text{расход}) \cdot k,\qquad k \approx 1{,}3 \dots 1{,}5 $$
Запас $k$ нужен не «на всякий случай», а под конкретные вещи: фоновая сборка мусора, ретраи после сбоя соседнего сервиса, первые секунды после старта пода (JIT-прогрев, заполнение кэшей). Проверка успеха фикса — доля троттлящихся периодов упала ниже 1 %, а p99 сдвинулся вниз на величину, близкую к прежней добавке.
Рычаг 3: убрать всплеск, а не поднимать потолок
Троттлинг вызывается формой нагрузки. Иногда дешевле изменить форму:
- Не разворачивать параллелизм внутри одного запроса. Обработка, распиленная на 16 горутин ради 3 мс выигрыша в latency, покупает эти 3 мс ценой исчерпания квоты. При лимите в 2 CPU параллелизм внутри запроса выше двух — чистый вред.
- Размазать фон. Экспорт метрик, компакция, ротация логов, перестройка кэша, отправка трейсов — всё это любит запускаться по таймеру одновременно во всех подах. Добавьте джиттер:
interval + rand(0, interval/4). - Ограничить пул воркеров фоновых задач отдельно от пула обработки запросов, чтобы фон не съедал бюджет у переднего плана.
- Прогрев после старта. JIT-компиляция и заполнение кэшей — самый большой всплеск в жизни пода. Отсюда практика:
startupProbeс запасом и повышенный лимит на время старта (в Kubernetes — через sidecar-паттерн либо через отдельный джоб прогрева).
Рычаг 4: укоротить период
Если всплески короткие, а квота достаточная, помогает уменьшение окна: при cpu.cfs_period_us = 10000 (10 мс) максимальная добавка к задержке падает с 87 мс до 8,7 мс.
# на узле kubelet принимает флаг (значение по умолчанию 100ms):
kubelet --cpu-cfs-quota-period=10ms
Цена: накладные расходы на пополнение бюджета растут (ядро делает это в 10 раз чаще), а короткие всплески начинают резаться агрессивнее — суммарная пропускная способность может слегка упасть. Флаг узловой, включается на весь узел сразу, поэтому это решение для выделенных пулов под latency-critical сервисы, а не для кластера целиком. Документация — справочник kubelet.
Рычаг 5: снять лимит и управлять через requests
Радикальный вариант, вокруг которого идёт многолетний спор. Аргументы «за»: без лимита под может использовать свободные ядра узла, троттлинг исчезает как класс, а защита от захвата ресурсов обеспечивается requests — они превращаются в cpu.weight, то есть в гарантированную долю при конкуренции. Аргументы «против»: пропадает предсказуемость, один под с утечкой параллелизма деградирует соседей, а нагрузочные тесты перестают отражать прод, потому что в тесте узел пустой.
Рабочий компромисс, который встречается в зрелых кластерах: requests выставлены честно и всегда, лимиты сняты у latency-critical сервисов на выделенных пулах узлов и оставлены у всего остального. Плюс жёсткое правило: у любого пода без лимита обязателен алерт на аномальный рост потребления, иначе первый же баг с бесконечным циклом выносит узел.
Для сервисов с самыми жёсткими требованиями есть ещё один уровень — прибить контейнер к конкретным ядрам через CPU Manager static policy. Это убирает миграции между ядрами и вымывание L1/L2 (см. https://courses.digitable.life/post/performance/05-cache-and-locality/), но требует целых чисел в requests и класса QoS Guaranteed.
6. Steal time: время, отнятое снаружи гостя
Если ваш узел — виртуальная машина (а в облаке это почти всегда так), гипервизор может снять ваш vCPU с физического ядра, чтобы отдать его другой машине. Гость это видит как steal time — «процессор был готов исполнять, но его не дали».
mpstat -P ALL 1 5
# CPU %usr %nice %sys %iowait %irq %soft %steal %guest %idle
# all 31.2 0.00 6.10 0.40 0.00 1.20 4.80 0.00 56.28
# ↑ вот это
vmstat 1 5 # колонка st в блоке cpu — то же самое
grep -w cpu /proc/stat # восьмое поле — steal в тиках
Ориентиры: на shared-tenancy инстансах фон 0,3–1,5 % нормален и в хвост почти не проникает; устойчивые 3–10 % означают, что вы делите физическое ядро с активным соседом; 20 %+ — обычно не «сосед», а исчерпание кредитов (ниже). Важно, что steal не виден вашему профайлеру и не отражается в cpu.stat: с точки зрения cgroup вы своё время не тратили.
Burstable-инстансы и кредиты
Отдельный класс аварий — тарифные модели вроде AWS t3/t4g, Azure B-series или GCP shared-core. У такого инстанса есть baseline (например, 20 % ядра) и накопительные кредиты: пока кредиты есть, можно работать на полную; кончились — вас жёстко режут до baseline. Симптом характерный: сервис отлично живёт неделю, а потом за час деградирует в разы без единого изменения кода и без роста трафика.
# Косвенный признак изнутри гостя: %steal взлетел с 1 % до 60–80 %
# и не опускается. Прямой признак — метрика провайдера:
aws cloudwatch get-metric-statistics --namespace AWS/EC2 \
--metric-name CPUCreditBalance --dimensions Name=InstanceId,Value=i-0abc \
--start-time 2026-07-15T00:00:00Z --end-time 2026-07-16T00:00:00Z \
--period 300 --statistics Average
Правила: под постоянную нагрузку burstable-инстансы не берут вообще; режим unlimited снимает обрыв, но переводит проблему в счёт за овердрафт; нагрузочный тест на burstable-инстансе не имеет смысла — он измеряет остаток кредитов, а не систему. Детали модели — в документации AWS про burstable-инстансы; экономическая сторона выбора типа инстанса разобрана в статье https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/.
Частота, turbo и тепловой троттлинг
Ещё один источник расхождения «тот же код, другое время» — частота ядра. Turbo boost поднимает частоту, когда активны немногие ядра, и опускает при загрузке всех; широкие AVX-инструкции вызывают частотный откат; при нагреве срабатывает тепловой троттлинг.
turbostat --interval 1 # реальная частота, состояния C-states, температура
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # powersave душит latency
perf stat -e cycles,instructions,task-clock ./bench # IPC и реальные такты
Практический вывод для замеров: никогда не сравнивайте два прогона, между которыми могла измениться частота. На бенчмарк-стенде фиксируйте governor в performance, а в облаке — считайте частоту неконтролируемой переменной и компенсируйте её дизайном эксперимента (см. https://courses.digitable.life/post/performance/02-benchmarking/). Физика частотных и тепловых пределов подробно разобрана в статье https://courses.digitable.life/post/hardware/13-power-and-limits/.
7. Шумные соседи на уровне железа
Квота и steal — про время. Есть ещё разделяемые ресурсы, где конкуренция идёт не за время, а за пропускную способность, и её не видно ни в одном счётчике утилизации:
- L3 (LLC) — общий на сокет. Сосед с большим рабочим набором вымывает ваши строки, и ваш рабочий набор, который «помещался», перестаёт помещаться.
- Шина памяти — суммарная пропускная способность делится на всех. При насыщении растёт латентность каждого обращения к DRAM.
- SMT-сиблинг — два логических ядра одного физического делят исполнительные устройства, L1 и L2.
cpu: 2может означать два гипертреда одного ядра, то есть заметно меньше, чем два ядра. - Сетевая карта, PCIe, диск — очереди и прерывания общие.
Диагностический признак один и он надёжный: ваша нагрузка не менялась, а IPC упал.
# Снимок «здорового» и «больного» периода по одному и тому же процессу
perf stat -p $(pgrep -f myservice) -e cycles,instructions,LLC-loads,LLC-load-misses sleep 30
# Здоровый: IPC 1.42, LLC-load-misses 6.1 % of all LL-cache accesses
# Больной: IPC 0.71, LLC-load-misses 34.8 % ← вымыли из L3, работа та же
Инструментальная защита существует на уровне железа: Intel RDT через интерфейс resctrl позволяет мониторить занятость L3 и пропускную способность памяти по группам задач (CMT/MBM) и даже нарезать L3 на классы (CAT). В Kubernetes прямого управления этим нет, поэтому на практике применяют более грубые средства: выделенные пулы узлов для latency-critical сервисов, taints/tolerations, static CPU policy с topology manager, а в самых требовательных случаях — bare-metal или dedicated-инстансы.
Академическая база темы — работы Bubble-Up (Mars et al., MICRO 2011) про предсказание деградации от совместного размещения и Heracles (Lo et al., ISCA 2015) про безопасное подселение фоновых задач к latency-critical. Механика самих кэшей — в статье https://courses.digitable.life/post/performance/05-cache-and-locality/, устройство многоядерных систем и SMT — в статье https://courses.digitable.life/post/hardware/11-multicore/.
8. Память в общей среде: memory.high, reclaim и page cache
Про аллокации, GC и утечки трек рассказал в статье https://courses.digitable.life/post/performance/04-memory/. Здесь — только то, что появляется именно от совместной жизни в cgroup.
вытесняя page cache Reclaim --> Norm: удалось освободить Reclaim --> Max: освобождать нечего, только anon Max --> OOM: аллокация невозможна OOM --> [*]: контейнер перезапущен Reclaim --> Reclaim: «тонущий» режим:
жив, но задержки выросли в разы note right of Reclaim самое опасное состояние: сервис НЕ падает, метрика памяти упирается в потолок и стоит ровно, а p99 растёт непрерывно end note
Три вещи, которые надо знать про память в контейнере:
memory.maxубивает,memory.highдушит. Первый механизм заметен сразу (рестарт,OOMKilled, запись вmemory.events). Второй — нет: процесс входит в режим прямого reclaim, каждая аллокация начинает стоить микросекунды вместо наносекунд, и деградация выглядит как «код стал медленнее», а не как проблема памяти.- Page cache принадлежит cgroup. Файловый кэш, который вы прогрели, учитывается в
memory.currentи вытесняется первым при давлении. Отсюда типичный сюжет: подняли RPS, кэш вытеснился, чтение с диска стало реальным чтением, латентность выросла в 20 раз — при неизменном профиле CPU. Диагностика —memory.stat(поляfile,anon) и разбор из статьи https://courses.digitable.life/post/performance/06-io-and-syscalls/. - Считать надо cgroup, а не процесс.
RSSпроцесса не включает page cache и slab, которые вам засчитаны.
PSI: прибор, измеряющий дефицит, а не занятость
Pressure Stall Information — механизм ядра (с 4.20), который отвечает на правильный вопрос: сколько времени задачи не могли работать из-за нехватки ресурса. Утилизация отвечает на вопрос «занято ли», PSI — «мешает ли», и для производительности второй вопрос полезнее.
cat /sys/fs/cgroup/cpu.pressure
# some avg10=18.42 avg60=12.03 avg300=9.51 total=88213400000
cat /sys/fs/cgroup/memory.pressure
# some avg10=0.00 avg60=0.11 avg300=0.30 total=812340000
# full avg10=0.00 avg60=0.05 avg300=0.12 total=402110000
cat /proc/pressure/io # то же самое для узла целиком
Читается так: some — доля времени, когда хотя бы одна задача ждала ресурс; full — доля времени, когда ждали все (то есть полезной работы не шло вообще). avg10/60/300 — экспоненциальные средние за 10, 60 и 300 секунд.
| Показатель | Порог, при котором стоит смотреть | Что означает |
|---|---|---|
cpu.pressure some avg10 |
> 10 % | задачи стоят в очереди за процессором |
memory.pressure some avg10 |
> 5 % | начался заметный reclaim |
memory.pressure full avg10 |
> 1 % | группа «тонет», близко к OOM |
io.pressure full avg10 |
> 5 % | диск стал узким местом группы |
Практическая ценность PSI в том, что он одинаково хорошо ловит и «нам мало квоты», и «на узле давка», и «reclaim съел латентность» — то есть служит первым фильтром в дереве диагностики из раздела 4. Введение в механизм — документация ядра и сайт проекта PSI.
9. Как бенчмаркать там, где соседей не выключить
Статья https://courses.digitable.life/post/performance/02-benchmarking/ требовала подавления шума: фиксированная частота, изоляция ядер, отсутствие посторонней нагрузки. В облаке половина этих рычагов недоступна. Значит, шум компенсируется дизайном эксперимента, а не настройками:
- Интерливинг вместо блоков. Не «сначала 30 прогонов A, потом 30 прогонов B», а
ABABAB…. Дрейф соседей и частоты бьёт по обоим вариантам одинаково и уходит из разницы. - Много коротких повторов вместо одного длинного. Длинный прогон гарантированно поймает чужой всплеск целиком; из двадцати коротких испорченные видны как выбросы.
- Записывать окружение в результат. Модель CPU, ядро,
%stealза прогон, доля троттлящихся периодов, средняя частота. Без этих полей сравнение двух прогонов через неделю невозможно. - Отбраковывать прогоны по внешним признакам, а не по результату. Правило формулируется заранее: «прогон отбрасывается, если средний
%steal> 2 % или доля троттлящихся периодов > 0,5 %». Отбраковка «потому что число не понравилось» — путь к самообману. - Канарейка-калибратор. Рядом с бенчмарком крутится крошечная детерминированная нагрузка с известным временем. Её результат — датчик состояния среды: поехала канарейка, значит, поехала и среда.
- Сравнивать на одном узле и в одно время. A/B двух версий на разных узлах кластера сравнивает узлы, а не версии.
Всё это вписывается в перф-джобы CI из статьи https://courses.digitable.life/post/performance/12-optimization-workflow/: там же живут пороги и правила триажа.
10. Цикл right-sizing: измерить, задать, проверить
Лимиты — такая же измеряемая величина, как всё остальное в треке. Рецепт на один сервис, полдня работы:
Шаг 1. Снять реальный расход. За окно, покрывающее суточный и недельный пики (минимум 7 дней), собрать p50/p95/p99 CPU и p99 RSS+cache. Обязательно отдельно — период старта пода: он почти всегда самый прожорливый.
Шаг 2. Снять текущий троттлинг. Доля троттлящихся периодов и cpu.pressure по тем же окнам. Если доля уже < 1 %, лимит не является проблемой — не трогайте его.
Шаг 3. Посчитать целевые значения.
| Величина | Формула | Смысл запаса |
|---|---|---|
requests.cpu |
p50 расхода | честная заявка планировщику, от неё зависит вес при конкуренции |
limits.cpu |
p99 расхода × 1,3…1,5 | всплески, GC, ретраи, прогрев |
requests.memory |
p99 (anon + необходимый page cache) | под ниже этого не выживет |
limits.memory |
requests × 1,2, но с запасом на нативную память | OOM дороже перерасхода |
| параллелизм рантайма | по limits.cpu, не по nproc |
иначе рычаг 1 обнуляет всё остальное |
Шаг 4. Выкатить и проверить. Критерий успеха задаётся заранее и в числах: доля троттлящихся периодов < 1 %, p99 сдвинулся вниз не меньше чем на ожидаемую величину, memory.pressure full = 0, стоимость не выросла сверх согласованного. Если p99 не сдвинулся — гипотеза была неверна, откатывайте и возвращайтесь к дереву из раздела 4.
Шаг 5. Автоматизировать пересмотр. Vertical Pod Autoscaler в режиме recommendation даёт неплохую отправную точку и снимает рутину, но у него два ограничения: он не видит троттлинг напрямую (работает от потребления) и в режиме auto перезапускает поды. Разумная практика — рекомендации от VPA, решение за человеком, проверка по cpu.stat.
# Быстрый однострочник для расследования прямо в поде
cat /sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.stat /sys/fs/cgroup/cpu.pressure \
/sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.events
Планирование ёмкости уровнем выше — сколько подов и узлов нужно под прогноз трафика — разобрано в статье https://courses.digitable.life/post/sre/09-capacity/; настройка самих ресурсов в манифестах — в статье https://courses.digitable.life/post/devops/06-kubernetes/.
11. Типичные ошибки
- Считать утилизацию доказательством отсутствия проблемы. «CPU 30 %» и «троттлинг в 30 % периодов» — совместимые утверждения. Всегда смотрите обе метрики рядом.
- Мерить троттлинг в секундах, а не в доле периодов.
throttled_usecрастёт с числом потоков и не сравним между сервисами. - Оставлять рантайму параллелизм по числу ядер узла. Самая дешёвая победа в этой статье и самая частая упущенная.
- Ставить лимит равным среднему потреблению «чтобы не переплачивать». Средний расход не покрывает всплески; экономия оплачивается хвостом.
- Забывать про BLAS и OpenMP в Python-сервисах. Библиотека внутри зависимости поднимает поток на каждое ядро хоста молча.
- Снимать лимиты без алертов на потребление. Один цикл без выхода — и деградирует весь узел.
- Нагружать burstable-инстанс. Результат теста — функция от остатка кредитов, а не от системы.
- Игнорировать
%stealпри расследовании. Пять минутmpstatэкономят день чтения своего кода. - Считать
-Xmxравным лимиту памяти. Вне кучи живут metaspace, стеки, direct-буферы и нативные библиотеки; подробности — в статье про память трека. - Сравнивать прогоны с разных узлов. В общей среде узел — переменная эксперимента, а не константа.
- Чинить код, не исключив слои 2–5. Неделя оптимизации функции, которая всё это время просто не была на процессоре, — реальный и очень обидный сценарий.
Мини-итог
- В проде ваш процесс делит машину с четырьмя категориями соседей: собственными потоками, соседними контейнерами, процессами узла и чужими виртуальными машинами — плюс делит железо на уровне L3 и шины памяти.
- CFS bandwidth control выдаёт бюджет на окно в 100 мс и останавливает всю группу, когда бюджет кончился. Чем больше потоков, тем быстрее это происходит: $N$ потоков жгут квоту в $N$ раз быстрее.
- Правильная метрика троттлинга — доля троттлящихся периодов
nr_throttled / nr_periods. Выше 1 % — заметно на p99, выше 5 % — определяет p99. - Первый и самый дешёвый фикс — согласовать параллелизм рантайма с квотой:
GOMAXPROCS,ActiveProcessorCount,UV_THREADPOOL_SIZE,OMP_NUM_THREADS. - Steal time и кредиты burstable-инстансов отнимают время незаметно для cgroup и профайлера; их видно только через
%stealи метрики провайдера. - Шумный сосед по железу диагностируется по падению IPC при неизменной собственной нагрузке.
- PSI отвечает на вопрос «мешает ли», а не «занято ли», и потому служит лучшим первым фильтром в диагностике.
- Лимиты — измеряемая величина: p99 расхода × 1,3…1,5, проверка по доле троттлящихся периодов, пересмотр по расписанию.
Источники
- Документация ядра Linux: CFS Bandwidth Control, Control Group v2, PSI — Pressure Stall Information, User Interface for Resource Control feature (resctrl).
- Коммит de53fd7aedb1 — removing expiration of cpu-local slices и тред kubernetes#67577 «CFS quotas can lead to unnecessary throttling» — образцовое коллективное расследование.
- Kubernetes: CPU Management Policies, справочник kubelet, Vertical Pod Autoscaler.
- Рантаймы: Go 1.25 Release Notes про контейнеро-осведомлённый
GOMAXPROCS, uber-go/automaxprocs, java(1) — опции JVM, конфигурация рантайма .NET. - Brendan Gregg. Systems Performance, 2nd ed. — главы про облака и контейнеры; CPU Utilization is Wrong.
- Совместное размещение и шумные соседи: J. Mars et al. Bubble-Up, MICRO 2011; D. Lo et al. Heracles, ISCA 2015; S. Kanev et al. Profiling a Warehouse-Scale Computer, ISCA 2015.
- Облако: AWS — Burstable performance instances; практические разборы — Omio Engineering о троттлинге в Kubernetes, Datadog о requests и limits.
- Смежное на портале: «Виртуализация и контейнеры» про устройство изоляции, «Процессы и планирование» про CFS изнутри, «Наблюдаемость и производительность в Linux» про уровни инструментов.
Что дальше
На этом трек «Производительность систем» закончен. Мы прошли путь от карты и методики через измерение, честный бенчмаркинг, профилирование CPU, память, кэши процессора, ввод-вывод, конкурентность, базы данных, кэширование, сеть и нагрузочное тестирование к рабочему процессу оптимизации, тулчейну и к среде, в которой всё это исполняется.
Куда идти дальше, в зависимости от того, где болит:
- Инфраструктура, в которой живут ваши лимиты — DevOps и особенно «Kubernetes»: манифесты, классы QoS, автоскейлинг.
- Как устроен тулчейн изнутри — Компиляторы, особенно «Оптимизации» и «JIT-компиляция»: механика того, чем предыдущая статья пользовалась как чёрным ящиком.
- Ёмкость и надёжность как процесс — SRE и «Планирование ёмкости»: сколько узлов нужно под прогноз, а не под текущий день.
- Операционная система под всем этим — Операционные системы и «Виртуализация и контейнеры»: что именно изолирует cgroup и чего она не изолирует.
- Качество как дисциплина — Тестирование и «Нагрузочное и перф-тестирование»: те же прогоны со стороны QA-процесса.
- Масштаб за пределами узла — Распределённые системы и «Наблюдаемость»: хвосты, ретраи и координация, необъяснимые локальными приборами.
- Клиентская сторона — Фронтенд и «Веб-производительность»: свои бюджеты и свои метрики восприятия.
- Алгоритмическая основа — Алгоритмы и «Практическая оптимизация»: когда упёрлись в потолок и надо менять подход, а не константу.
Общая карта портала и порядок изучения треков — в «Дорожной карте».