Ресурсы в Kubernetes: requests, limits и QoS

requests и limits — не одно и то же, и путаница между ними стоит дорого: CPU throttling там, где нагрузки нет, и OOMKill там, где памяти хватало. Разбираем QoS-классы, механику CFS и cgroups и как выставлять ресурсы по факту, а не наугад.

Requests и limits выглядят как две настройки одного и того же, но управляют они разным: requests — про планирование и гарантии, limits — про потолок. Перепутать их дорого — от CPU throttling на простое до OOMKill пода, которому памяти хватало. Разберём, что именно делают эти числа, как из них выводятся QoS-классы и как выставлять их по данным, а не по интуиции.

Это вторая статья серии «Kubernetes на практике» и одновременно фундамент для автоскейлинга — «Автоскейлинг в Kubernetes: HPA, VPA и KEDA»: без корректных requests автоскейлерам просто не с чего считать. Все числа ниже — из живого стенда kubernetes/resources в публичном репозитории digital-cookbook: Kubernetes 1.36.2 на узлах Talos, cgroup v2. Кластер собран так же, как в статье про четыре отказа при bootstrap на Talos: SSH на узлы нет, всё наблюдение идёт только через API-сервер — kubectl exec, kubectl top, kubectl describe, и этого достаточно, чтобы увидеть механику целиком.

Ретрофутуристская схема распределения ресурсов в кластере: слева перфокарточная панель манифеста пода, от неё отходят две трубки — серо-синяя ведёт к диспетчерскому пульту с манометрами и ползунками, где выбирается узел, оранжевая уходит в машинный кожух, где вращается лопастной регулятор CFS-квоты и стоит красный клапан-пресс памяти; внизу лесенка из трёх убывающих ящиков QoS со стрелкой порядка вытеснения

В статье

Два числа, две судьбы

В манифесте requests и limits стоят рядом, в одном блоке resources, и от этого кажутся парой «мягкое ограничение / жёсткое ограничение». На самом деле числа расходятся сразу после того, как под принят API-сервером, и дальше живут в разных подсистемах.

requests читает планировщик. Это заявка: «мне нужно столько-то, чтобы работать». Планировщик подбирает узел так, чтобы сумма requests всех подов на нём не превышала доступную ёмкость. Отсюда первое следствие: requests — это не то, сколько под потребляет, а то, сколько под резервирует. Пустой pause-контейнер с requests.cpu: 100m не потребляет ничего, но в учёте планировщика сто миллиядер узла заняты: новый под на них уже не поставят. Для выполнения же простаивающий CPU никуда не пропадает — пока контейнер его не использует, процессорное время достаётся соседям по узлу; request гарантирует долю CPU при конкуренции, а не выделяет ядра в монопольное пользование. Второе следствие: сумму requests переподписать нельзя — планировщик просто не поставит под, для которого не осталось места, и тот повиснет в Pending.

limits читает kubelet и превращает в настройки cgroup. Это потолок: «дальше не пущу». Потолок применяется на узле, локально, независимо от того, что происходит в кластере. И вот тут — ключевая асимметрия, из которой растёт половина инцидентов: CPU сжимаемый ресурс, память — нет. Процесс, упёршийся в CPU-лимит, просто начинает ждать и рано или поздно доедет; процесс, упёршийся в лимит памяти, в лучшем случае притормозит, пока ядро возвращает что может, а когда возвращать станет нечего — его убьют. Одна и та же строчка limits даёт в двух случаях принципиально разные последствия — и обе разберём ниже на живых замерах.

Отсюда третье следствие: сумму limits переподписывать (overcommit) — нормально и обычно нужно. Если каждый под получил limits.cpu: 2, а пиков одновременно они не дают, сумма лимитов на узле спокойно превышает число ядер: лимит расходуется только тогда, когда под реально работает. Сумма requests при этом должна оставаться в пределах ёмкости — иначе резервирование перестаёт быть резервированием.

На стенде оба этих потолка заданы явно на уровне namespace, и удобно увидеть их сразу:

$ kubectl get resourcequota,limitrange -n cookbook-k8s
NAME                           REQUEST                                                 LIMIT                                   AGE
resourcequota/cookbook-quota   pods: 0/30, requests.cpu: 0/4, requests.memory: 0/4Gi   limits.cpu: 0/8, limits.memory: 0/8Gi   5s

NAME                         CREATED AT
limitrange/cookbook-limits   2026-07-22T23:20:23Z

Квота на requests вдвое ниже квоты на limits (4 против 8 по CPU, 4Gi против 8Gi по памяти) — ровно потому, что это разные величины: первая про гарантированное резервирование, вторая про предельный размах.

QoS-классы и порядок вытеснения

Из соотношения двух чисел Kubernetes сам выводит третье — класс качества обслуживания, status.qosClass. Его не задают явно, он вычисляется:

  • Guaranteed — у каждого контейнера пода requests и limits заданы и равны, и по CPU, и по памяти;
  • Burstable — заданы не всё или не поровну (обычно requests меньше limits);
  • BestEffort — не задано ничего.

Два пода в основном namespace стенда показывают первые два класса живьём:

$ kubectl get pod -n cookbook-k8s guaranteed burstable \
    -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass,REQ:.spec.containers[0].resources.requests,LIM:.spec.containers[0].resources.limits'
NAME         QOS          REQ                         LIM
guaranteed   Guaranteed   map[cpu:100m memory:64Mi]   map[cpu:100m memory:64Mi]
burstable    Burstable    map[cpu:100m memory:64Mi]   map[cpu:200m memory:128Mi]

Третьего класса здесь нет, и это не оформительская мелочь. Получить BestEffort в основном namespace стенда невозможно: там действует LimitRange, который дописывает дефолтные requests и limits любому контейнеру, где их не указали. Под без ресурсов не остаётся без ресурсов — он получает дефолты и становится Burstable. Честный BestEffort пришлось поднимать в отдельном namespace, где LimitRange нет, и это уже другой вывод, со своей шапкой:

$ kubectl get pod -n cookbook-k8s-noqos besteffort \
    -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass
NAME         QOS
besteffort   BestEffort

Содержательный урок ровно в том, что это два разных namespace: в кластере с ограждениями «забыть указать ресурсы» — не то же самое, что «не иметь ресурсов», и класс пода определяется не только его манифестом.

Класс важен потому, что от него зависит, кого узел принесёт в жертву, когда на нём кончится память. Только сам QoS-класс ключом сортировки при вытеснении не является — вопреки распространённой формулировке «сначала BestEffort, потом Burstable, потом Guaranteed». Kubelet при node-pressure eviction ранжирует кандидатов по трём признакам подряд: сначала — превышает ли под свои requests по дефицитному ресурсу, затем — Pod Priority, и только потом — насколько сильно превышение. Привычный порядок по классам получается как следствие: BestEffort не заявил requests вообще, поэтому превышает их всегда; Guaranteed по определению не может выйти за свои requests, поэтому в первую группу не попадает.

Разница между «правило» и «следствие» видна там, где вмешивается приоритет: низкоприоритетный Burstable, вышедший за requests, уедет раньше высокоприоритетного BestEffort — порядок по классам перевернётся. Отдельно и независимо работает ядро: контейнерам разных классов проставляется разный oom_score_adj — у Guaranteed он минимальный, у BestEffort максимальный, так что при глобальном OOM на узле ядро выбирает жертву в ту же сторону. Практический вывод от этого не меняется: под без requests — доброволец на выбывание, и никакие «он же важный» на это не влияют, пока «важный» не выражен через requests и priorityClassName.

Два числа стоят рядом в манифесте и расходятся по разным подсистемамresources подаrequestslimitskube-scheduler — выбор узла и резервированиесумма requests не выше ёмкости узла: переподписать нельзяcgroup v2 на узле — потолок применяет kubeletCPU: сжимаемыйтроттлинг в окне 100 мспамять: не сжимаемаяOOMKill, exitCode 137Соотношение тех же двух чисел задаёт QoS — и типичную очередь на вытеснениеBestEffortнет ни requests, ни limitsBurstablerequests меньше limitsGuaranteedrequests равны limitsуходит первымпорядок меняет Pod Priorityдержится дольше всех

CPU throttling: механика CFS-квот

limits.cpu не режет частоту и не отбирает ядра — он превращается в квоту планировщика Linux. Kubelet переводит лимит в настройку cgroup cpu.max: 500m — это 50 мс процессорного времени на каждые 100 мс периода CFS. Израсходовал квоту в текущем окне — все задачи cgroup засыпают до начала следующего. Это и есть троттлинг: не «медленнее», а «стой и жди».

Чтобы увидеть это в чистом виде, на стенде запущен stress --cpu 4 — четыре потока, каждый хочет полное ядро — с limits.cpu: 500m. Cgroup v2 отдаёт статистику прямо внутрь контейнера, и её можно прочитать без всякого доступа к узлу:

kubectl exec -n cookbook-k8s cpu-throttle -- cat /sys/fs/cgroup/cpu.stat
# первый снимок (значимые строки)
$ kubectl exec -n cookbook-k8s cpu-throttle -- cat /sys/fs/cgroup/cpu.stat
usage_usec 42664095
nr_periods 853
nr_throttled 853
throttled_usec 142285008

# второй снимок
$ kubectl exec -n cookbook-k8s cpu-throttle -- cat /sys/fs/cgroup/cpu.stat
usage_usec 56899867
nr_periods 1137
nr_throttled 1137
throttled_usec 189639688

Самое важное здесь — равенство nr_periods == nr_throttled, и в обоих снимках. Контейнер троттлится в каждом без исключения окне: между снимками прошло 284 периода — и все 284 закончились принудительной паузой, доля троттлинга ровно единица. Счётчики дают и собственные часы: 284 окна CFS по 100 мс — это 28,4 секунды по часам самого планировщика, и за них usage_usec вырос на 14 235 772 мкс, то есть на 14,24 секунды процессорного времени. Делим одно на другое — 0,501 ядра. Ровно квота.

Независимое подтверждение — с другой стороны:

$ kubectl top pod -n cookbook-k8s cpu-throttle
NAME           CPU(cores)   MEMORY(bytes)
cpu-throttle   502m         0Mi

kubectl top показывает 502m при лимите 500m — 0,502 ядра против 0,501, посчитанных по cpu.stat: в пределах округления то же самое, только измеренное снаружи. Метрика выглядит идеально: «под потребляет ровно столько, сколько ему выделили, никаких аномалий». А внутри четыре потока, которым нужно четыре ядра, получают полядра на всех и стоят в очереди в 100% окон. Отсюда неприятная особенность CPU-лимитов: средняя утилизация не показывает троттлинг. Приложение, у которого запрос обрабатывается пачкой горутин или пулом потоков, легко упирается в квоту на десятки миллисекунд внутри окна, получает рваные хвосты latency — а на дашборде видно скромные проценты загрузки, потому что усреднение по минуте съедает всю картину.

Счётчик throttled_usec (со 142 285 008 до 189 639 688 мкс) полезен как тренд, но не как «столько секунд простоя»: он суммируется по сущностям планировщика внутри cgroup и потому легко превышает астрономическое время. Смотреть надо на отношение nr_throttled к nr_periods — в Prometheus это container_cpu_cfs_throttled_periods_total к container_cpu_cfs_periods_total. Низкая доля на редких пиках — обычное дело и повода для тревоги не даёт; стабильно высокая доля, а тем более единица, как на стенде, означает, что лимит выбран не по делу.

Мелочь, которая тоже видна в выводе: MEMORY 0Mi. Нагрузка чисто процессорная, и памяти под неё уходит меньше, чем округление kubectl top, — 0Mi здесь означает «в пределах округления», а не буквальный ноль. Лишний повод не считать два ресурса одной сущностью.

OOMKill: память не сжимается

С памятью механика другая, и разница принципиальная. Сдаться на время, как процесс сдаётся на CPU, память не умеет: страница либо есть, либо её нет. Строго говоря, при упоре в memory.max cgroup v2 не убивает сразу — ядро сначала пытается вернуть что можно: выбрасывает страничный кеш, сбрасывает чистые файловые страницы, при этом притормаживая аллокации внутри cgroup. Убивает оно тогда, когда возвращать больше нечего. А нечего становится ровно в том случае, когда лимит выбрала анонимная память — куча, стеки, off-heap-буферы: без swap её выселять некуда. Тогда включается cgroup-OOM-killer и контейнер умирает.

На стенде под просит 250 МБ при лимите 128Mi:

containers:
  - name: stress
    image: polinux/stress:1.0.4
    command: ["stress"]
    args: ["--vm", "1", "--vm-bytes", "250M", "--vm-hang", "0"]
    resources:
      requests: { cpu: 50m, memory: 64Mi }
      limits:   { cpu: 100m, memory: 128Mi }

Результат живьём:

$ kubectl get pod -n cookbook-k8s oomkill -o jsonpath=\
'{range .status.containerStatuses[*]}reason={.lastState.terminated.reason} exitCode={.lastState.terminated.exitCode} restartCount={.restartCount}{"\n"}{end}'
reason=OOMKilled exitCode=137 restartCount=3
$ kubectl get events -n cookbook-k8s --field-selector involvedObject.name=oomkill \
    | grep -E '^LAST SEEN|Started|BackOff'
LAST SEEN   TYPE      REASON      OBJECT        MESSAGE
17s         Normal    Started     pod/oomkill   Container started
16s         Warning   BackOff     pod/oomkill   Back-off restarting failed container stress in pod oomkill_cookbook-k8s(…)

exitCode 137 — это 128 + 9, то есть «убит сигналом SIGKILL»; reason: OOMKilled в lastState.terminated говорит, кто именно его послал. Дальше включается restartPolicy, контейнер поднимается, снова просит свои 250 МБ, снова умирает — restartCount растёт (на снимке уже 3), интервалы между попытками увеличиваются, под уходит в CrashLoopBackOff. Ничего не «чинится» само: причина детерминированная, и каждый рестарт воспроизводит её заново.

Важно не путать это с вытеснением. OOMKill — локальное событие внутри cgroup одного контейнера: превысил свой лимит, убит, перезапущен на том же узле. Eviction — решение kubelet, когда память кончается у узла целиком: под выбирается по описанным выше правилам ранжирования и завершается. Сам он никуда не переезжает: если подом управляет контроллер (Deployment, ReplicaSet), тот создаёт замену — новый под, который планировщик размещает заново, возможно на другом узле. Первое лечится лимитом или профилем памяти приложения, второе — requests и вместимостью кластера.

Отдельный источник сюрпризов — что именно считается в потребление, и тут две разные цифры. Для наблюдения и решений о вытеснении kubelet (а за ним kubectl top) смотрит на working set: использование памяти cgroup за вычетом неактивных файловых страниц (inactive_file) — тех, что ядро выбросит по первому требованию. Лимит же применяет не kubelet, а ядро: memory cgroup учитывает всю память контейнера, включая страничный кеш, но при подходе к лимиту ядро сначала освобождает кеш и вызывает OOM killer, только когда освобождать больше нечего. Отсюда практический ориентир: чистое чтение файлов до OOMKill само по себе не доводит, а вот сервис, честно укладывающийся в лимит по своей куче, легко выходит за него на прочей анонимной памяти — off-heap-буферах, стеках потоков, аллокациях из cgo.

Отсюда практика для рантаймов, которые сами решают, сколько памяти занять:

  • Go. Сборщик мусора по умолчанию ориентируется на темп роста кучи, а не на лимит контейнера, — и спокойно доводит процесс до OOMKill. Лечится переменной GOMEMLIMIT: она задаёт мягкий потолок, при приближении к которому GC начинает работать агрессивнее. Лимит охватывает всю память, которой управляет рантайм Go, — не только кучу, но и стеки горутин и служебные структуры рантайма. Вне его учёта остаётся то, что рантайм не видит: аллокации cgo и другого нативного кода, файлы, отображённые в память, страничный кеш, который cgroup засчитывает контейнеру. Поэтому ставить GOMEMLIMIT нужно ниже limits.memory — с запасом именно на эту память, а при её заметном объёме — с большим.
  • JVM. Современные версии контейнер видят, но по умолчанию берут под кучу лишь долю доступной памяти. Явное -XX:MaxRAMPercentage привязывает максимальный размер кучи к лимиту контейнера — с тем же запасом на метапространство, стеки потоков, кодовый кеш и прямые буферы, которые в кучу не входят, но в лимит контейнера входят.

Общее правило: рантайм должен знать про лимит. Если он о нём не знает, он рано или поздно в него упрётся.

Ограждения namespace: LimitRange и ResourceQuota

Два объекта закрывают два разных класса проблем, и оба работают на уровне namespace.

LimitRange действует на отдельный контейнер: подставляет дефолты, если ресурсы не указаны, и не даёт выйти за минимум/максимум. На стенде он настроен так: request по умолчанию 100m/64Mi, limit по умолчанию 200m/128Mi, максимум на контейнер — 2/2Gi. Под без единого поля resources в манифесте получает всё это молча, при создании:

$ kubectl get pod -n cookbook-k8s no-resources -o jsonpath='{.spec.containers[0].resources}'
{"limits":{"cpu":"200m","memory":"128Mi"},"requests":{"cpu":"100m","memory":"64Mi"}}

Это и есть механизм, из-за которого выше не получилось создать BestEffort. Побочный эффект стоит держать в голове: дефолт всегда неверен для конкретной нагрузки — он спасает кластер от подов-безбилетников, но не заменяет осмысленных чисел в манифесте.

ResourceQuota действует на сумму по namespace: сколько всего requests и limits могут запросить все поды вместе, плюс потолок на количество объектов. На стенде — requests.cpu 4, requests.memory 4Gi, limits.cpu 8, limits.memory 8Gi, pods 30. Чтобы увидеть отказ, разворачивается Deployment на 6 реплик по requests.memory: 1Gi:

$ kubectl get deploy -n cookbook-k8s quota-buster
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
quota-buster   3/6     3            3           10s

Поднялись три реплики из шести. Остальные упёрлись в квоту, и контроллер честно сказал, во что именно:

$ kubectl describe rs -n cookbook-k8s -l app=quota-buster | grep -m1 'Warning  FailedCreate'
  Warning  FailedCreate      10s              replicaset-controller  Error creating: pods "quota-buster-cbdfdd97f-kpksz" is forbidden: exceeded quota: cookbook-quota, requested: requests.memory=1Gi, used: requests.memory=3264Mi, limited: requests.memory=4Gi

Арифметика сходится: 3264Mi — это три поднявшиеся реплики по 1Gi (то есть 3072Mi) плюс 192Mi, уже занятые тремя лёгкими подами из предыдущих демонстраций по 64Mi каждый — guaranteed, burstable и no-resources, получивший свои 64Mi от LimitRange. Следующая заявка на 1Gi в остаток до 4Gi не влезает.

Дальше важен характер отказа. Deployment не падает и не откатывается — он остаётся в состоянии частичного развёртывания: ReplicaFailure: FailedCreate, три пода работают, три не существуют. Для приложения это выглядит как «масштабирование не сработало», и без взгляда на события ReplicaSet причина не видна. Особенно неприятно, когда в квоту упирается автоскейлер: HPA считает, что реплик должно быть больше, а квота этого не даёт — и разбираться приходится уже во время инцидента.

Именно за этим ограждения и нужны в общем кластере: они переводят потенциальное «одна команда выела узлы у всех» в предсказуемый отказ с точным текстом. Цена — необходимость держать квоты в актуальном состоянии и мониторить приближение к ним заранее, а не по факту FailedCreate.

Как выставлять по факту

Числа надо брать из наблюдения. Формально помочь тут может Vertical Pod Autoscaler в режиме updateMode: Off — в нём оператор считает рекомендации, но ничего не применяет и подов не пересоздаёт (в отличие от Auto/Recreate, которые ради новых requests под перезапустят). Это безопасно на проде: наблюдаем, читаем, решаем сами.

На стенде под с stress --cpu 1 --vm-bytes 80M намеренно получил заниженные requests: {cpu: 50m, memory: 32Mi} — при limits: {cpu: 500m, memory: 256Mi}. kubectl top для него показывал 501m CPU и 80Mi памяти. Рекомендация:

$ kubectl get vpa -n cookbook-k8s vpa-sample -o jsonpath=\
'{range .status.recommendation.containerRecommendations[*]}container={.containerName}{"\n"}'\
'  target     = {.target}{"\n"}  lowerBound = {.lowerBound}{"\n"}  upperBound = {.upperBound}{"\n"}{end}'
container=app
  target     = {"cpu":"587m","memory":"250Mi"}
  lowerBound = {"cpu":"307m","memory":"250Mi"}
  upperBound = {"cpu":"224997m","memory":"42092091239"}

Направление рекомендатор уловил верно: target.cpu 587m против заявленных 50m — почти в двенадцать раз выше. Заниженные requests опознаны как заниженные, и это ровно то, ради чего инструмент ставят.

А вот доверять этим конкретным числам как ответу — нельзя, и это вторая половина урока. Причин три, и каждая — про то, откуда взялось число, а не про его точность.

Первая: 501m в kubectl top — это не потребность пода, это его собственный потолок. stress --cpu 1 крутит бесконечный цикл и хочет полное ядро, то есть около 1000m, а limits.cpu у него — те самые 500m. Перед нами ровно тот же упор в CFS-квоту, что и разделом выше, только без демонстративных четырёх потоков: под троттлится и отдаёт наружу аккуратную половинку ядра. Рекомендатор считает по тому, что видит через Metrics API, а видит он подрезанное лимитом наблюдение — и 587m посчитаны от него, а не от реальных ~1000m. Так что сравнивать 587m с 501m и делать вывод «рекомендатор чуть перестраховался» — нельзя: 501m не факт потребления, а отметка потолка, и относительно настоящей потребности 587m скорее занижены. Это, пожалуй, главное, что стоит унести про VPA: рекомендации считаются по наблюдаемому потреблению, а наблюдаемое потребление ограничено сверху лимитом, который вы уже поставили. Занизили лимит — получите рекомендацию, аккуратно оправдывающую заниженный лимит.

Вторая: 250Mi по памяти — это не измеренная потребность, а нижняя граница самого инструмента. Ловушки с лимитом здесь нет: потребление ~80Mi ниже limits.memory 256Mi, наблюдение не режется. Но и «переоценкой втрое» 250Mi называть не стоит. Обратите внимание, что target.memory и lowerBound.memory совпали в точности — 250Mi и там, и там, — при том что по CPU те же две оценки разъехались (587m против 307m). Так выглядит не разброс оценки, а упор в пол: 250Mi — ровно нижняя граница рекомендации по памяти, которую vpa-recommender держит по умолчанию. Для сервиса, живущего на восьмидесяти мегабайтах, VPA вернёт эти же 250Mi и через неделю наблюдений — длина окна тут ни при чём. Отсюда и оговорка к предыдущему абзацу: target.memory выше request.memory в восемь раз, но говорит это не о том, что рекомендатор опознал занижение по памяти, а лишь о том, что 32Mi ниже его минимума. Requests в этом стенде действительно занижены — просто доказывает это число по CPU, а не по памяти. Практический вывод: увидев в рекомендации ровное значение пола, не принимайте его за потребность.

Третья: окно наблюдения короткое, около пяти минут, тогда как рекомендатору для осмысленных выводов нужны сутки и больше. На таком окне рекомендатор закладывается на неопределённость, и видно это по upperBound — 224997m CPU (это 225 ядер) и 42092091239 байт (около 39 ГиБ). Это не рекомендация вообще: верхняя граница доверительного интервала, которая на коротком окне разъезжается до бессмысленных величин. Чем меньше истории, тем шире интервал.

Отсюда рабочая схема:

  1. Стартовая точка — рекомендация, но именно как точка старта: направление и порядок величины, а не готовое значение. upperBound на неустоявшемся окне игнорировать целиком, а подозрительно ровные значения — сверять с нижней границей рекомендации: совпадение target и lowerBound означает, что перед вами минимум инструмента, а не измерение.
  2. Копить историю днями, а не минутами, и обязательно захватывая реальные пики: ночной батч, утренний прогрев кеша, релизный трафик. Нагрузочный профиль в пять минут показывает пять минут.
  3. Проверять, не упирается ли наблюдение в лимит. Рядом с потреблением всегда смотреть долю троттлинга: если она заметно выше нуля, измеренный CPU — это лимит, а не потребность, и любая рекомендация поверх такого измерения унаследует ошибку.
  4. requests по CPU ставить по типовому потреблению — по тому уровню, на котором сервис живёт большую часть времени, с небольшим запасом. Не по пику: CPU сжимаем, пик выше request под получает из свободных ядер узла, а при конкуренции — просто медленнее; резервировать пик значит оплачивать воздух. requests по памяти — ближе к устойчивому максимуму, а не к среднему: память не сжимается, и под, который превышает свой request, при нехватке памяти на узле первым попадает в кандидаты на вытеснение. Чем дальше рабочее потребление уходит выше request, тем выше этот риск.
  5. Лимит памяти ставить жёстко и с запасом на не-кучу: он защищает соседей по узлу, а перерасход всё равно закончится OOMKill — вопрос лишь, где именно. Сюда же — согласование рантайма (GOMEMLIMIT, -XX:MaxRAMPercentage).
  6. Лимит CPU ставить осторожно. Без него под забирает свободные ядра узла, а под давлением всё равно получит свою долю пропорционально requests: cpu.weight в cgroup выводится из requests, и это уже гарантия. Лимит же режет всегда — включая моменты, когда ядра простаивают.

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

Антипаттерны

Ставить limits == requests везде «ради Guaranteed». Класс Guaranteed действительно даёт лучшую позицию при вытеснении, но по CPU это означает добровольный жёсткий потолок и постоянный троттлинг на пиках — при том что реального конкурента за ядра может и не быть. Для памяти равенство разумно (гарантия и потолок совпадают, поведение предсказуемо), для CPU — редко.

Не ставить requests вовсе. Что получится, зависит от остального манифеста и namespace. Если в контейнере указан limit, а request нет, Kubernetes сам приравняет request к лимиту — под зарезервирует потолок вместо типового потребления, а при равенстве по всем ресурсам ещё и получит класс Guaranteed, о котором автор не думал. Если в namespace есть LimitRange с дефолтами, это молчаливая подмена: под получит чужие числа, которые к его нагрузке отношения не имеют. И только если нет ни лимитов, ни дефолтов, под оказывается BestEffort: кандидат на вытеснение номер один, а планировщик считает, что он ничего не потребляет, и упаковывает узел до отказа.

Огромные limits «на всякий случай». Лимит памяти в несколько гигабайт у сервиса, который живёт на сотне мегабайт, не защищает ни от чего: утечка спокойно доходит до лимита, и OOMKill случается позже и на большем объёме — вместе с давлением на узел по дороге. Лимит — граница нормального поведения, а не размер самого страшного сценария.

Копирование чисел между сервисами. Профиль потребления определяется нагрузкой и рантаймом, а не тем, что «у соседнего сервиса стоит так же». Особенно это касается пары requests/limits по CPU: одинаковые числа у сервиса на четырёх горутинах и у сервиса на пуле в сорок потоков дают совершенно разное поведение под квотой.

Одноразовая настройка. Ресурсы — не константа деплоймента: меняется код, версия рантайма, характер трафика. Доля троттлинга, частота OOMKill, отношение фактического потребления к requests — обычные метрики наблюдаемости, за которыми надо следить так же, как за latency и ошибками.

Дальше эти же числа становятся входом для автоматики: HPA масштабирует по проценту от requests, VPA двигает сами requests, KEDA добавляет внешние источники сигнала — об этом рассказывает статья «Автоскейлинг в Kubernetes: HPA, VPA и KEDA». Другая сторона того же планирования — не «сколько», а «где»: taints, affinity и topology spread разбираются в статье «Планирование подов вглубь: taints, affinity, topology и приоритеты», а особенности stateful-нагрузок, которым мало и памяти, и дисков, — в «StatefulSets и хранилище: stateful-нагрузки в Kubernetes». Если же вопрос стоит шире — стоит ли вообще заводить кластер под свою задачу, — на него отвечает «Kubernetes для домашней лаборатории».

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии