Ресурсы, лимиты и QoS

requests и limits — это не просто два числа: от них зависит, кого кластер убьёт первым при нехватке памяти; разбираемся в классах QoS.

QoS-класс (Quality of Service) — категория, которую Kubernetes присваивает поду по соотношению его requests и limits; именно она определяет приоритет пода при вытеснении под давлением ресурсов.

Зачем это на практике

В базовом разделе мы задавали requests и limits и считали тему закрытой. Но на проде наступает ночь, узел упирается в память, и kubelet начинает кого-то убивать — вопрос только, кого именно. Ответ напрямую следует из того, как вы выставили ресурсы. Под, которому повезло иметь равные requests и limits, выживает; под без лимитов умирает первым. Это не случайность, а механика QoS-классов, и понимать её — значит уметь защитить критичные сервисы.

requests против limits — разная роль

Эти два поля часто путают, хотя они отвечают за разное:

ПолеЧто значитКто использует
requestsгарантированный минимум, бронь ресурсапланировщик (scheduler) — куда поставить под
limitsжёсткий потолок потребленияkubelet/ядро — когда притормозить или убить

requests — это обещание кластера: «этот под гарантированно получит столько». Планировщик ставит под только на узел, где суммы requests ещё помещаются. limits — это запрет: «больше — нельзя». Память и CPU при этом ведут себя по-разному. Превышение лимита CPU не убивает под, а троттлит его: ядро не даёт процессорное время сверх квоты, и приложение тормозит. Превышение лимита памяти убивает: память нельзя «притормозить», поэтому процесс получает OOMKilled.

apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  containers:
    - name: app
      image: myapp:1.0
      resources:
        requests:
          cpu: "250m"
          memory: "256Mi"
        limits:
          cpu: "500m"
          memory: "256Mi"

Здесь CPU может «всплеснуть» вдвое (с 250m до 500m), а память жёстко зафиксирована на 256Mi и сверху, и снизу.

Три класса QoS

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

КлассУсловиеПриоритет при вытеснении
Guaranteedrequests = limits по CPU и памяти у всех контейнеровубивают последним
Burstablerequests заданы, но меньше limits (или limits не у всех)в середине
BestEffortни requests, ни limits не заданы вовсеубивают первым

Под из примера выше по памяти имеет requests = limits, но по CPU — нет (250m против 500m), поэтому он Burstable. Чтобы получить Guaranteed, нужно выровнять requests и limits и для CPU тоже. Класс пода видно прямо в его статусе:

kubectl get pod web -o jsonpath='{.status.qosClass}'

Вывод:

Burstable

Практический вывод: критичные сервисы (база, платёжный шлюз) делают Guaranteed — их кластер не тронет почти ни при каких обстоятельствах. Фоновые задачи, которые не жалко перезапустить, можно оставить Burstable. А BestEffort в проде — почти всегда ошибка: такой под умирает первым и не даёт планировщику никаких гарантий размещения.

OOMKilled: почему под умирает

Когда контейнер превышает limits.memory, ядро Linux вызывает OOM-killer в его cgroup и убивает процесс. Под перезапускается, а в его описании появляется характерная отметка:

kubectl describe pod web
# ...
# Last State:   Terminated
#   Reason:     OOMKilled
#   Exit Code:  137

Код выхода 137 (= 128 + 9, где 9 — сигнал SIGKILL) — верный признак OOM. Важно отличать это от бага: код приложения может быть корректен, ему просто не хватило выделенного лимита. Лечится либо поднятием limits.memory, либо устранением утечки. А вот если под Burstable убит не своим лимитом, а под давлением памяти на узле — это уже вытеснение по QoS, и спасает повышение класса до Guaranteed или добавление узлов.

ResourceQuota и LimitRange: правила на весь namespace

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

apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-quota
  namespace: team-a
spec:
  hard:
    requests.cpu: "10"
    requests.memory: 20Gi
    limits.cpu: "20"
    limits.memory: 40Gi
    pods: "50"

Теперь все поды namespace team-a вместе не запросят больше 10 ядер и 20Gi и не создадут больше 50 подов. Важная деталь: если в namespace действует ResourceQuota на requests/limits, то под без указанных ресурсов вообще не будет принят — квота требует, чтобы каждый под объявлял свою долю.

LimitRange работает на уровне отдельного пода: задаёт значения по умолчанию и границы для каждого контейнера:

apiVersion: v1
kind: LimitRange
metadata:
  name: defaults
  namespace: team-a
spec:
  limits:
    - type: Container
      default:
        cpu: "500m"
        memory: 512Mi
      defaultRequest:
        cpu: "250m"
        memory: 256Mi
      max:
        cpu: "2"
        memory: 2Gi

Если разработчик забыл указать ресурсы, LimitRange проставит defaultRequest/default автоматически, а попытку запросить больше max отклонит. Связка очевидна: LimitRange гарантирует, что у каждого пода есть разумные requests (а значит, он не BestEffort и помещается в квоту), а ResourceQuota держит суммарный потолок namespace.

Как это работает под капотом

requests и limits — это, по сути, настройки cgroups v2 ядра Linux на узле. limits.memory пишется в memory.max cgroup контейнера; при превышении срабатывает OOM-killer именно этой группы. limits.cpu транслируется в cpu.max (квота и период): 500m означает «500 мс CPU за каждые 1000 мс», и сверх этого ядро притормаживает процесс. requests.cpu же превращается в cpu.weight — относительный вес при дележе процессора между cgroup, когда CPU в дефиците. А QoS-класс kubelet кодирует в oom_score_adj процесса: у Guaranteed он минимальный (ядро трогает их в последнюю очередь), у BestEffort — максимальный (кандидаты на отстрел номер один). То есть приоритет вытеснения — это не абстракция Kubernetes, а реальная настройка ядра.

Частые ошибки

  • Лимиты без requests или наоборот. Перекос ломает планирование и QoS; задавайте оба осознанно.
  • BestEffort в проде. Под без ресурсов умирает первым и не даёт планировщику гарантий — почти всегда ошибка.
  • Путать OOMKilled (свой лимит) с вытеснением (давление на узле). Первое лечится лимитом памяти, второе — классом QoS или узлами.
  • Жёсткий лимит CPU там, где нужны всплески. Троттлинг растит задержки, хотя память и аптайм идеальны; подбирайте лимит по профилю нагрузки.
  • ResourceQuota без LimitRange. Квота начнёт отклонять поды без ресурсов, и разработчики упрутся в ошибки; LimitRange проставит дефолты и сгладит переход.

Итоги

  • requests — бронь для планировщика, limits — потолок для ядра; роли у них разные.
  • Память за лимитом убивает (OOMKilled, exit 137), CPU за лимитом лишь троттлит.
  • QoS-класс выводится автоматически: Guaranteed (requests=limits) выживает, BestEffort (без ресурсов) умирает первым.
  • Критичные сервисы делайте Guaranteed; BestEffort в проде избегайте.
  • ResourceQuota ограничивает namespace в сумме, LimitRange задаёт дефолты и границы поду — используйте их в паре.
Проверьте себя
1. Чем поведение пода при превышении limits.cpu отличается от превышения limits.memory?
AВ обоих случаях под мгновенно убивается
BПревышение CPU троттлит (тормозит) под, а превышение памяти убивает его (OOMKilled)
CПревышение CPU убивает под, а память просто игнорируется
DОба превышения только пишут предупреждение в лог и ничего не делают
2. Какой QoS-класс получит под, у которого requests и limits заданы и равны по CPU и по памяти?
ABestEffort
BBurstable
CGuaranteed
DКласс задаётся вручную в spec.qosClass
3. Зачем в namespace добавляют LimitRange вместе с ResourceQuota?
ALimitRange шифрует секреты namespace
BResourceQuota отклоняет поды без указанных ресурсов, а LimitRange проставляет им значения по умолчанию и границы
CLimitRange увеличивает суммарную квоту namespace
DОни взаимоисключающие, нужен только один из них