Тема 03

Kubernetes

Как работает кластер, в котором живёт твой сервис. Модель желаемого состояния, базовые объекты, ConfigMap и Secret. Что делает kubelet с подом: containerd и pause-контейнер, requests и limits в файлах cgroup, QoS, выселение и порядок остановки. Как выкатывается новая версия: ReplicaSet и ревизии, PDB и drain, HPA, Job и CronJob, Helm и Kustomize. И как запрос доходит до пода: правила kube-proxy, DNS и ndots:5, NetworkPolicy, права ServiceAccount и отладка через kubectl debug.

Что реально проверяют этой секцией

Держишь ли ты в голове цепочку целиком: Deployment создаёт ReplicaSet, тот — поды, Service находит их по лейблам через EndpointSlice, kube-proxy раскладывает правила. Кандидат, который говорит «под перезапустился сам», описывает. Кандидат, который называет, кто именно его перезапустил и почему readiness-проба не пускала трафик раньше времени, объясняет. На дежурство берут второго.

3.1Модель и базовые объекты

От Go-разработчика не ждут навыков администратора кластера. Ждут другого: что ты знаешь, куда попадает твой образ, кто и когда убивает твой под, почему при деплое теряются запросы и что именно надо написать в манифесте, чтобы этого не было. Всё это и есть контракт между приложением и платформой.

Зачем k8s и модель желаемого состояния

До кластеров деплой был императивным: «зайди на четыре сервера, останови сервис, положи бинарь, запусти, проверь». Каждый шаг мог упасть посередине, и состояние системы становилось неизвестным. Kubernetes переворачивает подход: ты описываешь желаемое состояние («хочу 6 реплик образа app:1.4.2 с такими лимитами»), а кластер сам приводит к нему реальность и дальше удерживает её там вечно.

Технически всё держится на одном паттерне, цикле согласования (reconciliation loop). API-сервер с базой в etcd хранит объекты. Контроллер подписывается на изменения нужного типа объектов (watch), в бесконечном цикле сравнивает spec (чего хотят) с status (что есть) и делает шаг в сторону сближения. Никто не отдаёт команды напрямую: Deployment-контроллер создаёт ReplicaSet, ReplicaSet-контроллер создаёт Pod-объекты, scheduler проставляет им nodeName, kubelet на этой ноде видит «мой под» и просит container runtime запустить контейнеры. Каждый занят своим маленьким инвариантом.

Формулировка, которую хочет услышать интервьюер

«Kubernetes не столько запускает контейнеры, сколько поддерживает состояние. Отсюда все свойства: самовосстановление (упал под — контроллер заметил расхождение и создал новый), декларативность (манифест в git = источник правды, а GitOps просто автоматизирует apply), идемпотентность (kubectl apply дважды не ломает ничего), и eventual consistency, то есть изменения применяются не мгновенно, а через какое-то время согласования. Последнее важнее всего на практике: половина проблем с трафиком при деплое идёт от гонок между контроллерами, которые сходятся с разной скоростью.»

Базовые объекты: от Pod до Ingress

ОбъектЧто этоЧто важно помнить
Podминимальная единица планирования: один или несколько контейнеров с общим сетевым namespace, IP и томамиэфемерен, IP меняется при пересоздании; напрямую поды не создают
ReplicaSetдержит заданное число одинаковых подовруками не трогают — им управляет Deployment; на каждую версию свой RS
Deploymentуправляет ReplicaSet-ами и выкаткой между нимихранит историю ревизий → rollout undo
StatefulSetто же для stateful: стабильные имена app-0…app-N, свой PVC на реплику, упорядоченный запускдля БД, Kafka, всего, где реплики не взаимозаменяемы
DaemonSetпо одному поду на каждую нодуагенты логов, метрик, CNI
Job / CronJobразовая и периодическая задачамиграции БД — обычно Job; следи за backoffLimit и конкурентностью
Serviceстабильный виртуальный адрес и балансировка на поды по label-селекторуэто не процесс, а правила в ядре каждой ноды
Ingressправила L7-маршрутизации (host/path → Service) для контроллерасам по себе ничего не делает: нужен Ingress Controller; API заморожен, новое развивают в Gateway API

Три типа Service

  • ClusterIP (по умолчанию) даёт виртуальный IP, доступный только внутри кластера. DNS-имя app.default.svc.cluster.local. Основной рабочий вариант: сервис-к-сервису ходят именно так.
  • NodePort добавляет к этому один и тот же порт из диапазона 30000–32767, открытый на каждой ноде кластера. Прийти можно на любую ноду, дальше DNAT уведёт на под, возможно на другой ноде. В проде сам по себе почти не нужен: это строительный блок для внешнего балансировщика.
  • LoadBalancer добавляет к NodePort запрос к облаку на внешний балансировщик, который раскидывает трафик по нодам. Неприятный нюанс тут один: каждый такой Service оборачивается отдельным платным LB в облаке, поэтому наружу обычно торчит один LoadBalancer перед Ingress Controller, а все приложения живут за ним на ClusterIP.
  • Ещё есть headless (clusterIP: None): DNS отдаёт не один VIP, а список IP всех готовых подов. Нужен, когда клиент балансирует сам: gRPC-клиент, драйвер БД, StatefulSet с адресацией конкретной реплики.
Путь одного HTTP-запроса: снаружи внутрь Клиент api.site.ru Cloud LB Service типа LoadBalancer Ingress Controller (под с nginx) Service 10.96.0.14:80 процесса нет kube-proxy: iptables / nftables DNAT на под 10.1.5.7:8080 контейнер твой Go-бинарь Правила DNAT берутся не из воздуха — их пишет kube-proxy по EndpointSlice: EndpointSlice сервиса app 10.1.5.7:8080 ready 10.1.2.3:8080 ready 10.1.9.1:8080 notReady EndpointSlice-контроллер следит за подами с нужными labels и их readiness readiness=false — адрес помечают notReady, и только потом трафик перестаёт идти Ключевое: Service — это не прокси и не процесс, а набор правил в ядре каждой ноды. Балансировка происходит на исходящей ноде и на первый пакет соединения: в iptables и nftables выбор случайный. Дальше соединение живёт на одном поде.
Ingress → Service → EndpointSlice → Pod. Данные идут по верхней цепочке, но управляет ей нижняя: пока EndpointSlice не обновился, трафик продолжает лететь в под, который уже уходит.
Два уточнения, которые отличают «читал доку» от «работал»
  • Ingress-контроллеры чаще всего минуют ClusterIP. ingress-nginx по умолчанию читает EndpointSlice сам и балансирует прямо на IP подов, потому что iptables-балансировка случайная и не умеет ни sticky-сессий по cookie, ни ретраев, ни L7-политик. Через Service он ходит, только если включить service-upstream. Сам проект в марте 2026 закрыт: обновлений, в том числе по безопасности, больше не будет, и Kubernetes советует переходить на Gateway API или другой контроллер.
  • gRPC и keep-alive ломают балансировку Service. DNAT работает на уровне соединения, а gRPC держит одно долгоживущее HTTP/2-соединение и льёт в него все запросы. Итог: один под получает весь трафик клиента. Лечится headless-сервисом плюс client-side round-robin в gRPC-клиенте, либо service mesh, либо периодическим MAX_CONNECTION_AGE на сервере.

ConfigMap и Secret: env против volume

ConfigMap и Secret устроены одинаково: наборы пар ключ-значение в etcd. Главных отличий три: значения Secret в API передаются в base64 (в etcd лежат сырые байты), к нему обычно применяют отдельные RBAC-правила, а смонтированный томом он лежит у kubelet в tmpfs, а не на диске. Base64 не шифрует, а кодирует; kubectl get secret -o yaml плюс base64 -d отдают пароль в открытом виде любому, у кого есть права на чтение секретов в неймспейсе.

Подключить и то и другое можно двумя способами, и разница между ними принципиальна.

apiVersion: apps/v1
kind: Deployment
metadata: { name: app }
spec:
  selector: { matchLabels: { app: app } }
  template:
    metadata: { labels: { app: app } }
    spec:
      containers:
        - name: app
          image: registry.local/app:1.4.2
          env:
            # 1) отдельный ключ из ConfigMap
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef: { name: app-config, key: log_level }
            # 2) секрет как переменная окружения
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef: { name: app-secrets, key: db_password }
            # 3) поля самого пода через Downward API
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: POD_NAMESPACE
              valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
          envFrom:
            - configMapRef: { name: app-config }   # все ключи разом
          volumeMounts:
            - name: config
              mountPath: /etc/app        # файлы, обновляются на лету
              readOnly: true
      volumes:
        - name: config
          configMap: { name: app-config }
env / envFromvolume (файлы)
Когда читаетсяодин раз, при execveв любой момент, как обычный файл
Обновление ConfigMapсам не подхватится — нужен перезапуск контейнера или новый подфайл обновится сам, но с задержкой до ~1–2 минут (kubelet sync period + задержка его кэша)
Утечкивидно в crictl inspect на ноде, в /proc/PID/environ, попадает в дочерние процессы и часто в креш-дампывиден только тому, кто читает файл; можно ограничить права
Форматтолько плоские строкилюбой файл: YAML, PEM-сертификат, keytab
Размерлимит на суммарное окружениедо 1 МиБ на объект (проверка API-сервера)
Итоггодится для неизменяемых настроек и для 12-factor-стиляобязателен для секретов, сертификатов и всего, что хочется менять без рестарта
«Мы поменяли ConfigMap, а сервис работает по-старому»

Классика. Если конфиг прокинут через env, правка ConfigMap вообще ничего не меняет: процесс прочитал окружение при старте, а перезапускать его кластеру незачем, spec Deployment-а ведь не трогали. Есть два честных решения: (1) считать хеш ConfigMap и класть его в аннотацию pod-шаблона (checksum/config: {{ ... | sha256sum }}, так принято в Helm-чартах), тогда изменение конфига меняет шаблон и запускает нормальный rolling update; (2) монтировать конфиг файлом и перечитывать его в приложении по fsnotify или по SIGHUP. Второй путь красив, но требует, чтобы весь код умел безопасно переключаться на новые значения на лету. Это не бесплатно.

Почему Secret «по умолчанию не защищён» и что с этим делают
  • В etcd секреты лежат как есть, пока не включён EncryptionConfiguration (encryption at rest) на API-сервере. Бэкап etcd забирает с собой все пароли.
  • Права на get secrets в неймспейсе = права на все пароли этого неймспейса. Право создавать поды даёт то же самое: под смонтирует любой секрет своего неймспейса. RBAC надо резать по-настоящему, а не «разработчикам admin на dev».
  • Секрет, смонтированный томом, обновит файл сам, и это плюс к ротации.
  • Промышленные варианты: External Secrets Operator или CSI-драйвер, тянущие значения из Vault / облачного менеджера секретов; Sealed Secrets или SOPS, чтобы зашифрованный секрет можно было хранить в git; в облаках привязывают идентичность пода к IAM-роли (Workload Identity, IRSA), чтобы длинных статических ключей не существовало вовсе.

Вопросы

8
Суть: Kubernetes нужен не для того, чтобы «запускать контейнеры» — это умеет и systemd. Он нужен, чтобы непрерывно приводить реальность к описанному состоянию: держать N живых реплик, знать их адреса, заменять их по одной при выкатке и делить между ними ресурсы ноды. Ежедневный набор объектов узкий: Deployment (который делает ReplicaSet, который делает Pod), Service, Ingress, ConfigMap/Secret. Остальное — по мере надобности.

Что реально даёт кластер

  • Самовосстановление. Процесс упал, kubelet перезапустил контейнер. Нода умерла, контроллер увидел, что подов меньше, чем в spec.replicas, и создал новые на других нодах. Никто не звонит дежурному.
  • Service discovery без внешнего реестра. Адрес зависимости живёт в DNS: billing.prod.svc.cluster.local вместо списка IP в конфиге, который протухает при каждом перезапуске.
  • Управляемая выкатка. Rolling update с контролем скорости и автоматической остановкой, если новые поды не становятся Ready. Откат делается одной командой, потому что старый ReplicaSet никуда не делся.
  • Bin-packing и изоляция ресурсов. Планировщик раскладывает поды по нодам, глядя на requests, а cgroups не дают одному сервису съесть ноду.
  • Горизонтальное масштабирование по метрике делает HPA, а ноды добавляет Cluster Autoscaler.
  • Единый декларативный интерфейс. Вся конфигурация инфраструктуры живёт в git как YAML и применяется идемпотентно. На этом и стоит GitOps.

Объекты снизу вверх

  • Pod планируется как единое целое: один или несколько контейнеров, которые делят network namespace (общий localhost и один IP) и могут делить тома. Под смертен и почти неизменяем: на месте меняются только образ контейнера и (с 1.35) ресурсы, остальное не «обновить», можно только удалить и создать новый под, уже с другим IP. Всё, что ты пишешь про «под перезапустился», почти всегда означает «перезапустился контейнер внутри того же пода» (restartCount растёт, IP не меняется).
  • ReplicaSet держит один инвариант: «подов с такими labels должно быть ровно N». Руками его почти никогда не создают.
  • Deployment управляет ReplicaSet-ами и задаёт стратегию обновления: на каждую версию pod-шаблона создаётся свой ReplicaSet, а деплой перекачивает реплики из старого в новый. Старые RS остаются (revisionHistoryLimit), поэтому rollout undo просто возвращает старый шаблон, а реплики перекачиваются обратно тем же rolling update.
  • Service даёт стабильное имя и виртуальный IP меняющемуся множеству подов. Живёт над labels, а не над подами напрямую.
  • Ingress описывает L7-маршрутизацию снаружи внутрь: хост, путь, TLS. Сам по себе это просто объект-описание; работает он, только если в кластере есть ingress-контроллер (Traefik, HAProxy, NGINX Ingress Controller от F5), который читает эти объекты и перенастраивает себя. API Ingress заморожен: Kubernetes рекомендует Gateway API.
  • ConfigMap / Secret держат конфигурацию и чувствительные данные отдельно от образа.
  • Рядом: StatefulSet (стабильные имена и своё хранилище на реплику, нужен для БД, Kafka, всего, где реплики не взаимозаменяемы), DaemonSet (по одному поду на каждую ноду, так живут агенты логов и метрик), Job/CronJob (разовые и периодические задачи), PVC (заявка на диск).

Типы Service — что за чем стоит

ТипЧто делаетКогда используют
ClusterIPвиртуальный IP, доступный только внутри кластера; правила DNAT в ядре нодпо умолчанию — межсервисное общение
NodePortто же плюс открытый порт 30000–32767 на каждой нодеотладка, dev-кластер, вход для внешнего балансировщика
LoadBalancerNodePort плюс заказ облачного L4-балансировщика с внешним IPпубличный вход; в облаке каждый такой Service — это отдельный платный LB
ExternalNameпросто CNAME в DNS кластераобёртка над внешним адресом, чтобы код не знал, внутри сервис или снаружи
headless (clusterIP: None)никакого VIP; DNS отдаёт A-записи всех готовых подовStatefulSet, gRPC с клиентской балансировкой

Именно поэтому наружу обычно торчит один LoadBalancer, за ним ingress-контроллер, а дальше уже десятки ClusterIP-сервисов: платить за LB на каждый микросервис никто не хочет, да и TLS с маршрутизацией по хостам всё равно нужны в одном месте.

Чем добить

«Все объекты связаны через labels, а не через ссылки. Service не знает про Deployment, он знает про селектор. Отсюда и хорошее (canary делается вторым Deployment с теми же labels), и плохое: опечатка в селекторе даёт Service без эндпоинтов, который молча отвечает connection refused, и в манифестах ничего не подсвечено красным. При “сервис не отвечает” я первым делом смотрю kubectl get endpointslices -l kubernetes.io/service-name=app: если список пуст, проблема не в сети, а в labels или в readiness.»

Честный ответ на «а когда k8s не нужен»

Когда сервисов два, нагрузка ровная, а команда одна. Кластер сам становится отдельной системой со своей эксплуатацией: обновления control plane, CNI, RBAC, ingress, мониторинг самого кластера. Для одного бинаря systemd + nginx честнее и дешевле. k8s начинает окупаться, когда сервисов десятки, команд несколько и нужен единый способ выкатывать и ограничивать их всех.

Суть: технически ConfigMap и Secret — одно и то же (пары ключ-значение в etcd), отличаются base64 в API, отдельными RBAC-правилами и tmpfs на ноде; Secret не зашифрован сам по себе. Выбор способа монтирования важнее выбора объекта: env читается один раз при старте и в работающем процессе не обновится, volume обновляется на лету, но с задержкой, и не светится в /proc/PID/environ.

Разница между объектами

Secret отличается от ConfigMap тремя вещами, и ни одна из них не даёт шифрования: в API значения передаются в base64 (это кодирование, а не защита), к секретам обычно применяют отдельные RBAC-правила, и kubelet держит их в tmpfs, а не на диске ноды. В etcd секрет лежит открытым текстом, пока на API-сервере не включён EncryptionConfiguration. Практический вывод: кто унёс бэкап etcd, унёс все пароли, а право get secrets в неймспейсе равно знанию всех паролей этого неймспейса.

env против volume — разница принципиальная

  • env/envFrom. Значения подставляются в момент execve и после этого заморожены навсегда. Правка ConfigMap проходит мимо: spec Deployment-а не поменялся, значит поды пересоздавать незачем. Плюс значения видны в crictl inspect на ноде, в /proc/PID/environ, наследуется всеми дочерними процессами и регулярно попадает в креш-дампы и в трассировки паник.
  • Volume. kubelet проецирует ключи как файлы через симлинки и обновляет их при изменении объекта, но не мгновенно, а с задержкой до пары минут (период синхронизации kubelet плюс задержка его кэша). Обновление атомарное: подменяется симлинк на новый каталог, так что частично записанного файла приложение не увидит. Секрет файлом можно ротировать без рестарта, и это главный аргумент.

Есть ещё immutable: true: помеченный так ConfigMap или Secret нельзя изменить (только удалить и создать заново), зато kubelet закрывает на него watch, и на больших кластерах это заметно снимает нагрузку с API-сервера.

Как правильно перезапускать поды на смену конфига

# Helm-идиома: хеш конфига в аннотации pod-шаблона.
# Меняется ConfigMap -> меняется аннотация -> меняется шаблон -> честный rolling update.
spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
# Без Helm то же самое руками:
kubectl rollout restart deployment/app      # проставит kubectl.kubernetes.io/restartedAt

# Посмотреть, что реально лежит в секрете:
kubectl get secret app-secrets -o jsonpath='{.data.db_password}' | base64 -d
На чём ловят
  • «Secret и так безопасен». Нет. Безопасность даёт encryption at rest плюс жёсткий RBAC плюс отсутствие длинных статических ключей вообще (External Secrets Operator, Vault, IRSA/Workload Identity).
  • Секреты в git. Даже в приватном репозитории plaintext лежать не должен. Ответ уровня мидла: SOPS или Sealed Secrets, чтобы в git лежал шифротекст, а расшифровывали уже в кластере.
  • «Смонтировали файлом, значит обновится сразу». Не сразу, и только если приложение перечитывает файл (fsnotify, SIGHUP, таймер). Иначе разницы с env нет никакой, а иллюзия есть.
  • subPath ломает обновление. Файл, смонтированный через subPath, в работающем контейнере не обновляется: kubelet подменяет каталог целиком, а тут привязан конкретный inode. Новое содержимое контейнер увидит только после перезапуска.
Суть: три пробы отвечают на три разных вопроса. Startup — «процесс ещё поднимается, не трогайте его». Readiness — «можно ли слать трафик прямо сейчас» (провал = адрес пода в EndpointSlice помечают неготовым, процесс жив). Liveness — «процесс безнадёжно завис» (провал = kubelet убивает контейнер). Правило: liveness должна проверять только сам процесс, readiness — готовность обслуживать. Смешение этих двух ролей — главный способ уронить сервис пробами.
Таймлайн одного контейнера startup readiness liveness опрашивается, двум другим — стоп не запускается каждые periodSeconds: «слать ли трафик?» не запускается каждые periodSeconds: «процесс жив?» t = 0 старт startup: успех пробы включаются readiness: fail адрес помечен notReady в EndpointSlice, процесс НЕ трогают, вернётся — вернут liveness: fail × failureThreshold SIGTERM, затем SIGKILL контейнеру, restartCount += 1 Пока идёт startup, ни readiness, ни liveness не выполняются — именно поэтому медленный старт больше не убивает контейнер.
Три пробы — три исхода. Провал readiness обратим и стоит трафика; провал liveness необратим и стоит перезапуска. Startup нужен ровно для того, чтобы долгий старт не выглядел как зависание.

Что проверять в каждой

startupreadinessliveness
Вопрос«поднялся?»«готов принимать трафик?»«не завис ли навсегда?»
Провалрестарт контейнера (после исчерпания порога)адрес помечают неготовым в EndpointSliceрестарт контейнера
Проверятьтот же эндпоинт, что readiness, но с большим бюджетом временипрогрет ли кэш, есть ли соединение с БД и брокером, не идёт ли graceful shutdownтолько «HTTP-хендлер отвечает» — плоский 200 OK
Внешние зависимостиможноможно и нужно — но осторожнокатегорически нельзя

Как пробы роняют сервис — четыре сценария

  1. БД в liveness. База моргнула на 30 секунд. Liveness падает у всех подов сразу, kubelet перезапускает их все, поды стартуют, снова не видят базу, снова умирают, и весь сервис уходит в CrashLoopBackOff. Проблема была на полминуты, а недоступность растянулась на десять минут. Правильно проверять БД в readiness. Тогда поды живы, соединения переустановятся сами, и как только база вернётся, трафик пойдёт без единого рестарта.
  2. Слишком короткий liveness при медленном старте. Приложение поднимается 40 секунд (миграции, прогрев кэша), initialDelaySeconds: 10, порог 3, период 5. Контейнер убивают на 20–25-й секунде, и он никогда не стартует. Раньше это лечили большим initialDelaySeconds, но тогда зависший под тоже не перезапускался бы полминуты. Правильно ставить startupProbe: бюджет failureThreshold × periodSeconds задаётся щедро (скажем, 30 × 5 = 150 с), а после успеха liveness работает с жёсткими нормальными таймингами.
  3. Тяжёлая проба. Readiness, которая на каждый вызов делает SELECT с диска, под нагрузкой начинает отваливаться по таймауту. Поды по очереди выпадают из балансировки, нагрузка перераспределяется на оставшихся, те тоже перестают успевать. Каскад. Проба должна быть дешёвой: читать заранее посчитанный флаг, а не ходить в базу синхронно.
  4. Общий пул горутин/потоков. Если хендлер пробы обслуживается тем же сервером, что забит запросами, проба падает не потому, что сервис мёртв, а потому что он занят. Отсюда практика: отдельный порт и отдельный http.Server для /healthz и /readyz, не проходящий через общий middleware, rate limit и очередь.
startupProbe:                 # бюджет на старт: 30 × 5 = 150 секунд
  httpGet: { path: /readyz, port: 8081 }
  periodSeconds: 5
  failureThreshold: 30
readinessProbe:               # быстро выводим из ротации и быстро возвращаем
  httpGet: { path: /readyz, port: 8081 }
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 2
  successThreshold: 1
livenessProbe:                # долго терпим и только потом убиваем
  httpGet: { path: /healthz, port: 8081 }
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 6
Детали, которые выдают практику
  • Пробы выполняет kubelet ноды, а не API-сервер и не Service. Поэтому проба ходит на IP пода напрямую и не зависит от Ingress и Service. NetworkPolicy её тоже не режет: по спецификации трафик с собственной ноды пода разрешён всегда.
  • Liveness не чинит дедлок в одной горутине, если HTTP-сервер продолжает отвечать. Хочешь ловить зависания, проверяй в /healthz «сердцебиение» фоновых воркеров: таймстемп последнего успешного цикла, обновляемый атомарно. Условие «последний тик был больше 5 минут назад» даёт законный повод упасть.
  • readiness при graceful shutdown: 503 не обязателен, но помогает. По SIGTERM можно первым делом перевести /readyz в 503 и выждать несколько секунд, прежде чем закрывать сервер. Из EndpointSlice удаляемый под выводят и без пробы (условие ready сбрасывается сразу), но kube-proxy и ingress-контроллер узнают об этом с задержкой, а внешний балансировщик, который сам опрашивает поды, — только по пробе.
  • exec-пробы дороги. Каждый вызов запускает в контейнере отдельный процесс. На сотнях подов с периодом 1 секунда это заметная нагрузка на ноду; HTTP или gRPC-проба (grpc в spec с 1.24) дешевле.
  • Порядок SIGTERM/SIGKILL при провале liveness: контейнеру шлётся SIGTERM, даётся terminationGracePeriodSeconds (можно переопределить прямо в пробе полем terminationGracePeriodSeconds), потом SIGKILL.
Суть: requests — это то, что видит планировщик (сколько зарезервировать на ноде), limits — то, что видит ядро (cgroup-ограничение). CPU — ресурс сжимаемый: превышение лимита даёт троттлинг. Память — несжимаемый: превышение даёт SIGKILL от cgroup-OOM-killer. Соотношение requests и limits определяет QoS-класс (quality of service, «класс обслуживания» — ярлык, который Kubernetes вешает на под; от него зависят cgroup пода и то, кого ядро убьёт первым, когда на ноде кончится память), а по нему можно прикинуть, кого kubelet выселит с ноды первым.

Механика

  • requests используются прежде всего при планировании: scheduler ищет ноду, где сумма requests всех подов плюс наш под влезает в allocatable. После размещения requests продолжают работать для CPU как вес: requests.cpu: 500m превращается в cpu.weight в cgroup v2, то есть в долю при конкуренции. Память requests не ограничивают вообще, это чисто учётная величина (по ней же считаются OOM-скоринг и порядок вытеснения).
  • limits.cpu задаёт жёсткую квоту CFS: cpu.max, то есть «не больше X микросекунд процессорного времени за период 100 мс». Исчерпал квоту, и тебя останавливают до конца периода. Это троттлинг: процесс не падает, он просто стоит, и это видно как рост latency при низкой средней утилизации CPU.
  • limits.memory превращается в memory.max. Превысил, и ядро вызывает OOM-killer внутри cgroup. На cgroup v2 kubelet (с 1.28) по умолчанию включает групповой OOM-kill, так что гибнут все процессы контейнера разом, а не один самый прожорливый. Никакого предупреждения, никакого шанса «освободить память»: SIGKILL, exit code 137.

QoS-классы: кого выселят первым

КлассУсловиеЧто это значит
Guaranteedдля каждого контейнера limits == requests по CPU и памятивыселяют последним; при статической CPU-политике kubelet и целом числе CPU получает эксклюзивные ядра
Burstablerequests заданы, но не равны limits (или заданы не для всех ресурсов)обычный случай; при нехватке памяти на ноде kubelet смотрит на приоритет и на то, насколько под превысил свои requests
BestEffortни requests, ни limits не заданывыселяют первым; на проде так жить нельзя

С QoS связаны две разные вещи, которые часто путают. Eviction устраивает kubelet, когда на ноде кончается память: выселяет поды по превышению requests и приоритету. При жёстком пороге (по умолчанию настроены только такие) под убивают почти без grace period, при мягком дают урезанный. OOMKill устраивает ядро, когда контейнер упёрся в собственный limits.memory: мгновенно и без разговоров. Первое видно как Evicted в статусе пода, второе как OOMKilled в lastState контейнера.

Диагностика OOMKilled

kubectl describe pod app-7d9f8b6c5d-xk2wq | sed -n '/Last State/,/Restart Count/p'
# так выглядит блок в kubectl 1.37 (время условное):
#   Last State:     Terminated
#     Reason:       OOMKilled
#     Exit Code:    137          # 128 + 9 (SIGKILL)
#     Started:      Thu, 17 Sep 2026 10:41:02 +0300
#     Finished:     Thu, 17 Sep 2026 10:44:19 +0300
#   Ready:          True
#   Restart Count:  4

kubectl get pod app-7d9f8b6c5d-xk2wq -o jsonpath='{.status.containerStatuses[0].lastState}'
kubectl logs app-7d9f8b6c5d-xk2wq --previous  # логи убитого контейнера, а не нового
kubectl top pod -l app=billing --containers   # текущее потребление (нужен metrics-server)

# На ноде это подтвердит ядро:
dmesg -T | grep -i -E 'oom|killed process'

Дальше вопрос сводится к «это утечка или просто мало памяти». Различить их помогает форма графика RSS: ровная пила с растущим основанием выдаёт утечку; ступенька под нагрузкой говорит, что лимит просто мал; одиночный всплеск значит, что какой-то запрос тянет в память гигантский ответ или файл целиком. Для Go дальше идут pprof (/debug/pprof/heap, сравнение двух снимков через go tool pprof -base) и метрики рантайма: go_memstats_heap_inuse_bytes, go_memstats_heap_idle_bytes, go_goroutines. Если число горутин растёт, почти наверняка течёт и память.

Специфика Go, которую ждут в этом вопросе

По CPU это уже история — но её надо знать. До Go 1.25 рантайм не видел cgroup-лимиты: GOMAXPROCS брался из числа ядер ноды, и на 64-ядерной машине с лимитом 500m рантайм создавал 64 P, параллелил GC на 64 потока и мгновенно выбирал квоту CFS. Отсюда жестокий троттлинг и рост p99 на ровном месте. Лечилось это automaxprocs или явным GOMAXPROCS из Downward API. Начиная с Go 1.25 рантайм читает cpu.max своей cgroup сам (если в go.mod указана версия 1.25 или новее), дробный лимит округляет вверх, но не ниже 2, и пересматривает значение на лету, если лимит поменяли. Внешняя библиотека и переменная из Downward API больше не нужны и даже мешают: библиотека при заданном лимите вызывает runtime.GOMAXPROCS(n), переменная задаёт значение вручную, а ручная установка выключает автоматику рантайма (вернуть её можно вызовом runtime.SetDefaultGOMAXPROCS()). А вот по памяти автоматики нет до сих пор, и это остаётся на тебе: GC ориентируется на GOGC (рост кучи вдвое), а про limits.memory не знает, поэтому куча спокойно перерастёт лимит, и вместо сборки мусора ты получишь OOMKill. Лечится GOMEMLIMIT, выставленным примерно в 80–90 % от лимита пода: при приближении к нему GC начинает работать чаще и не даёт куче вырасти. Предел этот мягкий, от настоящей утечки он не спасёт, зато спасёт от «просто пиковой нагрузки».

Практические правила
  • limits.memory ставить всегда, иначе один под с утечкой выселит с ноды соседей. requests.memory держать близко к реальному рабочему потреблению, а лимит с запасом на пик.
  • С limits.cpu спорно. Многие команды сознательно его не ставят (оставляют только requests), потому что CFS-троттлинг бьёт по хвостовым перцентилям даже при средней утилизации 30 %: квота выбирается всплеском в начале периода, а остаток 100-миллисекундного окна процесс просто стоит. Как компромисс, лимит ставят не впритык и всегда смотрят метрику container_cpu_cfs_throttled_seconds_total.
  • Разрыв между requests и limits означает овербукинг ноды. Он полезен (плотность выше), но при синхронном пике все поды разом захотят свой burst, и начнётся eviction. Чем важнее сервис, тем ближе requests к limits.
  • Exit code 137 не всегда OOM. Это просто «убит SIGKILL»: так же выглядит превышение terminationGracePeriodSeconds. Отличать по полю Reason, а не по коду.
Суть: HPA — контроллер, который раз в 15 секунд считает желаемые реплики = ceil(текущие × текущая метрика / целевая метрика) и правит spec.replicas. Хорошая метрика для масштабирования — та, что линейно растёт с нагрузкой и линейно падает от добавления реплики. CPU этому условию удовлетворяет только у сервисов, которые действительно считают; у типичного I/O-bound Go-сервиса лучше работают RPS и длина очереди.

Как считает

Формула такая: desired = ceil(current × (metric / target)). Если 4 пода при целевых 60 % CPU показывают 90 %, HPA хочет ceil(4 × 90/60) = 6. Есть зона нечувствительности (по умолчанию 10 %), чтобы не дёргаться на шуме, и есть behavior, политики скорости: вверх обычно разрешают быстро, вниз медленно (stabilizationWindowSeconds: 300 на scale down по умолчанию), потому что за лишнюю реплику платишь деньгами, а за преждевременное снятие отказом.

Источники метрик: metrics-server (CPU/память подов, тип Resource), custom.metrics и external.metrics через адаптер (обычно prometheus-adapter или KEDA) — оттуда берутся RPS, длина очереди Kafka, глубина RabbitMQ, что угодно.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 4
  maxReplicas: 40
  metrics:
    - type: Resource                       # база: не даём упереться в CPU
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }
    - type: Pods                           # основное: запросов в секунду на под
      pods:
        metric: { name: http_requests_per_second }
        target: { type: AverageValue, averageValue: "120" }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]   # можно удваиваться
    scaleDown:
      stabilizationWindowSeconds: 300
      policies: [{ type: Percent, value: 10, periodSeconds: 60 }]    # снимаем по чуть-чуть

Когда метрик несколько, HPA считает желаемое число по каждой и берёт максимум — то есть метрики работают как «или», а не как «и». Это удобно: CPU остаётся страховкой, когда бизнес-метрика ведёт себя нетипично.

Почему CPU часто врёт

  • I/O-bound сервис не грузит CPU. Go-сервис, который ходит в базу и в три соседних API, при полном насыщении по latency показывает 25 % CPU: горутины стоят в ожидании сети. HPA по CPU не сработает. Сервис уже деградировал, а автоскейлер спокоен.
  • Utilization считается от requests, а не от лимита и не от ядра ноды. Занизили requests, и «утилизация» показывает 300 %, HPA раздувает деплой на пустом месте. Завысили — HPA сработает поздно или не сработает вовсе. Метрика зависит от цифры, которую человек написал в манифесте.
  • Троттлинг маскирует нагрузку. Под упёрся в квоту CFS: latency растёт, а cpu utilization держится ровно на лимите и выше не идёт. Сигнал есть в container_cpu_cfs_throttled_seconds_total, но HPA его не видит.
  • Старт нового пода стоит CPU. Прогрев, JIT-подобные эффекты, установка пула соединений — новый под первые секунды жрёт больше, чем работающий. Пока под не стал Ready, HPA его CPU не учитывает (первые 5 минут после старта), но прогрев после readiness попадает в среднее и подталкивает HPA к следующему шагу. Отсюда «эффект качелей», если behavior не настроен.
  • Средняя по подам скрывает перекос. Один под с «горячим» ключом нагружен на 100 %, три остальных на 10 %, в среднем выходит 32,5 %, HPA доволен, пользователи нет.
Что выбирать вместо/вместе с CPU
  • RPS на под остаётся самой честной метрикой для stateless HTTP/gRPC. Заранее меряется нагрузочным тестом: «один под держит 120 rps при p99 < 200 мс», это и есть target.
  • Количество активных запросов / насыщенность пула сводится к закону Литтла: конкурентность = RPS × latency. Отлично ловит «зависимость затормозила», чего CPU не увидит.
  • Лаг консьюмера для воркеров: kafka_consumergroup_lag, длина очереди в RabbitMQ/Redis. Здесь HPA по CPU почти всегда бессмысленен, а KEDA умеет ещё и scale to zero.
  • Память для HPA почти не годится: она у сервисов с пулами и кэшами не падает от добавления реплики. Масштабируешь по памяти, значит на деле нужен вертикальный скейл или починка утечки.
На чём ловят

Первое: «HPA спасёт от нагрузки» — не спасёт, если узкое место снаружи пода. Десять новых реплик, каждая со своим пулом на 20 соединений, просто добьют базу, у которой max_connections 200. Второе: HPA бесполезен, если в кластере нет свободных нод — нужен ещё и Cluster Autoscaler, а он поднимает ноду не мгновенно, а за минуты. Третье: HPA и replicas в git-манифесте дерутся друг с другом, так что при GitOps поле replicas из Deployment надо убрать (или добавить в игнор), иначе каждый sync будет откатывать масштабирование. И четвёртое: масштабирование не заменяет лимитов на входе — рядом всегда должен стоять rate limit, иначе автоскейлер будет честно масштабировать тебя под DDoS.

Суть: Deployment заменяет поды партиями, ограничивая «сколько можно создать сверх нормы» (maxSurge) и «сколько можно недосчитаться» (maxUnavailable). Запросы теряются не из-за этого, а из-за гонки: kubelet шлёт SIGTERM поду одновременно с тем, как контроллер EndpointSlice помечает его адрес неготовым, а kube-proxy и балансировщики узнают об этом с задержкой. Эти два процесса независимы, и второй почти всегда медленнее. Лечится связкой preStop-задержка + graceful shutdown в коде.

Параметры и что они означают на практике

spec:
  replicas: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%          # можно временно иметь до 13 подов
      maxUnavailable: 0      # но ни одного меньше 10 доступных
  minReadySeconds: 10        # под считается доступным, только продержавшись Ready 10 с
  progressDeadlineSeconds: 600
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          lifecycle:
            preStop:
              sleep: { seconds: 10 }   # дать время эндпоинтам разойтись
  • maxUnavailable: 0 + maxSurge: 25% — безопасный выбор для сервисов, которые нельзя просаживать (по умолчанию оба параметра 25 %, процент maxSurge округляется вверх, maxUnavailable — вниз): сначала поднимаем новые, и только когда они Ready, гасим старые. Взамен нужен запас ресурсов на ноде.
  • maxSurge: 0 + maxUnavailable: 1 берут, когда ресурсов в обрез или под держит эксклюзивный ресурс (лицензия, GPU, единственный PVC ReadWriteOnce). Правда, во время выкатки ёмкость меньше номинала.
  • minReadySeconds недооценивают зря. Без него под, который стал Ready и через секунду упал, считается успешным, и выкатка едет дальше, убивая старые. С ним поломанный релиз останавливается сам.
  • progressDeadlineSeconds задаёт, через сколько выкатка признаётся провалившейся (Progressing=False). Она не откатывается автоматически, просто останавливается; откатывать будешь ты сам или шаг CI.

Гонка, из-за которой теряются запросы

Удаление пода — это всего лишь deletionTimestamp и grace period в etcd. Дальше на него независимо реагируют две цепочки:

  1. Быстрая: kubelet видит удаление → вызывает preStop → шлёт SIGTERM процессу → ждёт terminationGracePeriodSecondsSIGKILL.
  2. Медленная: EndpointSlice-контроллер помечает адрес в EndpointSlice как не готовый (terminating) → это событие разъезжается по всем нодам → kube-proxy на каждой ноде переписывает правила iptables/nftables → и параллельно ingress-контроллер обновляет свой апстрим, а облачный LB правит свой target group (это может занимать десятки секунд).

Вторая цепочка почти всегда финиширует позже первой. Значит, есть окно, в котором процесс уже начал закрываться, а трафик на него всё ещё направляют: клиент получает connection refused или обрыв соединения. Поэтому preStop: sleep 5..15 стал стандартной практикой: в этот момент процесс ещё полностью работоспособен и обслуживает запросы, а система успевает узнать, что под уходит.

Правильная последовательность выключения

  1. Под помечен на удаление: адрес метят notReady по самому deletionTimestamp, readiness для этого не нужна. Отдавать 503 в коде по SIGTERM всё равно стоит — это видят балансировщики, которые сами проверяют под своим health check или при дренаже смотрят на условие serving завершающегося адреса, — но случится это уже после preStop.
  2. preStop: sleep на время, заведомо большее, чем задержка распространения эндпоинтов в твоей инсталляции (5–15 секунд, для облачных LB бывает больше). Трафик за это время перетекает на другие поды.
  3. SIGTERM → в коде srv.Shutdown(ctx): сервер перестаёт принимать новые соединения, но дожидается текущих запросов.
  4. Закрываются фоновые воркеры и консьюмеры (дообработать сообщение и закоммитить оффсет), затем пул БД, затем flush метрик и трейсов.
  5. Процесс выходит сам, не дожидаясь SIGKILL.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

go func() { _ = srv.ListenAndServe() }()

<-ctx.Done()                 // пришёл SIGTERM
ready.Store(false)           // 1) /readyz -> 503 для балансировщиков с health check
stop()                       // вернуть дефолтную обработку сигналов: второй Ctrl+C убьёт сразу

sh, cancel := context.WithTimeout(context.Background(), 30*time.Second) // < terminationGracePeriod
defer cancel()
if err := srv.Shutdown(sh); err != nil {   // 2) дожидаемся активных запросов
    _ = srv.Close()                        //    не дождались, рвём принудительно
}
workers.Wait()               // 3) фоновые задачи и консьюмеры
db.Close()                   // 4) пул соединений
tp.Shutdown(sh)              // 5) flush трейсов и метрик

Тайминги надо свести: terminationGracePeriodSeconds должен быть больше, чем preStop + максимальное время обработки запроса. Grace period отсчитывается с момента удаления и включает в себя время preStop — если поставить preStop 30 с при grace 30 с, SIGTERM придёт лишь за 2 секунды до SIGKILL, и дообработать запросы процесс не успеет.

Что ещё нужно, чтобы деплой был по-настоящему бесшовным
  • PodDisruptionBudget. Rolling update контролирует Deployment, но есть ещё добровольные выселения — drain ноды при обновлении кластера, работа Cluster Autoscaler. PDB (minAvailable: 80%) — то, что не даст выселить все твои поды разом.
  • Keep-alive со стороны клиента. Даже идеальный shutdown не поможет, если клиент держит долгоживущее соединение и не переустанавливает его. Go-сервер шлёт Connection: close при Shutdown — это работает для HTTP/1.1; для gRPC нужен GOAWAY, который GracefulStop отправляет сам.
  • Ретраи на клиенте. Часть запросов в окне переключения потерять почти неизбежно; идемпотентный retry с бэкоффом на стороне вызывающего превращает «пятисотку у пользователя» в «лишние 20 мс».
  • Anti-affinity. Если все реплики уехали на одну ноду, «10 реплик» не значит «переживём падение ноды». topologySpreadConstraints размажет их по зонам и нодам.
  • Проверка на практике. Единственный честный способ убедиться, что деплой бесшовный, — гонять vegeta/hey с постоянным rps во время выкатки и смотреть на счётчик не-200. Пока этого не сделали, ты не знаешь, теряешь запросы или нет.
Суть: kubectl apply ничего не запускает — он только записывает объект в etcd. Дальше цепочка независимых контроллеров, каждый из которых делает один маленький шаг: Deployment → ReplicaSet → Pod → scheduler проставляет ноду → kubelet на этой ноде тянет образ и просит runtime запустить контейнер → пробы → endpoints → трафик. Хороший ответ — назвать участников и точки, где всё обычно ломается.

Шаг за шагом

  1. Сборка и публикация образа. CI собирает multi-stage Dockerfile, тегирует образ неизменяемым тегом (git SHA, а не latest) и пушит в registry. Заодно стоит подписать образ и приложить SBOM.
  2. Обновление манифеста. Меняется тег образа в Helm-values / kustomize-overlay. При GitOps это коммит в репозиторий конфигурации, который подхватывает Argo CD или Flux; при push-модели это helm upgrade прямо из CI.
  3. API-сервер. Принимает объект: аутентификация → авторизация (RBAC) → admission (mutating — сюда влезают sidecar-инжекторы service mesh и defaulting) → валидация схемы объекта → admission (validating — сюда OPA/Kyverno, «без лимитов не пущу») → запись в etcd. На этом kubectl apply возвращает управление. Дальше всё асинхронно.
  4. Deployment-контроллер видит новый template, считает его хеш и создаёт новый ReplicaSet, начиная перекачивать реплики из старого по правилам maxSurge/maxUnavailable.
  5. ReplicaSet-контроллер создаёт Pod-объекты, пока без ноды (nodeName пуст, статус Pending).
  6. Scheduler для каждого такого пода прогоняет две фазы: filter (какие ноды вообще подходят: хватает ли allocatable под requests, проходят ли nodeSelector, affinity, taints/tolerations, есть ли нужный том) и score (какая из подходящих лучше: балансировка, spread по зонам, уже скачанный образ). Победителю проставляется nodeName — это тоже просто запись в etcd.
  7. kubelet нужной ноды видит «мой под»: монтирует тома и секреты, через CRI просит runtime создать sandbox (pause-контейнер, который держит network namespace), и runtime зовёт CNI за IP. Дальше для каждого контейнера kubelet тянет образ согласно imagePullPolicy (при необходимости — с imagePullSecrets) и запускает его: сначала init-контейнеры по очереди, затем основные (containerd → runc → namespaces + cgroups + процесс).
  8. Пробы. startup держит остальные выключенными, потом readiness решает, пора ли пускать трафик.
  9. EndpointSlice-контроллер помечает адрес пода в срезе готовым, kube-proxy на каждой ноде правит iptables/nftables, ingress-контроллер обновляет апстримы — и запросы поехали в новый под.
  10. Старые поды гасятся по описанной выше процедуре (preStop → SIGTERM → grace → SIGKILL), Deployment досчитывает реплики и переходит в Progressing: NewReplicaSetAvailable.
# Как это выглядит в командах
docker build -t registry.local/app:$(git rev-parse --short HEAD) .
docker push registry.local/app:9f3c2a1

kubectl set image deploy/app app=registry.local/app:9f3c2a1   # или helm upgrade
kubectl rollout status deploy/app --timeout=5m                # ждём и падаем по таймауту
kubectl rollout undo   deploy/app                             # если что-то не так
Где это обычно ломается — и что смотреть
СтатусПричинаКуда смотреть
Pendingscheduler не нашёл ноду: не хватает ресурсов под requests, taint без toleration, PVC не привязалсяkubectl describe pod — секция Events, сообщение FailedScheduling
ImagePullBackOffнет такого тега, нет доступа к registry, забыт imagePullSecretsEvents; проверить тег руками через crane/docker pull
CrashLoopBackOffпроцесс падает на старте (конфиг, миграции, паника) или liveness убивает раньше времениkubectl logs --previous, затем describe на Last State
Running, но 0/1 Readyreadiness не проходит: не тот порт, не тот путь, зависимость недоступнаEvents пробы, kubectl exec + curl localhost:8081/readyz
Ready, но трафика нетселектор Service не совпал с labels подаkubectl describe svc — в поле Endpoints пусто, значит опечатка в labels
Выкатка «зависла»новые поды не становятся Ready, а maxUnavailable: 0 не даёт гасить старыеkubectl rollout status, kubectl get rs — видно оба поколения
Чем добить

«Хорошая новость в том, что архитектура вся асинхронная и восстанавливаемая: любой контроллер можно перезапустить, он просто заново сравнит spec и status. Плохая в том, что kubectl apply успешен ровно в том смысле, что YAML принят; он ничего не говорит о том, поедет ли под. Поэтому в CI после apply обязателен kubectl rollout status --timeout, а по его ненулевому коду автоматический rollout undo. Без этого “зелёный” пайплайн и лежащий прод отлично сосуществуют.»

Суть: рабочий набор — пять команд: get (что вообще происходит), describe (Events — 80 % ответов), logs (обязательно с --previous для упавшего контейнера), exec (заглянуть изнутри) и port-forward (подключиться к поду напрямую, минуя балансировку). Плюс rollout для выкатки и отката.
# 1. Что происходит
kubectl get pods -l app=billing -o wide          # + нода и IP пода
kubectl get pods --sort-by='.status.containerStatuses[0].restartCount'
kubectl get events --sort-by=.lastTimestamp -A | tail -40   # события всего кластера
kubectl get deploy,rs,endpointslices -l app=billing   # оба поколения RS и адреса за Service

# 2. Почему под в таком состоянии: секция Events внизу
kubectl describe pod billing-6f8c9d7b54-2xk9q

# 3. Логи
kubectl logs billing-6f8c9d7b54-2xk9q -c app --tail=200 -f
kubectl logs billing-6f8c9d7b54-2xk9q --previous   # логи упавшего контейнера
kubectl logs -l app=billing --max-log-requests=20 --since=10m --tail=-1 --prefix   # со всех подов сразу

# 4. Внутрь
kubectl exec -it billing-6f8c9d7b54-2xk9q -- sh
kubectl debug -it billing-6f8c9d7b54-2xk9q --image=nicolaka/netshoot --target=app  # для distroless

# 5. Напрямую к поду, мимо Service и Ingress
kubectl port-forward pod/billing-6f8c9d7b54-2xk9q 8081:8081
curl localhost:8081/readyz
curl localhost:8081/debug/pprof/heap -o heap.out

# 6. Выкатка
kubectl rollout status deploy/billing --timeout=5m
kubectl rollout history deploy/billing
kubectl rollout undo deploy/billing --to-revision=7
kubectl rollout restart deploy/billing            # перечитать смонтированный конфиг

# 7. Ресурсы
kubectl top pod -l app=billing --containers
kubectl describe node ip-10-0-3-14 | sed -n '/Allocated resources/,$p'
Приёмы, которые отличают ежедневного пользователя
  • -o jsonpath и -o custom-columns вместо грепа по -o yaml. Например, «какие образы сейчас в проде»: kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}'.
  • kubectl debug запускает в поде эфемерный контейнер: сеть у него общая с подом, а с флагом --target видны и процессы основного контейнера. Главный способ отладить distroless/scratch-образ, где нет ни sh, ни curl. Ещё через kubectl debug node/<node> -it --image=ubuntu можно попасть на ноду.
  • --previous доводи до рефлекса. Когда упавший контейнер уже перезапущен и снова работает, обычные logs покажут лог нового экземпляра, а причина смерти лежит в предыдущем.
  • kubectl get --watch вместо цикла с sleep, и kubectl wait --for=condition=Ready pod -l app=billing --timeout=120s в скриптах.
  • Осторожно с kubectl edit и scale на проде. Императивная правка расходится с git; при GitOps с self-heal её откатит ближайший sync, при обычном CI это сделает следующий деплой. Инцидентные правки надо фиксировать в репозитории.
  • Контекст и неймспейс. Половина инцидентов «команда ничего не сделала» сводится к работе не в том кластере. kubectl config current-context, kubens/kubectx и приглашение шелла с именем кластера (kube-ps1) стоят пяти минут настройки.
  • kubectl api-resources и kubectl explain deploy.spec.strategy дают встроенную документацию и помогают не гуглить поля манифеста.

3.2Под изнутри

В API под занимает одну запись, а на узле это процесс pause, процессы приложения и ветка дерева cgroup. Между ними работают планировщик, kubelet и containerd, и почти любая странность пода (Pending на пустом кластере, выселение не того сервиса, SIGKILL при аккуратном shutdown) объясняется тем, что каждый из них делает со spec.

Что сможешь объяснить после этой главы
  • Что 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 (Container Runtime Interface). Это 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 секунды.

3.3Выкатка и упаковка

Deployment хранит историю в ReplicaSet-ах, HPA делит потребление на requests, CronJob ищет пропущенные слоты по своему статусу, а Helm кладёт релиз целиком в Secret. Отсюда видно, почему выкатка встала на полпути, drain висит час, а ночной отчёт стартовал в 03:14 вместо 03:10.

Что сможешь объяснить после этой главы
  • Откуда в имени пода 559f6c778d и почему после rollout undo номер ревизии растёт.
  • Сколько подов контроллер поднимет и погасит на каждом шаге выкатки.
  • Почему kubectl drain может ждать бесконечно.
  • Почему память Go-сервиса плохой сигнал для HPA.
  • Что CronJob делает с пропущенным запуском при Forbid.
  • Почему helm upgrade --set без -f сбрасывает prod-values.

Прогоны: kind с Kubernetes 1.37.0 без записи в кластер, kubectl 1.37.0 с Kustomize 5.8.1, Helm 4.3.0, helm-diff 3.15.13, Go 1.27.1. Поведение контроллеров сверено с исходниками 1.37.

Ревизии Deployment живут в ReplicaSet-ах

Готовый пример в кластере есть: Deployment CoreDNS, его ставит kubeadm.

$ kubectl get rs -n kube-system -L pod-template-hash
NAME                 DESIRED   CURRENT   READY   AGE   POD-TEMPLATE-HASH
coredns-559f6c778d   2         2         2       64m   559f6c778d
$ kubectl get rs coredns-559f6c778d -n kube-system -o yaml | sed -n '/^  annotations:/,/^  creationTimestamp/p'
  annotations:
    deployment.kubernetes.io/desired-replicas: "2"
    deployment.kubernetes.io/max-replicas: "3"
    deployment.kubernetes.io/revision: "1"
  creationTimestamp: "2026-09-17T07:22:13Z"
$ kubectl rollout history deployment/coredns -n kube-system
deployment.apps/coredns
REVISION  CHANGE-CAUSE
1         <none>

Хеш 559f6c778d контроллер считает FNV-32a от spec.template (ComputeHash в pkg/controller/controller_utils.go) и пишет в имя ReplicaSet, метку pod-template-hash и селектор: метка k8s-app: kube-dns общая у всех ревизий, а по хешу ReplicaSet отличает свои поды. CHANGE-CAUSE берётся из аннотации kubernetes.io/change-cause, её ставит разве что пайплайн.

Новая ревизия появляется только при смене шаблона пода. replicas, strategy и minReadySeconds лежат вне его, что видно в kubectl diff без записи в кластер:

# coredns-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coredns
  namespace: kube-system
spec:
  minReadySeconds: 10
  strategy:
    rollingUpdate:
      maxUnavailable: 0
$ kubectl diff -f coredns-patch.yaml
…
-  generation: 1
+  generation: 2
…
 spec:
+  minReadySeconds: 10
…
-      maxUnavailable: 1
+      maxUnavailable: 0
     type: RollingUpdate
   template:

С --server-side тот же diff падает с conflict with "kubeadm", успев напечатать «keeps changing»: конфликт владельца поля приходит с кодом 409, как конфликт версии, и kubectl делает четыре попытки.

kubectl rollout undo работает на стороне kubectl (polymorphichelpers/rollback.go): берёт шаблон ReplicaSet нужной ревизии, убирает метку pod-template-hash и патчем кладёт в Deployment. Дальше идёт обычный rolling update: контроллер находит этот ReplicaSet и поднимает его по тем же maxSurge и maxUnavailable. Номер ему достаётся максимальный плюс один, прежний уходит в аннотацию deployment.kubernetes.io/revision-history (SetNewReplicaSetAnnotations): после отката с 3 на 1 в истории останутся 2 и 3. У CoreDNS откатываться некуда:

$ kubectl rollout undo deployment/coredns -n kube-system --dry-run=server
error: no rollout history found for deployment "coredns"
$ kubectl rollout undo deployment/coredns -n kube-system --to-revision=1 --dry-run=server
deployment.apps/coredns skipped rollback (current template already matches revision 1) (server dry run)

Старые ReplicaSet с нулём реплик хранятся до revisionHistoryLimit (по умолчанию 10), лишние удаляет cleanupDeployment, когда выкатка закончена.

Шаг выкатки: maxSurge, maxUnavailable, minReadySeconds

Проценты округляет ResolveFenceposts: maxSurge вверх, maxUnavailable вниз. У CoreDNS 25 % от двух реплик дают 0,5, вверх это 1, отсюда max-replicas: "3". Если оба ушли в ноль, контроллер берёт maxUnavailable за 1, а явные нули в обоих полях не пропустит валидация. За проход rolloutRolling (pkg/controller/deployment/rolling.go) делает одно из двух:

  • добавляет поды новому ReplicaSet, если сумма spec.replicas всех ReplicaSet меньше replicas + maxSurge, но не больше недостающего до replicas;
  • убирает поды у старых, если доступных больше replicas − maxUnavailable; ещё не доступные новые поды вычитаются из запаса, неготовые старые гасятся первыми.

Доступным под становится, пробыв Ready minReadySeconds. Шаги ниже посчитаны по этим формулам в предположении, что новые поды становятся доступными одновременно.

replicas: 4 · maxSurge 25% → 1 (вверх) · maxUnavailable 25% → 1 (вниз) старая ревизия, Ready новая, ещё не Ready новая, доступна предел: 5 подов всего, не меньше 3 доступных 1 старт: старый ReplicaSet держит 4 пода v1 v1 v1 v1 всего 4 · доступно 4 2 новый ReplicaSet +1: всего 5 = 4 + maxSurge v1 v1 v1 v1 v2 всего 5 · доступно 4 3 старый −1: доступно 3 = 4 − maxUnavailable v1 v1 v1 v2 всего 4 · доступно 3 4 новый +1; гасить старые больше нельзя v1 v1 v1 v2 v2 всего 5 · доступно 3 ждёт Ready 5 оба новых доступны: старый −2 v1 v2 v2 всего 3 · доступно 3 6 новый +2, до четырёх подов v1 v2 v2 v2 v2 всего 5 · доступно 3 ждёт Ready 7 новые доступны: старый −1, выкатка закончена v2 v2 v2 v2 всего 4 · доступно 4 готово
Шаги выкатки при четырёх репликах. Контроллер чередует два действия: добавляет поды новому ReplicaSet, пока всего меньше пяти, и убирает старые, пока доступных больше трёх. Неготовые новые поды блокируют второе действие.

progressDeadlineSeconds отсчитывается от последнего прогресса, а не от начала выкатки: DeploymentTimedOut сравнивает время с lastUpdateTime условия Progressing, а syncRolloutStatus (progress.go) обновляет его при каждом продвижении. Выкатка, застрявшая в состоянии 4, через 600 секунд получит Progressing=False с причиной ProgressDeadlineExceeded, и на этом всё.

Поды в Terminating в расчёт не входят: старый под с долгим terminationGracePeriodSeconds живёт сверх replicas + maxSurge, в 1.37 их число видно в status.terminatingReplicas. Recreate поднимает новые поды, только когда старые завершились полностью.

PodDisruptionBudget и drain

PodDisruptionBudget ограничивает только eviction API, подресурс pods/eviction. Через него выселяет поды kubectl drain. Rolling update удаляет поды через ReplicaSet, а kubectl delete pod и упавший узел обходятся без eviction: бюджет их не останавливает, но потери учитывает.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
spec:
  maxUnavailable: 1                        # проценты округляются вверх
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels: { app: api }

Ожидаемое число подов бюджет берёт из spec.replicas владельца, а при целом minAvailable считает сами поды. EvictionREST.Create (pkg/registry/core/pod/storage/eviction.go) выселяет без проверки поды в Pending, Succeeded, Failed и уже удаляемые. Неготовый под при AlwaysAllow удаляется сразу, при умолчании IfHealthyBudget только если currentHealthy ≥ desiredHealthy. При disruptionsAllowed == 0 ответ 429 «Cannot evict pod as it would violate the pod's disruption budget.», иначе счётчик уменьшается и под удаляется с обычным grace period.

kubectl drain ставит cordon и выселяет поды параллельно (staging/src/k8s.io/kubectl/pkg/drain/drain.go). На 429 он пишет (will retry after 5s) и повторяет до --timeout, по умолчанию бесконечного (0). Прочие ошибки drain не повторяет.

Одна реплика и minAvailable: 1

Такой бюджет запрещает любое выселение, и drain узла не закончится: документация это допускает. Для нескольких реплик удобнее maxUnavailable: 1 и AlwaysAllow, чтобы узел не держали поды в CrashLoopBackOff.

HPA изнутри

Контроллер (pkg/controller/podautoscaler/horizontal.go) раз в 15 секунд берёт наибольшее из предложений по метрикам. Для Resource (GetResourceReplicas):

  1. потребление подов с метриками делится на сумму их requests, отношение к цели даёт ratio;
  2. при 0,9 ≤ ratio ≤ 1,1 число реплик не меняется; порог задаёт флаг --horizontal-pod-autoscaler-tolerance, а с 1.37 стабильно поле tolerance в behavior.scaleUp и scaleDown;
  3. поды без метрик при росте считаются нулём, при уменьшении полным request (или целью, если она выше 100 %), неготовые поды для CPU при росте тоже нулём; если поправка меняет направление, масштаба нет;
  4. результат: ceil(ratio × число учтённых подов).

Если часть метрик недоступна, реплики HPA не убирает. Рекомендации контроллер хранит в памяти и сглаживает: при уменьшении берёт наибольшую за окно stabilizationWindowSeconds (300 секунд), при росте наименьшую (окно 0). Что подставит API, видно на серверном dry-run HPA с одной политикой уменьшения { type: Pods, value: 1, periodSeconds: 60 }:

$ kubectl apply --dry-run=server -f hpa.yaml -o jsonpath='{.spec.behavior.scaleUp}{"\n"}{.spec.behavior.scaleDown}{"\n"}'
{"policies":[{"periodSeconds":15,"type":"Pods","value":4},{"periodSeconds":15,"type":"Percent","value":100}],"selectPolicy":"Max","stabilizationWindowSeconds":0}
{"policies":[{"periodSeconds":60,"type":"Pods","value":1}],"selectPolicy":"Max"}

Окно уменьшения API оставил пустым, 300 секунд подставит контроллер. Без behavior API не заполняет ничего, и рост за проход ограничен max(2 × текущее, 4). Справка kubectl explain hpa.spec.behavior пишет «4 pods per 60 seconds», а в коде, в dry-run и на kubernetes.io период 15 секунд. explain hpa.spec.minReplicas называет нулевой минимум alpha, но кластер принял minReplicas: 0 с метрикой External: gate HPAScaleToZero в 1.37 в beta и включён.

ИсточникAPIТип в HPAЧто важно
metrics-server 0.9metrics.k8s.ioResource, ContainerResourceCPU и память от kubelet; память в виде working set: memory.current − inactive_file
prometheus-adaptercustom.metrics.k8s.io, external.metrics.k8s.ioPods, Object, ExternalPromQL в метрики; последний релиз 0.12.0, май 2024
KEDA 2.20external.metrics.k8s.ioHPA создаёт сам из ScaledObject0 ↔ 1 масштабирует оператор, 1 ↔ N обычный HPA; сервер внешних метрик в кластере может быть только один (FAQ KEDA)

Job: попытки, индексы, уборка

backoffLimit (по умолчанию 6) считает упавшие поды, а при restartPolicy: OnFailure и перезапуски контейнеров. Новый под после падения создаётся с задержкой от 10 секунд с удвоением. Документация пишет «capped at six minutes», в коде 1.37 потолок MaxJobPodFailureBackOff = 10 * time.Minute (pkg/controller/job/job_controller.go). activeDeadlineSeconds сильнее backoffLimit. Разные причины падений разводят политика падений и индексы:

apiVersion: batch/v1
kind: Job
metadata:
  name: reindex
spec:
  completions: 8
  parallelism: 4
  completionMode: Indexed          # номер части в JOB_COMPLETION_INDEX
  backoffLimitPerIndex: 2          # у каждого индекса свои попытки
  maxFailedIndexes: 3              # больше трёх упавших - Job провалена
  ttlSecondsAfterFinished: 3600
  podFailurePolicy:
    rules:
      - action: FailIndex          # код 42: повтор не поможет
        onExitCodes: { containerName: reindex, operator: In, values: [42] }
      - action: Ignore             # вытеснение с узла не считать
        onPodConditions: [{ type: DisruptionTarget }]
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: reindex
          image: registry.local/api:1.4.2
          args: ["reindex", "--shard=$(JOB_COMPLETION_INDEX)"]
$ kubectl apply --dry-run=server -f job.yaml -o jsonpath='{.spec.backoffLimit} {.spec.podReplacementPolicy} {.spec.completionMode}{"\n"}'
2147483647 Failed Indexed

С backoffLimitPerIndex общий лимит становится MaxInt32, с podFailurePolicy замена пода создаётся после его полного завершения. Справка для обеих требует restartPolicy: Never, но API 1.37 проверяет это только у podFailurePolicy. ttlSecondsAfterFinished удаляет законченную Job вместе с подами. Миграции отдельной Job разобраны в главе 4.3 статьи «CI/CD».

CronJob: расписание, пропуски, конкуренция

CronJob заменяет пару .service и .timer из главы 1.4 статьи «Linux и ОС». Задачи запускает контроллер в kube-controller-manager, а пропущенные слоты ищет от status.lastScheduleTime.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: report
spec:
  schedule: "0 3 * * *"
  timeZone: Europe/Moscow
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: registry.local/api:1.4.2
              args: ["report", "daily"]
$ kubectl apply --dry-run=server -f cronjob.yaml -o jsonpath='{.spec.concurrencyPolicy} {.spec.successfulJobsHistoryLimit} {.spec.failedJobsHistoryLimit}{"\n"}'
Forbid 3 1
$ kubectl apply --dry-run=server -f cronjob-badtz.yaml
The CronJob "report" is invalid: spec.timeZone: Invalid value: "MSK": unknown time zone MSK

С TZ или CRON_TZ в schedule новый объект не пройдёт валидацию, а без timeZone расписание считается в часовом поясе процесса kube-controller-manager. Запускать ли Job, решает syncCronJob (pkg/controller/cronjob/cronjob_controllerv2.go):

  • Слот. Берётся последний наступивший слот после lastScheduleTime: сколько бы их ни пропало за простой или suspend, Job будет одна.
  • Дедлайн. Слот старше startingDeadlineSeconds пропускается, и пропуски ищутся только в этом окне.
  • Конкуренция. Allow запускает поверх активной Job, Forbid пропускает слот с событием JobAlreadyActive, Replace удаляет активную Job.
  • Имя. report-<время слота в минутах Unix>: второй Job на слот не будет и после перезапуска контроллера.

При сотне с лишним пропусков документация обещает не запускать Job. В 1.37 контроллер пишет событие TooManyMissedTimes и всё равно создаёт Job, так и в тесте "prev ran but done, long overdue, not past deadline, A".

schedule: "*/10 * * * *", каждая задача работает 14 минут 03:00 03:10 03:20 03:30 Allow по умолчанию задача 03:00 задача 03:10 задача 03:20 03:30 Forbid без дедлайна задача 03:00 пропуск слот 03:10, старт в 03:14 пропуск слот 03:20 пропуск Forbid startingDeadlineSeconds: 120 задача 03:00 пропуск, в 03:14 уже поздно задача 03:20 пропуск Replace старую удаляет 03:00, удалена 03:10, удалена 03:20, удалена 03:30
Один CronJob, три политики. Задача длиннее интервала. Allow запускает всё по расписанию, задачи идут параллельно. Forbid откладывает слот до конца предыдущей задачи, если к тому времени он не старше startingDeadlineSeconds. Replace удаляет незаконченную задачу, и ни одна не доходит до конца.

StatefulSet: когда он действительно нужен

StatefulSet нужен программам, которые сами хранят данные или адресуются по имени реплики (Postgres, брокер, NATS с JetStream), а не Go-сервису без состояния. Deployment с общим PVC тут не годится: диск ReadWriteOnce подключается к одному узлу, и новый под на другом узле ждёт с событием FailedAttachVolume «Volume is already used by pod(s) …» (reportMultiAttachError в attach/detach-контроллере).

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nats
spec:
  serviceName: nats              # headless Service: nats-0.nats.<ns>.svc
  replicas: 3
  selector:
    matchLabels: { app: nats }
  template:
    metadata:
      labels: { app: nats }
    spec:
      containers:
        - name: nats
          image: nats:2.12
          args: ["--jetstream", "--store_dir=/data"]
          volumeMounts:
            - { name: data, mountPath: /data }
  volumeClaimTemplates:          # у каждого пода свой PVC: data-nats-0, data-nats-1…
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests: { storage: 10Gi }
$ kubectl apply --dry-run=server -f sts.yaml -o jsonpath='{.spec.podManagementPolicy}{"\n"}{.spec.updateStrategy}{"\n"}{.spec.persistentVolumeClaimRetentionPolicy}{"\n"}'
OrderedReady
{"rollingUpdate":{"maxUnavailable":1,"partition":0},"type":"RollingUpdate"}
{"whenDeleted":"Retain","whenScaled":"Retain"}

OrderedReady создаёт поды от 0 вверх, дожидаясь готовности предыдущего, обновление идёт с наибольшего номера, partition: 2 обновит только nats-2. maxUnavailable в 1.37 включён (beta), но при OrderedReady может не действовать. PVC после удаления остаются.

Откат шаблона не лечит застрявший StatefulSet

Если новая версия не становится Ready, выкатка при OrderedReady стоит, и возврат старого шаблона её не сдвинет: контроллер ждёт готовности сломанного пода. Документация (раздел «Forced rollback») требует после отката удалить руками поды, запущенные с плохой конфигурацией.

Helm: чарт, values, шаблоны

helm create api создаёт Chart.yaml (version чарта и appVersion приложения), values.yaml и templates/ с типовыми объектами. Шаблоны пишутся на text/template с функциями Sprig. Файлы с подчёркиванием в начале имени движок в манифесты не рендерит (pkg/engine/engine.go), там определяют шаблоны для include: api.image собирает репозиторий и тег, по умолчанию appVersion. Чарт урезан до Deployment и хука:

# values.yaml
replicaCount: 2
image:
  repository: registry.local/api
  tag: ""              # пусто: берётся appVersion
resources:
  requests: { cpu: 100m, memory: 128Mi }
  limits: { memory: 256Mi }
tolerations:
  - { key: pool, value: general, effect: NoSchedule }

# values-prod.yaml
replicaCount: 6
resources:
  limits: { memory: 512Mi }
tolerations:
  - { key: pool, value: prod, effect: NoSchedule }
# templates/deployment.yaml, фрагмент
  replicas: {{ .Values.replicaCount }}
      containers:
        - name: api
          image: {{ include "api.image" . }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
$ helm template api ./api -f api/values-prod.yaml --set image.tag=1.4.3 --show-only templates/deployment.yaml
…
  replicas: 6
…
          image: registry.local/api:1.4.3
          resources:
            limits:
              memory: 512Mi
            requests:
              cpu: 100m
              memory: 128Mi
      tolerations:
        - effect: NoSchedule
          key: pool
          value: prod
$ helm template api ./api --set replicaCount=8 -f api/values-prod.yaml --show-only templates/deployment.yaml | grep replicas
  replicas: 8

Словари сливаются вглубь: prod заменил limits.memory, requests остались. Списки заменяются целиком: от общего tolerations ничего не осталось. --set resources.limits=null убирает ключ из вывода. Порядок жёсткий, позиция флага не важна (MergeValues в pkg/cli/values/options.go): файлы -f по очереди, затем --set-json, --set, --set-string, --set-file, --set-literal, и всё это поверх values.yaml чарта. Поэтому --set перед -f победил. Что ловят helm lint, helm template и серверный dry-run, разобрано в главе 4.3 статьи «CI/CD».

Релиз, история и хуки

Каждая ревизия лежит в Secret пространства имён релиза (pkg/storage/driver/secrets.go): sh.helm.release.v1.<релиз>.v<ревизия>, тип helm.sh/release.v1, метки name, owner=helm, status, version. В data.release лежит JSON релиза в gzip и base64, а API добавляет ещё один base64. Ниже Secret, который драйвер Helm 4.3.0 записал в поддельный клиент (secret-last.json, как его отдал бы API):

$ jq -r '.data.release' secret-last.json | base64 -d | base64 -d | gunzip \
    | jq -c '{name, version, status: .info.status, chart: .chart.metadata.version, apply_method}'
{"name":"api","version":5,"status":"deployed","chart":"0.4.0","apply_method":"ssa"}

На кластере вместо файла будет kubectl get secret sh.helm.release.v1.api.v5 -o jsonpath='{.data.release}'. Внутри чарт целиком, values пользователя (config), манифест, хуки и статус, а Secret ограничен 1 МиБ (MaxSecretSize). Ревизий хранится 10 (--history-max), последняя успешная не удаляется, helm rollback создаёт следующую ревизию «Rollback to N».

--wait в Helm 4 принимает стратегию: без флага hookOnly (ждать только хуки), голый --wait означает watcher (готовность объектов через kstatus), есть legacy в стиле Helm 3. --atomic на helm upgrade печатает «Flag --atomic has been deprecated, use --rollback-on-failure instead».

Job миграции в чарте помечена helm.sh/hook: pre-install,pre-upgrade, hook-weight: "-5" и hook-delete-policy: before-hook-creation,hook-succeeded. Серверный dry-run печатает её отдельно:

$ helm upgrade --install api ./api -n default -f api/values-prod.yaml --dry-run=server
Release "api" does not exist. Installing it now.
…
STATUS: pending-install
REVISION: 1
DESCRIPTION: Dry run complete
TEST SUITE: None
HOOKS:
---
# Source: api/templates/job-migrate.yaml
…
MANIFEST:
---
# Source: api/templates/deployment.yaml
…

pkg/action/hooks.go сортирует хуки по весу, при равенстве по имени, удаляет прежний объект по before-hook-creation (это умолчание), создаёт новый и ждёт завершения Job в пределах --timeout, 5 минут. Упавший хук проваливает релиз. Хуки не входят в манифест релиза: helm uninstall их не удалит, откат не вернёт (документация Helm). Почему миграция в хуке компромисс, разобрано в главе 4.3 статьи «CI/CD». Объекты, пропавшие из чарта, helm upgrade удаляет, кроме помеченных helm.sh/resource-policy: keep.

helm diff остаётся плагином. Из git-репозитория Helm 4 ставит его только с --verify=false («plugin source does not support verification»), подписанный архив релиза проходит проверку. Плагин сравнивает рендер с манифестом последней ревизии из Secret и ручную правку в кластере не увидит, живые объекты сравнивает --three-way-merge. --detailed-exitcode даёт ненулевой код при разнице, для CI.

Kustomize: base, overlays, патчи

Kustomize не шаблонизирует: он берёт валидный YAML из base и накладывает преобразования overlay. kubectl kustomize печатает результат, kubectl apply -k применяет.

# base/kustomization.yaml
resources:
  - deployment.yaml
configMapGenerator:
  - name: api-config
    literals:
      - LOG_LEVEL=info

# overlays/prod/resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      containers:
        - name: api
          resources:
            limits: { memory: 512Mi }
# overlays/prod/kustomization.yaml
namespace: shop-prod
resources:
  - ../../base
images:
  - name: registry.local/api
    digest: sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4
replicas:
  - name: api
    count: 6
configMapGenerator:
  - name: api-config
    behavior: merge
    literals:
      - LOG_LEVEL=warn
patches:
  - path: resources.yaml          # strategic merge
  - target: { kind: Deployment, name: api }
    patch: |-                     # JSON 6902
      - op: add
        path: /spec/template/spec/priorityClassName
        value: business-critical
$ kubectl kustomize base | grep -E 'name: api-config|LOG_LEVEL'
  LOG_LEVEL: info
  name: api-config-hf678c7m2b
            name: api-config-hf678c7m2b
$ kubectl kustomize overlays/prod
…
  name: api-config-dfk4bdbtkk
  namespace: shop-prod
…
  replicas: 6
…
            name: api-config-dfk4bdbtkk
        image: registry.local/api@sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4
…
      priorityClassName: business-critical

Хеш содержимого в имени ConfigMap меняет шаблон пода при правке конфига, и начинается выкатка, как с checksum/config из главы 3.1. Старые ConfigMap остаются: kubectl apply без --prune лишнее не удаляет.

Helm или Kustomize

Helm 4.3Kustomize 5.8 в kubectl
Модельшаблоны, из которых получается YAMLвалидный YAML плюс патчи и преобразования
Параметрыvalues любой формы, условия и циклытолько то, что умеют трансформеры и патчи
Состояниерелиз с историей в Secret, helm rollbackнет, история в git, откат коммитом
Удаление лишнегопри upgrade, кроме resource-policy: keep--prune или prune в Argo CD и Flux
Ожидание и хуки--wait, --rollback-on-failure, хукинет, ждут kubectl rollout status
Чужие компонентычарты из репозиториев и OCIупаковки нет; --enable-helm рендерит чарт Helm-ом
Где уместенраспространяемый чарт, сторонние системысвои сервисы с небольшой разницей окружений

Вопросы

4
Суть: у бюджета этого пода disruptionsAllowed равен нулю, а drain повторяет выселение раз в 5 секунд без ограничения по времени. Ищут бюджет и причину нуля; удалять поды мимо бюджета стоит в последнюю очередь.

Где смотреть

kubectl get pdb -n <ns> покажет ноль в столбце ALLOWED DISRUPTIONS, -o yaml даст currentHealthy, desiredHealthy, expectedPods и условие DisruptionAllowed. Два бюджета на один под дают не 429, а 500, и на таком поде drain завершится с ошибкой.

Частые причины

Что видноПочему нольЧто сделать
expectedPods: 1, minAvailable: 1одна реплика, любое выселение нарушает бюджетдве реплики и maxUnavailable: 1
currentHealthy < desiredHealthy, поды в CrashLoopBackOffпри IfHealthyBudget неготовый под выселяется, только если приложение целоunhealthyPodEvictionPolicy: AlwaysAllow
после первого выселения замена в Pendingместа нет, здоровых стало меньшеосвободить или добавить узел, drain продолжит сам
--disable-eviction отключает защиту

Флаг удаляет поды без бюджета: сервис с одной репликой получит простой, сервис с неготовыми подами может потерять последние живые. В скриптах обслуживания drain запускают с явным --timeout, иначе зависший drain никто не остановит.

Суть: при Forbid слот, пропущенный из-за активной задачи, запускается сразу после её окончания, если он не старше startingDeadlineSeconds. После простоя контроллер запускает одну Job за самый поздний слот, тоже только внутри дедлайна.

Поздний старт

В 03:10 предыдущая Job работает, контроллер пишет JobAlreadyActive и не трогает lastScheduleTime. В 03:14 Job завершается, её изменение ставит CronJob в очередь (updateJob), поиск от 03:00 находит неисполненный слот 03:10, активных Job нет, и Job за 03:10 создаётся в 03:14. С дедлайном 120 секунд этот слот уже вне окна, следующей будет Job за 03:20.

Пропуск после простоя

Очереди слотов нет. Отчёт на 03:00 с дедлайном 600 секунд запустится, если контроллер ожил до 03:10, и не запустится после. Без дедлайна он запустится при любом опоздании, но один раз, даже после трёх суток простоя.

$ kubectl get cronjob report -o jsonpath='{.status.lastScheduleTime} {.status.lastSuccessfulTime}{"\n"}'
$ kubectl events --for cronjob/report
$ kubectl get jobs -o custom-columns='NAME:.metadata.name,SLOT:.metadata.annotations.batch\.kubernetes\.io/cronjob-scheduled-timestamp'

Алерт строят на времени последнего успеха (kube_cronjob_status_last_successful_time в kube-state-metrics): несостоявшийся запуск ошибок не даёт.

Forbid не гарантирует одну копию

Комментарий в исходнике у проверки Forbid признаёт, что активную Job контроллер может не увидеть. Под внутри Job тоже иногда стартует дважды (глава 4.3 статьи «CI/CD»). Задачу делают идемпотентной: отчёт за дату перезаписывается, рассылки защищены ключом или блокировкой в базе.

Суть: HPA рассчитывает, что метрика на под падает, когда подов больше. Память Go-сервиса от числа реплик почти не зависит: пулы, кэши и цель кучи есть на каждом поде. Если потребление выше цели больше чем на 10 %, реплики растут до максимума, а повода их убрать формула не находит.

Арифметика контроллера

requests.memory: 256Mi, цель 70 %, то есть 179,2 МиБ. Прогретый под держит 200 МиБ при любой нагрузке, ratio = 1,116. Контроллер хочет ceil(4 × 1,116) = 5, у пяти подов память та же, и ряд идёт 4, 5, 6, 7, 8, 9, 11, 13, 15, 17, 19, 20. Уменьшение наступит, когда ratio опустится ниже 0,9 на все 300 секунд окна. При 190 МиБ (ratio 1,06) не будет и роста.

Что показал прогон

Go 1.27.1, контейнер с --memory=512m. Программа держит 300 МиБ, отпускает их, вызывает runtime.GC() и раз в 15 секунд печатает memory.current, working set (минус inactive_file) и метрики рантайма:

    0s after GC       memory.current=306Mi  working_set=306Mi  heap_objects=0Mi    heap_free=300Mi  heap_released=2Mi
  120s idle           memory.current=306Mi  working_set=306Mi  heap_objects=0Mi    heap_free=300Mi  heap_released=2Mi
  135s idle           memory.current=208Mi  working_set=208Mi  heap_objects=0Mi    heap_free=202Mi  heap_released=100Mi
  150s idle           memory.current=7Mi    working_set=7Mi    heap_objects=0Mi    heap_free=0Mi    heap_released=302Mi

Объекты собраны сразу, а для cgroup и metrics-server память занята ещё две минуты. Цель возврата памяти (gcPaceScavenger) пересчитывается в конце цикла GC по куче на момент разметки, когда мёртвые 300 МиБ ещё не выметены. Без аллокаций следующий цикл запускается через forcegcperiod, 2 минуты. Под нагрузкой GC чаще, но куча между циклами растёт до цели, при GOGC=100 примерно вдвое больше живой.

Как настроить

Масштабируют по тому, что делится между репликами (CPU, RPS на под, лаг консьюмера), а память задают размером пода: requests, лимит и GOMEMLIMIT (глава 3.1).

Утилизация считается от requests

Та же память при меньших requests.memory даёт ratio выше, и HPA растёт без нагрузки, как с CPU в главе 3.1. Подбором requests и цели это не лечится: память не падает с числом реплик.

Суть: values прошлой ревизии Helm подставляет, только если новых values нет совсем. Один --set, и values собираются заново из флагов, prod-файл забыт. Надёжно передавать полный набор -f каждый раз; --reuse-values опасен при смене версии чарта.

Что показал прогон

Helm 4.3.0 запущен как библиотека: action.Install и action.Upgrade, релизы драйвер Secret пишет в поддельный клиент, объекты никуда не применяются. Чарт 0.4.0 добавляет ключ probe.path, который шаблон читает как .Values.probe.path.

КомандаРевизияreplicasОбраз
install -f values-prod.yaml16api:1.4.2
upgrade --set image.tag=1.4.322api:1.4.3
upgrade без -f и --set32api:1.4.3
upgrade -f values-prod.yaml --set image.tag=1.4.346api:1.4.3
чарт 0.4.0, --reuse-values --set image.tag=1.4.4ошибка nil pointer evaluating interface {}.path
чарт 0.4.0, --reset-then-reuse-values --set image.tag=1.4.456api:1.4.4

Почему так

В reuseValues (pkg/action/upgrade.go) --reset-values берёт только новые флаги. --reuse-values сливает их с config прошлой ревизии и подменяет values.yaml нового чарта значениями старого, поэтому ключ probe пропал. --reset-then-reuse-values оставляет values.yaml нового чарта и кладёт сверху старый config и новые флаги. Без флагов старый config копируется, только когда новых values нет: так ревизия 3 унаследовала тег от ревизии 2. Что лежит в config, покажет helm get values api, полный итог --all.

Ревизия 3 выглядит безопасной

helm upgrade без флагов сохранил values, и легко решить, что Helm их всегда помнит. Следующий --set опровергнет это в prod. Перед выкаткой запускают helm diff upgrade: он показал бы replicas: 62 до записи в кластер.

3.4Сеть, доступ и отладка

Запрос из пода к соседу проходит через DNS-имя, адрес Service, правила kube-proxy и сетевую политику, а право ходить в API держится на токене и RBAC. Ломается любое звено одинаково невнятно, таймаутом или отказом, так что ниже звенья разобраны по очереди.

Что сможешь объяснить после этой главы
  • Какие цепочки kube-proxy пишет ради ClusterIP и откуда у правила вероятность 0,5.
  • Почему внешнее имя из пода стоит восьми DNS-запросов и что меняют точка в конце и dnsConfig.
  • Что отдаёт DNS для headless Service и почему gRPC-клиент не сразу видит новые поды.
  • Чем Gateway API отличается от Ingress и что стало с ingress-nginx.
  • Почему NetworkPolicy принимается, даже если её некому исполнять, и как default deny отрезает DNS.
  • Сколько живёт токен пода и как проверить права через kubectl auth can-i.
  • Что читать в событиях и describe и как отладить distroless-под.

Кластер: kind 0.33.0, Kubernetes 1.37.0, один узел dv-k8s-control-plane, containerd 2.3.4, kindnet, CoreDNS 1.14.6. Его только читали: манифесты проверены --dry-run=server, а то, что без записи не увидеть, описано по документации и исходникам 1.37. Команды с # выполнены на узле.

Service: адрес, которого нет ни на одном интерфейсе

В главе 3.1 Service назван набором правил в ядре узла. Два таких Service есть почти в любом кластере:

$ kubectl get svc,endpointslices -A -o wide
NAMESPACE     NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE     SELECTOR
default       service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP                  4m57s   <none>
kube-system   service/kube-dns     ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   4m55s   k8s-app=kube-dns

NAMESPACE     NAME                                            ADDRESSTYPE   PORTS        ENDPOINTS               AGE
default       endpointslice.discovery.k8s.io/kubernetes       IPv4          6443         172.23.0.2              4m57s
kube-system   endpointslice.discovery.k8s.io/kube-dns-wkh2n   IPv4          53,53,9153   10.244.0.4,10.244.0.2   4m49s
$ kubectl get cm kube-proxy -n kube-system -o jsonpath='{.data.config\.conf}' | grep '^mode:'
mode: iptables

Адрес 10.96.0.10 никто не слушает, его знают только правила kube-proxy на каждом узле. Если режим не задан, kube-proxy 1.37 берёт iptables. Режим nftables стабилен с 1.33, на свежих ядрах советуют его, а ipvs устарел в 1.35: kube-proxy обещает отключить его по умолчанию в 1.40 и удалить в 1.43. Правила для DNS по UDP:

# iptables-save -t nat | grep 'A KUBE-SERVICES.*kube-dns:dns cluster IP'
-A KUBE-SERVICES -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-SVC-TCOU7JCQXEZGVUNU
# iptables-save -t nat | grep '^-A KUBE-SVC-TCOU7JCQXEZGVUNU'
-A KUBE-SVC-TCOU7JCQXEZGVUNU ! -s 10.244.0.0/16 -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-MARK-MASQ
-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.2:53" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-YIL6JZP7A3QYXJU2
-A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.4:53" -j KUBE-SEP-WXWGHGKZOCNYRYI7

KUBE-SERVICES подключена к PREROUTING и OUTPUT и по адресу и порту отправляет пакет в цепочку сервиса. Там первое правило метит для MASQUERADE пакеты не из сети подов, например с узла: им подставят адрес узла, и ответ вернётся тем же путём. Дальше лестница: первое правило срабатывает с вероятностью 0,5, второе забирает остальное, а цепочка KUBE-SEP-… делает DNAT на под. Для N эндпоинтов вероятности 1/N, 1/(N−1) и так до 1/2 (writeServiceToEndpointRules в pkg/proxy/iptables/proxier.go).

Пакет на 10.96.0.10:53/udp: цепочки таблицы nat на узле dig на узле src 172.23.0.2 dst 10.96.0.10:53 OUTPUT, из пода PREROUTING KUBE-SERVICES -d 10.96.0.10 --dport 53 KUBE-SVC-TCOU7JCQXEZGVUNU ! -s 10.244.0.0/16: метка для MASQUERADE p = 0,5 остаток KUBE-SEP-YIL6JZP7A3QYXJU2 DNAT 10.244.0.2:53 KUBE-SEP-WXWGHGKZOCNYRYI7 DNAT 10.244.0.4:53 coredns …gx9h8 10.244.0.2:53 coredns …tdsh4 10.244.0.4:53 conntrack первый пакет прошёл цепочки и создал запись, остальные пакеты потока идут по ней мимо таблицы nat udp src=172.23.0.2 dst=10.96.0.10 sport=33098 dport=53 src=10.244.0.4 dst=10.244.0.1 sport=53 dport=54814 1000 запросов: 978 новых потоков, 487 к 10.244.0.2 и 491 к 10.244.0.4; 22 запроса совпали по порту с живой записью
ClusterIP в режиме iptables. Адрес сервиса существует только в правилах: цепочка сервиса выбирает эндпоинт по вероятности, цепочка эндпоинта меняет адрес назначения на адрес пода. Под выбирается один раз на поток, дальше пакеты идут по записи conntrack.

Случайность видна по счётчикам. dig в сетевом namespace узла отправил тысячу запросов на ClusterIP:

# iptables-save -c -t nat | grep -o '^\[[0-9:]*\].*kube-dns:dns -> [0-9.:]*'
[1434:153438] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.2:53
[1491:159522] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.4:53
$ docker run --rm --network container:dv-k8s-control-plane --entrypoint sh \
    registry.k8s.io/e2e-test-images/agnhost:2.39 -c \
    'i=0; while [ $i -lt 1000 ]; do echo kube-dns.kube-system.svc.cluster.local; i=$((i+1)); done > /tmp/q; dig @10.96.0.10 +short -f /tmp/q | sort | uniq -c'
   1000 10.96.0.10
# iptables-save -c -t nat | grep -o '^\[[0-9:]*\].*kube-dns:dns -> [0-9.:]*'
[1921:205547] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.2:53
[1982:212059] -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns -> 10.244.0.4:53
# conntrack -L -p udp --orig-dst 10.96.0.10 2>/dev/null | grep -o 'src=10.244.0.[0-9]*' | sort | uniq -c
    487 src=10.244.0.2
    491 src=10.244.0.4

Счётчики выросли на 487 и 491, как и число записей conntrack, а запросов было 1000: таблицу nat проходит только первый пакет потока (глава 1.3 статьи «Linux и ОС»), а 22 запроса ушли с порта, для которого запись в этой серии уже была. С TCP под выбирается на SYN. Если у Service нет готовых эндпоинтов, в filter стоит REJECT с комментарием has no endpoints, и клиент сразу получает отказ. В режиме nftables сервис ищется в словаре service-ips, без перебора всех сервисов подряд, а эндпоинт выбирает numgen random mod N.

EndpointSlice и sessionAffinity

Адреса kube-proxy берёт из EndpointSlice: до 100 адресов в срезе (--max-endpoints-per-slice), у каждого свои условия.

$ kubectl get endpointslices -n kube-system -l kubernetes.io/service-name=kube-dns \
    -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} {.conditions} {.targetRef.name}{"\n"}{end}'
10.244.0.4 {"ready":true,"serving":true,"terminating":false} coredns-559f6c778d-tdsh4
10.244.0.2 {"ready":true,"serving":true,"terminating":false} coredns-559f6c778d-gx9h8

ready значит «готов и не удаляется», serving повторяет готовность пода, terminating говорит об удалении; как они меняются при остановке, разобрано в главе 3.2. Endpoints в 1.33 объявлен устаревшим, и kubectl get endpoints из главы 3.1 печатает об этом Warning.

# orders-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: orders
spec:
  selector:
    app: orders
  ports:
    - name: http
      port: 80
      targetPort: 8080
  sessionAffinity: ClientIP
$ kubectl apply --dry-run=server -f orders-svc.yaml \
    -o jsonpath='{.spec.clusterIP} {.spec.sessionAffinity} {.spec.sessionAffinityConfig}{"\n"}'
10.96.0.0 ClientIP {"clientIP":{"timeoutSeconds":10800}}

sessionAffinity: ClientIP закрепляет клиента за подом на 10800 секунд (допустимо до 86400). Адрес 10.96.0.0 подставил dry-run, настоящий ClusterIP не выделен. В iptables закрепление держит модуль recent: KUBE-SEP-… запоминает источник, а в KUBE-SVC-… перед лестницей встают правила -m recent --rcheck --seconds 10800 --reap. Список у каждого узла свой, клиентом считается IP, и весь трафик из-за одного NAT или от ingress-контроллера выглядит одним клиентом.

CoreDNS и записи Service

$ kubectl get cm coredns -n kube-system -o jsonpath='{.data.Corefile}'
.:53 {
    errors
    health {
       lameduck 5s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
       pods insecure
       fallthrough in-addr.arpa ip6.arpa
       ttl 30
    }
    prometheus :9153
    forward . /etc/resolv.conf {
       max_concurrent 1000
    }
    cache 30 {
       disable success cluster.local
       disable denial cluster.local
    }
    loop
    reload
    loadbalance
}

kubernetes отвечает за cluster.local по данным из API, pods insecure добавляет записи вида 10-244-0-2.kube-system.pod.cluster.local без проверки пода. forward отправляет остальное серверам из resolv.conf пода CoreDNS, cache зону кластера не кэширует, loadbalance перемешивает A-записи. Ниже dig из образа agnhost в сетевом namespace узла:

# dig +noall +answer @10.96.0.10 kube-dns.kube-system.svc.cluster.local
kube-dns.kube-system.svc.cluster.local.	30 IN A	10.96.0.10
# dig +noall +answer @10.96.0.10 SRV _dns._udp.kube-dns.kube-system.svc.cluster.local
_dns._udp.kube-dns.kube-system.svc.cluster.local. 30 IN	SRV 0 100 53 kube-dns.kube-system.svc.cluster.local.
# dig @10.96.0.10 api.example.com.default.svc.cluster.local | grep -E "status|flags"
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 25361
;; flags: qr aa rd; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
; EDNS: version: 0, flags:; udp: 4096

На несуществующее имя зоны CoreDNS отвечает NXDOMAIN сам, с флагом aa. У headless Service нет ни адреса, ни правил kube-proxy, а записи по спецификации DNS для Kubernetes 1.1.0 такие:

# orders-headless.yaml
apiVersion: v1
kind: Service
metadata:
  name: orders-headless
spec:
  clusterIP: None
  selector:
    app: orders
  ports:
    - name: grpc
      port: 9000
$ kubectl apply --dry-run=server -f orders-headless.yaml \
    -o jsonpath='{.spec.clusterIP} {.spec.clusterIPs}{"\n"}'
None ["None"]
ЗапросОбычный ServiceHeadless Service
A orders.shop.svc.cluster.localClusterIPадреса готовых эндпоинтов; нет готовых — NXDOMAIN
SRV _grpc._tcp.orders.shop.svc…запись на именованный портзапись на пару «готовый эндпоинт, порт»
A orders-0.orders.shop.svc…в спецификации нет, но CoreDNS отвечает так жеадрес пода, если у эндпоинта есть hostname

Hostname эндпоинт получает, когда у пода есть hostname и subdomain с именем Service, StatefulSet ставит их сам. gRPC за обычным Service держит одно соединение с одним подом (статья «Сети», глава 9.4). Headless Service отдаёт готовые адреса на момент запроса, а grpc-go 1.83 перечитывает DNS при обрыве соединения, не чаще раза в 30 секунд и без оглядки на TTL. Новые поды клиент увидит после обрыва, поэтому на сервере ставят MaxConnectionAge.

ndots:5: восемь запросов на одно имя

Под с dnsPolicy: ClusterFirst, это значение по умолчанию, получает resolv.conf от kubelet:

# cat /var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/$(crictl pods -q --name local-path-provisioner)/resolv.conf
search local-path-storage.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

kubelet (GetPodDNS в pkg/kubelet/network/dns/dns.go) ставит домены <namespace>.svc.<домен>, svc.<домен>, <домен> и домены узла, сервер из clusterDNS и ndots:5, а опции узла отбрасывает. Поды CoreDNS с dnsPolicy: Default получили файл узла как есть: сервер 192.168.65.254 (DNAT на DNS Docker) и ndots:0. Под с hostNetwork и ClusterFirst kubelet тоже переводит на Default, нужен ClusterFirstWithHostNet.

С ndots:5 имя, где точек меньше пяти, резолвер сначала пробует с каждым доменом поиска и потом как есть. В api.example.com две точки, в orders.default.svc.cluster.local четыре. Стенд в Docker: CoreDNS 1.14.6 с плагином log, у клиента resolv.conf пода из default, где вместо 10.96.0.10 этот CoreDNS, а resolve на Go 1.27.1 (CGO_ENABLED=0) вызывает net.DefaultResolver.LookupHost:

$ docker run --rm --network dv-c4-dns -v "$PWD/resolv.conf:/etc/resolv.conf:ro" \
    -v "$PWD/resolve:/resolve:ro" debian:13 /resolve api.example.com
api.example.com [203.0.113.10] <nil> 1ms
$ docker logs dv-c4-coredns 2>&1 | awk '/INFO/ {print $5, $7, $12}' | tr -d '"'
AAAA api.example.com.default.svc.cluster.local. NXDOMAIN
A api.example.com.default.svc.cluster.local. NXDOMAIN
AAAA api.example.com.svc.cluster.local. NXDOMAIN
A api.example.com.svc.cluster.local. NXDOMAIN
A api.example.com.cluster.local. NXDOMAIN
AAAA api.example.com.cluster.local. NXDOMAIN
A api.example.com. NOERROR
AAAA api.example.com. NOERROR
LookupHost("api.example.com") в поде при ndots:5 resolv.conf пода, namespace default search default.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5 в api.example.com две точки, меньше ndots: сначала имя с каждым доменом search, потом как есть; на каждое имя сразу A и AAAA имя в запросе A AAAA 1 api.example.com.default.svc.cluster.local. NXDOMAIN NXDOMAIN 2 api.example.com.svc.cluster.local. NXDOMAIN NXDOMAIN 3 api.example.com.cluster.local. NXDOMAIN NXDOMAIN 4 api.example.com. 203.0.113.10 нет записей 8 запросов, 6 ответов NXDOMAIN; адрес пришёл только на последнее имя api.example.com. с точкой в конце полное имя, домены поиска не нужны: одно имя, 2 запроса dnsConfig: ndots:2 две точки не меньше двух: имя как есть первым, 2 запроса несуществующее имя Go и glibc всё равно проведут через домены поиска (у Go 8 NXDOMAIN), musl остановится на 2
ndots и домены поиска. К имени, где точек меньше ndots, резолвер по очереди дописывает домены поиска и потом спрашивает его как есть; на каждое имя уходят A и AAAA. Точка в конце или меньший ndots убирают лишние шаги для существующих внешних имён.

Шесть запросов из восьми ушли впустую, и каждый прошёл DNAT и conntrack. Отказы CoreDNS даёт сам, наружу уходят последние два. Число запросов зависит от библиотеки:

Резолвер, тот же стендapi.example.comapi.example.com.
Go 1.27.1, чистый резолвер8: A и AAAA на четыре имени2
glibc 2.41, getent ahosts (Debian 13)4: только A (AI_ADDRCONFIG, IPv6-адреса нет); с --no-addrconfig 81
musl 1.2.6, wget из busybox (Alpine 3.24)82 (getent hosts)
  • Точка в конце делает имя полным. HTTP-клиент Go 1.27.1 шлёт SNI и сверяет сертификат без точки, а Host уходит как api.example.com., и принять его должен сервер.
  • dnsConfig с ndots: "2" пускает имя с двумя точками как есть первым. Несуществующее имя Go и glibc всё равно проведут по доменам, а musl при точках не меньше ndots поиск не делает (name_from_dns_search), и orders.default.svc у него не нашёлся.
  • NodeLocal DNSCache (стабилен с 1.18) ставит кэш на узел: запросов не меньше, но они минуют DNAT и conntrack, а отказы кэшируются рядом с подом.
# orders-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: orders-dns
spec:
  containers:
    - name: app
      image: registry.local/orders:1.4.2
  dnsConfig:
    options:
      - name: ndots
        value: "2"
$ kubectl apply --dry-run=server -f orders-pod.yaml \
    -o jsonpath='{.spec.dnsPolicy} {.spec.dnsConfig}{"\n"}'
ClusterFirst {"options":[{"name":"ndots","value":"2"}]}

Ingress, Gateway API и конец ingress-nginx

Таймауты, переписывание путей и canary в Ingress каждый контроллер задаёт своими аннотациями, и манифест под один контроллер другому не подходит. Документация называет API Ingress замороженным (не удалят, но и развивать не будут) и рекомендует Gateway API. Его CRD от SIG Network ставят отдельно, в kind их нет:

$ kubectl api-resources | grep -i -E 'gateway|ingress'
ingressclasses                                   networking.k8s.io/v1              false        IngressClass
ingresses                           ing          networking.k8s.io/v1              true         Ingress
$ kubectl apply --dry-run=server -f orders-route.yaml
error: resource mapping not found for name: "orders" namespace: "default" from "orders-route.yaml": no matches for kind "HTTPRoute" in version "gateway.networking.k8s.io/v1"
ensure CRDs are installed first
# orders-route.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: orders
  namespace: default
spec:
  parentRefs:
    - name: public
      namespace: infra
  hostnames: ["api.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /orders }
      backendRefs:
        - name: orders
          port: 80

Вход поделён между владельцами:

  • GatewayClass задаёт контроллер: Envoy Gateway, Istio, NGINX Gateway Fabric, Traefik и другие.
  • Gateway с портами, сертификатами и allowedRoutes отдан платформе.
  • HTTPRoute, GRPCRoute лежат в namespace приложения и цепляются через parentRefs.
  • ReferenceGrant разрешает ссылку в чужой namespace.

В v1.6.2 (3 сентября 2026) стандартный канал держит в v1 GatewayClass, Gateway, HTTPRoute, GRPCRoute, ReferenceGrant, BackendTLSPolicy, ListenerSet, TLSRoute, TCPRoute и UDPRoute, последние два с v1.6.0. GRPCRoute сопоставляет запросы по сервису и методу gRPC, то есть шлюз видит отдельные вызовы, а не соединение.

Что стало с ingress-nginx

11 ноября 2025 года SIG Network и Security Response Committee объявили о закрытии kubernetes/ingress-nginx: поддержка по мере сил шла до марта 2026, дальше ни выпусков, ни исправлений уязвимостей. Последние выпуски, v1.15.1, v1.14.5 и v1.13.9, вышли 19 марта 2026, репозиторий ушёл в архив. Установленные контроллеры работают. Переезжать советуют на Gateway API с утилитой ingress2gateway (1.0 от 20 марта 2026) или на другой Ingress-контроллер.

NetworkPolicy: кто её исполняет

NetworkPolicy описывает разрешённые соединения, а исполняет её CNI. API-сервер примет политику в любом кластере, поля «применена» у неё нет, а по документации без контроллера, который её реализует, политика не действует.

# orders-netpol.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: default}
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: default}
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: {kubernetes.io/metadata.name: kube-system}
          podSelector:
            matchLabels: {k8s-app: kube-dns}
      ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
$ kubectl apply --dry-run=server -f orders-netpol.yaml
networkpolicy.networking.k8s.io/default-deny created (server dry run)
networkpolicy.networking.k8s.io/allow-dns created (server dry run)
$ kubectl logs -n kube-system ds/kindnet \
    | grep -E 'kube-network-policies|Policy engine'
I0917 07:22:14.174492       1 controller.go:173] "Starting controller" name="kube-network-policies"
I0917 07:22:14.675365       1 controller.go:185] "Policy engine is ready."

Под изолирован в направлении, если его выбирает хоть одна политика с этим типом в policyTypes; разрешения складываются, запретов в API нет. Ответ на разрешённое соединение проходит сам, трафик с узла пода разрешён всегда, и пробы kubelet политика не режет. Service по имени не указать: поды выбирают метками, namespace — меткой kubernetes.io/metadata.name.

В kind 0.33 политики исполняет kindnet: с kind 0.24 в него встроен kube-network-policies. У его ClusterRole есть watch на networkpolicies, а на узле таблица inet kindnet-network-policies с набором podips-v4 и правилами ip saddr @podips-v4 queue flags bypass to 101: пакеты подов под политикой уходят через NFQUEUE в kindnetd, разрешённое соединение помечается меткой conntrack.

Политика принята, а трафик идёт

created одинаково там, где политики исполняются, и там, где их молча игнорируют. Проверяй делом: после default deny соединение без разрешения должно перестать устанавливаться.

ServiceAccount, токен пода и RBAC

Под работает от имени ServiceAccount, по умолчанию default, и admission-плагин монтирует ему том kube-api-access-…, это видно и в dry-run:

$ kubectl apply --dry-run=server -f orders-pod.yaml -o jsonpath='{.spec.serviceAccountName} {.spec.volumes[*].name}{"\n"}'
default kube-api-access-h8g6x
$ kubectl describe pod -n kube-system coredns-559f6c778d-gx9h8 | sed -n '/kube-api-access-b6wbz:/,/DownwardAPI/p'
  kube-api-access-b6wbz:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    Optional:                false
    DownwardAPI:             true

Projected-том кладёт в /var/run/secrets/kubernetes.io/serviceaccount токен, CA кластера и имя namespace. Токен выдаёт TokenRequest, это JWT с привязкой к поду: в нём имя и UID пода, узла и ServiceAccount. Когда объект пода с этим UID исчезает из API, токен перестаёт действовать. Срок 3607 секунд, час и семь секунд, API-сервер узнаёт по константе WarnOnlyBoundTokenExpirationSeconds: с --service-account-extend-token-expiration (включён по умолчанию) выписывает токен на год, а настоящий срок пишет в warnafter. Дальше токен работает, но в аудите каждый запрос с ним помечен authentication.k8s.io/stale-token. kubelet обновляет файл после 80% срока, около 48 минут. Сервису без API том не нужен: automountServiceAccountToken: false.

Права даёт RBAC: Role действует в namespace, ClusterRole во всём кластере или как шаблон, RoleBinding и ClusterRoleBinding связывают роль с субъектом. Правила только разрешают и складываются. CoreDNS:

$ kubectl get clusterrolebinding system:coredns -o jsonpath='{.roleRef.name} {.subjects}{"\n"}'
system:coredns [{"kind":"ServiceAccount","name":"coredns","namespace":"kube-system"}]
$ kubectl auth can-i --list --as=system:serviceaccount:kube-system:coredns \
    | grep -E '^(Resources|endpoints|namespaces|pods|services|endpointslices)'
Resources                                       Non-Resource URLs                      Resource Names   Verbs
endpoints                                       []                                     []               [list watch]
namespaces                                      []                                     []               [list watch]
pods                                            []                                     []               [list watch]
services                                        []                                     []               [list watch]
endpointslices.discovery.k8s.io                 []                                     []               [list watch]
$ kubectl auth can-i get secrets -n kube-system --as=system:serviceaccount:kube-system:coredns
no

--as работает через impersonation, а встроенная роль edit в 1.37 разрешает impersonate на ServiceAccount: разработчик с edit может действовать от имени любого ServiceAccount своего namespace.

События, describe и логи упавшего контейнера

Event живёт час (--event-ttl API-сервера), и ночное падение утром в событиях не найти. kubectl get events выводит их в порядке имён, а имя события начинается с имени объекта, так что строки сгруппированы по объектам. kubectl events сортирует по времени, умеет --for, --types=Warning и -w:

$ kubectl events -n kube-system --for pod/coredns-559f6c778d-gx9h8
LAST SEEN   TYPE      REASON             OBJECT                         MESSAGE
27m         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.
26m         Normal    Scheduled          Pod/coredns-559f6c778d-gx9h8   Successfully assigned kube-system/coredns-559f6c778d-gx9h8 to dv-k8s-control-plane
26m         Normal    Pulled             Pod/coredns-559f6c778d-gx9h8   Container image "registry.k8s.io/coredns/coredns:v1.14.6" already present on machine and can be accessed by the pod
26m         Normal    Created            Pod/coredns-559f6c778d-gx9h8   Container created
26m         Normal    Started            Pod/coredns-559f6c778d-gx9h8   Container started
26m         Warning   Unhealthy          Pod/coredns-559f6c778d-gx9h8   Readiness probe failed: Get "http://10.244.0.2:8181/ready": dial tcp 10.244.0.2:8181: connect: connection refused
26m         Warning   Unhealthy          Pod/coredns-559f6c778d-gx9h8   Readiness probe failed: HTTP probe failed with statuscode: 503

Здесь вся история старта: планировщик ждал готовности узла, потом readiness-проба не достучалась до порта, а затем получала 503, пока плагин kubernetes не синхронизировался (в логе пода plugin/ready: Plugins not ready: "kubernetes"). На старте это обычный шум. describe pod показывает события внизу, а выше то, чего в них нет:

Поле describeЧто из него следует
Last State, Reason, Exit Codeкак умер прошлый экземпляр: OOMKilled, Error с кодом, Completed
Restart Countрастёт — причина в логе прошлого экземпляра
Ready, Conditionsпускают ли трафик; PodScheduled False — под не размещён
Liveness, Readinessфактические порт, путь, таймаут и пороги

logs --previous отдаёт лог прошлого экземпляра, а kubelet хранит один мёртвый экземпляр на контейнер (--maximum-dead-containers-per-container, по умолчанию 1): лог позапрошлого падения уже не достать. Без перезапусков ответ такой:

$ kubectl logs -n kube-system coredns-559f6c778d-gx9h8 --previous
Error from server (BadRequest): previous terminated container "coredns" in pod "coredns-559f6c778d-gx9h8" not found
$ kubectl logs -n kube-system -l k8s-app=kube-proxy | wc -l
10

С селектором kubectl logs отдаёт по 10 последних строк с пода (справка к --tail): начало лога так не увидеть.

kubectl debug и port-forward

В distroless нет shell (для Docker это разобрано в главе 2.2 статьи «Docker»). kubectl debug добавляет в работающий под эфемерный контейнер через подресурс pods/ephemeralcontainers, не перезапуская его. Команды раздела не запускались (эфемерный контейнер меняет под), поведение взято из kubectl explain и исходников kubectl 1.37.

$ kubectl debug -it orders-7d9f-xk2 --image=busybox:1.37 --target=app -- sh

Эфемерный контейнер нельзя изменить или удалить, у него нет resources, портов и проб, после выхода он остаётся в поде завершённым. Сеть у контейнеров пода общая, а --target=app добавляет пространство процессов app: ps покажет сервис, его файлы видны в /proc/<pid>/root, если хватает прав на ptrace. Остальное решает профиль:

--profileЭфемерный контейнерdebug node/…
general, по умолчанию с 1.36SYS_PTRACEсеть, PID и IPC узла, корень узла в /host
baseline, restrictedничего; у restricted не root и drop: ALLобычный под на узле
netadminNET_ADMIN, NET_RAWnamespace узла без /host
sysadminprivilegednamespace узла, /host, privileged

legacy был умолчанием до 1.36 и уйдёт в 1.39. Под node-debugger-… от debug node/ терпит все taint и после выхода остаётся, удаляют его руками. С general он не привилегирован, но chroot /host вопреки документации пройдёт: право на chroot есть по умолчанию.

kubectl port-forward открывает поток через API-сервер (pods/portforward, с 1.31 по умолчанию поверх WebSocket) до kubelet, а containerd в сетевом namespace пода подключается к localhost:<порт> (sandbox_portforward_linux.go в containerd 2.3.4). Service, kube-proxy и политики в этом пути не участвуют, и port-forward service/orders лишь выбирает один под по селектору.

Через port-forward отвечает, через Service нет

Сервис на 127.0.0.1:8080 через port-forward доступен: containerd подключается к localhost внутри пода. Соседи и Service ходят на IP пода и получают отказ (адрес слушателя разобран в главе 1.3 статьи «Linux и ОС»).

Вопросы

4
Суть: иди по пути запроса: имя, эндпоинты, правила узла, политика, процесс. Звено подскажет текст ошибки: no such host указывает на DNS, мгновенный отказ на эндпоинты или порт, таймаут на политику или сеть.

Звено за звеном

  1. Имя. Короткое orders находится только из своего namespace, из другого нужно orders.shop. Проверяй тем же резолвером, что у сервиса (GODEBUG=netdns=2, getent hosts), а не dig (глава 1.3 статьи «Linux и ОС»).
  2. Эндпоинты. kubectl get endpointslices -n shop -l kubernetes.io/service-name=orders: если пусто, селектор не совпал с метками, если ready ложно, под не проходит readiness. Сверь и targetPort.
  3. Узел. iptables-save -t nat | grep 'shop/orders' покажет цепочку сервиса.
  4. Политика. kubectl get networkpolicy в обоих namespace: Egress клиента, Ingress сервера.
  5. Процесс. Эфемерный контейнер с --target и netstat -tln покажет адрес, который слушает сервис.
Ошибка Go-клиентаКуда смотреть
lookup orders on 10.96.0.10:53: no such hostимя и namespace
lookup …: i/o timeoutдо CoreDNS не доходят пакеты: default deny без allow-dns, сам CoreDNS
connect: connection refused сразунет готовых эндпоинтов; процесс слушает не тот адрес или порт
i/o timeout при соединенииNetworkPolicy, сеть между узлами
Таймаут DNS длиннее, чем кажется

Когда пакеты до сервера имён не доходят, Go 1.27.1 при ndots:5 вернул i/o timeout через 40 секунд (опыт в Docker): четыре имени × attempts:2 × timeout:5. Если в ошибке есть lookup, виноват DNS, а не сервис.

Суть: живой под отлаживают эфемерным контейнером с --target, падающий копией через --copy-to без проб и меток, узел через debug node/. Нужны права на подресурсы и разрешение Pod Security.

Под жив

Эфемерный контейнер с --target=app видит порты, файлы процесса в /proc/<pid>/fd и /debug/pprof/goroutine?debug=2 через wget localhost:…. Если на уровне пода задан пользователь не root, capabilities профиля могут не подействовать, предупреждает справка kubectl debug.

Под падает

$ kubectl debug orders-7d9f-xk2 -it --copy-to=orders-debug --image=busybox:1.37 -- sh

Копия повторяет спецификацию пода и добавляет отладочный контейнер. Профили снимают с неё метки, аннотации и пробы (init-контейнеры остаются: --keep-init-containers=true) и делают процессы общими. На копию не пойдёт трафик Service, её не посчитает ReplicaSet, и liveness не убьёт зависший на старте процесс. --set-image=app=… подменит образ. Удаляют копию руками. Для беды на узле есть kubectl debug node/<узел> --profile=sysadmin и chroot /host.

Что может не пустить

  • RBAC. kubectl шлёт PATCH на pods/ephemeralcontainers, а во встроенных edit и admin 1.37 этого подресурса нет, хотя pods/exec и pods/portforward есть (grep по всем ClusterRole кластера). Для копии и узла нужно право создавать поды.
  • Pod Security Admission проверяет и ephemeralcontainers (его нет в ignoredPodSubresources), так что sysadmin в namespace уровня baseline отклонят, как и профиль по умолчанию.
Суть: Egress без правил закрыл и DNS, и любое соединение по имени падает на первом шаге, причём долго. Нужны явные разрешения для DNS, своих сервисов, API-сервера и внешних адресов, а селекторы стоит проверить на И и ИЛИ.

Почему упало всё

Default deny на Egress блокирует и DNS: пакет к 10.96.0.10:53 после DNAT идёт на под CoreDNS, которого никто не разрешил, и Go-резолвер ждёт до 40 секунд (вопрос 1). Первым добавляют allow-dns, по UDP и TCP. У API-сервера hostNetwork, его эндпоинт на адресе узла, и podSelector его не выберет, нужен ipBlock:

# orders-egress-api.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: orders-to-apiserver, namespace: default}
spec:
  podSelector:
    matchLabels: {app: orders}
  policyTypes: [Egress]
  egress:
    - to: [{ipBlock: {cidr: 172.23.0.2/32}}]
      ports: [{protocol: TCP, port: 6443}]
$ kubectl get endpointslices kubernetes \
    -o jsonpath='{.endpoints[0].addresses[0]} {.ports[0].port}{"\n"}'
172.23.0.2 6443
$ kubectl apply --dry-run=server -f orders-egress-api.yaml
networkpolicy.networking.k8s.io/orders-to-apiserver created (server dry run)

Сверяет CNI ipBlock до трансляции адреса или после, документация не определяет, и судьбу открытых соединений при новой политике тоже оставляет CNI: такое проверяют на своём.

И или ИЛИ

Два манифеста отличаются одним дефисом перед podSelector, и API понимает их по-разному:

$ kubectl apply --dry-run=server -f orders-from-api.yaml -o jsonpath='{.spec.policyTypes} {.spec.ingress[0].from}{"\n"}'
["Ingress"] [{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"shop"}},"podSelector":{"matchLabels":{"app":"api"}}}]
$ kubectl apply --dry-run=server -f orders-from-api-or.yaml -o jsonpath='{.spec.policyTypes} {.spec.ingress[0].from}{"\n"}'
["Ingress"] [{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"shop"}}},{"podSelector":{"matchLabels":{"app":"api"}}}]

В первом один элемент from: поды app: api из shop. Во втором два: все поды shop и поды app: api из namespace политики. policyTypes в файлах нет, и API записал ["Ingress"].

Суть: свой ServiceAccount, Role на один ConfigMap через resourceNames и RoleBinding, проверка через kubectl auth can-i --as. Токен обновляет kubelet, client-go перечитывает файл сам, ломается клиент, прочитавший токен один раз.
# orders-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata: {name: orders, namespace: default}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: orders-config-reader, namespace: default}
rules:
  - apiGroups: [""]
    resources: [configmaps]
    resourceNames: [orders-flags]
    verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: orders-config-reader, namespace: default}
subjects:
  - {kind: ServiceAccount, name: orders, namespace: default}
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: orders-config-reader}
$ kubectl apply --dry-run=server -f orders-rbac.yaml
serviceaccount/orders created (server dry run)
role.rbac.authorization.k8s.io/orders-config-reader created (server dry run)
rolebinding.rbac.authorization.k8s.io/orders-config-reader created (server dry run)

resourceNames сужает права до одного объекта, но list и watch по документации RBAC пройдут, только если в запросе есть селектор полей metadata.name с этим именем: в client-go это fields.OneTermEqualSelector("metadata.name", "orders-flags").String() в ListOptions.FieldSelector.

Проверка и токен

Dry-run не покажет права роли, а auth can-i видит только существующие привязки. После применения kubectl auth can-i watch configmaps/orders-flags -n default --as=system:serviceaccount:default:orders должен ответить yes, а get secretsno. rest.InClusterConfig() в client-go 1.37 задаёт BearerTokenFile, и транспорт перечитывает файл раз в минуту, так что обновление токена kubelet клиент не замечает. Самописный клиент, прочитавший токен при старте, не позже чем через час и семь секунд попадёт в stale-token, а с --service-account-extend-token-expiration=false получит 401.

can-i отвечает yes, а информер получает 403

can-i watch configmaps/orders-flags спрашивает про объект по имени и проходит по resourceNames. Информер без селектора полей просит все ConfigMap namespace, и роль его не пускает. Расширять роль не нужно, нужно добавить селектор metadata.name.