3.2Под изнутри
Проследишь путь от Pod spec до процессов и cgroup на узле. Разберёшь, как scheduler учитывает requests, как kubelet выбирает жертву при давлении на ресурсы и сколько времени есть у приложения на остановку.
- Что kubelet просит у containerd через CRI и зачем в каждом поде процесс pause.
- Как планировщик отсеивает и оценивает узлы и когда
topologySpreadConstraintsоставляет поды в Pending. - Чем нативный sidecar отличается от второго контейнера в
containers. - Почему одни и те же 100m CPU дают
cpu.weight4 у пода иcpu.weight17 у контейнера. - Кого выселяет kubelet при нехватке памяти и диска и чем это отличается от OOM killer.
- Сколько ждёт CrashLoopBackOff и что происходит между
kubectl deleteи SIGKILL.
Прогоны сняты на kind-кластере из одного узла: Kubernetes 1.37.0, containerd 2.3.4, runc 1.4.3, cgroup v2, узел
работает контейнером в Docker Desktop (ядро 7.0.12-linuxkit, 4 CPU, 8 ГБ). В кластер ничего не записывалось, только
чтение и apply --dry-run=server, поэтому остановка, выселение и перезапуски описаны по исходникам тех же
версий.
kubelet, CRI и pause-контейнер
kubelet следит за подами, у которых spec.nodeName совпадает с его узлом, но процессы сам не создаёт,
а просит runtime по CRI. Это gRPC-API из двух сервисов: RuntimeService
(RunPodSandbox, CreateContainer, StopContainer…) и
ImageService (PullImage…). На другом конце сокета сидит containerd со встроенным CRI-плагином, под ним
shim и runc, который собирает namespaces и cgroups (глава 2.1 статьи «Docker»). SyncPod в
pkg/kubelet/kuberuntime запускает sandbox пода, потом эфемерные, init- и основные контейнеры, каждый
по схеме: образ, создание, запуск, postStart.
Узел kind сам работает Docker-контейнером с systemd, kubelet и containerd, а crictl в нём ходит по тому
же CRI. Команды с приглашением # выполнены в узле после
docker exec -it dv-k8s-control-plane bash:
# crictl pods --name coredns-559f6c778d-gx9h8
POD ID CREATED STATE NAME NAMESPACE ATTEMPT RUNTIME
72dc4b02b885b About an hour ago Ready coredns-559f6c778d-gx9h8 kube-system 0 (default)
# crictl inspectp 72dc4b02b885b | jq -r '.info.runtimeSpec.linux.namespaces[] | "\(.type) \(.path // "")"'
pid
ipc
uts
mount
network /var/run/netns/cni-fedd7ca7-3931-e378-385a-7a85106f2848
# ps -o pid,ppid,comm -p 4419,4508,4704
PID PPID COMMAND
4419 1 containerd-shim
4508 4419 pause
4704 4419 coredns
В списке контейнеров crictl ps pause нет: для CRI это sandbox пода. В нём один процесс,
/pause из образа registry.k8s.io/pause:3.10, и работы у него нет: он держит namespaces пода,
пока контейнеры приложения падают и перезапускаются. Четыре namespaces runc создаёт с нуля, а сетевой получает
готовым: его заранее создал containerd, а вызванный им CNI-плагин завёл там адрес. Сравним namespaces pause и
CoreDNS, детей одного shim:
# for ns in net ipc uts pid mnt; do echo "$ns $(readlink /proc/4508/ns/$ns) $(readlink /proc/4704/ns/$ns)"; done
net net:[4026532730] net:[4026532730]
ipc ipc:[4026533122] ipc:[4026533122]
uts uts:[4026533121] uts:[4026533121]
pid pid:[4026533123] pid:[4026533139]
mnt mnt:[4026533120] mnt:[4026533138]
# stat -L -c %i /var/run/netns/cni-fedd7ca7-3931-e378-385a-7a85106f2848
4026532730
net, ipc и uts общие, и inode сетевого namespace совпадает с файлом в netns;
pid и mnt у контейнера свои. Контейнеры пода делят localhost, hostname и System V
IPC, но процессов друг друга не видят без shareProcessNamespace: true. IP принадлежит sandbox и
переживает перезапуск приложения. Если же sandbox потерян (pause убит, пропал IP), kubelet создаёт новый с
ATTEMPT на единицу больше и перезапускает в нём все контейнеры пода (PodSandboxChanged).
Планировщик: отсев и оценка узлов
Дальше все примеры про один под, billing (в Deployment это был бы шаблон пода):
# billing-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: billing
labels: { app: billing }
spec:
terminationGracePeriodSeconds: 45
tolerations:
- { key: dedicated, operator: Equal, value: backend, effect: NoSchedule }
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector: { matchLabels: { app: billing } }
initContainers:
- name: migrate
image: registry.example.com/billing:1.8.0
command: ["/billing", "migrate", "up"]
- name: otel-agent # нативный sidecar
image: otel/opentelemetry-collector-contrib:0.135.0
restartPolicy: Always
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
containers:
- name: app
image: registry.example.com/billing:1.8.0
resources:
requests: { cpu: 250m, memory: 128Mi, ephemeral-storage: 100Mi }
limits: { cpu: "1", memory: 256Mi, ephemeral-storage: 1Gi }
lifecycle:
preStop:
sleep: { seconds: 10 }
kube-scheduler берёт из очереди под без nodeName и прогоняет через плагины. На этапе Filter
хватает одного «нельзя» от любого плагина, чтобы узел выбыл. На этапе Score плагины ставят оставшимся
узлам от 0 до 100 очков, очки умножаются на вес плагина и складываются; побеждает наибольшая сумма, ничью решает
случай. На большом кластере Filter останавливается, набрав достаточно подходящих узлов: 50 % минус число узлов / 125, но не меньше 5 % и не меньше
100 узлов, так что из 1000 узлов до оценки доходят не больше 420 (numFeasibleNodesToFind).
| Плагин (1.37) | Отсеивает узел | Оценивает, вес |
|---|---|---|
NodeResourcesFit | requests пода не влезают в allocatable минус requests подов узла; больше maxPods | свободнее по requests - лучше, 1 |
TaintToleration | taint NoSchedule или NoExecute без toleration | taints PreferNoSchedule, 3 |
NodeAffinity | nodeSelector, required-правила | preferred-правила, 2 |
InterPodAffinity | required (anti-)affinity к подам | preferred-правила, 2 |
PodTopologySpread | ограничения с DoNotSchedule | с ScheduleAnyway, 2 |
Ещё узлы отсеивают по cordon, hostPort и томам, а очки добавляют за баланс CPU и памяти и за скачанный
образ. Считается всё по requests, реальную загрузку узла планировщик не видит. Под без requests в фильтре весит
ноль, а в оценке NodeResourcesFit идёт как 100m CPU и 200 МиБ.
Taints и tolerations
Taint на узле говорит «сюда нельзя», toleration в поде отвечает «мне можно», но никуда под не тянет: billing с
toleration dedicated=backend сядет и на обычный узел. Чтобы он шёл только на выделенные, добавляют
nodeAffinity на их метку. NoSchedule проверяет планировщик, PreferNoSchedule лишь снижает оценку,
NoExecute ещё и выгоняет работающие поды: этим занят taint-eviction-controller в kube-controller-manager.
Часть taints ставит сам кластер: узел без отчётов 50 секунд (node-monitor-grace-period) получает
node.kubernetes.io/unreachable, узел с Ready=False получает not-ready, оба NoExecute. На этот
случай admission-плагин DefaultTolerationSeconds дописывает каждому поду по toleration:
$ kubectl apply --dry-run=server -f billing-pod.yaml -o yaml | sed -n '/^ tolerations:/,/^ topologySpreadConstraints:/p;/^status:/,$p'
tolerations:
- effect: NoSchedule
key: dedicated
operator: Equal
value: backend
- effect: NoExecute
key: node.kubernetes.io/not-ready
operator: Exists
tolerationSeconds: 300
- effect: NoExecute
key: node.kubernetes.io/unreachable
operator: Exists
tolerationSeconds: 300
topologySpreadConstraints:
status:
phase: Pending
qosClass: Burstable
Под с пропавшего узла контроллер сразу помечает неготовым, но выселяет только через пять минут. Для
stateless-сервиса это долго; своя toleration на тот же ключ с меньшим tolerationSeconds сохранится, а
недостающую плагин допишет. У узла kind taints нет вовсе, хотя это control plane: kubeadm ставит
node-role.kubernetes.io/control-plane:NoSchedule, но kind снимает его, если узел в кластере один.
Affinity против topologySpreadConstraints
Жёсткая podAntiAffinity по kubernetes.io/hostname не даст запустить реплик больше, чем узлов:
лишние останутся в Pending. Мягкая только снижает оценку; у CoreDNS в kind мягкая, с весом 100, и обе реплики
живут на единственном узле. topologySpreadConstraints следит за перекосом: насколько подходящих подов в
домене topologyKey больше, чем в самом пустом. Узел без метки из topologyKey фильтр отсеивает, и
зональное ограничение с DoNotSchedule на кластере без зон оставит в Pending все поды. Если своих ограничений нет, а под выбран
Service, ReplicaSet или StatefulSet, планировщик подставляет системные: по hostname maxSkew: 3, по зоне
maxSkew: 5, оба ScheduleAnyway.
Init-контейнеры и нативные sidecar
Init-контейнеры идут по одному, каждый должен выйти с кодом 0. Упавший init-контейнер kubelet перезапускает с растущей паузой,
а при restartPolicy: Never весь под становится Failed. В kubectl get pods это
Init:1/2, Init:Error или Init:CrashLoopBackOff.
Агенту или прокси в init места нет: init обязан завершиться. Раньше агента клали вторым контейнером в
containers и получали три беды: приложение стартовало раньше агента, Job не завершался, потому что
агент не выходит, а при остановке агент получал SIGTERM вместе с приложением и мог умереть первым. Нативный
sidecar устроен иначе: это init-контейнер с restartPolicy: Always, стабильный с Kubernetes 1.33.
Второй контейнер в containers | Нативный sidecar | |
|---|---|---|
| Старт | после всех init, вместе с приложением | в очереди init; дальше пускают после старта и startupProbe |
| Перезапуск | по restartPolicy пода | всегда |
| Job | держит под, пока не выйдет | не держит: основные вышли - sidecars останавливают |
| Остановка | SIGTERM вместе со всеми | после выхода основных, в обратном порядке объявления |
| Пробы | есть | есть; у обычного init API их отвергает |
oom_score_adj | по QoS | не выше, чем у основного контейнера с наименьшим requests.memory |
Запрос пода планировщик берёт по формуле KEP-753 (AggregateContainerRequests): большее из суммы основных
контейнеров со всеми sidecars и самого тяжёлого шага инициализации, где работает init-контейнер и запущенные до
него sidecars. У billing 250m + 50m = 300m и 192 МиБ; миграция с requests.memory: 2Gi сделала бы весь под
двухгигабайтным. Ненулевые коды выхода sidecars при остановке пода документация считает нормой. А
restartPolicy: Always у контейнера в containers API с 1.35 тоже примет (бета
ContainerRestartRules), но sidecar из него не получится: это просто политика перезапуска.
Как requests и limits становятся файлами cgroup
QoS-класс API пишет в status.qosClass при создании пода. По нему kubelet выбирает ветку cgroup (у
Burstable и BestEffort свои slice, Guaranteed висят прямо в kubepods) и создаёт slice пода, а runc внутри него
заводит scope на каждый контейнер, включая pause. Приставку kubelet- дал kind: он ставит kubelet
cgroupRoot: /kubelet, а на узле kubeadm путь начнётся с kubepods.slice/kubepods-burstable.slice.
# cat /proc/4704/cgroup
0::/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/kubelet-kubepods-burstable-pod7d672ea5_d2db_4c8e_be14_cd10571ef470.slice/cri-containerd-27db794ea19c9b8b338df14d71e507f1bd2f04a6846aeb20201883322d62877a.scope
# cd /sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice
# for d in . kubelet-kubepods-burstable.slice kubelet-kubepods-besteffort.slice; do echo "$d: cpu.weight=$(cat $d/cpu.weight) memory.max=$(cat $d/memory.max)"; done
.: cpu.weight=157 memory.max=8320299008
kubelet-kubepods-burstable.slice: cpu.weight=37 memory.max=max
kubelet-kubepods-besteffort.slice: cpu.weight=1 memory.max=max
# cd kubelet-kubepods-burstable.slice/kubelet-kubepods-burstable-pod7d672ea5_d2db_4c8e_be14_cd10571ef470.slice
# for f in cpu.weight cpu.max memory.max memory.oom.group memory.high; do echo "$f: pod=$(cat $f) coredns=$(cat cri-containerd-27db*/$f) pause=$(cat cri-containerd-72dc*/$f)"; done
cpu.weight: pod=4 coredns=17 pause=1
cpu.max: pod=max 100000 coredns=max 100000 pause=max 100000
memory.max: pod=178257920 coredns=178257920 pause=max
memory.oom.group: pod=0 coredns=1 pause=0
memory.high: pod=max coredns=max pause=max
У CoreDNS requests.cpu: 100m, requests.memory: 70Mi и limits.memory: 170Mi.
- kubepods: в
memory.maxвся память узла (8125292Ki) за вычетомkube-reservedиsystem-reserved, которых в kind нет;cpu.weight157 получен из 4 CPU. - QoS-slice: вес Burstable 37 посчитан из суммы CPU-requests Burstable-подов узла (950m); kubelet пересчитывает его при запуске и остановке подов и раз в минуту.
- slice пода: в
memory.maxиcpu.maxидут суммы лимитов, если лимит есть у каждого контейнера, включая init (ResourceConfigForPod). У billing нет лимита CPU у агента и памяти уmigrate, так что slice пода получитmaxв обоих файлах. - scope контейнера:
memory.max=limits.memory, вcpu.maxквота и период в микросекундах (глава 3.1): уappс лимитом 1 CPU это100000 100000, без лимитаmax 100000. memory.oom.group=1kubelet на cgroup v2 ставит каждому контейнеру, и при OOM гибнут все его процессы (глава 1.4 статьи «Linux и ОС»).memory.high=max: MemoryQoS в kubelet 1.37 в бете и включена, но безmemoryThrottlingFactor(по умолчанию пуст) файл не пишется.
requests.cpu переводится в cpu.weight дважды, разными
формулами.
kubelet считает CPU в shares из cgroup v1: 100m × 1024 / 1000 = 102. Для своих slice он переводит shares в
cpu.weight линейно (getCPUWeight): 1 + (102 − 2) × 9999 / 262142, в целых 4. Для контейнера
те же 102 shares через CRI уходят в runc, а там с версии 1.4 формула квадратичная по логарифму
(ConvertCPUSharesToCgroupV2Value), её подбирали под стандартные 100 при 1024 shares. Вес работает только
при конкуренции и только между соседями по уровню. Slice подов посчитаны одной формулой, и пропорции между подами
верны, а контейнеры одного пода со 100m и 1 CPU получат 17 и 100, не 1 к 10 (у slice вышло бы 4 и 39).
Две детали API меняют эти числа ещё до узла. Контейнер с одними limits (limits-only.yaml)
получает такие же requests и молча становится Guaranteed:
$ kubectl apply --dry-run=server -f limits-only.yaml -o jsonpath='{.spec.containers[0].resources}{"\n"}{.status.qosClass}{"\n"}'
{"limits":{"cpu":"500m","memory":"256Mi"},"requests":{"cpu":"500m","memory":"256Mi"}}
Guaranteed
А ресурсы уровня пода, spec.resources, задают общий бюджет, в котором контейнеры без своих запросов
делят CPU и память, и QoS тогда считается по поду. В 1.37 это бета и включена, dry-run такого пода проходит.
Нехватка памяти: выселение kubelet и OOM killer
OOM killer живёт в ядре и срабатывает, когда cgroup упёрлась в memory.max или кончилась память
машины (выбор жертвы разобран в главе 1.4 статьи «Linux и ОС»). Выселение (node-pressure eviction) устраивает
kubelet: раз в 10 секунд сравнивает сигналы узла с порогами и останавливает под целиком, помечая его Evicted. На
выбор ядра kubelet влияет через oom_score_adj контейнеров:
# for id in $(crictl ps -q); do set -- $(crictl inspect -o go-template --template '{{.info.pid}} {{.status.metadata.name}}' $id); echo "$(cat /proc/$1/oom_score_adj) $2"; done
992 coredns
992 coredns
1000 local-path-provisioner
-997 kindnet-cni
-999 kube-proxy
-997 etcd
…
По GetContainerOOMScoreAdjust Guaranteed и любой под с
system-node-critical получают −997 (так у kindnet и etcd, хотя они Burstable), BestEffort получает 1000,
Burstable 1000 − 1000 × requests.memory / память узла в пределах от 3 до 999: у CoreDNS 1000 − 1000 × 70 МиБ /
7,75 ГиБ = 992. Чем меньше запросил, тем ближе к BestEffort. kube-proxy ставит себе −999 сам, pause получает −998 от
containerd.
Пороги и порядок выселения
kubelet следит за сигналами memory.available (ёмкость минус working set), nodefs.* и
imagefs.* (место и inode), pid.available. Жёсткие пороги по умолчанию в 1.37 на Linux:
memory.available<100Mi, nodefs.available<10%, nodefs.inodesFree<5%,
imagefs.available<15%, imagefs.inodesFree<5%. Мягкие (evictionSoft)
срабатывают, продержавшись свой период ожидания. За диск kubelet сначала чистит мёртвые контейнеры и
неиспользуемые образы. Жертв при нехватке памяти упорядочивает rankMemoryPressure, и QoS среди ключей
нет: первыми идут поды, чьё потребление больше requests.memory, среди них с меньшим priority, затем
с наибольшим превышением. BestEffort уходит первым, потому что нулевые requests превышены всегда, а
Guaranteed выше своих requests почти не вырастет. Статические поды и поды с priority от 2 000 000 000
(system-cluster-critical, system-node-critical) kubelet не выселяет.
У выселенного пода phase Failed, reason Evicted и условие DisruptionTarget. При жёстком пороге kubelet
даёт 1 секунду вместо terminationGracePeriodSeconds (документация пишет «0s»), но между сигналом и
SIGKILL всё равно оставляет свой минимум в 2 секунды. При мягком пороге он берёт меньшее из
evictionMaxPodGracePeriod и grace period пода. Порог памяти вычитается из Allocatable, но не из
memory.max kubepods: остаётся окно, чтобы выселить под раньше ядра, но быстрый рост памяти его проскакивает.
$ kubectl describe node dv-k8s-control-plane | grep -E '^(Taints|Capacity|Allocatable):|^ (cpu|memory):'
Taints: <none>
Capacity:
cpu: 4
memory: 8125292Ki
Allocatable:
cpu: 4
memory: 8125292Ki
$ kubectl get --raw /api/v1/nodes/dv-k8s-control-plane/proxy/configz | jq -c '.kubeletconfig | {evictionHard, mergeDefaultEvictionSettings, crashLoopBackOff, containerLogMaxSize, containerLogMaxFiles}'
{"evictionHard":{"imagefs.available":"0%","nodefs.available":"0%","nodefs.inodesFree":"0%"},"mergeDefaultEvictionSettings":false,"crashLoopBackOff":{"maxContainerRestartPeriod":"5m0s"},"containerLogMaxSize":"10Mi","containerLogMaxFiles":5}
Allocatable памяти в kind равен Capacity, хотя порог в 100 МиБ должен был его уменьшить. kind задаёт kubelet только
дисковые пороги, а kubelet не дополняет заданный evictionHard умолчаниями, пока
mergeDefaultEvictionSettings: false, а это значение по умолчанию.
Порога памяти здесь нет, и нехватку встретит только OOM killer. На своём узле так же: дописал в конфиг один
imagefs.available и потерял memory.available.
ephemeral-storage
ephemeral-storage учитывает диск узла, который под тратит мимо PersistentVolume: слой записи контейнеров,
логи в /var/log/pods и emptyDir на диске. Requests сверяет планировщик
(Insufficient ephemeral-storage), а limits в cgroup не попадают: лимита на занятое место у cgroup v2 нет.
Их проверяет kubelet в том же десятисекундном цикле и выселяет под с grace period в 1 секунду, если контейнер
превысил свой лимит (логи плюс слой записи, когда образы на той же файловой системе), если сумма по поду превысила
сумму лимитов или если emptyDir перерос sizeLimit. В сообщении будет «Container app exceeded
its local ephemeral storage limit "1Gi"».
Логи входят в счёт, и болтливый Go-сервис с маленьким лимитом может выселить сам себя: ротацию по
containerLogMaxSize и containerLogMaxFiles (на kind 10Mi и 5) kubelet проверяет раз в 10 секунд.
А emptyDir с medium: Memory живёт в tmpfs и считается памятью (глава 2.3 статьи «Docker»).
Фазы, состояния и растущая пауза перезапуска
Фаз у пода пять: Pending, Running, Succeeded, Failed, Unknown, и все они грубые: Running значит лишь «контейнеры созданы, хотя бы один работает или перезапускается». Подробности лежат в состояниях контейнеров и условиях пода:
$ kubectl get pod -n kube-system coredns-559f6c778d-gx9h8 -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}{"\n"}'
PodReadyToStartContainers=True Initialized=True Ready=True ContainersReady=True PodScheduled=True
PodReadyToStartContainers (стабильно с 1.37) означает, что готовы sandbox и сеть, Initialized
говорит о законченных init-контейнерах. В колонке STATUS у kubectl get pods фаза стоит не всегда:
printPod заменяет её причинами ожидания и завершения контейнеров.
| STATUS | Что за этим стоит |
|---|---|
ContainerCreating | kubelet монтирует тома, создаёт sandbox, тянет образ |
ErrImagePull, ImagePullBackOff | образ не скачался; повторы с паузой от 10 до 300 секунд |
CreateContainerConfigError | конфиг не собрать: нет ConfigMap или Secret для env, runAsNonRoot при нечисловом USER образа |
CrashLoopBackOff | контейнер завершился, kubelet выжидает паузу перед перезапуском |
Terminating | есть deletionTimestamp, а фаза ещё не конечная; такой фазы нет |
Паузу считает flowcontrol.Backoff в kubelet, ключ у неё «под плюс контейнер». После первого падения
контейнер перезапускается сразу, дальше пауза 10 секунд и удваивается: 10, 20, 40, 80, 160, затем 300. Счёт
сбрасывается, если от прошлого перезапуска до нового падения прошло больше 10 минут (HasExpiredFunc), и
сервис, падающий раз в полчаса, каждый раз встаёт сразу. Потолок в 1.37
настраивается на узле: KubeletCrashLoopBackOffMax в бете с 1.35, поле
crashLoopBackOff.maxContainerRestartPeriod принимает от 1 до 300 секунд (на kind 5m0s). Альфа
ReduceDefaultCrashLoopBackOffDecay (от 1 до 60 секунд) выключена.
Остановка пода по шагам
Две независимые цепочки удаления (процессы на узле и адрес в балансировке) и пауза между ними разобраны в главе 3.1. Здесь они по участникам:
- API-сервер. Берёт grace period из запроса, иначе из
spec.terminationGracePeriodSeconds(по умолчанию 30); под безnodeNameили завершённый удаляет сразу. СтавитdeletionTimestampв будущее, на сейчас плюс grace period, и оставляет объект в etcd (CheckGracefulDelete). - EndpointSlice. Адрес пода сразу получает
ready: falseиterminating: true, аservingповторяет readiness (podToEndpoint). kube-proxy убирает адрес из правил, когда до него дойдёт обновление, а адресаserving+terminatingберёт, только если готовых не осталось. Открытые TCP-соединения правила не рвут. - kubelet. Выключает liveness- и startup-пробы (readiness работает дальше) и останавливает контейнеры параллельно; отсчёт grace period начинается здесь.
- preStop. kubelet выполняет хук и ждёт не дольше grace period; время хука вычитается из остатка.
sleepотсчитывает сам kubelet,execзапускает команду в контейнере. - Сигнал. CRI
StopContainerполучает остаток, но не меньше 2 секунд. containerd шлёт первому процессу контейнераlifecycle.stopSignal(альфа, выключена), иначеSTOPSIGNALобраза, иначе SIGTERM, а когда время выйдет, SIGKILL. Sidecars ждут выхода основных контейнеров и получают сигнал после них. - Конец. Когда всё остановлено, kubelet удаляет под с grace period 0 и условием на UID.
Действие sleep стабильно с 1.34 и принимает ноль. API сверяет его с grace period; в
sleep-too-long.yaml у пода terminationGracePeriodSeconds: 30 и sleep: { seconds: 40 }:
$ kubectl apply --dry-run=server -f sleep-too-long.yaml
The Pod "billing" is invalid: spec.containers[0].lifecycle.preStop.sleep: Invalid value: 40: must be non-negative and less than terminationGracePeriodSeconds (30)
$ kubectl get endpointslice -n kube-system -l kubernetes.io/service-name=kube-dns -o jsonpath='{range .items[*].endpoints[*]}{.targetRef.name} {.conditions}{"\n"}{end}'
coredns-559f6c778d-tdsh4 {"ready":true,"serving":true,"terminating":false}
coredns-559f6c778d-gx9h8 {"ready":true,"serving":true,"terminating":false}
Сообщение говорит «less than», но validateSleepAction пропускает и равное: sleep: 30 при
30 секундах проходит. Второй вывод показывает условия живых адресов CoreDNS; у удаляемого пода они сменятся, как в
шаге 2.
Хук exec: { command: ["sleep", "10"] } требует бинарника sleep в образе. В scratch и
distroless его нет: хук падает сразу, kubelet пишет событие FailedPreStopHook и без паузы шлёт SIGTERM,
так что гонка с балансировкой возвращается. Образ CoreDNS как раз такой:
# ls /proc/4704/root/bin /proc/4704/root/sbin /proc/4704/root/usr/bin
/proc/4704/root/bin:
/proc/4704/root/sbin:
/proc/4704/root/usr/bin:
Для них и сделано действие sleep.
Что делать при остановке самому Go-сервису, разобрано в главе 10.2 статьи «Веб-сервисы». Со стороны пода важно,
что сигнал получает только первый процесс контейнера, а Go-бинарь без обработчика умирает по SIGTERM сразу, с
кодом 143. Бюджет считают от хука:
terminationGracePeriodSeconds ≥ preStop + самый долгий запрос + закрытие ресурсов + запас, у billing
10 + 25 + 5 + 5 = 45.
Вопросы
3Как собрано сообщение
Шаблон «0/%v nodes are available» лежит в FitError.Error(). К нему дописываются пары «число
причина», отсортированные как строки, и сообщение вытеснения с приставкой «preemption:». Один узел может дать
несколько причин, поэтому сумма чисел бывает больше числа узлов. Живой пример нашёлся в событиях CoreDNS с
первых секунд кластера, когда узел ещё не был Ready:
$ kubectl events -n kube-system --for pod/coredns-559f6c778d-gx9h8 | head -3
LAST SEEN TYPE REASON OBJECT MESSAGE
81m Warning FailedScheduling Pod/coredns-559f6c778d-gx9h8 0/1 nodes are available: 1 node(s) had untolerated taint(s). preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.
80m Normal Scheduled Pod/coredns-559f6c778d-gx9h8 Successfully assigned kube-system/coredns-559f6c778d-gx9h8 to dv-k8s-control-plane
Какой именно taint не подошёл, сообщение в 1.37 не говорит.
| Причина в 1.37 | Что проверить |
|---|---|
Insufficient cpu, Insufficient memory | requests пода с init-контейнерами и sidecars против Allocated resources в describe node |
Too many pods | maxPods узла, по умолчанию 110 |
node(s) had untolerated taint(s) | spec.taints узлов и tolerations пода |
node(s) didn't match Pod's node affinity/selector | nodeSelector, nodeAffinity, метки узлов |
node(s) didn't match pod topology spread constraints | maxSkew; с «(missing required label)» у узлов нет метки topologyKey |
node(s) were unschedulable | узлы в cordon, например во время drain |
Что происходит с подом дальше
Pending-под ждёт в очереди и идёт на новую попытку, когда в кластере меняется что-то для него важное (новый
узел, удалённый под, снятый taint), с паузой от 1 до 10 секунд и не реже раза в 5 минут. Под с priority выше,
чем у подов на узле, может их вытеснить (DefaultPreemption), тогда у него появится
status.nominatedNodeName. Под такие поды Cluster Autoscaler добавляет узлы, если это поможет.
kubectl describe node <узел> | sed -n '/Allocated resources/,/Events/p' # сколько уже расписано
«Узлы пустые, а Insufficient memory»: фильтр складывает requests, и поды, запросившие по 4 ГиБ при потреблении 300 МиБ, занимают узел целиком. Тяжёлый init-контейнер раздувает запрос всего пода.
oom_score с поправкой от
QoS, а если упёрлась cgroup контейнера, то только внутри неё. Первым часто успевает ядро: kubelet проверяет раз в
10 секунд.Как выбирает kubelet
На узле кэш с requests.memory: 4Gi тратит 3,5 ГиБ, а API с requests.memory: 256Mi
тратит 600 МиБ. Кэш ест больше, но в свои requests укладывается; API превысил их на 344 МиБ и уходит первым. За
одну проверку kubelet выселяет один под и снова смотрит на сигналы. У пода будет reason: Evicted и
сообщение «The node was low on resource: memory. Threshold quantity: …, available: …. Container api was using …,
request is …», а на узле - событие EvictionThresholdMet.
Откуда OOMKilled без Evicted
- Контейнер упёрся в свой
limits.memory. Это OOM внутри его cgroup, память узла ни при чём: вlastStatereason OOMKilled, растётrestartCount, а под остаётся (глава 3.1). - Память узла кончилась быстрее проверки kubelet. Глобальный OOM killer решает по
oom_score_adj: процесс BestEffort или Burstable с маленьким запросом (поправка около 1000) и 300 МиБ проиграет Guaranteed-процессу с −997 и 3 ГиБ. kubelet пишет на узел событиеSystemOOM.
kubectl get pod api-7d9f8b6c5d-xk2wq -o jsonpath='{.status.reason} {.status.containerStatuses[*].lastState.terminated.reason}{"\n"}'
kubectl events --for node/<узел> | grep -E 'EvictionThresholdMet|SystemOOM'
requests.memory ставь по рабочему потреблению под нагрузкой: заниженный запрос делает сервис первой
жертвой и выселения, и OOM killer. GOMEMLIMIT влияет на выселение только через фактическое
потребление: kubelet сравнивает working set с requests, про лимит он не знает.
Куда уходят 30 секунд
| Время | Что происходит |
|---|---|
| 0 с | kubelet начал остановку, идёт sleep 10; адрес уже ready: false |
| 10 с | SIGTERM; StopContainer получил остаток, 20 секунд |
| 10–30 с | readiness 503, своя пауза в коде, srv.Shutdown(ctx), закрытие ресурсов |
| 30 с | SIGKILL всем, кто не вышел; exit code 137 |
Типичная ошибка: таймаут Shutdown в 30 секунд из примера без preStop или своя пауза в коде поверх
preStop. Пример из главы 10.2 спит 5 секунд и даёт Shutdown 20: с preStop на 10 секунд ему нужно
не меньше 35 секунд grace period, лучше 40.
Сигнал не доходит или не тот
- Первым процессом оказался shell.
ENTRYPOINTв shell-форме запускает/bin/sh -c, и dash в debian остаётся PID 1: SIGTERM забирает он, а приложение через 20 секунд умирает от SIGKILL (глава 2.1 статьи «Docker»). STOPSIGNALв образе. containerd шлёт его вместо SIGTERM. Go-процесс без обработчика наSTOPSIGNAL SIGQUITпечатает стеки горутин и выходит с кодом 2.- Упал хук.
execсоsleepв distroless падает мгновенно, SIGTERM приходит сразу (событиеFailedPreStopHook).
Shutdown не укладывается
srv.Shutdown ждёт активные запросы, и потоковая выдача или long polling держат его до таймаута, если
хендлеры не слушают общий контекст остановки (подробно в главе 10.2 статьи «Веб-сервисы»).
kubectl delete --grace-period=5 и drain с тем же флагом заменяют значение из spec: хук на 10 секунд
получит 5, а между SIGTERM и SIGKILL пройдут минимальные 2 секунды.
Что стоит запомнить
- Scheduler размещает Pod по requests и ограничениям, а не по текущему потреблению.
- Eviction kubelet и OOM killer - разные механизмы и оставляют разные признаки.
- При удалении Pod приложение получает grace period, затем контейнер может завершиться принудительно.
Частые ошибки
- CPU limit ведёт к throttling, memory limit может привести к OOM kill.
- Низкий QoS не означает немедленное удаление; важны приоритет и фактическое давление на узел.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Найди условие для scheduler
У узла осталось 300m allocatable CPU. Pod запрашивает 400m CPU и 128Mi памяти. Почему он остаётся Pending при почти нулевой фактической нагрузке?
resources:
requests:
cpu: 400m
memory: 128Mi
Ответ и разбор
Scheduler сопоставляет requests с allocatable, а не с мгновенным использованием CPU. 400m больше доступных 300m, поэтому этот узел не подходит. Низкая загрузка не отменяет уже зарезервированные requests. Нужна подходящая ёмкость или скорректированные по измерениям requests.
Код-ревью
2. Разведи пробы запуска
Сервис обычно стартует за 40 секунд. В манифесте liveness проверяет HTTP endpoint каждые 2 секунды и допускает один отказ. Почему Pod будет перезапускаться и как исправить?
livenessProbe:
httpGet: {path: /health, port: 8080}
periodSeconds: 2
failureThreshold: 1
Ответ и разбор
До готовности приложения первая неудача liveness приводит к рестарту, поэтому старт не успевает завершиться. Добавь startupProbe с окном, покрывающим измеренное время запуска; readiness используй для вывода Pod из Service endpoints, а liveness оставь для зависшего процесса, который сам не восстановится.
Применение
3. Различи eviction и OOM
Pod A завершился с OOMKilled, Pod B получил Evicted с причиной MemoryPressure. Какой компонент принял решение в каждом случае и где искать подтверждение?
Ответ и разбор
OOMKilled указывает на убийство процесса OOM-механизмом ядра при превышении лимита или нехватке памяти. Evicted означает, что kubelet удалил Pod при давлении ресурсов узла. Сверь состояние контейнера и cgroup memory events для первого случая, Events узла и описание Pod для второго.
Применение
4. Сверь бюджет shutdown
У Pod terminationGracePeriodSeconds: 30. Приложение тратит 10 секунд на отказ от новых запросов и до 25 секунд на текущие. Что произойдёт и какой бюджет согласуешь?
Ответ и разбор
Остановка может занять 35 секунд, что больше grace period. После 30 секунд kubelet попросит runtime завершить контейнер принудительно. Уложи прекращение приёма и drain в общий бюджет или увеличь grace period в пределах срока остановки платформы. Учти, что время preStop тоже расходует этот бюджет.