Прерываемые ноды Selectel MKS: как устроено вытеснение и как настроить graceful shutdown подов

от автора

Прерываемые (preemptible) ноды в Selectel MKS дешевле примерно на 70%, и плата за это честная: провайдер может забрать ноду в любой момент, а больше суток она не живёт в любом случае. То есть вытеснение — не авария, а ежедневное штатное событие, и вопрос не «случится ли», а «что в этот момент произойдёт с подами».

В облаках, где прерываемые инстансы появились раньше, ответ на этот вопрос давно обкатан: инстанс заранее получает уведомление о вытеснении, а в кластере живёт демон, который это уведомление ловит и успевает увести нагрузку с ноды — у AWS это aws-node-termination-handler.

В Selectel MKS такого механизма нет ни в каком виде: нет уведомления, которое подобный демон мог бы поймать, и нет включённого graceful node shutdown у kubelet (shutdownGracePeriod: 0s). То есть по умолчанию kubelet при вытеснении не делает для подов вообще ничего — ни рассылки SIGTERM, ни ожидания завершения, ни соблюдения terminationGracePeriodSeconds, — и всё, что работает на прерываемой ноде, при каждом суточном вытеснении просто умирает вместе с ней.

Документация провайдера механику вытеснения не описывает, поэтому всё ниже получено экспериментально, на живом кластере: что именно происходит при вытеснении, сколько времени реально есть у приложений и как это время себе выдать.

Окружение: dev-кластер Tuna в Selectel MKS, регион ru-7a, Kubernetes 1.35.3, Ubuntu 24.04.4, systemd 255, containerd 2.2.4. Дата проверки — июль 2026.

Коротко

Вопрос

Ответ

Есть ли уведомление о предстоящем вытеснении?

Нет. Ни в metadata service, ни через API

Как выключают ноду?

Штатный systemd-shutdown по сигналу от гипервизора

Работает ли kubelet graceful node shutdown?

Да, но по умолчанию выключен (shutdownGracePeriod: 0s)

Есть ли дедлайн со стороны гипервизора?

Не обнаружен: 280 секунд выждали без вмешательства

Чем ограничено окно?

Только InhibitDelayMaxSec в logind — в образе ноды 30 с

Как включить?

user_data группы нод — скрипт в конце документа

Как быстро нода возвращается?

С сетевым загрузочным диском — десятки секунд и со своим состоянием, с локальным — до 10 минут и с нуля

Итоговая конфигурация даёт приложениям 120 секунд на завершение — столько же, сколько даёт AWS Spot, и вчетверо больше, чем GCP Spot VM и Azure Spot.

Что обещает провайдер — и о чём умалчивает

Фича не новая: прерываемые ноды в Managed Kubernetes появились ещё в феврале 2025 года, и Selectel анонсировал их отдельной статьёй в своём блоге — про экономию до 70% и про то, что «нода оперативно вернётся в кластер, а восстановление займёт не более минуты». Про поды, которые в этот момент на ноде работают, там не сказано ничего: ни про SIGTERM, ни про drain, ни про PDB, ни про потерю данных. Как раз эту дырку мы и полезли закрывать.

Из документации Selectel:

  • нода живёт не более 24 часов, после каждого восстановления отсчёт заново;

  • Selectel может остановить ноду в любой момент, англоязычная версия документации прямо пишет «may terminate nodes without notice»;

  • восстановление начинается сразу: с сетевым загрузочным диском — меньше минуты и с сохранением состояния, с локальным — до 10 минут и с потерей всех данных;

  • SLA на прерываемые группы не распространяется;

  • доступно только в кластерах на облачных серверах (СПб, Москва, Новосибирск);

  • ресурсы дешевле примерно на 70%.

Из 24-часового лимита и следует то, с чего мы начали: вытеснение это ежедневное штатное событие, а не редкая авария. Значит и настраивать поведение при нём нужно как обычную рабочую нагрузку, а не как аварийный сценарий «на всякий случай».

Чего нет: ищем preemption notice и не находим

Первое, куда мы полезли по аналогии с AWS/GCP/Azure, — metadata service. Там у гиперскейлеров лежит признак предстоящего вытеснения, и вся индустриальная практика построена вокруг него: демон опрашивает эндпоинт, видит уведомление, сам делает cordon и drain — нагрузка уходит с ноды до выключения. Запуск пода с hostNetwork на прерываемой ноде:

/latest/meta-data/spot/instance-action    → 404/latest/meta-data/spot/termination-time   → 404/latest/meta-data/instance-action         → none   (статическая заглушка Nova)/openstack/latest/meta_data.json          → ни одного поля про preemptible

Metadata service — стандартный OpenStack без каких-либо расширений под прерываемые инстансы. В апстримной Nova механизма spot-уведомлений нет вообще, и Selectel его не добавлял.

Отсюда вывод: вешать node-termination-handler по аналогии с AWS не на что — источника события не существует. Планы сделать свой хендлер у Selectel есть — об этом отдельная заметка в конце, — но пока единственный путь к мягкому завершению это успеть среагировать на само выключение.

Побочно: MKS не помечает прерываемые ноды собственным лейблом, у них mks.operational/node-type: standard. Отличать их можно только по своим labels и taints, заданным при создании группы.

Что есть: вытеснение — это OpenStack shelve и штатный systemd-shutdown

Вытеснение реализовано как OpenStack shelve (в документации восстановление описано командой openstack server unshelve). С точки зрения гостевой ОС это выглядит так:

systemd-logind[711]: System is powering down (hypervisor initiated shutdown).systemd[1]: Stopping kubelet.service - Kubernetes Kubelet...systemd[1]: Reached target shutdown.target - System Shutdown.systemd-shutdown[1]: Sending SIGTERM to remaining processes...

Это полноценная последовательность выключения, а не выдёргивание питания. Значит у kubelet есть возможность вмешаться — через механизм Graceful Node Shutdown, который использует systemd inhibitor locks.

Механика тут простая: процесс берёт у logind delay-лок на shutdown, и logind перед выключением рассылает держателям локов PrepareForShutdown и ждёт, пока те отпустят лок, — но не дольше InhibitDelayMaxSec. Kubelet держит такой лок ровно для того, чтобы успеть разослать подам SIGTERM и дождаться их завершения. Отсюда и вся дальнейшая арифметика этого документа: окно на завершение подов не может быть больше, чем logind согласен ждать.

По умолчанию в MKS он выключен:

$ kubectl get --raw "/api/v1/nodes/<node>/proxy/configz" | jq .kubeletconfig{ "shutdownGracePeriod": "0s", "shutdownGracePeriodCriticalPods": "0s" }

Потолок в 30 секунд, о который спотыкается kubelet

Kubelet при старте пытается поднять InhibitDelayMaxSec в logind до запрошенного shutdownGracePeriod: пишет /etc/systemd/logind.conf.d/99-kubelet.conf и шлёт logind SIGHUP. Если значение не поднялось, менеджер не стартует:

E0729 16:32:38 kubelet.go:1769] "Failed to start node shutdown manager"  err="node shutdown manager was timed out after 5 attempts waiting for logind  InhibitDelayMaxSec to update to 45s (ShutdownGracePeriod), current value is 30s"

Причина — в образе Ubuntu лежит чужой drop-in:

/usr/lib/systemd/logind.conf.d/unattended-upgrades-logind-maxdelay.conf  InhibitDelayMaxSec=30

Drop-in’ы logind собираются из всех каталогов и сортируются по имени файла глобально, а unattended-upgrades-… идёт лексикографически после 99-kubelet.conf и перебивает его. Приоритет /etc над /usr здесь не спасает — он работает только при совпадении имён файлов.

Механизм самого kubelet при этом исправен: в журнале видно, что logind на его SIGHUP отвечает Config file reloaded. Мы проверили это и напрямую — правка своего drop-in’а с последующим systemctl reload systemd-logind меняет InhibitDelayMaxUSec без перезапуска сервиса:

$ busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \    org.freedesktop.login1.Manager InhibitDelayMaxUSect 180000000# правим drop-in на 200 и делаем reloadt 200000000

То есть kubelet перечитать конфиг заставляет, но своего добиться не может: после reload побеждает всё тот же чужой drop-in.

Отсюда два следствия:

  1. Без вмешательства максимум, что можно запросить, — shutdownGracePeriod: 30s.

  2. Чтобы поднять потолок, нужен свой drop-in с именем, сортирующимся после пакетного — мы используем zz-kubelet-graceful.conf.

Вариант «положить в /etc файл-двойник с тем же именем» тоже работает, но завязывается на имя пакетного файла, которое может измениться при обновлении.

Замеры: четыре прогона с реальным shelve

Все прогоны делали на одной ноде, вытеснение имитировали командой openstack server shelve <id> — то есть ровно тем же механизмом, которым ноду вытесняет Selectel.

Прогон 1: только DaemonSet’ы, окно 30 с — гаснет мгновенно

На ноде нет пользовательской нагрузки, только calico-node, kube-proxy, csi-cinder-nodeplugin, node-exporter, vector.

16:55:30 systemd-logind: System is powering down (hypervisor initiated shutdown)16:55:30 systemd[1]: Stopping kubelet.service16:55:30 systemd[1]: Reached target shutdown.target

Задержки нет вообще. Это не поломка: системные поды завершаются мгновенно, kubelet отпускает inhibitor lock досрочно, и logind сразу гасит систему. Окно — это лимит, а не фиксированная пауза.

Прогон 2: канарейка против SIGTERM, окно 30 с — подам достаётся 20 секунд

Под, который ловит SIGTERM и после этого ещё две минуты пишет отсчёт в лог на hostPath (чтобы файл пережил выключение и удаление пода):

17:32:43 alive17:32:43 *** SIGTERM RECEIVED ***17:32:43 term+0s   …17:33:02 term+19s          ← последняя запись17:33:03 systemd-logind: System is powering down (hypervisor initiated shutdown)

Приложение получило SIGTERM за 20 секунд до начала выключения. Ровно shutdownGracePeriodByPodPriority для Priority 0 — то есть 30s общих минус 10s, зарезервированных под critical-поды.

Здесь же видно, как logind на самом деле обрабатывает delay-локи: сначала рассылает PrepareForShutdown, ждёт держателей, и только потом пишет powering down. Строка про выключение появляется в конце ожидания, а не в начале.

Прогон 3: окно 300 с — дедлайна со стороны гипервизора нет

Потолок logind поднят до 300 с, shutdownGracePeriod: 300s / shutdownGracePeriodCriticalPods: 20s (то есть 280 с обычным подам). Канарейка с terminationGracePeriodSeconds: 600.

18:16:19 *** SIGTERM RECEIVED ***   …18:20:59 term+279s        ← 280 отсчётов, ровно окно фазы

Два вывода:

  • Nova не вмешивается. Канарейка выработала всё окно целиком и была убита не гипервизором, а самим kubelet по истечении фазы. Дедлайна со стороны гипервизора обнаружить не удалось — по крайней мере, он больше 280 секунд.

  • Берётся минимум. Под просил 600 секунд, получил 280 — окно фазы обрезает terminationGracePeriodSeconds пода.

Полный цикл «вытеснение → возврат ноды в кластер» занял при этом около шести минут — прямая цена длинного окна. Для сравнения: до включения graceful вся процедура от команды до ACTIVE в Nova укладывалась в 28 секунд. Разница и есть то время, которое мы сознательно выдаём приложениям на завершение.

Прогон 4: полный стенд, окно 120 с — stateless переезжает, stateful ждёт том

Итоговая конфигурация (150s/30s), на прерываемой ноде одновременно:

  • holder — под, игнорирующий SIGTERM, terminationGracePeriodSeconds: 300;

  • migrant — Deployment на 8 реплик (4 на подопытной ноде), выходят по SIGTERM мгновенно, tolerationSeconds: 10 для not-ready/unreachable;

  • fakedb-0 — StatefulSet с Cinder-томом (universal, RWO), пишет в том и по SIGTERM делает 15-секундный flush; вторая реплика на соседней ноде, между ними requiredDuringScheduling antiAffinity.

Ноду, поды и VolumeAttachment опрашивали раз в секунду. shelve выдали в 06:49:14:

время

Δ

событие

06:49:14

+0 с

SIGTERM всем подам ноды одновременно

06:49:14.42

+0.4 с

Ready=False / KubeletNotReady

06:49:16.14

+2 с

taint node.kubernetes.io/not-ready:NoSchedule

06:49:17–21

+3…7 с

все 8 реплик migrant работают на соседней ноде

06:49:29

+15 с

fakedb-0 дописал flush, завершился

06:49:30

+16 с

StatefulSet удалил и заново создал fakedb-0Pending

06:49:58

+44 с

taint node.kubernetes.io/not-ready:NoExecute

06:50:40

+86 с

autoscaler TriggeredScaleUp 2→3 из-за висящего Pending

06:51:13

+119 с

holder дописал term+119s — окно выбрано ровно

06:51:14

+120 с

DaemonSet controller пересоздаёт поды (реагирует на их Failed, нода ещё гаснет)

06:52:57

+224 с

Ready=Unknown / NodeStatusUnknown — controller зафиксировал пропажу heartbeat’ов

06:53:09

+235 с

Rebooted с новым boot id и Starting kubelet — машина загрузилась

06:53:10

+236 с

нода снова Ready

06:53:41

+267 с

том отвязался от выключенной ноды

06:53:53

+279 с

fakedb-0 запустился на другой ноде, данные целы

Выводы:

Нода закрывается для планирования почти мгновенно. Condition переключается за доли секунды, taint NoSchedule — за две. Задержка появления NoExecute плавает: 44 секунды здесь и 5 секунд в другом прогоне.

Основное время цикла съедает graceful, а не перезагрузка. От команды до работающей ноды прошло 236 секунд, из них 120 — окно, которое целиком выбрал holder, дальше критическая фаза и обычная последовательность systemd-shutdown. Загрузившийся kubelet отчитался в Ready через секунду после старта.

Точный момент, когда машина погасла, мы не зафиксировали, и разложить эти 236 секунд по частям не получится. Ready=Unknown для этого не годится: NodeStatusUnknown ставит не kubelet, а node lifecycle controller — после --node-monitor-grace-period без heartbeat’ов, то есть с запаздыванием порядка сорока секунд. Надёжный якорь тут только journalctl --list-boots на самой ноде, снятый до того, как её заберёт autoscaler.

Stateless-миграция обгоняет taint-эвакуацию. Реплики уже работали на соседней ноде к +7 с, то есть за 37 секунд до появления NoExecute. Переезд делает связка «kubelet завершил под → ReplicaSet создал замену», а tolerationSeconds для таких подов вообще не успевает сыграть.

Stateful не мигрирует, а ждёт освобождения тома. Простой fakedb-0 составил 279 секунд против 7 секунд у stateless — в сорок раз больше. Событиями это видно буквально:

06:52:01  Scheduled → tuna-node-hprry06:52:01  FailedAttachVolume: Multi-Attach error for volume "pvc-a0e7c02d…"          Volume is already exclusively attached to one node and can't be attached to another06:53:51  SuccessfulAttachVolume06:53:53  Container started

Эти 279 секунд складываются так: 16 секунд ушло на flush и пересоздание пода, дальше около двух с половиной минут он ждал планирования, и с 06:52:01 до 06:53:53 — ещё почти две минуты стоял на Multi-Attach уже назначенным на живую ноду. Значит добавление свободных нод проблему не решает: том RWO остаётся привязанным к выключенной ноде, и ждать приходится не планировщик, а detach.

Причина подвисания не в том, что csi-cinder-nodeplugin гасится в первой фазе — detach выполняет вообще другой компонент, csi-cinder-controllerplugin, который живёт на инфраструктурной ноде и всё это время работает. Дело в том, что kubelet при выключении не отмонтирует том: в журнале за ту загрузку нет ни одного NodeUnpublish/UnmountVolume, все unmount’ы датированы уже следующей загрузкой. А attach-detach controller не отвяжет том, пока нода не подтвердит через node.status.volumesInUse, что он свободен — чего мёртвый kubelet сделать не может. В этом прогоне detach разблокировал сам возврат ноды; если бы она не вернулась, сработал бы force detach по таймауту maxWaitForUnmountDuration (6 минут).

Данные не теряются. Пятнадцатисекундный flush уложился в окно, clean shutdown записан на диск, после переезда том прочитался полностью.

Autoscaler реагирует на Pending. Висящий stateful-под спровоцировал подъём третьей ноды, хотя исходная вернулась бы сама. Чем длиннее окно, тем выше шанс временно оплатить лишнюю ноду.

Один под задерживает всю ноду. holder выбрал ровно 120 секунд, хотя все остальные ушли за 15.

Механика завершения — и чего в ней нет

Собранная из четырёх прогонов и документации Kubernetes картина:

  1. Гипервизор инициирует выключение, logind рассылает PrepareForShutdown держателям delay-локов и ждёт.

  2. Kubelet немедленно выставляет ноде Ready=False и синхронизирует статус с API. Reason при этом обычный — KubeletNotReady, а текст «node is shutting down» приходит в message: kubelet складывает ошибку от shutdown manager в тот же агрегированный список, что и ошибки runtime, сети и хранилища. Дальше срабатывает штатная механика: node lifecycle controller вешает node.kubernetes.io/not-readyNoSchedule закрывает ноду для планирования, NoExecute запускает вытеснение подов по их tolerationSeconds. Дополнительно kubelet режет новые поды на PodAdmission, поэтому даже под с толерацией к not-ready:NoSchedule там не стартует.

  3. Kubelet делит поды на фазы и проходит их последовательно: сначала обычные, затем system-node-critical. Порядок важен: сеть и CSI гасятся последними, поэтому обычные поды доживают с рабочей сетью и примонтированными томами.

  4. Каждому поду даётся минимум из его terminationGracePeriodSeconds и окна его фазы.

  5. Фаза ждёт самый медленный под в группе, но не дольше своего окна.

  6. Как только все фазы отработали, kubelet отпускает lock — система гаснет, не досиживая остаток.

То есть по эффекту это действительно cordon плюс drain, только выполняемые не администратором, а самим кластером: нода закрывается для планирования в первые же секунды, а поды параллельно и завершаются на месте (kubelet), и вытесняются по taint (taint manager).

Чего при этом всё же не происходит:

  • PodDisruptionBudget не соблюдается ни на одном из двух путей. Kubelet завершает поды по расписанию фаз, а taint manager удаляет их напрямую — оба в обход Eviction API. Полагаться на PDB для прерываемых нод нельзя, нужен topologySpread по типу нод и запас реплик.

  • Поды не переезжают заранее. Миграции «до выключения» нет: под доживает на уходящей ноде, а замену контроллер поднимает параллельно. Для stateless это незаметно, для нагрузки, которой нужно передать роль перед уходом, — нет.

Практическое следствие для тайминга: если tolerationSeconds пода меньше окна фазы, замена появится раньше, чем оригинал будет добит, и какое-то время они проработают параллельно.

Значение tolerationSeconds в манифестах обычно не пишут — его подставляет apiserver из --default-not-ready-toleration-seconds. В этом кластере оно равно 120 секундам, что видно у любого пода без явных tolerations:

$ kubectl get pod -n kube-system <любой-под> -o jsonpath='{.spec.tolerations}' | jq[{"key":"node.kubernetes.io/not-ready","operator":"Exists",  "effect":"NoExecute","tolerationSeconds":120}, …]

То есть по умолчанию оно совпадает с нашим окном фазы (тоже 120 секунд), и вытеснение по taint сходится с завершением kubelet’ом примерно в одной точке. Проверьте это значение в своём кластере: апстримный дефолт Kubernetes — 300 секунд, и с ним замена появится уже после того, как оригинал добьют.

Конфигурация

Единственный поддерживаемый способ донести настройки до каждой новой ноды — user_data группы нод. Это base64-кодированный скрипт, который cloud-init выполняет при инициализации ноды.

Важное ограничение: user_data нельзя изменить у существующей группы — в терминах Terraform это ForceNew, любая правка означает пересоздание группы нод. Значения стоит подобрать заранее.

Скрипт для user_data

preemptible-user-data.sh:

#!/bin/bash# Включает kubelet graceful node shutdown: при вытеснении прерываемой ноды# logind рассылает PrepareForShutdown и ждёт delay-lock, kubelet успевает# послать подам SIGTERM и дать им доработать.## Свой drop-in для logind обязателен: unattended-upgrades кладёт# InhibitDelayMaxSec=30 в /usr/lib/systemd/logind.conf.d/, drop-in'ы сортируются# по имени файла глобально по всем каталогам, поэтому наш начинается с zz.# Без этого kubelet падает с "Failed to start node shutdown manager".set -euGRACE=150sGRACE_CRITICAL=30sINHIBIT_MAX=180install -d /etc/systemd/logind.conf.dcat > /etc/systemd/logind.conf.d/zz-kubelet-graceful.conf <<EOF[Login]InhibitDelayMaxSec=${INHIBIT_MAX}EOFsystemctl restart systemd-logindCONF=/etc/kubernetes/kubelet-config.conffor _ in $(seq 60); do  [ -f "$CONF" ] && break  sleep 5doneif [ ! -f "$CONF" ]; then  echo "kubelet config did not appear in time" >&2  exit 1fiif ! grep -q '^shutdownGracePeriod:' "$CONF"; then  cat >> "$CONF" <<EOFshutdownGracePeriod: ${GRACE}shutdownGracePeriodCriticalPods: ${GRACE_CRITICAL}EOFfiif systemctl is-active --quiet kubelet; then  systemctl restart kubeletfi

Пояснения к деталям, каждая из которых стоила отдельной итерации:

  • INHIBIT_MAX (180) больше GRACE (150) — иначе kubelet откажется стартовать. Запас в 30 секунд.

  • GRACE минус GRACE_CRITICAL = 120 секунд достаётся обычным подам. Если нужно дать приложениям ровно N секунд, GRACE должен быть N + GRACE_CRITICAL.

  • install -d для каталога logind обязателен: на свежей ноде его нет. Он появляется только если kubelet успел создать там свой 99-kubelet.conf.

  • systemctl restart systemd-logind — здесь хватило бы и reload (проверено выше), но в cloud-init сессий ещё нет, а рестарт гарантированно перечитывает конфиг.

  • Ожидание kubelet-config.conf до 5 минут: cloud-init может отработать раньше, чем MKS развернёт компоненты ноды.

  • Перезапуск kubelet только если он уже поднят — иначе он стартует позже сам и подхватит готовый конфиг.

Дописывание в основной конфиг — вынужденное решение: этим файлом владеет MKS, и при обновлении компонентов ноды правка может быть затёрта. Обойтись флагом запуска нельзя: аргументы kubelet заданы через Environment= внутри самого юнита, EnvironmentFile в нём нет, так что добавить --config-dir можно только переопределив ExecStart — а это уже завязка на внутреннее устройство MKS. Подробнее с примерами — в разделе «Рекомендации провайдеру».

Terraform: группа прерываемых нод

resource "selectel_mks_nodegroup_v1" "preemptible" {  cluster_id        = selectel_mks_cluster_v1.cluster.id  project_id        = local.project_id  region            = local.region  availability_zone = "ru-7a"  nodes_count         = 2  enable_autoscale    = true  autoscale_min_nodes = 2  autoscale_max_nodes = 6  cpus      = 4  ram_mb    = 8192  volume_gb = 40  # Сетевой загрузочный диск: вытесненная нода возвращается за минуту вместо  # 10 и с сохранением состояния, локальный диск при вытеснении теряется.  local_volume = false  volume_type  = "universal.ru-7a"  preemptible = true  user_data   = base64encode(file("${path.module}/preemptible-user-data.sh"))  labels = {    "type"        = "preemptible"    "preemptible" = "true"  }  taints {    key    = "preemptible"    value  = "true"    effect = "NoSchedule"  }}

local_volume, volume_type и user_data — все три ForceNew.

Про сетевой диск: самая выгодная строчка в конфиге

Из всей конфигурации local_volume = false — самая выгодная строчка, и поставить её стоит даже тем, кому graceful shutdown не нужен вовсе: именно она определяет, вернётся вытесненная нода за десятки секунд или за десять минут.

С локальным диском нода пересоздаётся из образа: hostname сохраняется, но сервер новый, IP новый, Node-объект в кластере пересоздаётся, всё содержимое диска теряется, восстановление до 10 минут. Каждые сутки, на каждой ноде группы.

С сетевым диском нода возвращается со своим состоянием за десятки секунд — вместе с кэшем образов containerd, то есть после возврата ей не нужно заново тянуть образы. Плата — 40 ГБ сетевого диска продолжают тарифицироваться, пока нода выключена. На фоне 70% скидки на vCPU/RAM это несущественно.

Отдельного параметра для сохранения диска в Terraform нет: MKS сам создаёт тома с delete_on_termination: false, когда группа объявлена с local_volume = false. Проверить можно только со стороны облака:

$ openstack server show <node> -f json | jq '.volumes_attached'[{"id": "5d4f4f3f-…", "delete_on_termination": false}]

Проверка после применения

$ kubectl get --raw "/api/v1/nodes/<node>/proxy/configz" \    | jq -c '.kubeletconfig | {shutdownGracePeriod, shutdownGracePeriodCriticalPods}'{"shutdownGracePeriod":"2m30s","shutdownGracePeriodCriticalPods":"30s"}

Дальше — с самой ноды (например, через kubectl debug node/<node> --profile=sysadmin):

# потолок logind поднялся$ busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \    org.freedesktop.login1.Manager InhibitDelayMaxUSect 180000000# менеджер стартовал и раздал окна по фазам$ journalctl -b 0 -u kubelet | grep "shutdown manager""Creating node shutdown manager" shutdownGracePeriodRequested="2m30s"  shutdownGracePeriodCriticalPods="30s"  shutdownGracePeriodByPodPriority=[{"Priority":0,"ShutdownGracePeriodSeconds":120},                                    {"Priority":2000000000,"ShutdownGracePeriodSeconds":30}]# lock взят$ systemd-inhibit --list | grep kubeletkubelet  0  root  2303  kubelet  shutdown  Kubelet needs time to handle node shutdown  delay

Главный признак ошибки — строка Failed to start node shutdown manager в журнале kubelet. Она означает, что запрошенный shutdownGracePeriod больше текущего InhibitDelayMaxSec.

Проверка боем: канарейка и ручной shelve

Чтобы убедиться, что всё работает end-to-end, достаточно канарейки, логирующей на диск ноды, и ручного shelve. Ключевой момент — писать лог на hostPath: файлы в /var/log/pods/ kubelet удаляет вместе с подом, когда контроллер поднимает замену, и разбираться будет уже не с чем.

apiVersion: v1kind: Podmetadata:  name: canaryspec:  restartPolicy: Never  terminationGracePeriodSeconds: 600  nodeName: <preemptible-node>  tolerations:    - key: preemptible      operator: Equal      value: "true"      effect: NoSchedule  volumes:    - name: hostlog      hostPath:        path: /var/log/preempt-canary        type: DirectoryOrCreate  containers:    - name: canary      image: busybox:1.36      command: ["sh", "-c"]      args:        - |          LOG=/hostlog/canary.log          log() { echo "$(date -u +%H:%M:%S) $1" | tee -a $LOG; }          term() {            log "*** SIGTERM RECEIVED ***"            i=0            while [ $i -lt 590 ]; do              log "term+${i}s"; i=$((i+1)); sleep 1 & wait $!            done            exit 0          }          trap term TERM          log "=== started ==="          while true; do log alive; sleep 1 & wait $!; done      volumeMounts:        - name: hostlog          mountPath: /hostlog

Важная деталь скрипта — sleep 1 & wait $! вместо простого sleep 1: shell доставляет сигналы только между командами, и обычный sleep задержал бы реакцию на SIGTERM.

Требования к нагрузке

terminationGracePeriodSeconds в манифесте пода. Kubelet берёт минимум из окна фазы и этого значения, поэтому под с дефолтными 30 секундами получит их же, а не 120: настройка ноды сама по себе приложению времени не добавляет. Тем, кому нужно две минуты, надо указать это явно:

spec:  terminationGracePeriodSeconds: 120

Только те, кому реально нужно. Фаза ждёт самый медленный под в группе: один воркер с длинным grace, не реагирующий на SIGTERM, задержит выключение всей ноды на всё окно, даже если остальные ушли мгновенно.

Запас реплик и раскладка. PDB при node shutdown не соблюдается, поэтому защита от одновременной потери реплик — только topologySpreadConstraints по лейблу типа нод и replicas >= 2.

Никакого stateful. Данные на прерываемых нодах жить не должны, несмотря на сетевой диск: 24-часовой цикл вытеснения гарантирует регулярную недоступность. Замер выше показывает почему: под с RWO-томом простаивает минуты, а не секунды, и добавление свободных нод этого не лечит — том остаётся привязанным к выключенной ноде до detach. Если stateful там всё же нужен, стоит закладываться на несколько минут недоступности при каждом суточном вытеснении и не ставить requiredDuringScheduling antiAffinity, которая дополнительно сужает выбор.

Готовность к отсутствию ноды. Пока нода выключается и возвращается, её нет в кластере несколько минут. Autoscaler в это время может поднять временную замену, и группа кратковременно вырастет сверх nodes_count.

Сравнение с гиперскейлерами: notice против дедлайна

облако

предупреждение заранее

окно на завершение

жёсткий дедлайн

AWS Spot

2 минуты (IMDS spot/instance-action)

2 минуты

да

GCP Spot VM

нет, сразу ACPI G2 Soft Off

~30 секунд, best effort

да

Azure Spot

30 секунд (Scheduled Events)

30 секунд, best effort

да

Selectel MKS

нет

не ограничено (280 с проверено)

не обнаружен

У Selectel нет самого удобного — заблаговременного уведомления, по которому можно было бы увести нагрузку с ноды до начала выключения. Зато нет и дедлайна: гипервизор ждёт, пока гость закончит.

Для stateless-нагрузки с корректной обработкой SIGTERM это выгодный размен: пятиминутный воркер, который на AWS был бы убит на второй минуте, здесь успеет доработать. Для нагрузки, которой нужно передать роль перед уходом, отсутствие notice принципиально — такой сценарий на прерываемых нодах Selectel не реализуется.

Итоговые значения

InhibitDelayMaxSec               180sshutdownGracePeriod              150sshutdownGracePeriodCriticalPods   30s→ обычные поды                   120s→ system-node-critical            30s

Выбраны по образцу AWS Spot. Замеры показывают, что окно можно поднимать практически произвольно (проверено до 280 секунд обычным подам) — ограничение только в том, что нода дольше отсутствует в кластере.

Выводы

Механика на стороне Selectel оказалась аккуратнее, чем можно было ожидать от фичи, продаваемой со словами «without notice»: вытеснение — это штатное выключение гостя, гипервизор терпеливо ждёт, дедлайна мы не нашли даже на 280 секундах. Всё, что нужно для мягкого завершения подов, в kubelet уже есть — но выключено, и включить это можно только самому, через user_data. Отсюда первый и самый скучный практический вывод: значения нужно подобрать до создания группы нод, потому что user_data — ForceNew, и передумать без пересоздания группы не получится.

Второй: ограничение здесь не количество времени, а его расположение. Двух минут (и, судя по замерам, сколько угодно больше) хватает практически на любое корректное завершение, но получить их можно только после того, как выключение уже началось. Заранее увести нагрузку не на что — источника события не существует. Всё, что требует «передать роль и уйти», на прерываемых нодах не живёт; всё, что умеет дописать текущую работу по SIGTERM и умереть, живёт хорошо.

Третий: сам переезд stateless-нагрузки оказался быстрее, чем ожидалось от механики taint’ов, — реплики работали на соседней ноде через семь секунд, за полминуты до появления NoExecute. Отвечает за это не таймаут вытеснения, а обычная связка «kubelet завершил под → контроллер создал замену». Поэтому от приложения нужны всего две вещи: реальная обработка SIGTERM и явный terminationGracePeriodSeconds — с дефолтными 30 секундами под получит 30 секунд, сколько бы ни было выставлено на ноде.

Четвёртый: stateful туда ставить не нужно, и сетевой загрузочный диск этого не меняет. RWO-том остаётся привязанным к выключенной ноде, пока её kubelet не подтвердит освобождение, — а он уже мёртв, поэтому под ждёт минуты, а не секунды, и свободные ноды в кластере не помогают. Плюс PDB при node shutdown не соблюдается ни на одном из двух путей завершения, так что единственная защита реплик — topologySpreadConstraints по типу нод и запас реплик.

И пятый: у длинного окна есть цена, причём не только в ожидании. Чем дольше завершение, тем дольше нода отсутствует в кластере и тем выше шанс, что autoscaler успеет поднять временную замену, за которую придётся заплатить. В прогоне с окном 280 секунд полный цикл «вытеснение → возврат» вырос с тридцати секунд до шести минут. Мы остановились на 120 секундах обычным подам — это совпадает с привычным окном AWS Spot, хватает подавляющему большинству нагрузки и ещё не начинает заметно мешать.

При этом собственно перезагрузка в этой сумме занимает малую часть — и это следствие ещё одного решения. До включения graceful весь цикл «команда → ACTIVE в Nova» укладывался в 28 секунд, то есть с сетевым загрузочным диском нода гаснет и поднимается со своим состоянием и прогретым кэшем образов за десятки секунд. С локальным она пересоздавалась бы из образа до десяти минут, теряя всё содержимое. local_volume = false — самая дешёвая по усилиям строчка во всей конфигурации и единственная, которую стоит поставить даже без всего остального.

Итого размен выглядит так: минус ~70% стоимости vCPU и RAM в обмен на одно требование к приложению — умей корректно умирать за две минуты, и умей делать это раз в сутки.

Если коротко, по сценариям:

  • Stateless с корректной обработкой SIGTERM — берите, оговорок почти нет. Только не забудьте явный terminationGracePeriodSeconds и topologySpreadConstraints по типу нод.

  • Батчи и воркеры, которым нужно больше двух минут — берите. Жёсткого дедлайна нет, окно поднимается почти до пяти минут (проверено 280 секунд), и такого не даёт ни один гиперскейлер.

  • Нагрузка, которой нужно передать роль перед уходом — не берите. Без preemption notice увести её с ноды заранее просто нечем.

  • Stateful с RWO-томом — не берите. Это несколько минут недоступности на detach каждые сутки, и свободные ноды в кластере тут не помогают.

  • Настраивать ничего не хочется — поставьте хотя бы local_volume = false. Одна строчка, которая превращает десять минут пересоздания ноды из образа в десятки секунд возврата со своим состоянием.

Рекомендации провайдеру

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

Дать штатную точку расширения для kubelet: EnvironmentFile или —config-dir

Сейчас единственный способ изменить KubeletConfiguration — дописывать в /etc/kubernetes/kubelet-config.conf. Этим файлом владеет mk-node-adm («Receives requests from mk-master-adm to install/upgrade node components»), то есть при очередном обновлении компонентов ноды правка может молча исчезнуть вместе с graceful shutdown.

Обойти это через аргументы запуска тоже не выйдет. Аргументы задаются не во внешнем файле, а прямо в юните, которым владеет MKS:

# /etc/systemd/system/kubelet.service[Service]Environment="KUBELET_ARGS= \  --bootstrap-kubeconfig=/etc/kubernetes/kubelet-bootstrap.conf \  --kubeconfig=/etc/kubernetes/kubelet-kubeconfig.conf \  --config=/etc/kubernetes/kubelet-config.conf \  --cloud-provider=external \  --node-labels=…"ExecStart=/usr/bin/kubelet $KUBELET_ARGS

Никакого EnvironmentFile в юните нет, так что «дописать один флаг» некуда: клиенту остаётся либо править сам юнит, либо переопределять ExecStart через drop-in — и то и другое завязывается на внутреннее устройство MKS.

Достаточно одной строки, чтобы это исправить, — ровно так делает kubeadm:

EnvironmentFile=-/etc/default/kubelet

Дефис означает «нет файла — не страшно», так что на поведение по умолчанию это никак не влияет. Зато у клиента появляется законное место для своих флагов, и --config-dir=/etc/kubernetes/kubelet.conf.d каждый сможет включить себе сам, не трогая ничего, принадлежащего MKS.

Альтернатива, если менять юнит нежелательно, — включить --config-dir сразу на стороне MKS. Kubelet поддерживает его с 1.28: файлы *.conf из каталога мержатся поверх основного конфига в алфавитном порядке по стратегии replace. Пользовательские параметры лежат отдельным файлом, MKS свободно перегенерирует основной конфиг, конфликта нет.

Сделать это самостоятельно клиент может только переопределив ExecStart своим drop-in’ом. Аккуратнее всего — не дублируя список флагов:

# /etc/systemd/system/kubelet.service.d/20-config-dir.conf[Service]Environment="KUBELET_EXTRA_ARGS=--config-dir=/etc/kubernetes/kubelet.conf.d"ExecStart=ExecStart=/usr/bin/kubelet $KUBELET_ARGS $KUBELET_EXTRA_ARGS

Здесь KUBELET_ARGS остаётся за MKS и продолжает обновляться вместе с юнитом, а дублируется только строка запуска. Но зависимость от неё и остаётся: стоит MKS изменить путь к бинарю или добавить в ExecStart ещё одну переменную — нода поднимется без части флагов или не поднимется вовсе, причём молча. Мы этот вариант в бою не проверяли и в итоговой конфигурации не используем: тихая потеря graceful shutdown при перезаписи конфига — меньшее зло, чем сломанный запуск kubelet.

Проставить system-node-critical для csi-cinder-nodeplugin

В кластере под csi-cinder-nodeplugin идёт с priority=0 и без priorityClassName, тогда как calico-node и kube-proxy корректно помечены system-node-critical. Из-за этого CSI-плагин гасится в первой фазе вместе с приложениями, а не в критической.

Практических последствий при вытеснении не обнаружено. Как видно из прогона 4, kubelet при node shutdown вообще не выполняет NodeUnpublish/UnmountVolume, а том освобождает attach-detach controller; для ввода-вывода в уже смонтированную ФС плагин не нужен, поэтому под с томом спокойно дописывает данные и после его смерти.

Тем не менее upstream cinder-csi ставит своему nodeplugin system-node-critical, и приведение MKS к этому поведению убрало бы неочевидное расхождение.

Научить CCM распознавать SHELVED_OFFLOADED как выключенный инстанс

Самая заметная задержка при вытеснении достаётся stateful-нагрузке: том остаётся привязанным к выключенной ноде, и под простаивает минуты на Multi-Attach error.

В Kubernetes для этого есть штатный путь: cloud controller manager помечает выключенный инстанс taint’ом node.cloudprovider.kubernetes.io/shutdown, что позволяет отвязать тома, не дожидаясь шестиминутного таймаута. Но за весь прогон такой taint не появился — на ноде побывали только not-ready, unreachable, network-unavailable и DeletionCandidateOfClusterAutoscaler.

Причина в том, что cloud-provider-openstack считает инстанс выключенным по статусу SHUTOFF, а вытеснение прерываемой ноды даёт SHELVED_OFFLOADED. Для Selectel это не экзотика, а штатный механизм собственной фичи, поэтому поддержку shelve-состояний в CCM (или патч в апстрим cloud-provider-openstack) стоило бы добавить: это ускорило бы возврат stateful-нагрузки для всех пользователей прерываемых нод, без костылей на стороне клиента.

Клиентская альтернатива — свой контроллер, который вешает node.kubernetes.io/out-of-service=nodeshutdown:NoExecute после проверки статуса сервера через Nova API. Работает, но требует безошибочного определения состояния: taint, повешенный на живую ноду, приводит к повреждению данных, а не к простою.

Согласовать InhibitDelayMaxSec в образе ноды

unattended-upgrades в образе Ubuntu кладёт InhibitDelayMaxSec=30, и из-за правил сортировки drop-in’ов logind это значение перебивает то, что kubelet пытается выставить сам. В результате любой shutdownGracePeriod больше 30 секунд приводит к отказу запуска node shutdown manager — с сообщением, по которому неочевидно, что виноват сторонний пакет.

Разумно либо поднять это значение в образе до величины, согласованной с shutdownGracePeriod, либо задавать оба параметра вместе при включении graceful shutdown.

Включать graceful shutdown по умолчанию

Механика на инфраструктуре Selectel работает без нареканий и не имеет дедлайна со стороны гипервизора — тем досаднее, что для прерываемых групп нод она выключена. Разумные значения по умолчанию (хотя бы 30/10 секунд) избавили бы пользователей прерываемых нод от жёсткого убийства подов при каждом суточном вытеснении. Фича доступна с февраля 2025 года, так что речь уже не про молодую функциональность.

Заметка: свой termination handler в планах

На момент написания продакт-менеджер Selectel в Telegram-чате сообщества сообщил, что в планах — сделать свой аналог aws-node-termination-handler.

Тут стоит сказать, что правки выше закрывают задачу почти целиком и без него. Включённый по умолчанию graceful shutdown вместе с согласованным InhibitDelayMaxSec дал бы корректное завершение всем пользователям прерываемых нод сразу, без единой строчки user_data. --config-dir дал бы штатную точку, где это можно подстроить под себя. А единственная содержательная проблема, которую graceful не решает сам, — залипший detach RWO-томов — закрывается поддержкой SHELVED_OFFLOADED в CCM. Всё остальное в замерах отработало без нареканий, и именно поэтому список рекомендаций получился таким скромным: механика уже есть и уже работает, ей не хватает включённости и одной интеграции.

Полноценный хендлер добавил бы то, чего у текущей механики нет структурно: заблаговременное уведомление, а значит возможность увести нагрузку до выключения — настоящий evict через Eviction API, с соблюдением PDB и с переездом до завершения, а не параллельно с ним. Это заметно лучше, но и стоит дороже: одним демоном в кластере не обойтись, сначала нужен сам источник события, которого в апстримной Nova нет, — то есть работа на стороне облака, а не только в Kubernetes. Разрыв между «поды убивают жёстко» и «поды корректно доживают» закрывается не хендлером, а парой значений в конфиге kubelet.

Ссылки


P.S. На этом у меня всё, спасибо, что дочитали до конца 🙂 Если тема близка — заглядывайте в другие мои статьи на Хабре.

ссылка на оригинал статьи https://habr.com/ru/articles/1064830/