Привет, Хабр!

Управление ресурсами в Kubernetes десять лет держалось на двух числах в манифесте. Request определял, куда планировщик поставит под и сколько ему достанется при драке за процессор. Limit ограничивал сверху. Оба числа выставлялись один раз, менялись только пересозданием пода, а проверить их правильность было нечем, кроме графиков утилизации.

Оба конца этой схемы изменились за последние три релиза. Ресурсы работающего пода теперь меняются без рестарта: изменение ресурсов на уровне контейнера стало stable в 1.35, в 1.36 то же самое завезли для пода целиком, а в 1.37 от 26 августа 2026 стабилизировались pod‑level resources.

Параллельно kubelet научился отдавать метрики давления из ядра — PSI получил GA в 1.36, и это первый нативный сигнал, который говорит не «сколько процентов занято», а «сколько времени задачи простояли в ожидании».

Вторая часть важнее первой, хотя шума вокруг неё меньше.

Почему утилизация вводила в заблуждение

Начать стоит с того, во что превращаются два числа из манифеста на самом деле.

CPU request в cgroup v2 становится весом в cpu.weight — долей процессорного времени при конкуренции. CPU limit становится парой чисел в cpu.max: квота и период, по умолчанию сто миллисекунд. Механика жёсткая. Контейнер с лимитом 500m получает 50 миллисекунд процессорного времени в каждом стомиллисекундном окне, и, выбрав их за первые 20 миллисекунд на четырёх потоках, встаёт до конца окна. Просто стоит, ничего не делая.

Средняя утилизация такого контейнера покажет процентов двадцать. Задержки при этом вырастут кратно, и по графику загрузки это не увидит никто.

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

$ cat /sys/fs/cgroup/cpu.max
50000 100000

$ cat /sys/fs/cgroup/cpu.stat
usage_usec 48219301
nr_periods 71204
nr_throttled 19386
throttled_usec 402118773

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

С памятью история другая. Request не резервирует ничего, он влияет только на планировщик и на порядок вытеснения. Limit становится memory.max, и при его превышении ядро убивает процесс. Промежуточного состояния нет: до последнего байта всё хорошо, после — OOM.

Зато есть состояние, которое не видно вообще: контейнер, живущий у самого лимита, постоянно вытесняет страницы page cache, читает их обратно с диска и тратит на это время. Утилизация памяти в этот момент показывает ровные девяносто пять процентов.

PSI показывает потерянное время

Ядро Linux с версии 4.20 считает величину, которая закрывает ровно этот пробел. Pressure Stall Information измеряет не занятость ресурса, а время, которое задачи простояли в ожидании этого ресурса.

Считается по трём ресурсам — процессор, память, ввод‑вывод — и в двух состояниях. some означает, что хотя бы одна задача стоит в ожидании; это ранний сигнал конкуренции. full означает, что стоят все незаблокированные задачи одновременно, то есть прогресса нет вообще.

Каждое состояние отдаётся четырьмя числами: доли времени за последние 10, 60 и 300 секунд плюс накопительный счётчик в микросекундах.

На cgroup v2 у каждой группы есть свои файлы давления:

$ cat /sys/fs/cgroup/memory.pressure
some avg10=12.43 avg60=8.91 avg300=3.77 total=48211903
full avg10=4.02 avg60=2.55 avg300=0.98 total=15883210

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

Kubelet в 1.36 читает эти файлы сам, без node‑exporter и сайдкаров, и отдаёт данные через Summary API на уровне ноды, пода и контейнера. Гейт KubeletPSI залочен в true и отключить его нельзя. Для сбора метрик есть счётчики cAdvisor:

# доля времени, которое контейнер простоял в ожидании памяти
rate(container_pressure_memory_waiting_seconds_total[5m])

# нода, где процессы реально стоят, а не просто заняты
avg_over_time(node_pressure_memory_stalled_seconds_total[5m]) > 0.1

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

А, ну и еще. Троттлинг по CFS механически и есть остановка: ядро намеренно отбирает время у cgroup, и это честно попадает в PSI как давление. Один под с жёстким лимитом, упирающийся в квоту, вытягивает агрегированный показатель давления CPU по всей ноде в красную зону, хотя у остальных арендаторов процессора хватает с избытком.

Ставить taint по агрегированному CPU‑давлению ноды нельзя — так вы будете выселять поды с нод, где всё в порядке.

Работает обратная комбинация: полное давление по памяти и вводу‑выводу смотрят на уровне ноды, давление по процессору — на уровне отдельного пода.

Ресайз без рестарта

Вторая половина изменений — возможность поменять реквесты и лимиты у работающего пода.

Управляет поведением поле resizePolicy, и заполнять его надо осознанно, потому что значение по умолчанию подходит не всем:

apiVersion: v1
kind: Pod
metadata:
  name: api
spec:
  containers:
  - name: app
    image: registry.example.com/api:1.4.2
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired
    - resourceName: memory
      restartPolicy: RestartContainer
    resources:
      requests:
        cpu: "500m"
        memory: "512Mi"
      limits:
        cpu: "1"
        memory: "512Mi"

NotRequired применяет изменение к живому контейнеру, RestartContainer перезапускает его с новыми значениями. Для процессора первое подходит почти всегда. Для памяти выбор зависит от рантайма: у JVM и CPython размер кучи фиксируется при старте, поэтому увеличение memory.max без рестарта не даст процессу ничего, кроме более далёкого потолка.

Меняются ресурсы через отдельный подресурс, не через обычный patch:

kubectl patch pod api --subresource=resize --type=json -p='[
  {"op":"replace","path":"/spec/containers/0/resources/requests/cpu","value":"1"},
  {"op":"replace","path":"/spec/containers/0/resources/limits/cpu","value":"2"}
]'

Дальше смотрим, что получилось. Ключевые поля лежат в статусе:

# что реально выделено kubelet
kubectl get pod api -o jsonpath='{.status.containerStatuses[0].resources}'

# перезапускался ли контейнер
kubectl get pod api -o jsonpath='{.status.containerStatuses[0].restartCount}'

# состояние самой операции
kubectl get pod api -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'

Условие PodResizeInProgress означает, что kubelet принял изменение и применяет его. PodResizePending с причиной Infeasible означает, что запрошенное не помещается на ноду в принципе.

Причина Deferred появляется, когда сейчас места нет, но оно может появиться: с 1.36 под в этом случае остаётся в состоянии Running со старыми ресурсами, kubelet пишет событие ResizeDeferred с объяснением, чего именно не хватает, и продолжает пробовать. Как только Cluster Autoscaler или Karpenter добавят ёмкость, изменение применится само.

Разница со старым поведением тут принципиальная для дежурного: раньше неудачная попытка увеличить ресурсы либо пересоздавала под, либо молча ничего не делала.

Чего ресайз не умеет

  • Уменьшение лимита памяти работает по принципу наилучшего усилия. Kubelet проверяет текущее потребление и пропускает изменение, если оно уже выше нового лимита, — иначе следующее же обращение к памяти привело бы к OOM. Рассчитывать на то, что память отдастся обратно по требованию, нельзя.

  • Поды, которым статический CPU manager выдал эксклюзивные ядра, не ресайзятся на месте вообще. То же касается Windows‑нод и обычных init‑контейнеров; сайдкары, объявленные через init‑контейнер с restartPolicy: Always, ресайзить можно.

  • Убрать request или limit после того, как он установлен, нельзя — только изменить значение. И класс QoS у пода не меняется: под, созданный как Burstable, не станет Guaranteed, даже если вы приведёте реквесты к лимитам.

Общий бюджет вместо арифметики по контейнерам

Раньше на каждый контейнер приходилось выставлять свои числа, и сумма этих чисел определяла, что резервируется под пода целиком. Сайдкар с нагрузкой, скачущей от нуля до заметной, приходилось либо переоценивать, либо регулярно ловить его на OOM.

Теперь бюджет задаётся на уровне пода, а контейнеры делят общий пул:

apiVersion: v1
kind: Pod
metadata:
  name: api-with-sidecar
spec:
  resources:
    requests:
      cpu: "1"
      memory: "1Gi"
    limits:
      cpu: "2"
      memory: "2Gi"
  containers:
  - name: app
    image: registry.example.com/api:1.4.2
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired
  - name: envoy
    image: registry.example.com/envoy:1.34
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired

Контейнерные значения при этом никуда не деваются и для своего контейнера имеют приоритет над общими.

Что должно быть готово на кластере

Прежде чем планировать внедрение, стоит проверить четыре вещи.

  • Версия кластера. Изменение ресурсов контейнера на месте доступно как стабильная функциональность с 1.35, до этого — бета с 1.33. Общий бюджет пода меняется на живом поде с 1.36. Managed‑кластеры у провайдеров подтягиваются с задержкой, так что реальная граница обычно проходит не там, где в документации проекта.

  • Cgroup v2 на нодах. PSI без неё не читается на уровне контейнеров вообще, а часть механики ограничений работает иначе. Проверяется одной командой на ноде, и в старых образах узлов до сих пор встречается первая версия.

  • Ядро с включённым PSI. Нужна версия 4.20 или новее, собранная с соответствующей опцией; у части дистрибутивов для узлов она выключена, и файлы давления тогда просто отсутствуют.

  • Рантайм и его версия. Обновление ресурсов живого контейнера идёт через отдельный вызов CRI, и поддержка со стороны containerd или CRI‑O должна быть достаточно свежей. Симптом несовпадения узнаваемый: API принимает изменение, условие PodResizeInProgress появляется и не уходит.

# cgroup v2 на ноде
stat -fc %T /sys/fs/cgroup    # ожидаем cgroup2fs

# PSI доступен
cat /proc/pressure/cpu

# что kubelet отдаёт по конкретной ноде
kubectl get --raw "/api/v1/nodes/$NODE/proxy/stats/summary" | jq '.node.cpu, .node.memory'

Последняя команда полезна ещё и тем, что показывает структуру ответа Summary API, именно оттуда давление забирают агенты сбора, и по ней удобно проверять, дошло ли оно до вашей системы мониторинга.

Кто именно дёргает ресайз

Вариантов три, и они не взаимозаменяемы.

Вертикальный автоскейлер работает по историческому потреблению и подходит для равномерных нагрузок, где вчерашний профиль предсказывает завтрашний.

Внешние платформы оптимизации строят рекомендации по своим моделям и берут за это деньги. Третий вариант — собственный контроллер на сто строк, который реагирует на конкретный сигнал; он оправдан, когда сигнал у вас специфический и вы точно знаете, что с ним делать.

Логика такого контроллера укладывается в цикл: прочитать давление по подам, отфильтровать тех, кто стоит выше порога дольше заданного окна, увеличить лимит на шаг, записать событие. Важны при этом не сами шаги, а предохранители.

MAX_CPU_MILLI = 4000        # потолок, выше не растём никогда
STEP = 1.25                 # шаг увеличения
COOLDOWN_SECONDS = 600      # пауза между изменениями одного пода
PRESSURE_THRESHOLD = 0.10   # доля времени в ожидании за 60 секунд

def should_grow(pod_state) -> bool:
    if pod_state.seconds_since_last_resize < COOLDOWN_SECONDS:
        return False
    if pod_state.cpu_limit_milli >= MAX_CPU_MILLI:
        return False
    return pod_state.cpu_pressure_avg60 > PRESSURE_THRESHOLD

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


В итоге

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

Теперь у VPA есть режим, который сначала пробует изменить ресурсы на месте и только при неудаче пересоздаёт.

Оговорка при этом остаётся. Механизм появился, а интеллект нет: рекомендации по‑прежнему строятся на исторической утилизации, то есть на той самой величине, которая не видит ни троттлинга, ни давления.

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

А вы уже смотрите на PSI или пока живёте на утилизации?

ЧТО ПОЧИТАТЬ

Если хотите глубже разобраться в Kubernetes, контейнерах и эксплуатации инфраструктуры:

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

Продолжить разбор можно на бесплатных уроках:

  • 23 сентября в 20:00. «eBPF: рентгеновское зрение для production». Записаться

  • 1 октября в 20:00. «Kagent + Ollama: ИИ‑агент для работы с Kubernetes». Записаться

  • 14 октября в 20:00. «ИИ для мониторинга: что Prometheus и Grafana могут рассказать агенту». Записаться

А больше бесплатных уроков и материалов по инфраструктуре можно найти в этом посте.

Комментарии (0)