Приложение отвечает медленнее, а на графике растёт CPU throttling. Первая идея — пересоздать под на более свободной ноде. Но свободные CPU не помогут, если процесс остановлен из-за собственной квоты.

Перед переносом нужно установить, что ограничивает выполнение: бюджет CPU-времени или конкуренция за доступные процессоры. Эти причины могут существовать одновременно, но требуют разных действий.

Ниже — диагностическая схема и самостоятельные учебные опыты. Это не разбор рабочего инцидента и не отчёт о выполненных замерах. Рассматриваются Linux, cgroup v2 и обычный класс планирования fair. Аппаратное снижение частоты процессора здесь не называется throttling.

Квота контейнера не равна загрузке ноды

В Kubernetes requests.cpu участвует в выборе ноды. На Linux запрос CPU также влияет на относительный вес контейнера при конкуренции. Это не верхняя граница потребления. limits.cpu задаёт ограничение, которое обычно реализуется через CPU-квоту cgroup.1

В cgroup v2 значение cpu.max состоит из квоты и периода в микросекундах. Например:

25000 100000

Это бюджет 25 мс CPU-времени за период 100 мс, то есть 0,25 CPU. Значение max вместо числа означает отсутствие квоты на этом уровне.2

Бюджет расходуют потоки группы, в том числе работающие параллельно на разных CPU. Когда он исчерпан, выполнение ограничивается до пополнения. Для этого ноде не обязательно быть загруженной.3

Среднее потребление за пять минут может быть ниже лимита, хотя в отдельных коротких периодах квота исчерпывается. Длинное окно усреднения скрывает такие всплески.

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

Есть и обратный случай. Конечной квоты нет, но готовые к выполнению потоки делят занятые CPU. Приложение ждёт процессорного времени без исчерпания собственного бюджета. Поднимать несуществующую квоту здесь бессмысленно.

Какие данные снять до изменения

Начинать стоит с одного проблемного контейнера и одного временного интервала. Усреднение по всему сервису может скрыть перегруженную реплику.

Что проверитьДля какого решения
Задержки, ошибки, входящую нагрузку и число завершённых запросовЕсть ли ухудшение работы приложения
Потребление CPU и приращения счётчиков throttlingСовпадает ли ухудшение с ограничением квотой
Фактические cpu.max и приращения cpu.stat контейнера и предковНа каком уровне срабатывает ограничение
Доступные процессу CPU, их загрузку и CPU pressureЕсть ли конкуренция за процессорное время
UID пода, ID контейнера, ноду и время перезапускаСравниваются ли наблюдения одной и той же сущности

Сначала прочитайте сохранённый в API объект Pod, а не только шаблон в Git. Затем сопоставьте его с фактической конфигурацией cgroup. Настройки при создании пода могут дополняться, например, через LimitRange.4

От пода к cgroup контейнера

Ниже один вариант: containerd с runc, cgroup v2 и обычный контейнер из spec.containers, который не создаёт дочерние cgroup. Нужен SSH-доступ к ноде с правом sudo: команды на ноде выполняются в её пространствах имён PID, mount и cgroup. Root внутри пода такого доступа не заменяет.

На рабочей машине нужны kubectl, jq и право get на Pod. Подставьте namespace и имя пода:

NS=example
POD=example-0
kubectl -n "$NS" get pod "$POD" -o json |
  jq '{uid: .metadata.uid, node: .spec.nodeName,
       resources: [.spec.containers[] | {name, resources}],
       containers: [.status.containerStatuses[]? |
         {name, containerID, restartCount, state}]}'

Выберите нужный контейнер по name. Сохраните UID пода, ноду, полный containerID, restartCount и state.running.startedAt. Продолжайте только для работающего контейнера с ID вида containerd://….5

Подключитесь по SSH к указанной ноде и откройте Bash через sudo -i. На ноде нужны jq, crictl, утилиты findmnt и taskset из util-linux, а для загрузки CPU — mpstat из sysstat. Здесь полная иерархия cgroup v2 смонтирована в /sys/fs/cgroup, контроллеры cpu и cpuset и PSI доступны. crictl должен быть заранее настроен на фактический endpoint containerd через /etc/crictl.yaml.6

set -euo pipefail
CID='<full ID without containerd://>'
findmnt -n -o FSTYPE,FSROOT,TARGET -T /sys/fs/cgroup
test "$(stat -fc %T /sys/fs/cgroup)" = cgroup2fs
test "$(findmnt -n -o FSROOT -T /sys/fs/cgroup)" = /
pid="$(crictl inspect -o json "$CID" |
  jq -er 'select(.status.state == "CONTAINER_RUNNING")
          | .info.pid | numbers | select(. > 0)')"
group="$(awk -F: '$1 == "0" {print $3}' "/proc/$pid/cgroup")"
case "$group" in
  /*) test "$group" != / ;;
  *) echo "Container cgroup is unavailable" >&2; exit 1 ;;
esac
cg="/sys/fs/cgroup$group"
test -r "$cg/cpu.stat"
printf 'PID=%s cgroup=%s\n' "$pid" "$cg"

У findmnt ожидаются cgroup2, / и /sys/fs/cgroup. Поле .info.pid — расширение containerd, а не общий контракт CRI.7 Путь берётся из /proc для найденного PID: формат каталогов systemd или cgroupfs не предполагается. В другом mount или cgroup namespace этот маршрут нельзя переносить без проверки видимости иерархии.8

Проверить всю доступную иерархию

cpu.max = max у контейнера ещё не означает отсутствия ограничений выше. Родительская cgroup может исчерпать общий бюджет и остановить дочернюю, даже если у той осталась собственная квота.3

Поэтому в той же root-сессии снимите данные контейнера и всех предков, включая cgroup пода. Каждому уровню нужна пара снимков за сопоставимый интервал:

snapshot_hierarchy() (
  set -eu
  dir="$cg"
  while :; do
    test -d "$dir"
    printf '\n%s uptime_s=' "$dir"
    cut -d ' ' -f 1 /proc/uptime
    for file in cpu.max cpu.stat cpu.pressure cpuset.cpus.effective; do
      printf '%s: ' "$file"
      if test -r "$dir/$file"; then
        cat "$dir/$file"
      else
        printf 'unavailable\n'
      fi
    done
    test "$dir" = /sys/fs/cgroup && break
    dir="$(dirname "$dir")"
  done
)
snapshot_hierarchy
sleep 5
snapshot_hierarchy

Для каждого пути сравните его cpu.max и приращения usage_usec, nr_periods, nr_throttled, throttled_usec из cpu.stat. Конечная квота показывает настройку, а рост счётчиков throttling — её срабатывание. Эти счётчики относятся к собственной квоте уровня, поэтому нулевой прирост у контейнера не исключает throttling предка. usage_usec предка включает всех его потомков, не только выбранный контейнер.2

Отсчёт uptime_s дан в секундах с загрузки ноды. Для расчёта возьмите разность отметок одного пути: чтение файлов добавляет время к sleep 5, а обход уровней не атомарен. У настоящего корня cpu.max отсутствует штатно. Другие недоступные файлы или поля не считайте нулями. Если часть предков скрыта или недоступна, исключить ограничение их квотой по этим данным нельзя.28

После измерения повторите запрос Pod на рабочей машине и crictl inspect на ноде. Проверьте прежние UID, ID контейнера, время старта, число перезапусков, PID и путь из /proc/$pid/cgroup. При замене контейнера, исчезновении cgroup или сбросе счётчиков начните измерение заново.

Отдельно нужно узнать, какие CPU доступны процессу. cpuset.cpus.effective показывает эффективный набор cgroup, а affinity потока может сузить его ещё сильнее. Свободное ядро вне этого набора не поможет потоку.29

В той же сессии ноды прочитайте affinity всех потоков каждого процесса контейнера:

cat "$cg/cpuset.cpus.effective"
while read -r process; do
  taskset -apc "$process"
done < "$cg/cgroup.procs"

В другом терминале этой ноды запустите mpstat -P ALL 1 5 одновременно со снимками. Сопоставляйте номера разрешённых CPU со строками каждого CPU, а не со средним all. taskset -apc читает affinity, не меняя её. %iowait не означает занятость CPU вычислениями; %steal у VM указывает на время, отнятое гипервизором.10

Различать потребление и ожидание

Загрузка CPU показывает выполненную работу. Для оценки ожидания полезен PSI: /proc/pressure/cpu на ноде и cpu.pressure нужной cgroup. Поле some отражает долю времени, когда хотя бы часть задач испытывала задержку из-за недоступности ресурса. Для короткого опыта полезно сравнивать приращение total, а не только сглаженный avg10.11

CPU pressure cgroup может расти и при ограничении квотой: высокий PSI сам по себе не подтверждает конкуренцию с соседями.12 Он также не доказывает причину задержек конкретного запроса. Нужна совместная картина: очередь готовых потоков, загрузка доступных им CPU, ограничения cgroup и поведение приложения.

Если один разрешённый CPU занят, а остальные простаивают, это ещё не перегрузка всей ноды. Нельзя выбирать ноду по среднему проценту загрузки, не проверив доступный приложению набор CPU.

Что означает процент throttled periods

В cpu.stat есть накопительные счётчики nr_periods, nr_throttled и throttled_usec. Для диагностики нужны их изменения за интервал, а не значения с момента создания cgroup.2

cAdvisor может экспортировать соответствующие метрики в Prometheus. Пример запроса для одного условного контейнера:13

100 *
rate(container_cpu_cfs_throttled_periods_total{
  namespace="example", pod="example-0", container="app"
}[5m])
/
rate(container_cpu_cfs_periods_total{
  namespace="example", pod="example-0", container="app"
}[5m])

Селектор нужно дополнить метками кластера и источника сбора так, чтобы не смешивать разные контейнеры и дублирующие серии. rate() учитывает сбросы счётчика, но не исправляет неверный выбор серий.14

Результат показывает долю учтённых периодов, в которых срабатывало ограничение. Он не показывает долю потерянных запросов или процент недополученного CPU. Если ограничение возникало ненадолго в каждом периоде, показатель тоже может приблизиться к 100%.13

container_cpu_cfs_throttled_seconds_total также нельзя автоматически пересчитать в необходимое приложению количество ядер: это время ограничений, а не измеренная потребность в вычислениях. Метрика описывает механизм, не пользовательский ущерб.

При нулевом приросте числа периодов доля не определена. Пустой результат запроса тоже не равен нулю. Сначала проверьте наличие метрик, метки и сбор данных. А отсутствие роста счётчиков дочерней cgroup не освобождает от проверки предков.

Пример расчёта по двум снимкам

Это условные числа, не выполненный замер. Одна и та же cgroup имеет один доступный CPU и неизменную cpu.max = 25000 100000. Между снимками ровно 5 с, то есть 5 000 000 мкс, контейнер не перезапускался:

ПоказательПервый снимокВторой снимокПриращение
usage_usec8 000 0009 000 0001 000 000 мкс
nr_periods40045050
nr_throttled8010020
cpu.pressure: some total6 000 0007 500 0001 500 000 мкс

Среднее потребление — 1 000 000 / 5 000 000 = 0,2 CPU: за пять секунд группа использовала одну секунду CPU-времени. Доля периодов с ограничением — 20 / 50 × 100 = 40%. Приращение some total даёт 1 500 000 / 5 000 000 × 100 = 30% времени с CPU pressure за этот интервал; это не сумма ожидания всех потоков.11

Собственная квота срабатывала, хотя среднее потребление ниже её 0,25 CPU. Эти данные не показывают долю потерянных запросов, необходимое число CPU или влияние ограничения на задержки приложения. Они также не исключают ограничение предка и не доказывают конкуренцию с соседями. Для решения нужны снимки предков, загрузка разрешённых CPU и показатели приложения за тот же интервал.

Два опыта, которые разделяют причины

Для изоляции механизмов достаточно отдельной Linux-VM без рабочих нагрузок. Нужны минимум два доступных логических CPU, systemd с поддержкой AllowedCPUs, cgroup v2 с контроллерами cpu и cpuset, включённый PSI и установленный stress-ng. Kubernetes на стенде не нужен: проверяем поведение ядра, а не планировщика подов.

Перед запуском проверьте отсутствие ограничивающей квоты у родительских cgroup. Зафиксируйте версии ядра, systemd и stress-ng. Для VM учитывайте и конкуренцию на гипервизоре: стенд с нестабильной доступностью vCPU плохо подходит для сравнения.

Выберите один разрешённый CPU. В примере используется CPU 1; он должен быть online и доступен тестовым сервисам. Это ограничит область нагрузки, но не зарезервирует процессор исключительно для опыта.15

В первом терминале подготовьте функцию:

CPU=1
STRESS_NG="$(command -v stress-ng)"
test -n "$STRESS_NG" || { echo "stress-ng is not installed" >&2; exit 1; }
test "$(stat -fc %T /sys/fs/cgroup)" = cgroup2fs || exit 1

run_cpu_case() {
  sudo systemd-run --unit="$1" --wait --pipe --collect \
    --property=Type=exec \
    --property="AllowedCPUs=$CPU" \
    --property=CPUWeight=100 \
    --property="CPUQuota=$3" \
    --property=CPUQuotaPeriodSec=100ms \
    --property=RuntimeMaxSec=30s \
    --property=TimeoutStopSec=3s \
    "$STRESS_NG" --cpu "$2" --cpu-method int64 \
      --timeout 20s --metrics-brief
}

CPUQuota=25% ограничивает сервис четвертью одного CPU, а не четвертью всей VM. Пустое значение снимает квоту только с самого сервиса.15 stress-ng запускает заданное число CPU-работников на 20 секунд. Ограничение времени сервиса дополнительно останавливает зависший опыт.1617

До первого запуска подготовьте во втором терминале функцию измерения. Она снимает два показания с паузой пять секунд:

measure_cpu_case() (
  set -eu
  unit="$1"
  group="$(systemctl show "$unit" --property=ControlGroup --value)"
  case "$group" in
    /*) test "$group" != / ;;
    *) echo "Service cgroup is unavailable" >&2; exit 1 ;;
  esac
  cg="/sys/fs/cgroup$group"

  snapshot() {
    date -Ins
    for file in cpu.max cpu.stat cpu.pressure cpuset.cpus.effective; do
      printf '\n%s\n' "$file"
      cat "$cg/$file"
    done
  }

  snapshot
  sleep 5
  snapshot
)

После завершения сервиса его cgroup может исчезнуть. Ошибка чтения в таком случае означает пропущенное измерение, а не нулевые счётчики. Повторите опыт и снимите показания во время работы.

Для расчётов используйте разность временных отметок снимков. run_cpu_case ждёт завершения сервиса, поэтому измерение выполняйте во втором терминале, пока первый занят нагрузкой.

Сначала контрольный запуск

Один работник без собственной квоты. В первом терминале:

run_cpu_case cpu-lab-baseline 1 ""

Во втором терминале сразу после запуска:

measure_cpu_case cpu-lab-baseline.service

Опыт 1: квота на свободном CPU

После окончания контрольного запуска в первом терминале выполните:

run_cpu_case cpu-lab-quota 1 "25%"

Во втором терминале сразу после запуска:

measure_cpu_case cpu-lab-quota.service

Ожидаемое отличие от контроля — конечная квота и рост счётчиков throttling. Один непрерывно работающий процесс хочет больше CPU-времени, чем разрешено его группе. Для возникновения этого ограничения второй нагружающий процесс не нужен.

Контрольная проверка гипотезы — повторить запуск с пустой квотой при том же числе работников и на том же CPU. Изменяется один фактор: ограничение времени выполнения.

Опыт 2: конкуренция без собственной квоты

После окончания предыдущего опыта в первом терминале выполните:

run_cpu_case cpu-lab-contention 2 ""

Во втором терминале сразу после запуска:

measure_cpu_case cpu-lab-contention.service

Два CPU-работника теперь делят один разрешённый CPU. При отсутствии квот у предков ожидается рост ожидания CPU без роста счётчиков ограничения квотой. Сравнивать нужно с контрольным запуском, где на тот же CPU приходился один работник.

Этот опыт моделирует конкуренцию внутри доступного набора CPU, а не загрузку каждого ядра ноды. Поэтому он одновременно показывает, почему свободные CPU на общем графике ещё не доказывают наличие ресурса у конкретного процесса.

После каждого запуска дождитесь завершения сервиса. Для досрочной остановки используйте sudo systemctl stop с точным именем текущего тестового сервиса.

Как читать результат

РежимКвота сервисаОжидаемый отличительный признак
Один работник, контрольНетМало конкуренции на выбранном CPU
Один работник, 25% CPUЕстьРост счётчиков ограничения квотой
Два работника на одном CPUНетОжидание CPU без ограничения квотой при неограниченных предках

Это ожидаемые признаки, а не готовые результаты измерений. Если они не воспроизводятся, проверьте эффективный cpuset, квоты предков, фоновую нагрузку и настройки планирования. Не подгоняйте вывод под таблицу.

Счётчики операций stress-ng служат для сравнения одинаковых тестов. Они не заменяют задержки и пропускную способность приложения. Опыты объясняют механизм, но не доказывают, что именно он вызвал конкретную деградацию.

Почему Kubernetes не перенесёт такой под сам

Один объект Pod назначается на ноду один раз. «Перенос» обычно означает завершение старого пода и создание нового, с другим UID. Сам по себе перезапуск контейнера не меняет ноду.18

Штатный планировщик учитывает запросы ресурсов и ограничения размещения. Это не постоянный балансировщик работающих подов по графикам загрузки CPU.1 В набор штатных сигналов node-pressure eviction входят память, дисковые ресурсы и PID, но не CPU pressure.19

Поэтому ожидать автоматического переселения только из-за роста CPU throttling нельзя. Дополнительная автоматика возможна, но ей всё равно потребуется различать причины задержки.

Выбирать действие по подтверждённой причине

Что подтвержденоЧто проверять следующим изменением
Мешает собственная CPU-квота, доступные CPU имеют запасИзменение лимита или снижение потребления CPU на единицу работы
Исчерпывается бюджет родительской cgroupИменно этот бюджет и его потребителей, а не только лимит контейнера
Разрешённые CPU заняты соседними нагрузкамиРазмещение, запросы ресурсов и доступную ёмкость
Собственные потоки конкурируют внутри узкого cpuset или affinityДоступный набор CPU и параллелизм приложения
Работу можно независимо распределить между репликамиМасштабирование при наличии ресурсов и фактическом распределении нагрузки
Приложение деградирует, но исчерпание квот и конкуренция за CPU не подтвержденыПолноту наблюдений, затем ожидания и узкое место приложения
Есть только красный график без ухудшения работы приложенияСначала установить влияние, не пересоздавать под автоматически

Если собственные потоки продолжают делить тот же узкий cpuset или affinity, перенос на другую ноду не устранит это ограничение. Если данных о CPU достаточно, но ни квоты, ни конкуренция не объясняют деградацию, переходите к ожиданиям приложения, например блокировкам или I/O. Эти данные пока не обосновывают изменение CPU-лимита или размещения.

Увеличивать лимит без оценки ноды опасно: вместо throttling одного контейнера можно усилить конкуренцию для соседей. Дополнительные реплики на тех же занятых CPU тоже не создают новых вычислительных ресурсов.

Перед пересозданием нужно проверить допустимость прерывания, контроллер-владелец, ограничения хранения и наличие подходящей целевой ноды. Для планового выселения используют согласованный процесс с учётом PodDisruptionBudget; обычное удаление пода не является эквивалентной защитой.20 Новый под также может попасть на прежнюю ноду, если правила размещения это допускают.

Эффект проверяют при сопоставимой входящей нагрузке. Меняют по одному фактору: лимит, размещение или число реплик. Сравнивают задержки, ошибки и число завершённых запросов после прогрева. Исчезновение throttling без улучшения работы приложения не доказывает, что проблема решена.

Вывод

Перед переносом пода нужны два разных ответа: исчерпывает ли он доступную CPU-квоту и ждёт ли занятые процессоры. Ни средняя загрузка ноды, ни процент throttled periods по отдельности этого не объясняют.

Перенос полезен, когда меняет подтверждённое ограничение. Если ограничением остаётся та же CPU-квота, сначала нужно разобраться с ней, а не менять поду адрес.


  1. Kubernetes: Resource Management for Pods and Containers — requests, limits и планирование. ↩︎ ↩︎

  2. Linux kernel: Control Group v2 — cpu.max, cpu.stat и эффективный набор CPU. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Linux kernel: CFS Bandwidth Control — расход квоты, ограничения иерархии и счётчики. ↩︎ ↩︎

  4. Kubernetes: Limit Ranges — значения ресурсов по умолчанию при создании пода. ↩︎

  5. Kubernetes: Pod API — нода, контейнеры и их текущее состояние. ↩︎

  6. Kubernetes: Debugging Kubernetes nodes with crictl — установка и настройка endpoint. ↩︎

  7. containerd: ContainerStatus — поле pid в дополнительных сведениях о контейнере. ↩︎

  8. Linux man-pages: cgroup_namespaces(7) — видимость иерархии cgroup. ↩︎ ↩︎

  9. Linux man-pages: sched_setaffinity(2) — ограничения набора CPU для потока. ↩︎

  10. taskset(1) и mpstat(1) — affinity всех потоков и загрузка отдельных CPU. ↩︎

  11. Linux kernel: PSI — Pressure Stall Information — some, total и наблюдение на уровне cgroup. ↩︎ ↩︎

  12. Linux kernel: kernel/sched/psi.c — CPU pressure при ограничении cgroup через cpu.max. ↩︎

  13. cAdvisor: Prometheus metrics — метрики потребления CPU и throttling. ↩︎ ↩︎

  14. Prometheus: rate() — скорость роста счётчиков и обработка сбросов. ↩︎

  15. systemd.resource-control — CPUQuota, CPUQuotaPeriodSec и AllowedCPUs. ↩︎ ↩︎

  16. stress-ng: руководство — CPU-работники, метод int64, время работы и метрики. ↩︎

  17. systemd.service и systemd-run — временные сервисы и ограничение продолжительности. ↩︎

  18. Kubernetes: Pod Lifecycle — назначение пода на ноду и замена объекта. ↩︎

  19. Kubernetes: Node-pressure Eviction — штатные сигналы выселения. ↩︎

  20. Kubernetes: Disruptions — добровольные прерывания и PodDisruptionBudget. ↩︎