voidlearn
 

3.2Под изнутри

Проследишь путь от Pod spec до процессов и cgroup на узле. Разберёшь, как scheduler учитывает requests, как kubelet выбирает жертву при давлении на ресурсы и сколько времени есть у приложения на остановку.

Что сможешь объяснить после этой главы
  • Что kubelet просит у containerd через CRI и зачем в каждом поде процесс pause.
  • Как планировщик отсеивает и оценивает узлы и когда topologySpreadConstraints оставляет поды в Pending.
  • Чем нативный sidecar отличается от второго контейнера в containers.
  • Почему одни и те же 100m CPU дают cpu.weight 4 у пода и cpu.weight 17 у контейнера.
  • Кого выселяет 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)Отсеивает узелОценивает, вес
NodeResourcesFitrequests пода не влезают в allocatable минус requests подов узла; больше maxPodsсвободнее по requests - лучше, 1
TaintTolerationtaint NoSchedule или NoExecute без tolerationtaints PreferNoSchedule, 3
NodeAffinitynodeSelector, required-правилаpreferred-правила, 2
InterPodAffinityrequired (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.weight 157 получен из 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=1 kubelet на cgroup v2 ставит каждому контейнеру, и при OOM гибнут все его процессы (глава 1.4 статьи «Linux и ОС»).
  • memory.high=max: MemoryQoS в kubelet 1.37 в бете и включена, но без memoryThrottlingFactor (по умолчанию пуст) файл не пишется.
Под CoreDNS на узле kind: от полей манифеста до файлов cgroup v2 spec.containers: coredns requests.cpu: 100m requests.memory: 70Mi limits.memory: 170Mi QoS: Burstable requests заданы и не равны limits: под идёт в ветку burstable Один запрос, два перевода shares = 100 × 1024 / 1000 = 102 kubelet, slice: 1 + 100 × 9999 / 262142 = 4 runc 1.4, scope: ⌈10^((l² + 125·l)/612 − 7/34)⌉ при l = log₂102 получается 17 kubelet.slice/kubelet-kubepods.slice cpu.weight 157 из 4 CPU · memory.max 8320299008 = память узла kubelet-kubepods-burstable.slice cpu.weight 37 из суммы requests 950m kubelet-kubepods-besteffort.slice cpu.weight 1: минимальный вес kubelet-kubepods-burstable-pod7d672ea5_…_cd10571ef470.slice пишет kubelet: cpu.weight 4 · cpu.max max 100000 · memory.max 178257920 cri-containerd-27db….scope процесс /coredns, пишет runc cpu.weight 17 · cpu.max max 100000 memory.max 178257920 memory.oom.group 1 cri-containerd-72dc….scope процесс /pause, sandbox пода cpu.weight 1 · memory.max max держит net, ipc и uts пода oom_score_adj −998 cpu.max везде max 100000: limits.cpu у CoreDNS нет. С limits.cpu 500m в scope было бы 50000 100000, 50 мс за 100 мс. Guaranteed-поды висят прямо в kubepods.slice, своей QoS-ветки у них нет. На узле kubeadm путь начинается с kubepods.slice.
requests и limits на узле. Класс QoS выбирает ветку, kubelet пишет slice пода, runc пишет scope контейнера. Один и тот же requests.cpu переводится в cpu.weight дважды, разными формулами.
Откуда 4 и 17 для одних и тех же 100m

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Что за этим стоит
ContainerCreatingkubelet монтирует тома, создаёт 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. Здесь они по участникам:

  1. API-сервер. Берёт grace period из запроса, иначе из spec.terminationGracePeriodSeconds (по умолчанию 30); под без nodeName или завершённый удаляет сразу. Ставит deletionTimestamp в будущее, на сейчас плюс grace period, и оставляет объект в etcd (CheckGracefulDelete).
  2. EndpointSlice. Адрес пода сразу получает ready: false и terminating: true, а serving повторяет readiness (podToEndpoint). kube-proxy убирает адрес из правил, когда до него дойдёт обновление, а адреса serving + terminating берёт, только если готовых не осталось. Открытые TCP-соединения правила не рвут.
  3. kubelet. Выключает liveness- и startup-пробы (readiness работает дальше) и останавливает контейнеры параллельно; отсчёт grace period начинается здесь.
  4. preStop. kubelet выполняет хук и ждёт не дольше grace period; время хука вычитается из остатка. sleep отсчитывает сам kubelet, exec запускает команду в контейнере.
  5. Сигнал. CRI StopContainer получает остаток, но не меньше 2 секунд. containerd шлёт первому процессу контейнера lifecycle.stopSignal (альфа, выключена), иначе STOPSIGNAL образа, иначе SIGTERM, а когда время выйдет, SIGKILL. Sidecars ждут выхода основных контейнеров и получают сигнал после них.
  6. Конец. Когда всё остановлено, kubelet удаляет под с grace period 0 и условием на UID.
Остановка billing: terminationGracePeriodSeconds 45, preStop sleep 10 API-сервер EndpointSlice kube-proxy, ingress kubelet: app kubelet: otel-agent DELETE: deletionTimestamp = сейчас + 45 с, объект ещё в etcd DELETE, grace 0 ready=false, terminating=true сразу; serving повторяет readiness трафик ещё идёт новые соединения на другие поды; открытые TCP-соединения живут, пока их не закроют preStop: sleep 10 SIGTERM → Shutdown дожидается запросов выход работает дальше: ждёт выхода основного контейнера SIGTERM, выход SIGKILL тем, кто не вышел 0: удаление 10 с: SIGTERM app вышел 45 с Опасное окно: пока kube-proxy и ingress не убрали адрес, запросы приходят на под. preStop держит процесс рабочим это время. 45 с отсчитываются от начала остановки на узле и включают preStop; после сигнала kubelet даёт не меньше 2 с.
Остановка пода по времени. В EndpointSlice адрес сразу становится неготовым, а из правил балансировки уходит с задержкой. preStop закрывает этот зазор, а бюджет в 45 секунд делят хук, shutdown приложения и sidecar.

Действие 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.

preStop exec sleep в distroless не спит

Хук 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
Суть: сообщение собирает планировщик из ответов фильтров: общее число узлов, потом по каждой причине число отсеянных ею узлов, в конце итог попытки вытеснения. Фактическую нагрузку узлов планировщик не видит, только requests.

Как собрано сообщение

Шаблон «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 memoryrequests пода с init-контейнерами и sidecars против Allocated resources в describe node
Too many podsmaxPods узла, по умолчанию 110
node(s) had untolerated taint(s)spec.taints узлов и tolerations пода
node(s) didn't match Pod's node affinity/selectornodeSelector, nodeAffinity, метки узлов
node(s) didn't match pod topology spread constraintsmaxSkew; с «(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-контейнер раздувает запрос всего пода.

Суть: механизмы два, и они независимы. kubelet ранжирует поды по превышению своих requests и priority, а не по абсолютному потреблению. Ядро выбирает процесс по 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, память узла ни при чём: в lastState reason 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'
Что из этого следует для Go-сервиса

requests.memory ставь по рабочему потреблению под нагрузкой: заниженный запрос делает сервис первой жертвой и выселения, и OOM killer. GOMEMLIMIT влияет на выселение только через фактическое потребление: kubelet сравнивает working set с requests, про лимит он не знает.

Суть: на всё у пода одни 30 секунд. preStop съедает 10, на shutdown остаётся 20, и если код ждёт дольше или сигнал не доходит до Go-процесса, через 20 секунд после SIGTERM приходит SIGKILL.

Куда уходят 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 статьи «Веб-сервисы»).

Grace period в запросе удаления

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 тоже расходует этот бюджет.