Типичная авария на проде выглядит так. Кто-то выкатил сервис без указания ресурсов. Под спокойно работал неделю, а в пятницу под нагрузкой раздулся по памяти, вытолкал с ноды соседей, и пол-кластера начало флапать. Или наоборот: ты поставил жесткий лимит памяти, приложение в пике вышло за него на пару мегабайт, и kubelet прибил процесс с кодом 137. Никаких логов, просто Exit Code 137 и рестарт.
Все это - про управление ресурсами. Тема, которую новички пропускают ("да зачем мне эти requests"), а потом именно она ломает прод. В этом уроке разберем механику до самого низа: чем requests отличается от limits на уровне ядра, как из этих двух чисел вычисляется QoS-класс, кто и в каком порядке выселяет поды при нехватке памяти, и как обложить namespace защитой через LimitRange и ResourceQuota. Связка requests limits kubernetes - это фундамент, на котором держится и планировщик, и автоскейлинг, и стабильность ноды.

requests против limits: две совершенно разные сущности
Самая частая путаница новичков - думать, что requests и limits это "минимум и максимум". Это не так. Это две разные машины, которые работают на разных этапах и через разные механизмы ядра Linux.
requests - это обещание планировщику. Когда ты пишешь requests, ты говоришь scheduler-у: "зарезервируй мне столько". Планировщик смотрит на свободную (allocatable) емкость ноды и складывает requests всех уже стоящих там подов. Если твой request влезает в остаток - под едет на ноду. Если нет - под висит в Pending. Важнейший момент: планировщик считает по requests, а НЕ по реальному потреблению. Нода может быть загружена на 30% по факту, но если сумма requests упёрлась в потолок - новые поды туда не поедут.
limits - это потолок, который enforce-ит ядро в рантайме. И вот тут CPU и память ведут себя принципиально по-разному, это ключ ко всему уроку.
- CPU limit - это троттлинг, не убийство. CPU сжимаемый (compressible) ресурс. Когда контейнер упирается в CPU limit, ядро через CFS-квоту (cgroups) просто притормаживает процесс: не дает ему планироваться на CPU до конца текущего периода. Процесс жив, но медленный. Никто его не убивает.
- Memory limit - это жесткая стена. Память несжимаемая. Нельзя "чуть-чуть отобрать память" у работающего процесса. Когда контейнер вышел за memory limit, ядро запускает OOM-killer внутри cgroup и убивает процесс. В статусе ты увидишь OOMKilled и Exit Code 137 (это 128 + сигнал 9, SIGKILL). Это и есть классический kubernetes oom.
Как это выглядит в манифесте:
Код: Выделить всё
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels: { app: api }
template:
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: registry.example.ru/api:1.4.2
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1"
memory: "256Mi"
kubernetes qos: три класса и почему они решают, кто умрет первым
QoS (Quality of Service) - это ярлык, который kubelet вешает на под автоматически, исходя из того, как заданы requests и limits. Ты не выставляешь QoS руками, он вычисляется. И именно по этому ярлыку нода решает, кого выселять при нехватке ресурсов. Три класса:
- Guaranteed - у КАЖДОГО контейнера в поде заданы и request, и limit, причем для CPU и памяти они равны (request == limit). Это под, которому нода гарантированно держит ресурсы. Выселяется последним. В примере выше память была бы Guaranteed-уровня, но из-за разных CPU request/limit весь под попадет в Burstable - для Guaranteed нужно равенство по ОБОИМ ресурсам у всех контейнеров.
- Burstable - задан хотя бы один request, но условие Guaranteed не выполнено (limits нет или они больше requests). Под может "выстреливать" выше своего request, когда на ноде есть свободное. Большинство нормальных продовых подов - именно тут.
- BestEffort - не задано вообще ничего: ни requests, ни limits. Под-побирушка. Жрет что осталось, выселяется первым, без сожалений.
Код: Выделить всё
kubectl get pod api-7d9f8c-x2k4l -o jsonpath='{.status.qosClass}{"\n"}'
# Burstable
kubernetes eviction: что делает kubelet, когда нода под давлением
Тут важно разделить два совершенно разных механизма, которые оба называют "вытеснением".
1. API-инициированное вытеснение (drain). Это когда ты делаешь kubectl drain или Pod Disruption Budget разрешает эвикцию. Вежливо, через Eviction API, с уважением к PDB. Про это - в уроке про обновления нод.
2. Node-pressure eviction (kubernetes eviction по давлению). Это локальное решение kubelet, и оно нас интересует сейчас. kubelet постоянно мониторит ресурсы ноды и сравнивает их с порогами вытеснения (eviction thresholds). По умолчанию есть жесткий порог memory.available<100Mi - то есть когда свободной памяти на ноде осталось меньше 100 мебибайт, kubelet объявляет ноде состояние MemoryPressure и начинает выселять поды, чтобы спасти ноду от системного OOM, который вырубил бы все подряд, включая сам kubelet.
Вот здесь и срабатывает QoS. Порядок выселения при memory pressure:
- Сначала kubelet сортирует поды по тому, превышают ли они свои requests. BestEffort и Burstable, которые жрут БОЛЬШЕ своего memory request - первые кандидаты.
- Внутри них ранжирование по pod priority (PriorityClass), а затем по тому, насколько потребление превышает request: формула (usage - request), кто сильнее вылез за свой request, того и выселят раньше.
- Guaranteed-поды и поды, которые сидят в пределах своих requests, трогают в последнюю очередь.
Кроме памяти, kubelet точно так же реагирует на нехватку места на диске - пороги nodefs.available и imagefs.available. При DiskPressure kubelet сначала чистит мусор (мертвые контейнеры, неиспользуемые образы), а если не помогло - начинает выселять поды. Посмотреть, что нода под давлением:
Код: Выделить всё
kubectl describe node worker-3 | grep -A6 Conditions
# Conditions:
# Type Status Reason
# MemoryPressure True KubeletHasInsufficientMemory
# DiskPressure False KubeletHasNoDiskPressure
# Ready True KubeletReady
Код: Выделить всё
kubectl get events --field-selector reason=Evicted -A
# NAMESPACE LAST SEEN TYPE REASON OBJECT MESSAGE
# shop 2m Warning Evicted pod/worker-xx The node was low on resource: memory. ...
LimitRange и resourcequota: оборона на уровне namespace
Полагаться на то, что каждый разработчик не забудет проставить ресурсы - наивно. Поэтому на namespace вешают два защитных объекта.
LimitRange работает на уровне ОТДЕЛЬНОГО пода/контейнера. Он умеет: проставлять дефолтные requests/limits, если разработчик их не указал (вот так BestEffort-поды автоматически превращаются в Burstable), и задавать минимум/максимум на контейнер - чтобы никто не запросил 64Gi "на всякий случай".
Код: Выделить всё
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
namespace: shop
spec:
limits:
- type: Container
default: # станет limit, если не задан
cpu: "500m"
memory: "512Mi"
defaultRequest: # станет request, если не задан
cpu: "100m"
memory: "128Mi"
max:
cpu: "2"
memory: "2Gi"
ResourceQuota работает на уровне ВСЕГО namespace - суммарно. Это про деньги и про справедливость между командами. resourcequota ограничивает общую сумму requests/limits и количество объектов:
Код: Выделить всё
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-shop
namespace: shop
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
pods: "100"
Код: Выделить всё
kubectl describe resourcequota team-shop -n shop
# Resource Used Hard
# requests.cpu 12 20
# requests.memory 22Gi 40Gi
# pods 47 100
Overcommit, right-sizing и диагностика CPU-троттлинга
Overcommit (переподписка) - это когда сумма limits на ноде больше ее физической емкости. Это нормально и даже желательно: не все поды жрут пик одновременно. Нода с 16 ядрами может держать поды с суммой CPU limits в 40 ядер - пока они не упрутся все разом. Опасен overcommit по памяти: память несжимаема, и если все поды разом потянутся к своим limits - получишь node-pressure eviction. Правило: по CPU overcommit-ить можно агрессивно, по памяти - осторожно.
Right-sizing - подбор адекватных цифр. Гадать вредно. Сними реальные метрики:
Код: Выделить всё
kubectl top pod -n shop --sort-by=memory
# NAME CPU(cores) MEMORY(bytes)
# api-7d9f8c-x2k4l 340m 412Mi
# worker-5c8b-aa1 12m 88Mi
Диагностика CPU-троттлинга. Если сервис "тормозит без причины", а CPU вроде не в потолке по top - проверь троттлинг. Метрика container_cpu_cfs_throttled_periods_total из cAdvisor показывает, сколько CFS-периодов контейнер был притормозлен квотой. Высокое отношение throttled к total периодам = ты упёрся в CPU limit, даже если средняя загрузка низкая (всплески внутри 100ms-периодов). Лечится поднятием CPU limit или полным его снятием (оставив только request).
Типичные грабли и антипаттерны
- Нет requests вообще. Под становится BestEffort, планировщик не резервирует под него ничего, и он первым летит при eviction. Никогда не катите прод без requests.
- Жесткий memory limit вплотную к потреблению. Любой всплеск - OOMKilled (Exit 137) и рестарт. Ставь memory limit с запасом над реальным пиком, а не "впритык".
- CPU limit там, где он не нужен. Многие команды на проде вообще снимают CPU limits (оставляя requests), чтобы убрать вредный троттлинг латентных сервисов. CPU сжимаем - сосед в любом случае не отберет у тебя request, а пиком ты воспользуешься свободным CPU. Memory limit при этом оставляют всегда.
- memory request != memory limit при желании получить стабильность. Если хочешь предсказуемости для критичного сервиса - делай его Guaranteed (req=lim по памяти), тогда он выселяется последним.
- Mi/M и Gi/G путаница. 1Gi=1073741824 байт, 1G=1000000000. Разница ~7%, и на этом ловят OOM, когда думали что дали достаточно.
- ResourceQuota без LimitRange. Половина деплоев начинает падать с "must specify requests", потому что забыли дефолты. Ставь их вместе.
Нужен любой кластер - подойдет kind, minikube, k3s или managed (Yandex Managed Kubernetes, VK Cloud). По шагам:
- Создай namespace и повесь LimitRange с дефолтами и ResourceQuota (манифесты выше). Примени, выполни kubectl describe resourcequota - убедись, что Used нулевой.
- Запусти под БЕЗ ресурсов: kubectl run be --image=nginx -n shop. Проверь, что LimitRange проставил ему дефолты (kubectl get pod be -n shop -o jsonpath='{.spec.containers[0].resources}') и какой QoS он получил.
- Спровоцируй OOM: запусти под с memory limit 64Mi и нагрузи память через stress (image polinux/stress, аргумент --vm-bytes 200M). Через kubectl get pod -w увидишь OOMKilled и RESTARTS, а в describe - Last State: Terminated, Reason: OOMKilled, Exit Code: 137.
- Сравни QoS: создай три пода - один без ресурсов (BestEffort), один с requests<limits (Burstable), один с req==lim (Guaranteed). Проверь .status.qosClass у каждого. Подумай, кого kubelet выселит первым при давлении.
- Чем enforcement memory limit отличается от CPU limit на уровне ядра? Что увидишь в статусе пода в каждом случае?
- У пода заданы requests=limits по памяти, но по CPU request меньше limit. Какой у него QoS-класс и почему не Guaranteed?
- В каком порядке kubelet выселяет поды при MemoryPressure и какую роль играет величина (usage - request)?
- Зачем ResourceQuota почти всегда ставят в паре с LimitRange? Что сломается, если поставить только Quota?
requests - обещание планировщику и основа автоскейлинга, limits - потолок, который ядро enforce-ит в рантайме: CPU троттлит, память убивает через OOM (Exit 137). Из соотношения requests и limits вычисляется QoS-класс, а по классу kubelet решает, кого выселить при node-pressure eviction: BestEffort падает первым, Guaranteed - последним. LimitRange проставляет дефолты и границы на под, ResourceQuota держит сумму по namespace, и ставить их надо вместе. Правильный right-sizing (адекватные requests, memory limit с запасом, CPU limit под вопросом) - это и есть граница между стабильным продом и ночными алертами.