Kubernetes для самых маленьких. Часть 10: Как устроены requests, limits и QoS?
Все части серии «Kubernetes для самых маленьких»У node-exporter в части 8 и у дампа базы в прошлой части в контейнерах стоял блок resources с двумя наборами чисел — requests и limits. Это самая недооценённая часть манифеста. От неё зависит, на какой узел попадёт под, сколько процессорного времени он получит, когда его процесс убьёт ядро, а когда kubelet выселит весь под с узла, и в каком порядке это произойдёт с соседями. Разберём, что Kubernetes делает с этими числами: как их читает планировщик, во что kubelet превращает их в cgroup v2 на узле, откуда берутся CPU throttling и OOMKilled, как устроены классы QoS и выселение при нехватке памяти, и как ограничить всё это на уровне неймспейса.
Requests и limits
spec:
containers:
- name: hello
image: ghcr.io/example/hello:1.0
resources:
requests:
cpu: 250m
memory: 128Mi
limits:
cpu: 500m
memory: 128Mi
requests— сколько ресурса контейнеру гарантировано. Планировщик размещает под только на узел, где сумма requests всех подов с новым помещается в доступное; на узле request процессора превращается в вес при дележе CPU.limits— потолок. Выше лимита процессора контейнер не получит времени, даже если узел простаивает; выше лимита памяти ядро убьёт его процесс.
Единицы. Процессор — в ядрах: 1 — одно ядро (физическое или виртуальное, смотря что видит узел), 250m — четверть ядра, «250 миллиядер». Точнее 1m задать нельзя. Память — в байтах, с суффиксами Ki, Mi, Gi (степени двойки) или k, M, G (степени десяти): 128Mi — 134 217 728 байт, 128M — 128 000 000. Классическая опечатка — memory: 400m: маленькая m означает «милли», и это запрос на 0,4 байта.
Значения задаются для каждого контейнера; запрос и лимит пода — сумма по контейнерам. Init-контейнеры работают по одному и до основных, поэтому для планирования берётся большее из двух: самого «тяжёлого» init-контейнера и суммы основных; sidecar-контейнеры из части 6 работают всё время и прибавляются к сумме. С 1.34 в статусе beta (включено по умолчанию) можно задать ресурсы и на уровне всего пода — spec.resources — и позволить контейнерам делить общий бюджет; это удобно для подов с десятком мелких контейнеров, но основной способ по-прежнему поконтейнерный.
Правила по умолчанию: если задан только limit, request принимается равным ему. Если задан только request, лимита нет — контейнер может занять всё, что есть на узле.
Как планировщик использует requests
У каждого узла kubelet публикует в статусе объекта Node две цифры по каждому ресурсу:
capacity— сколько всего: ядра и память машины;allocatable— сколько из этого можно отдать подам:capacityминус резерв для системы (systemReserved), минус резерв для kubelet и runtime (kubeReserved), минус порог выселения (evictionHard, о нём ниже).
capacity: 2 CPU, 3.32 GiB
┌──────────────────────────────────────────────────────────────┐
│ systemReserved │ kubeReserved │ evictionHard │ allocatable │
│ sshd, journald │ kubelet, │ запас, при │ то, что │
│ ядро, systemd │ containerd │ котором │ делят поды │
│ │ │ выселяют │ │
└──────────────────────────────────────────────────────────────┘
Планировщик kube-scheduler (часть 13) для каждого кандидата складывает requests всех подов, уже назначенных на узел, прибавляет requests нового и сравнивает с allocatable. Фактическое потребление он не смотрит: узел, загруженный на 5 %, но с requests на 100 %, для него полон. Это сознательное решение — расписание строится на обещаниях, а не на моментальном снимке, иначе в час пик поды, поставленные ночью, не поместились бы. Обратная сторона — под без requests для планировщика весит ноль, и на один узел можно насовать сотню таких.
Не поместился ни на один узел — под остаётся Pending с событием:
Warning FailedScheduling default-scheduler 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient memory. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.
Резерв для системы. kubeadm по умолчанию не резервирует ничего, кроме порога выселения в 100 МиБ памяти: allocatable почти равен capacity, и поды могут занять всю память, нужную kubelet, containerd и sshd. На загруженном узле это кончается тем, что kubelet не может ответить API-серверу и узел становится NotReady. Лучшая практика — задать резерв в конфигурации kubelet:
# фрагмент KubeletConfiguration
systemReserved:
cpu: 200m
memory: 512Mi
kubeReserved:
cpu: 200m
memory: 512Mi
evictionHard:
memory.available: 200Mi
nodefs.available: 10%
nodefs.inodesFree: 5%
imagefs.available: 15%
imagefs.inodesFree: 5%
У kubeadm конфигурация kubelet лежит в ConfigMap kubelet-config в kube-system и копируется на узлы в /var/lib/kubelet/config.yaml; правят ConfigMap, затем на каждом узле kubeadm upgrade node phase kubelet-config и systemctl restart kubelet — иначе следующее обновление кластера (часть 35) вернёт старое. Обратите внимание на evictionHard: задали один порог — остальные обнуляются, если не перечислить их явно или не включить mergeDefaultEvictionSettings. Поэтому в примере перечислены все.
По умолчанию kubelet применяет только ограничение pods из enforceNodeAllocatable: вся иерархия cgroup подов (kubepods.slice) ограничена величиной capacity минус systemReserved и kubeReserved, и поды вместе не могут съесть резерв, даже если у них нет лимитов. Порог выселения в это ограничение не входит: он нужен, чтобы kubelet начал выселять раньше, чем ядро упрётся в предел всей группы подов. Без резервов, как на нашем стенде, в memory.max группы kubepods.slice записана вся память узла. Резервы для системы и kubelet при этом только вычитаются из allocatable, а не превращаются в собственные cgroup-ограничения — так безопаснее, и менять это стоит, только понимая, что system.slice с ограничением памяти может уронить sshd.
Во что превращаются числа: cgroup v2
Requests и limits — данные в API. Применяет их kubelet: создаёт для подов иерархию cgroup и передаёт параметры контейнеров в containerd, а тот — в runc, который и пишет файлы в /sys/fs/cgroup. С SystemdCgroup = true из части 4 группы создаются через systemd и называются как его юниты — slice и scope:
/sys/fs/cgroup/
└─ kubepods.slice capacity минус резервы
├─ kubepods-pod<uid>.slice Guaranteed-поды — прямо здесь
│ └─ cri-containerd-<id>.scope контейнер
├─ kubepods-burstable.slice Burstable
│ └─ kubepods-burstable-pod<uid>.slice
│ ├─ cri-containerd-<id>.scope pause-контейнер
│ └─ cri-containerd-<id>.scope контейнер hello
└─ kubepods-besteffort.slice BestEffort, cpu.weight = 1
└─ kubepods-besteffort-pod<uid>.slice
Три уровня: общий для всех подов, по классу QoS (о классах ниже), по поду и по контейнеру. В имени slice дефисы UID пода заменены подчёркиваниями: дефис для systemd — разделитель уровней иерархии.
Что пишется в файлы:
| Поле манифеста | Файл cgroup v2 | Значение для 250m/500m/128Mi |
|---|---|---|
requests.cpu |
cpu.weight |
35 (новая формула) или 10 (старая) |
limits.cpu |
cpu.max |
50000 100000 |
нет limits.cpu |
cpu.max |
max 100000 |
limits.memory |
memory.max |
134217728 |
нет limits.memory |
memory.max |
max |
requests.memory |
— | не пишется никуда (без Memory QoS) |
cpu.weight — относительный вес при дележе процессора, от 1 до 10000, по умолчанию у любой группы 100. Пока процессора хватает, вес ни на что не влияет; когда несколько групп хотят больше, чем есть, время делится пропорционально весам. Исторически Kubernetes переводит request в cpu.shares из cgroup v1 (1000m → 1024), а runtime пересчитывает shares в вес cgroup v2. Старая линейная формула давала одному ядру вес 39 — меньше, чем у любого системного процесса по умолчанию, — поэтому в runc 1.3.2 и crun 1.23 её заменили на нелинейную, где одно ядро даёт примерно 100, а 250m — 35. На стенде с containerd 2.2 и runc 1.4 из пакетов Ubuntu вы увидите новые значения; на старом runc — 10 для 250m. Но только в группах контейнеров: группы пода и классов QoS kubelet настраивает сам через systemd и по-прежнему по старой формуле — у пода с 250m вес 10, у kubepods.slice на узле с двумя ядрами — 79. Важно не абсолютное число, а соотношение между соседями на одном уровне.
cpu.max — квота и период в микросекундах: «не больше 50 000 мкс процессорного времени за каждые 100 000 мкс». Это и есть лимит 500m. О том, как он работает и чем опасен, — в следующем разделе.
memory.max — жёсткий предел памяти группы, включая страничный кэш файлов, которые читает и пишет контейнер (кэш ядро сначала вытесняет, прежде чем объявить нехватку). При превышении — OOM внутри группы.
requests.memory в ядро не попадает: это обещание только для планировщика и для порядка выселения. Альфа-функция Memory QoS умеет превращать его в memory.min/memory.low (защиту от вытеснения страниц) и ставить memory.high (мягкое торможение до лимита), но в 1.36 она по-прежнему альфа и по умолчанию выключена.
И ещё один файл, который kubelet ставит сам: memory.oom.group = 1 в группе каждого контейнера (на cgroup v2 так с 1.28; с 1.32 это можно отключить параметром kubelet singleProcessOOMKill: true). При OOM ядро убивает все процессы контейнера разом, а не один, — чтобы не оставлять полуживой контейнер, в котором умер рабочий процесс, а супервизор продолжает делать вид, что всё хорошо.
CPU: квота CFS и throttling
Процессор — сжимаемый ресурс: если его не хватает, процесс работает медленнее, но не умирает. Лимит процессора реализует механизм ядра CFS bandwidth control (контроль полосы планировщика CFS — основного планировщика процессов Linux): в каждом периоде (100 мс) группа может потратить не больше квоты; потратила — все её потоки замораживаются до конца периода. Это и называется throttling.
Ловушка в том, что квота считается в процессорном времени по всем ядрам сразу. Сервис с лимитом 1 и восемью потоками, обрабатывающими запрос параллельно, тратит 100 мс квоты за 12,5 мс реального времени и следующие 87,5 мс стоит:
лимит 1 CPU = 100 мс процессора на каждые 100 мс времени
8 потоков работают параллельно:
время, мс 0 12.5 100 112.5
├─────┼────────────────────────────────────────────┼─────┼────
потоки ████████ квота кончилась ░░░░░ заморожены ░░░░░░░░░ ██████ ...
ответ запрос пришёл ──────────────────────── ответ задержан на ~90 мс
В среднем за минуту такой сервис может использовать 30 % своего лимита и при этом регулярно отвечать на сотню миллисекунд дольше. Метрика throttled в cpu.stat и container_cpu_cfs_throttled_periods_total в системе мониторинга Prometheus (часть 32) показывает это прямо, а график средней загрузки — нет.
Вторая проблема — программы, которые определяют число потоков по числу ядер. На узле 32 ядра, лимит — 2, а рантайм видит 32 и запускает 32 потока сборщика мусора, которые съедают квоту за миллисекунды. Современные рантаймы читают cpu.max: Java делает это давно (её число процессоров можно задать флагом -XX:ActiveProcessorCount), Go — начиная с версии 1.25 (значение GOMAXPROCS по умолчанию учитывает лимит cgroup). Для остальных число рабочих потоков нужно задавать явно.
Убить за процессор Kubernetes не может: ни kubelet, ни ядро не завершают контейнер за превышение cpu.max. Только замедляют.
Память: OOMKilled и OOM killer узла
Память несжимаема: отданную страницу нельзя забрать обратно, не убив процесс (подкачки на узлах Kubernetes по умолчанию нет; с 1.34 её можно включить в режиме LimitedSwap только для Burstable-подов, но это отдельная история). Поэтому за память бывает два разных наказания, и их важно различать.
OOM в cgroup контейнера. Контейнер упёрся в memory.max, ядро не смогло освободить кэш — срабатывает OOM killer внутри этой группы и убивает её процессы (с memory.oom.group — все). Kubelet видит, что контейнер завершился по сигналу SIGKILL (код выхода 137 = 128 + 9) с причиной OOMKilled, и перезапускает его по restartPolicy пода. Под остаётся на узле, растёт RESTARTS, при повторах — CrashLoopBackOff. Соседей это не касается.
OOM узла. Если памяти не хватает всему узлу (у подов нет лимитов или их сумма больше физической памяти), срабатывает глобальный OOM killer ядра. Он выбирает жертву по oom_score — оценке, которая растёт с потреблением памяти и сдвигается значением oom_score_adj от −1000 до 1000. Kubelet заранее ставит это значение каждому контейнеру по классу QoS:
| Класс | oom_score_adj |
|---|---|
| Guaranteed | −997 |
| Burstable | от 2 до 999: 1000 − 1000 × request / память узла |
| BestEffort | 1000 |
| pause-контейнер любого пода | −998 |
| kubelet, containerd | −999 |
Чем больше request памяти у Burstable-пода относительно узла, тем меньше шанс, что убьют именно его. BestEffort умрут первыми, Guaranteed — последними, системные демоны — почти никогда.
До глобального OOM дело доходить не должно: раньше вмешивается kubelet со своим выселением.
Классы QoS
По requests и limits каждому поду при создании присваивается класс, записанный в status.qosClass:
| Класс | Условие |
|---|---|
| Guaranteed | у каждого контейнера заданы requests и limits и для CPU, и для памяти, и они равны |
| Burstable | не Guaranteed, но хотя бы у одного контейнера есть request или limit CPU или памяти |
| BestEffort | ни у одного контейнера нет ни requests, ни limits CPU и памяти |
Класс определяется один раз и до конца жизни пода не меняется. Он влияет на три вещи: в какой slice попадёт под на узле, какой oom_score_adj получат его процессы и — косвенно — порядок выселения. Есть и четвёртая: только Guaranteed-поды с целыми числами ядер могут получить эксклюзивные ядра от политики static менеджера CPU в kubelet, что нужно приложениям, чувствительным к задержкам.
Выселение при нехватке ресурсов узла
Kubelet раз в 10 секунд проверяет сигналы ресурсов узла и сравнивает их с порогами:
| Сигнал | Что это | Порог по умолчанию |
|---|---|---|
memory.available |
ёмкость памяти минус рабочий набор (без неактивного файлового кэша) | < 100Mi |
nodefs.available |
свободное место на файловой системе kubelet (/var/lib/kubelet, логи) |
< 10% |
nodefs.inodesFree |
свободные inode там же | < 5% |
imagefs.available |
свободное место там, где containerd хранит образы и слои | < 15% |
imagefs.inodesFree |
свободные inode там же | < 5% |
pid.available |
свободные номера процессов | не задан |
Пороги бывают жёсткие (evictionHard: перешли — выселяем немедленно, без срока на корректное завершение) и мягкие (evictionSoft плюс evictionSoftGracePeriod: выселяем, если превышение держится дольше заданного времени, и даём подам evictionMaxPodGracePeriod на остановку).
Когда порог перейдён, kubelet:
- ставит узлу условие
MemoryPressure,DiskPressureилиPIDPressure, а контроллер узлов — taint видаnode.kubernetes.io/memory-pressure:NoSchedule, чтобы новые поды сюда не шли; - пытается освободить ресурс сам: при нехватке диска удаляет мёртвые контейнеры и неиспользуемые образы;
- если не помогло — выселяет поды. Порядок: сначала те, чьё потребление превышает их request, среди них — по приоритету пода и по тому, насколько превышение велико; затем остальные, тоже по приоритету.
На практике это совпадает с классами QoS: BestEffort превышают свой нулевой request всегда и уходят первыми, Burstable — если вылезли за request, Guaranteed и Burstable в пределах request — в последнюю очередь. Именно поэтому честный request памяти — лучшая защита от выселения.
Выселенный под переходит в фазу Failed с причиной Evicted и остаётся в API, пока его не уберут; если им владеет ReplicaSet или StatefulSet, контроллер создаст замену — уже на другом узле. Выселение и OOM — разные вещи: выселение делает kubelet и убирает весь под, OOM делает ядро и убивает процессы контейнера.
ephemeral-storage
Третий ресурс, который поды делят на узле, — локальный диск. Под пишет на него в трёх местах: в записываемый слой контейнера (всё, что приложение пишет в свою файловую систему мимо томов), в тома emptyDir на диске и в свои логи /var/log/pods/.... Всё это — ephemeral storage, временное хранилище, исчезающее вместе с подом:
resources:
requests:
ephemeral-storage: 1Gi
limits:
ephemeral-storage: 2Gi
Request учитывается планировщиком так же, как CPU и память. Лимит kubelet проверяет периодическим подсчётом и при превышении выселяет под с сообщением вида Pod ephemeral local storage usage exceeds the total limit of containers 2Gi. У emptyDir можно задать собственный sizeLimit. А emptyDir с medium: Memory — это tmpfs, и он считается не диском, а памятью контейнера и упирается в memory.max: файл на 1 ГиБ в таком томе при лимите 512 МиБ кончится OOM. Подробно о томах — в части 21.
LimitRange: умолчания и границы в неймспейсе
Ставить requests всем контейнерам вручную — дисциплина, а дисциплина ломается. LimitRange — объект неймспейса, который проверяется при создании пода (его обрабатывает admission-контроллер LimitRanger в API-сервере — звено, через которое проходит каждый запрос на запись перед сохранением в etcd):
apiVersion: v1
kind: LimitRange
metadata:
name: defaults
namespace: quota-test
spec:
limits:
- type: Container
defaultRequest: # request, если не указан
cpu: 100m
memory: 128Mi
default: # limit, если не указан
memory: 128Mi
min:
memory: 32Mi
max:
cpu: "2"
memory: 2Gi
Контейнер без resources получит request 100m/128Mi, лимит памяти 128Mi — и лимит CPU в два ядра: если default для ресурса не задан, а max задан, умолчанием лимита становится max. Не хотите лимитов CPU по умолчанию — не задавайте и max.cpu. Контейнер, попросивший 4 ГиБ, будет отвергнут. Можно задать и maxLimitRequestRatio — во сколько раз лимит может превышать request. Ограничения бывают для Container, Pod (сумма) и PersistentVolumeClaim (размер заявки на том).
Важно: LimitRange действует только при создании пода. Поменяли LimitRange — уже работающие поды не изменятся. И он не проверяет, хватает ли ресурсов неймспейсу в целом, — это делает ResourceQuota. Неймспейсы подробно — в следующей части; здесь для опытов создан quota-test.
ResourceQuota: бюджет неймспейса
ResourceQuota ограничивает сумму по всему неймспейсу:
apiVersion: v1
kind: ResourceQuota
metadata:
name: budget
namespace: quota-test
spec:
hard:
requests.cpu: "2"
requests.memory: 4Gi
limits.memory: 4Gi
requests.ephemeral-storage: 10Gi
pods: "20"
persistentvolumeclaims: "5"
requests.storage: 50Gi
count/jobs.batch: "10"
Admission-контроллер ResourceQuota при каждом создании объекта прибавляет его запросы к уже использованным и отвергает запрос, если сумма выйдет за hard; отдельный контроллер в kube-controller-manager пересчитывает использованное в status.used. Ограничивать можно ресурсы (CPU, память, хранилище — в том числе по отдельному StorageClass) и число объектов любого типа через count/<ресурс>.<группа>.
Одно следствие, о котором стоит помнить: если квота ограничивает requests.cpu или requests.memory, то каждый новый под в неймспейсе обязан эти requests указать — иначе его нечем учесть, и он будет отвергнут. Поэтому ResourceQuota почти всегда ставят в паре с LimitRange, который подставит умолчания.
Квоты могут действовать только на часть подов — через scopes: BestEffort и NotBestEffort, Terminating и NotTerminating (есть ли у пода activeDeadlineSeconds), а также по классу приоритета (scopeSelector с PriorityClass). Например, запретить BestEffort-поды целиком — квота pods: "0" со scope BestEffort.
Изменение ресурсов на лету
Раньше resources пода были неизменяемы: чтобы дать контейнеру больше памяти, под пересоздавали. С 1.35 изменение ресурсов на месте (in-place resize) стабильно: requests и limits CPU и памяти работающего пода меняются через подресурс resize, и kubelet переписывает cpu.max, cpu.weight и memory.max в cgroup, не трогая процесс.
$ kubectl patch pod res-demo --subresource resize --patch \
'{"spec":{"containers":[{"name":"hello","resources":{"limits":{"cpu":"1"}}}]}}'
pod/res-demo patched
Нужен kubectl не старше 1.32. Что поменять можно без перезапуска, решает resizePolicy контейнера: по умолчанию NotRequired для обоих ресурсов, а для памяти разумно RestartContainer — большинство рантаймов (JVM, Node.js) выбирают размер кучи при старте и прибавку всё равно не увидят. Ход изменения видно в условиях пода PodResizePending (причины Infeasible — на этом узле невозможно никогда, Deferred — пока нет места) и PodResizeInProgress, а фактически применённые значения — в status.containerStatuses[].resources.
Ограничения: класс QoS пода измениться не может (у Guaranteed requests и limits должны остаться равными); уменьшение лимита памяти kubelet делает осторожно и откладывает, если текущее потребление выше нового лимита, но гарантий от OOM нет; убрать requests или limits совсем нельзя. И главное: это операция над подом. Изменение шаблона Deployment по-прежнему ведёт к выкатке новых подов. На месте ресурсы меняют те, кто управляет отдельными подами, — например, Vertical Pod Autoscaler (VPA, компонент, который подбирает requests по фактическому потреблению): его новые версии умеют применять рекомендации без пересоздания пода.
Что ставить: рекомендации
Requests — всем контейнерам, всегда. Без requests планировщик размещает вслепую, под попадает в BestEffort, первым уходит при выселении и первым умирает при OOM узла. Значения берутся из наблюдения: потребление под реальной нагрузкой за неделю, request — около обычного уровня (например, 90-го перцентиля), а не пика и не «на всякий случай 4 ГиБ». Как собирать эти данные — в части 32.
Лимит памяти равен request памяти. Если лимит больше request, то сумма лимитов на узле больше его памяти, и обещание «можно до лимита» выполнимо, только пока соседи ведут себя скромно. Когда все одновременно пойдут к своим лимитам, начнутся выселения и глобальный OOM, и пострадают не те, кто виноват. При limit = request под получает ровно то, что ему зарезервировано: превысил — OOM только у него, честно и предсказуемо. Утечка памяти видна сразу как рост RESTARTS, а не как ночное выселение соседей.
Лимит CPU — вопрос обсуждаемый. Аргументы против:
- requests уже гарантируют каждому его долю через
cpu.weightв момент конкуренции; лимит ничего к этой гарантии не добавляет; - лимит запрещает пользоваться простаивающим процессором: ночью узел свободен, а пакетная задача ползёт в своих 500m;
- throttling бьёт по задержкам многопоточных сервисов даже при низкой средней загрузке, и это трудно диагностировать.
Аргументы за:
- предсказуемость: сервис, который на тесте работал на свободном узле на шести ядрах, в эксплуатации на загруженном получит своё одно ядро — лучше узнать это на тесте;
- изоляция в общем кластере: когда в нём работают разные команды, лимиты не дают одному сервису на время монополизировать узел и сделать соседям хуже, чем они рассчитывали;
- Guaranteed требует лимита CPU, а эксклюзивные ядра менеджера CPU — Guaranteed;
- некоторые рантаймы и приложения выбирают размер пулов потоков по лимиту — без него они видят все ядра узла.
Практический вывод: в кластере одной команды для сервисов, чувствительных к задержкам, правильные requests без лимитов CPU обычно лучше; в общем кластере, для пакетных задач и там, где важна воспроизводимость, лимит ставят, но заметно выше request и следят за метрикой throttling. А вот requests CPU ставить нужно в любом случае.
Резерв на узлах — systemReserved и kubeReserved, как выше. LimitRange с умолчаниями — в каждом неймспейсе, куда деплоят люди, а не только конвейер, и ResourceQuota там, где неймспейсом пользуется отдельная команда.
Смотрим руками
Под с ресурсами из начала части — он Burstable, потому что requests и limits CPU различаются:
$ kubectl apply -f res-demo.yaml
pod/res-demo created
$ kubectl get pod res-demo -o jsonpath='{.status.qosClass}{" "}{.metadata.uid}{" "}{.spec.nodeName}{"\n"}'
Burstable 61fc9751-ad7b-4318-9db3-078bbf2e5eb5 k8s-node-2
На узле k8s-node-2 найдём его группу и прочитаем файлы:
user@k8s-node-2:~$ cd /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod61fc9751_ad7b_4318_9db3_078bbf2e5eb5.slice
user@k8s-node-2:~$ ls -d cri-containerd-*
cri-containerd-4507bad2a06b244791b8f45f3e1c4cb5be2edec0c219688831e39b7fe68b30db.scope
cri-containerd-70e2164251a90996f1ef42026416c808f3beab6db6ffc351e7d6b257846ad5c5.scope
user@k8s-node-2:~$ sudo crictl ps --pod $(sudo crictl pods --name res-demo -q) -o table
CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD NAMESPACE
70e2164251a90 f768da2949737 16 seconds ago Running hello 0 4507bad2a06b2 res-demo default
Две группы контейнеров: 4507… — pause-контейнер пода (его ID совпадает с ID «песочницы» пода в crictl — консольном клиенте CRI, интерфейса между kubelet и runtime), 70e2… — наш hello. Читаем:
user@k8s-node-2:~$ C=cri-containerd-70e2164251a90996f1ef42026416c808f3beab6db6ffc351e7d6b257846ad5c5.scope
user@k8s-node-2:~$ cat $C/cpu.weight $C/cpu.max $C/memory.max $C/memory.oom.group
35
50000 100000
134217728
1
user@k8s-node-2:~$ cat cpu.weight cpu.max memory.max memory.oom.group
10
50000 100000
134217728
0
user@k8s-node-2:~$ cat $C/memory.min $C/memory.low $C/memory.high
0
0
max
Вес 35 — новая формула (на старом runc было бы 10), квота 50 мс на 100 мс, предел памяти 128 МиБ, oom.group включён. Группа пода повторяет квоту и предел памяти — сумму по контейнерам, — а вес у неё 10: его kubelet считает по старой формуле. memory.oom.group у группы пода выключен — убивают процессы одного контейнера, а не всего пода. У pause-контейнера cpu.weight 1, cpu.max и memory.max — max. memory.min, memory.low, memory.high не тронуты — Memory QoS выключен. oom_score_adj процесса:
user@k8s-node-2:~$ cat /proc/$(cat $C/cgroup.procs | head -1)/oom_score_adj
963
Память узла стенда — 3 479 660 КиБ (3,32 ГиБ): 1000 − 1000 × 128 МиБ / 3,32 ГиБ ≈ 962,3, kubelet делит нацело и получает 963. У Guaranteed-пода на том же узле процесс получил −997, у BestEffort — 1000, у pause-контейнеров всех подов −998, у kubelet и containerd −999. Теперь throttling: запустим в другом поде бесконечный цикл с лимитом 200m:
$ kubectl run burner --image=busybox:1.37 --restart=Never \
--overrides='{"spec":{"containers":[{"name":"burner","image":"busybox:1.37","command":["sh","-c","while :; do :; done"],"resources":{"requests":{"cpu":"100m","memory":"16Mi"},"limits":{"cpu":"200m","memory":"16Mi"}}}]}}'
pod/burner created
$ kubectl get pod burner -o jsonpath='{.metadata.uid}{" "}{.spec.nodeName}{"\n"}'
342f2a06-f06b-4169-a1a0-2db441b449b4 k8s-node-1
user@k8s-node-1:~$ cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod342f2a06_f06b_4169_a1a0_2db441b449b4.slice/cpu.stat
usage_usec 12693723
user_usec 12679482
system_usec 14240
nice_usec 0
core_sched.force_idle_usec 0
nr_periods 637
nr_throttled 633
throttled_usec 18443637
nr_bursts 0
burst_usec 0
Это группа пода примерно через минуту после запуска. За 64 секунды 637 периодов, и в 633 из них группа упиралась в квоту: процессора потрачено 12,7 секунды — ровно 200m, остальное время поток стоял. throttled_usec здесь всего 18 секунд, потому что квота 20000 100000 стоит и на группе пода, и на группе контейнера, и ядро записывает остановку на тот уровень, где квота кончилась первой: в cpu.stat контейнера в тот же момент было ещё 32 секунды, вместе — около 51 секунды простоя из 64. С точки зрения процесса узел «медленный», хотя второе ядро свободно.
OOM. Под с лимитом 64 МиБ, который съедает память без ограничений (tail читает /dev/zero, где нет переводов строки, и копит «строку» в памяти). restartPolicy по умолчанию — Always, так что kubelet будет перезапускать контейнер:
$ kubectl run eater --image=busybox:1.37 \
--overrides='{"spec":{"containers":[{"name":"eater","image":"busybox:1.37","command":["tail","/dev/zero"],"resources":{"requests":{"cpu":"50m","memory":"64Mi"},"limits":{"memory":"64Mi"}}}]}}'
pod/eater created
$ kubectl get pod eater
NAME READY STATUS RESTARTS AGE
eater 0/1 OOMKilled 3 (25s ago) 46s
$ kubectl get pod eater -o jsonpath='{.status.containerStatuses[0].lastState.terminated}{"\n"}'
{"containerID":"containerd://7c2505a705bc3fcb923cf099e74da786d8990f455c02b6be026e9c6e0b3f1a73","exitCode":137,"finishedAt":"2026-10-28T11:11:19Z","reason":"OOMKilled","startedAt":"2026-10-28T11:11:19Z"}
Контейнер умирает через доли секунды после старта, между попытками kubelet выдерживает паузу backoff из части 6. Под встал на k8s-node-2; на узле ядро записывает каждое срабатывание в журнал, а cgroup — в счётчики. Группа контейнера исчезает вместе с его процессами, но группа пода живёт, пока под не завершится (у пода с restartPolicy: Never kubelet удаляет её сразу после остановки контейнера), а счётчики memory.events в cgroup v2 иерархические — учитывают события во вложенных группах:
user@k8s-node-2:~$ sudo journalctl -k --since -3min | grep -E 'oom-kill:|Killed process|Tasks in' | grep -A3 7c2505a7
Oct 28 14:11:19 k8s-node-2 kernel: oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=cri-containerd-7c2505a705bc3fcb923cf099e74da786d8990f455c02b6be026e9c6e0b3f1a73.scope,mems_allowed=0,oom_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podfcd90933_a943_41de_84fc_fc9b63303d88.slice,task_memcg=/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podfcd90933_a943_41de_84fc_fc9b63303d88.slice/cri-containerd-7c2505a705bc3fcb923cf099e74da786d8990f455c02b6be026e9c6e0b3f1a73.scope,task=tail,pid=595791,uid=0
Oct 28 14:11:19 k8s-node-2 kernel: Memory cgroup out of memory: Killed process 595791 (tail) total-vm:69252kB, anon-rss:64940kB, file-rss:1960kB, shmem-rss:0kB, UID:0 pgtables:184kB oom_score_adj:982
Oct 28 14:11:19 k8s-node-2 kernel: Tasks in /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podfcd90933_a943_41de_84fc_fc9b63303d88.slice/cri-containerd-7c2505a705bc3fcb923cf099e74da786d8990f455c02b6be026e9c6e0b3f1a73.scope are going to be killed due to memory.oom.group set
Oct 28 14:11:19 k8s-node-2 kernel: Memory cgroup out of memory: Killed process 595791 (tail) total-vm:69252kB, anon-rss:64940kB, file-rss:1960kB, shmem-rss:0kB, UID:0 pgtables:184kB oom_score_adj:982
user@k8s-node-2:~$ cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podfcd90933*.slice/memory.events
low 0
high 0
max 145
oom 4
oom_kill 8
oom_group_kill 4
sock_throttled 0
CONSTRAINT_MEMCG — OOM внутри группы, а не всего узла. В oom_memcg — группа пода: её предел — сумма лимитов контейнеров, те же 64 МиБ, и ядро упёрлось в него на уровне пода, но убивает процессы по memory.oom.group группы контейнера (Tasks in … are going to be killed). Сам tail попадает в журнал дважды — как выбранная жертва и как участник группы, — поэтому oom_kill вдвое больше oom и oom_group_kill. oom_score_adj:982 — та же формула для request 64 МиБ. Узел и соседи не пострадали.
Что видит планировщик на узле:
$ kubectl describe node k8s-node-2 | sed -n '/^Capacity:/,/^System Info:/p;/^Allocated resources:/,/^Events:/p'
Capacity:
cpu: 2
ephemeral-storage: 31761232Ki
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 3479660Ki
pods: 110
Allocatable:
cpu: 2
ephemeral-storage: 29271151363
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 3377260Ki
pods: 110
System Info:
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 250m (12%) 500m (25%)
memory 128Mi (3%) 128Mi (3%)
ephemeral-storage 0 (0%) 0 (0%)
hugepages-1Gi 0 (0%) 0 (0%)
hugepages-2Mi 0 (0%) 0 (0%)
Events:
Allocatable меньше Capacity ровно на 100 МиБ памяти (порог выселения kubeadm по умолчанию) и на 10 % диска; процессор не зарезервирован вовсе. После резерва из раздела о планировщике станет cpu: 1600m, а memory уменьшится ещё примерно на 1,1 ГиБ — на узле с 3,3 ГиБ это заметная доля. В Requests — сумма запросов всех подов узла; у calico-node, csi-node-driver и kube-proxy на нашем стенде requests нулевые, поэтому здесь только res-demo. Полный список с запросами каждого пода — в блоке Non-terminated Pods того же describe.
Изменение ресурсов на месте — поднимем лимит CPU res-demo до одного ядра:
$ kubectl patch pod res-demo --subresource resize --patch \
'{"spec":{"containers":[{"name":"hello","resources":{"limits":{"cpu":"1"}}}]}}'
pod/res-demo patched
$ kubectl get pod res-demo -o jsonpath='{.status.containerStatuses[0].resources.limits.cpu}{" restarts="}{.status.containerStatuses[0].restartCount}{"\n"}'
1 restarts=0
user@k8s-node-2:~$ cat $C/cpu.max cpu.max
100000 100000
100000 100000
Квота выросла до 100 мс на период и у контейнера, и у группы пода, процесс не перезапускался. Запрос, который не поместится на узел никогда, API-сервер 1.36 отвергает сразу, не доводя до Infeasible:
$ kubectl patch pod res-demo --subresource resize --patch \
'{"spec":{"containers":[{"name":"hello","resources":{"requests":{"cpu":"3"},"limits":{"cpu":"4"}}}]}}'
Error from server (Forbidden): pods "res-demo" is forbidden: node didn't have enough allocatable resources: cpu, requested: 3000, allocatable: 2000
Попробуем сделать под Guaranteed, сравняв request CPU с лимитом:
$ kubectl patch pod res-demo --subresource resize --patch \
'{"spec":{"containers":[{"name":"hello","resources":{"requests":{"cpu":"1"}}}]}}'
The Pod "res-demo" is invalid: spec: Invalid value: "Burstable": Pod QOS Class may not change as a result of resizing
Класс QoS неизменен — API отказал.
Наконец, квоты. В неймспейсе quota-test с LimitRange и ResourceQuota из примеров выше:
$ kubectl create namespace quota-test
namespace/quota-test created
$ kubectl apply -f limitrange.yaml -f quota.yaml
limitrange/defaults created
resourcequota/budget created
$ kubectl -n quota-test describe limitrange defaults
Name: defaults
Namespace: quota-test
Type Resource Min Max Default Request Default Limit Max Limit/Request Ratio
---- -------- --- --- --------------- ------------- -----------------------
Container cpu - 2 100m 2 -
Container memory 32Mi 2Gi 128Mi 128Mi -
$ kubectl -n quota-test run plain --image=nginx:1.29
pod/plain created
$ kubectl -n quota-test get pod plain -o jsonpath='{.spec.containers[0].resources}{"\n"}'
{"limits":{"cpu":"2","memory":"128Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}
$ kubectl -n quota-test run huge --image=nginx:1.29 \
--overrides='{"spec":{"containers":[{"name":"huge","image":"nginx:1.29","resources":{"requests":{"memory":"4Gi"},"limits":{"memory":"4Gi"}}}]}}'
Error from server (Forbidden): pods "huge" is forbidden: maximum memory usage per Container is 2Gi, but limit is 4Gi
$ kubectl -n quota-test run tiny --image=nginx:1.29 \
--overrides='{"spec":{"containers":[{"name":"tiny","image":"nginx:1.29","resources":{"requests":{"memory":"16Mi"},"limits":{"memory":"16Mi"}}}]}}'
Error from server (Forbidden): pods "tiny" is forbidden: minimum memory usage per Container is 32Mi, but request is 16Mi
$ kubectl -n quota-test create deployment big --image=nginx:1.29 --replicas=40
deployment.apps/big created
$ kubectl -n quota-test get rs -l app=big
NAME DESIRED CURRENT READY AGE
big-7b45b7cd5b 40 19 2 21s
$ kubectl -n quota-test describe rs -l app=big | grep -m1 'Warning FailedCreate'
Warning FailedCreate 16s replicaset-controller Error creating: pods "big-7b45b7cd5b-l55kz" is forbidden: exceeded quota: budget, requested: pods=1,requests.cpu=100m, used: pods=20,requests.cpu=2, limited: pods=20,requests.cpu=2
$ kubectl -n quota-test describe resourcequota budget
Name: budget
Namespace: quota-test
Resource Used Hard
-------- ---- ----
count/jobs.batch 0 10
limits.memory 2560Mi 4Gi
persistentvolumeclaims 0 5
pods 20 20
requests.cpu 2 2
requests.ephemeral-storage 0 10Gi
requests.memory 2560Mi 4Gi
requests.storage 0 50Gi
Под без resources получил умолчания LimitRange, включая лимит CPU из max; слишком большой и слишком маленький контейнеры отвергнуты сразу. Deployment на 40 реплик упёрся в квоту на 20-м поде — и одновременно в requests.cpu: 2 (20 × 100m), сообщение перечисляет оба ресурса. Отказ приходит не пользователю, а ReplicaSet-контроллеру: Deployment создан успешно, а недостающие поды видны только в событиях ReplicaSet.
Одна тонкость: admission-контроллер узнаёт о квотах из своего кеша, и в первые секунды после создания квоты может её ещё не видеть. На стенде под, созданный той же командой сразу после kubectl apply квоты, прошёл мимо неё, и в Used оказалось pods 21 при Hard 20. Поэтому квоту создают заранее, а не в одном скрипте с подами; вывод выше снят с паузой в несколько секунд между ними.
Уберём за собой: kubectl delete namespace quota-test, kubectl delete pod res-demo burner eater.
Источники
- Resource Management for Pods and Containers — requests и limits, единицы, ephemeral-storage, ресурсы уровня пода.
- Pod Quality of Service Classes — условия классов QoS и Memory QoS.
- Node-pressure Eviction — сигналы, пороги, порядок выселения и
oom_score_adj. - Reserve Compute Resources for System Daemons —
capacity,allocatable,systemReserved,kubeReserved,enforceNodeAllocatable. - Limit Ranges и Resource Quotas — ограничения неймспейса.
- Resize CPU and Memory Resources assigned to Containers — изменение ресурсов на месте и
resizePolicy. - Control Group v2 —
cpu.weight,cpu.max,memory.max,memory.oom.group,memory.eventsв документации ядра Linux. - CFS Bandwidth Control — квота и период CFS, throttling и
cpu.stat.
Мы уже второй раз создаём неймспейс мимоходом и выбираем поды по меткам, не останавливаясь на том, что это такое.
Дальше — Namespace, метки и аннотации: что неймспейс изолирует, а что нет, какие объекты не принадлежат ни одному неймспейсу и что происходит при удалении неймспейса, как работают селекторы по меткам и полям, какие метки рекомендует проект и чем аннотации отличаются от меток. С неё учебный неймспейс — demo.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.