Производительность систем Производительность в общей среде: cgroup-троттлинг, steal time и шумные соседи
0%

Производительность в общей среде: cgroup-троттлинг, steal time и шумные соседи

Производительность в общей среде: 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{мс простоя} $$

Таймлайн CFS: 12,5 мс работы и 87,5 мс троттлинга в каждом периоде

Восемьдесят семь миллисекунд, в течение которых процесс жив, здоров, ничего не ждёт и ничего не блокирует — и не исполняется. Именно это добавляется к хвосту как «необъяснимые 90 мс».

Три следствия, каждое из которых стоит отдельной строчки в чек-листе:

  1. Средняя утилизация не опровергает троттлинг. Средняя за минуту 0,6 CPU при лимите 2 — «30 %», а троттлинг происходил в 30 % стомиллисекундных окон. Усреднение по минуте уничтожает как раз тот масштаб времени, на котором работает механизм.
  2. Троттлинг квантуется периодом. Добавка к задержке всегда меньше $T$ и тяготеет к «пилообразным» значениям в районе десятков миллисекунд при стандартных 100 мс. Увидели в гистограмме задержек второй горб около 50–100 мс — первым делом проверьте cpu.stat, а не свой код.
  3. Виноват не средний расход, а всплеск. Сервис, который равномерно ест 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 или блокировка

У «необъяснимых пауз» есть четыре типовые причины, и они различимы по приборам, не по ощущениям.

Сводная таблица различий — её удобно держать в раннбуке:

Признак Троттлинг 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.

Три вещи, которые надо знать про память в контейнере:

  1. memory.max убивает, memory.high душит. Первый механизм заметен сразу (рестарт, OOMKilled, запись в memory.events). Второй — нет: процесс входит в режим прямого reclaim, каждая аллокация начинает стоить микросекунды вместо наносекунд, и деградация выглядит как «код стал медленнее», а не как проблема памяти.
  2. Page cache принадлежит cgroup. Файловый кэш, который вы прогрели, учитывается в memory.current и вытесняется первым при давлении. Отсюда типичный сюжет: подняли RPS, кэш вытеснился, чтение с диска стало реальным чтением, латентность выросла в 20 раз — при неизменном профиле CPU. Диагностика — memory.stat (поля file, anon) и разбор из статьи https://courses.digitable.life/post/performance/06-io-and-syscalls/.
  3. Считать надо 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. Типичные ошибки

  1. Считать утилизацию доказательством отсутствия проблемы. «CPU 30 %» и «троттлинг в 30 % периодов» — совместимые утверждения. Всегда смотрите обе метрики рядом.
  2. Мерить троттлинг в секундах, а не в доле периодов. throttled_usec растёт с числом потоков и не сравним между сервисами.
  3. Оставлять рантайму параллелизм по числу ядер узла. Самая дешёвая победа в этой статье и самая частая упущенная.
  4. Ставить лимит равным среднему потреблению «чтобы не переплачивать». Средний расход не покрывает всплески; экономия оплачивается хвостом.
  5. Забывать про BLAS и OpenMP в Python-сервисах. Библиотека внутри зависимости поднимает поток на каждое ядро хоста молча.
  6. Снимать лимиты без алертов на потребление. Один цикл без выхода — и деградирует весь узел.
  7. Нагружать burstable-инстанс. Результат теста — функция от остатка кредитов, а не от системы.
  8. Игнорировать %steal при расследовании. Пять минут mpstat экономят день чтения своего кода.
  9. Считать -Xmx равным лимиту памяти. Вне кучи живут metaspace, стеки, direct-буферы и нативные библиотеки; подробности — в статье про память трека.
  10. Сравнивать прогоны с разных узлов. В общей среде узел — переменная эксперимента, а не константа.
  11. Чинить код, не исключив слои 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, проверка по доле троттлящихся периодов, пересмотр по расписанию.

Источники

Что дальше

На этом трек «Производительность систем» закончен. Мы прошли путь от карты и методики через измерение, честный бенчмаркинг, профилирование CPU, память, кэши процессора, ввод-вывод, конкурентность, базы данных, кэширование, сеть и нагрузочное тестирование к рабочему процессу оптимизации, тулчейну и к среде, в которой всё это исполняется.

Куда идти дальше, в зависимости от того, где болит:

Общая карта портала и порядок изучения треков — в «Дорожной карте».

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов