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 с адресацией конкретной реплики.
- 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 / envFrom | volume (файлы) | |
|---|---|---|
| Когда читается | один раз, при execve | в любой момент, как обычный файл |
| Обновление ConfigMap | сам не подхватится — нужен перезапуск контейнера или новый под | файл обновится сам, но с задержкой до ~1–2 минут (kubelet sync period + задержка его кэша) |
| Утечки | видно в crictl inspect на ноде, в /proc/PID/environ, попадает в дочерние процессы и часто в креш-дампы | виден только тому, кто читает файл; можно ограничить права |
| Формат | только плоские строки | любой файл: YAML, PEM-сертификат, keytab |
| Размер | лимит на суммарное окружение | до 1 МиБ на объект (проверка API-сервера) |
| Итог | годится для неизменяемых настроек и для 12-factor-стиля | обязателен для секретов, сертификатов и всего, что хочется менять без рестарта |
Классика. Если конфиг прокинут через env, правка ConfigMap вообще
ничего не меняет: процесс прочитал окружение при старте, а перезапускать его кластеру незачем,
spec Deployment-а ведь не трогали. Есть два честных решения:
(1) считать хеш ConfigMap и класть его в аннотацию pod-шаблона
(checksum/config: {{ ... | sha256sum }}, так принято в Helm-чартах), тогда изменение конфига
меняет шаблон и запускает нормальный rolling update;
(2) монтировать конфиг файлом и перечитывать его в приложении по
fsnotify или по SIGHUP. Второй путь красив, но требует, чтобы
весь код умел безопасно переключаться на новые значения на лету. Это не бесплатно.
- В 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), чтобы длинных статических ключей не существовало вовсе.
Вопросы
8systemd. Он нужен, чтобы непрерывно приводить реальность
к описанному состоянию: держать 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-кластер, вход для внешнего балансировщика |
LoadBalancer | NodePort плюс заказ облачного 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.»
Когда сервисов два, нагрузка ровная, а команда одна. Кластер сам становится отдельной системой
со своей эксплуатацией: обновления control plane, CNI, RBAC, ingress, мониторинг
самого кластера. Для одного бинаря systemd + nginx честнее и дешевле.
k8s начинает окупаться, когда сервисов десятки, команд несколько и нужен
единый способ выкатывать и ограничивать их всех.
env читается один раз при старте и в работающем процессе не обновится, volume обновляется
на лету, но с задержкой, и не светится в /proc/PID/environ.Разница между объектами
Secret отличается от ConfigMap тремя вещами, и ни одна из них не даёт шифрования:
в API значения передаются в base64 (это кодирование, а не защита), к секретам обычно применяют
отдельные RBAC-правила, и kubelet держит их в tmpfs, а не на диске ноды.
В etcd секрет лежит открытым текстом, пока на API-сервере не включён
EncryptionConfiguration. Практический вывод: кто унёс бэкап etcd, унёс
все пароли, а право get secrets в неймспейсе равно знанию всех паролей
этого неймспейса.
env против volume — разница принципиальная
env/envFrom. Значения подставляются в моментexecveи после этого заморожены навсегда. Правка ConfigMap проходит мимо:specDeployment-а не поменялся, значит поды пересоздавать незачем. Плюс значения видны в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 | liveness | |
|---|---|---|---|
| Вопрос | «поднялся?» | «готов принимать трафик?» | «не завис ли навсегда?» |
| Провал | рестарт контейнера (после исчерпания порога) | адрес помечают неготовым в EndpointSlice | рестарт контейнера |
| Проверять | тот же эндпоинт, что readiness, но с большим бюджетом времени | прогрет ли кэш, есть ли соединение с БД и брокером, не идёт ли graceful shutdown | только «HTTP-хендлер отвечает» — плоский 200 OK |
| Внешние зависимости | можно | можно и нужно — но осторожно | категорически нельзя |
Как пробы роняют сервис — четыре сценария
- БД в liveness. База моргнула на 30 секунд. Liveness падает
у всех подов сразу, kubelet перезапускает их все, поды стартуют,
снова не видят базу, снова умирают, и весь сервис уходит в
CrashLoopBackOff. Проблема была на полминуты, а недоступность растянулась на десять минут. Правильно проверять БД в readiness. Тогда поды живы, соединения переустановятся сами, и как только база вернётся, трафик пойдёт без единого рестарта. - Слишком короткий liveness при медленном старте. Приложение поднимается 40 секунд
(миграции, прогрев кэша),
initialDelaySeconds: 10, порог 3, период 5. Контейнер убивают на 20–25-й секунде, и он никогда не стартует. Раньше это лечили большимinitialDelaySeconds, но тогда зависший под тоже не перезапускался бы полминуты. Правильно ставить startupProbe: бюджетfailureThreshold × periodSecondsзадаётся щедро (скажем, 30 × 5 = 150 с), а после успеха liveness работает с жёсткими нормальными таймингами. - Тяжёлая проба. Readiness, которая на каждый вызов делает
SELECTс диска, под нагрузкой начинает отваливаться по таймауту. Поды по очереди выпадают из балансировки, нагрузка перераспределяется на оставшихся, те тоже перестают успевать. Каскад. Проба должна быть дешёвой: читать заранее посчитанный флаг, а не ходить в базу синхронно. - Общий пул горутин/потоков. Если хендлер пробы обслуживается тем же сервером,
что забит запросами, проба падает не потому, что сервис мёртв, а потому что
он занят. Отсюда практика: отдельный порт и отдельный
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 получает эксклюзивные ядра |
| Burstable | requests заданы, но не равны 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. Если число горутин растёт, почти наверняка течёт и память.
По 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, а не по коду.
желаемые реплики = 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 доволен, пользователи нет.
- 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.
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.
Дальше на него независимо реагируют две цепочки:
- Быстрая: kubelet видит удаление → вызывает
preStop→ шлётSIGTERMпроцессу → ждётterminationGracePeriodSeconds→SIGKILL. - Медленная: EndpointSlice-контроллер помечает адрес в EndpointSlice как не готовый (terminating) → это событие разъезжается по всем нодам → kube-proxy на каждой ноде переписывает правила iptables/nftables → и параллельно ingress-контроллер обновляет свой апстрим, а облачный LB правит свой target group (это может занимать десятки секунд).
Вторая цепочка почти всегда финиширует позже первой. Значит, есть окно, в котором
процесс уже начал закрываться, а трафик на него всё ещё направляют: клиент получает
connection refused или обрыв соединения. Поэтому
preStop: sleep 5..15 стал стандартной практикой: в этот момент
процесс ещё полностью работоспособен и обслуживает запросы,
а система успевает узнать, что под уходит.
Правильная последовательность выключения
- Под помечен на удаление: адрес метят notReady по самому deletionTimestamp,
readiness для этого не нужна. Отдавать 503 в коде по SIGTERM всё равно стоит — это
видят балансировщики, которые сами проверяют под своим health check или при дренаже
смотрят на условие serving завершающегося адреса, — но случится это
уже после
preStop. preStop: sleep на время, заведомо большее, чем задержка распространения эндпоинтов в твоей инсталляции (5–15 секунд, для облачных LB бывает больше). Трафик за это время перетекает на другие поды.- SIGTERM → в коде
srv.Shutdown(ctx): сервер перестаёт принимать новые соединения, но дожидается текущих запросов. - Закрываются фоновые воркеры и консьюмеры (дообработать сообщение и закоммитить оффсет), затем пул БД, затем flush метрик и трейсов.
- Процесс выходит сам, не дожидаясь 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 → трафик. Хороший ответ — назвать участников и точки, где всё обычно ломается.Шаг за шагом
- Сборка и публикация образа. CI собирает multi-stage Dockerfile, тегирует
образ неизменяемым тегом (git SHA, а не
latest) и пушит в registry. Заодно стоит подписать образ и приложить SBOM. - Обновление манифеста. Меняется тег образа в Helm-values / kustomize-overlay.
При GitOps это коммит в репозиторий конфигурации, который подхватывает
Argo CD или Flux; при push-модели это
helm upgradeпрямо из CI. - API-сервер. Принимает объект: аутентификация → авторизация (RBAC) →
admission (mutating — сюда влезают sidecar-инжекторы service mesh
и defaulting) → валидация схемы объекта → admission (validating — сюда
OPA/Kyverno, «без лимитов не пущу») → запись в etcd. На этом
kubectl applyвозвращает управление. Дальше всё асинхронно. - Deployment-контроллер видит новый
template, считает его хеш и создаёт новый ReplicaSet, начиная перекачивать реплики из старого по правилам maxSurge/maxUnavailable. - ReplicaSet-контроллер создаёт Pod-объекты, пока без ноды
(
nodeNameпуст, статусPending). - Scheduler для каждого такого пода прогоняет две фазы:
filter (какие ноды вообще подходят: хватает ли
allocatableпод requests, проходят ли nodeSelector, affinity, taints/tolerations, есть ли нужный том) и score (какая из подходящих лучше: балансировка, spread по зонам, уже скачанный образ). Победителю проставляетсяnodeName— это тоже просто запись в etcd. - kubelet нужной ноды видит «мой под»: монтирует тома и секреты, через CRI
просит runtime создать sandbox (pause-контейнер, который держит network namespace),
и runtime зовёт CNI за IP. Дальше для каждого контейнера kubelet тянет образ
согласно
imagePullPolicy(при необходимости — сimagePullSecrets) и запускает его: сначала init-контейнеры по очереди, затем основные (containerd → runc → namespaces + cgroups + процесс). - Пробы. startup держит остальные выключенными, потом readiness решает, пора ли пускать трафик.
- EndpointSlice-контроллер помечает адрес пода в срезе готовым, kube-proxy на каждой ноде правит iptables/nftables, ingress-контроллер обновляет апстримы — и запросы поехали в новый под.
- Старые поды гасятся по описанной выше процедуре (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 # если что-то не так
| Статус | Причина | Куда смотреть |
|---|---|---|
Pending | scheduler не нашёл ноду: не хватает ресурсов под requests, taint без toleration, PVC не привязался | kubectl describe pod — секция Events, сообщение FailedScheduling |
ImagePullBackOff | нет такого тега, нет доступа к registry, забыт imagePullSecrets | Events; проверить тег руками через crane/docker pull |
CrashLoopBackOff | процесс падает на старте (конфиг, миграции, паника) или liveness убивает раньше времени | kubectl logs --previous, затем describe на Last State |
Running, но 0/1 Ready | readiness не проходит: не тот порт, не тот путь, зависимость недоступна | 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.weight4 у пода иcpu.weight17 у контейнера. - Кого выселяет kubelet при нехватке памяти и диска и чем это отличается от OOM killer.
- Сколько ждёт CrashLoopBackOff и что происходит между
kubectl deleteи SIGKILL.
Прогоны сняты на kind-кластере из одного узла: Kubernetes 1.37.0, containerd 2.3.4, runc 1.4.3, cgroup v2, узел
работает контейнером в Docker Desktop (ядро 7.0.12-linuxkit, 4 CPU, 8 ГБ). В кластер ничего не записывалось, только
чтение и apply --dry-run=server, поэтому остановка, выселение и перезапуски описаны по исходникам тех же
версий.
kubelet, CRI и pause-контейнер
kubelet следит за подами, у которых spec.nodeName совпадает с его узлом, но процессы сам не создаёт,
а просит runtime по CRI (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) | Отсеивает узел | Оценивает, вес |
|---|---|---|
NodeResourcesFit | requests пода не влезают в allocatable минус requests подов узла; больше maxPods | свободнее по requests — лучше, 1 |
TaintToleration | taint NoSchedule или NoExecute без toleration | taints PreferNoSchedule, 3 |
NodeAffinity | nodeSelector, required-правила | preferred-правила, 2 |
InterPodAffinity | required (anti-)affinity к подам | preferred-правила, 2 |
PodTopologySpread | ограничения с DoNotSchedule | с ScheduleAnyway, 2 |
Ещё узлы отсеивают по cordon, hostPort и томам, а очки добавляют за баланс CPU и памяти и за скачанный
образ. Считается всё по requests, реальную загрузку узла планировщик не видит. Под без requests в фильтре весит
ноль, а в оценке NodeResourcesFit идёт как 100m CPU и 200 МиБ.
Taints и tolerations
Taint на узле говорит «сюда нельзя», toleration в поде отвечает «мне можно», но никуда под не тянет: billing с
toleration dedicated=backend сядет и на обычный узел. Чтобы он шёл только на выделенные, добавляют
nodeAffinity на их метку. NoSchedule проверяет планировщик, PreferNoSchedule лишь снижает оценку,
NoExecute ещё и выгоняет работающие поды: этим занят taint-eviction-controller в kube-controller-manager.
Часть taints ставит сам кластер: узел без отчётов 50 секунд (node-monitor-grace-period) получает
node.kubernetes.io/unreachable, узел с Ready=False получает not-ready, оба NoExecute. На этот
случай admission-плагин DefaultTolerationSeconds дописывает каждому поду по toleration:
$ kubectl apply --dry-run=server -f billing-pod.yaml -o yaml | sed -n '/^ tolerations:/,/^ topologySpreadConstraints:/p;/^status:/,$p'
tolerations:
- effect: NoSchedule
key: dedicated
operator: Equal
value: backend
- effect: NoExecute
key: node.kubernetes.io/not-ready
operator: Exists
tolerationSeconds: 300
- effect: NoExecute
key: node.kubernetes.io/unreachable
operator: Exists
tolerationSeconds: 300
topologySpreadConstraints:
status:
phase: Pending
qosClass: Burstable
Под с пропавшего узла контроллер сразу помечает неготовым, но выселяет только через пять минут. Для
stateless-сервиса это долго; своя toleration на тот же ключ с меньшим tolerationSeconds сохранится, а
недостающую плагин допишет. У узла kind taints нет вовсе, хотя это control plane: kubeadm ставит
node-role.kubernetes.io/control-plane:NoSchedule, но kind снимает его, если узел в кластере один.
Affinity против topologySpreadConstraints
Жёсткая podAntiAffinity по kubernetes.io/hostname не даст запустить реплик больше, чем узлов:
лишние останутся в Pending. Мягкая только снижает оценку; у CoreDNS в kind мягкая, с весом 100, и обе реплики
живут на единственном узле. topologySpreadConstraints следит за перекосом: насколько подходящих подов в
домене topologyKey больше, чем в самом пустом. Узел без метки из topologyKey фильтр отсеивает, и
зональное ограничение с DoNotSchedule на кластере без зон оставит в Pending все поды. Если своих ограничений нет, а под выбран
Service, ReplicaSet или StatefulSet, планировщик подставляет системные: по hostname maxSkew: 3, по зоне
maxSkew: 5, оба ScheduleAnyway.
Init-контейнеры и нативные sidecar
Init-контейнеры идут по одному, каждый должен выйти с кодом 0. Упавший init-контейнер kubelet перезапускает с растущей паузой,
а при restartPolicy: Never весь под становится Failed. В kubectl get pods это
Init:1/2, Init:Error или Init:CrashLoopBackOff.
Агенту или прокси в init места нет: init обязан завершиться. Раньше агента клали вторым контейнером в
containers и получали три беды: приложение стартовало раньше агента, Job не завершался, потому что
агент не выходит, а при остановке агент получал SIGTERM вместе с приложением и мог умереть первым. Нативный
sidecar устроен иначе: это init-контейнер с restartPolicy: Always, стабильный с Kubernetes 1.33.
Второй контейнер в containers | Нативный sidecar | |
|---|---|---|
| Старт | после всех init, вместе с приложением | в очереди init; дальше пускают после старта и startupProbe |
| Перезапуск | по restartPolicy пода | всегда |
| Job | держит под, пока не выйдет | не держит: основные вышли — sidecars останавливают |
| Остановка | SIGTERM вместе со всеми | после выхода основных, в обратном порядке объявления |
| Пробы | есть | есть; у обычного init API их отвергает |
oom_score_adj | по QoS | не выше, чем у основного контейнера с наименьшим requests.memory |
Запрос пода планировщик берёт по формуле KEP-753 (AggregateContainerRequests): большее из суммы основных
контейнеров со всеми sidecars и самого тяжёлого шага инициализации, где работает init-контейнер и запущенные до
него sidecars. У billing 250m + 50m = 300m и 192 МиБ; миграция с requests.memory: 2Gi сделала бы весь под
двухгигабайтным. Ненулевые коды выхода sidecars при остановке пода документация считает нормой. А
restartPolicy: Always у контейнера в containers API с 1.35 тоже примет (бета
ContainerRestartRules), но sidecar из него не получится: это просто политика перезапуска.
Как requests и limits становятся файлами cgroup
QoS-класс API пишет в status.qosClass при создании пода. По нему kubelet выбирает ветку cgroup (у
Burstable и BestEffort свои slice, Guaranteed висят прямо в kubepods) и создаёт slice пода, а runc внутри него
заводит scope на каждый контейнер, включая pause. Приставку kubelet- дал kind: он ставит kubelet
cgroupRoot: /kubelet, а на узле kubeadm путь начнётся с kubepods.slice/kubepods-burstable.slice.
# cat /proc/4704/cgroup
0::/kubelet.slice/kubelet-kubepods.slice/kubelet-kubepods-burstable.slice/kubelet-kubepods-burstable-pod7d672ea5_d2db_4c8e_be14_cd10571ef470.slice/cri-containerd-27db794ea19c9b8b338df14d71e507f1bd2f04a6846aeb20201883322d62877a.scope
# cd /sys/fs/cgroup/kubelet.slice/kubelet-kubepods.slice
# for d in . kubelet-kubepods-burstable.slice kubelet-kubepods-besteffort.slice; do echo "$d: cpu.weight=$(cat $d/cpu.weight) memory.max=$(cat $d/memory.max)"; done
.: cpu.weight=157 memory.max=8320299008
kubelet-kubepods-burstable.slice: cpu.weight=37 memory.max=max
kubelet-kubepods-besteffort.slice: cpu.weight=1 memory.max=max
# cd kubelet-kubepods-burstable.slice/kubelet-kubepods-burstable-pod7d672ea5_d2db_4c8e_be14_cd10571ef470.slice
# for f in cpu.weight cpu.max memory.max memory.oom.group memory.high; do echo "$f: pod=$(cat $f) coredns=$(cat cri-containerd-27db*/$f) pause=$(cat cri-containerd-72dc*/$f)"; done
cpu.weight: pod=4 coredns=17 pause=1
cpu.max: pod=max 100000 coredns=max 100000 pause=max 100000
memory.max: pod=178257920 coredns=178257920 pause=max
memory.oom.group: pod=0 coredns=1 pause=0
memory.high: pod=max coredns=max pause=max
У CoreDNS requests.cpu: 100m, requests.memory: 70Mi и limits.memory: 170Mi.
- kubepods: в
memory.maxвся память узла (8125292Ki) за вычетомkube-reservedиsystem-reserved, которых в kind нет;cpu.weight157 получен из 4 CPU. - QoS-slice: вес Burstable 37 посчитан из суммы CPU-requests Burstable-подов узла (950m); kubelet пересчитывает его при запуске и остановке подов и раз в минуту.
- slice пода: в
memory.maxиcpu.maxидут суммы лимитов, если лимит есть у каждого контейнера, включая init (ResourceConfigForPod). У billing нет лимита CPU у агента и памяти уmigrate, так что slice пода получитmaxв обоих файлах. - scope контейнера:
memory.max=limits.memory, вcpu.maxквота и период в микросекундах (глава 3.1): уappс лимитом 1 CPU это100000 100000, без лимитаmax 100000. memory.oom.group=1kubelet на cgroup v2 ставит каждому контейнеру, и при OOM гибнут все его процессы (глава 1.4 статьи «Linux и ОС»).memory.high=max: MemoryQoS в kubelet 1.37 в бете и включена, но безmemoryThrottlingFactor(по умолчанию пуст) файл не пишется.
requests.cpu переводится в cpu.weight дважды, разными
формулами.
kubelet считает CPU в shares из cgroup v1: 100m × 1024 / 1000 = 102. Для своих slice он переводит shares в
cpu.weight линейно (getCPUWeight): 1 + (102 − 2) × 9999 / 262142, в целых 4. Для контейнера
те же 102 shares через CRI уходят в runc, а там с версии 1.4 формула квадратичная по логарифму
(ConvertCPUSharesToCgroupV2Value), её подбирали под стандартные 100 при 1024 shares. Вес работает только
при конкуренции и только между соседями по уровню. Slice подов посчитаны одной формулой, и пропорции между подами
верны, а контейнеры одного пода со 100m и 1 CPU получат 17 и 100, не 1 к 10 (у slice вышло бы 4 и 39).
Две детали API меняют эти числа ещё до узла. Контейнер с одними limits (limits-only.yaml)
получает такие же requests и молча становится Guaranteed:
$ kubectl apply --dry-run=server -f limits-only.yaml -o jsonpath='{.spec.containers[0].resources}{"\n"}{.status.qosClass}{"\n"}'
{"limits":{"cpu":"500m","memory":"256Mi"},"requests":{"cpu":"500m","memory":"256Mi"}}
Guaranteed
А ресурсы уровня пода, spec.resources, задают общий бюджет, в котором контейнеры без своих запросов
делят CPU и память, и QoS тогда считается по поду. В 1.37 это бета и включена, dry-run такого пода проходит.
Нехватка памяти: выселение kubelet и OOM killer
OOM killer живёт в ядре и срабатывает, когда cgroup упёрлась в memory.max или кончилась память
машины (выбор жертвы разобран в главе 1.4 статьи «Linux и ОС»). Выселение (node-pressure eviction) устраивает
kubelet: раз в 10 секунд сравнивает сигналы узла с порогами и останавливает под целиком, помечая его Evicted. На
выбор ядра kubelet влияет через oom_score_adj контейнеров:
# for id in $(crictl ps -q); do set -- $(crictl inspect -o go-template --template '{{.info.pid}} {{.status.metadata.name}}' $id); echo "$(cat /proc/$1/oom_score_adj) $2"; done
992 coredns
992 coredns
1000 local-path-provisioner
-997 kindnet-cni
-999 kube-proxy
-997 etcd
…
По GetContainerOOMScoreAdjust Guaranteed и любой под с
system-node-critical получают −997 (так у kindnet и etcd, хотя они Burstable), BestEffort получает 1000,
Burstable 1000 − 1000 × requests.memory / память узла в пределах от 3 до 999: у CoreDNS 1000 − 1000 × 70 МиБ /
7,75 ГиБ = 992. Чем меньше запросил, тем ближе к BestEffort. kube-proxy ставит себе −999 сам, pause получает −998 от
containerd.
Пороги и порядок выселения
kubelet следит за сигналами memory.available (ёмкость минус working set), nodefs.* и
imagefs.* (место и inode), pid.available. Жёсткие пороги по умолчанию в 1.37 на Linux:
memory.available<100Mi, nodefs.available<10%, nodefs.inodesFree<5%,
imagefs.available<15%, imagefs.inodesFree<5%. Мягкие (evictionSoft)
срабатывают, продержавшись свой период ожидания. За диск kubelet сначала чистит мёртвые контейнеры и
неиспользуемые образы. Жертв при нехватке памяти упорядочивает rankMemoryPressure, и QoS среди ключей
нет: первыми идут поды, чьё потребление больше requests.memory, среди них с меньшим priority, затем
с наибольшим превышением. BestEffort уходит первым, потому что нулевые requests превышены всегда, а
Guaranteed выше своих requests почти не вырастет. Статические поды и поды с priority от 2 000 000 000
(system-cluster-critical, system-node-critical) kubelet не выселяет.
У выселенного пода phase Failed, reason Evicted и условие DisruptionTarget. При жёстком пороге kubelet
даёт 1 секунду вместо terminationGracePeriodSeconds (документация пишет «0s»), но между сигналом и
SIGKILL всё равно оставляет свой минимум в 2 секунды. При мягком пороге он берёт меньшее из
evictionMaxPodGracePeriod и grace period пода. Порог памяти вычитается из Allocatable, но не из
memory.max kubepods: остаётся окно, чтобы выселить под раньше ядра, но быстрый рост памяти его проскакивает.
$ kubectl describe node dv-k8s-control-plane | grep -E '^(Taints|Capacity|Allocatable):|^ (cpu|memory):'
Taints: <none>
Capacity:
cpu: 4
memory: 8125292Ki
Allocatable:
cpu: 4
memory: 8125292Ki
$ kubectl get --raw /api/v1/nodes/dv-k8s-control-plane/proxy/configz | jq -c '.kubeletconfig | {evictionHard, mergeDefaultEvictionSettings, crashLoopBackOff, containerLogMaxSize, containerLogMaxFiles}'
{"evictionHard":{"imagefs.available":"0%","nodefs.available":"0%","nodefs.inodesFree":"0%"},"mergeDefaultEvictionSettings":false,"crashLoopBackOff":{"maxContainerRestartPeriod":"5m0s"},"containerLogMaxSize":"10Mi","containerLogMaxFiles":5}
Allocatable памяти в kind равен Capacity, хотя порог в 100 МиБ должен был его уменьшить. kind задаёт kubelet только
дисковые пороги, а kubelet не дополняет заданный evictionHard умолчаниями, пока
mergeDefaultEvictionSettings: false, а это значение по умолчанию.
Порога памяти здесь нет, и нехватку встретит только OOM killer. На своём узле так же: дописал в конфиг один
imagefs.available и потерял memory.available.
ephemeral-storage
ephemeral-storage учитывает диск узла, который под тратит мимо PersistentVolume: слой записи контейнеров,
логи в /var/log/pods и emptyDir на диске. Requests сверяет планировщик
(Insufficient ephemeral-storage), а limits в cgroup не попадают: лимита на занятое место у cgroup v2 нет.
Их проверяет kubelet в том же десятисекундном цикле и выселяет под с grace period в 1 секунду, если контейнер
превысил свой лимит (логи плюс слой записи, когда образы на той же файловой системе), если сумма по поду превысила
сумму лимитов или если emptyDir перерос sizeLimit. В сообщении будет «Container app exceeded
its local ephemeral storage limit "1Gi"».
Логи входят в счёт, и болтливый Go-сервис с маленьким лимитом может выселить сам себя: ротацию по
containerLogMaxSize и containerLogMaxFiles (на kind 10Mi и 5) kubelet проверяет раз в 10 секунд.
А emptyDir с medium: Memory живёт в tmpfs и считается памятью (глава 2.3 статьи «Docker»).
Фазы, состояния и растущая пауза перезапуска
Фаз у пода пять: Pending, Running, Succeeded, Failed, Unknown, и все они грубые: Running значит лишь «контейнеры созданы, хотя бы один работает или перезапускается». Подробности лежат в состояниях контейнеров и условиях пода:
$ kubectl get pod -n kube-system coredns-559f6c778d-gx9h8 -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}{"\n"}'
PodReadyToStartContainers=True Initialized=True Ready=True ContainersReady=True PodScheduled=True
PodReadyToStartContainers (стабильно с 1.37) означает, что готовы sandbox и сеть, Initialized
говорит о законченных init-контейнерах. В колонке STATUS у kubectl get pods фаза стоит не всегда:
printPod заменяет её причинами ожидания и завершения контейнеров.
| STATUS | Что за этим стоит |
|---|---|
ContainerCreating | kubelet монтирует тома, создаёт sandbox, тянет образ |
ErrImagePull, ImagePullBackOff | образ не скачался; повторы с паузой от 10 до 300 секунд |
CreateContainerConfigError | конфиг не собрать: нет ConfigMap или Secret для env, runAsNonRoot при нечисловом USER образа |
CrashLoopBackOff | контейнер завершился, kubelet выжидает паузу перед перезапуском |
Terminating | есть deletionTimestamp, а фаза ещё не конечная; такой фазы нет |
Паузу считает flowcontrol.Backoff в kubelet, ключ у неё «под плюс контейнер». После первого падения
контейнер перезапускается сразу, дальше пауза 10 секунд и удваивается: 10, 20, 40, 80, 160, затем 300. Счёт
сбрасывается, если от прошлого перезапуска до нового падения прошло больше 10 минут (HasExpiredFunc), и
сервис, падающий раз в полчаса, каждый раз встаёт сразу. Потолок в 1.37
настраивается на узле: KubeletCrashLoopBackOffMax в бете с 1.35, поле
crashLoopBackOff.maxContainerRestartPeriod принимает от 1 до 300 секунд (на kind 5m0s). Альфа
ReduceDefaultCrashLoopBackOffDecay (от 1 до 60 секунд) выключена.
Остановка пода по шагам
Две независимые цепочки удаления (процессы на узле и адрес в балансировке) и пауза между ними разобраны в главе 3.1. Здесь они по участникам:
- API-сервер. Берёт grace period из запроса, иначе из
spec.terminationGracePeriodSeconds(по умолчанию 30); под безnodeNameили завершённый удаляет сразу. СтавитdeletionTimestampв будущее, на сейчас плюс grace period, и оставляет объект в etcd (CheckGracefulDelete). - EndpointSlice. Адрес пода сразу получает
ready: falseиterminating: true, аservingповторяет readiness (podToEndpoint). kube-proxy убирает адрес из правил, когда до него дойдёт обновление, а адресаserving+terminatingберёт, только если готовых не осталось. Открытые TCP-соединения правила не рвут. - kubelet. Выключает liveness- и startup-пробы (readiness работает дальше) и останавливает контейнеры параллельно; отсчёт grace period начинается здесь.
- preStop. kubelet выполняет хук и ждёт не дольше grace period; время хука вычитается из остатка.
sleepотсчитывает сам kubelet,execзапускает команду в контейнере. - Сигнал. CRI
StopContainerполучает остаток, но не меньше 2 секунд. containerd шлёт первому процессу контейнераlifecycle.stopSignal(альфа, выключена), иначеSTOPSIGNALобраза, иначе SIGTERM, а когда время выйдет, SIGKILL. Sidecars ждут выхода основных контейнеров и получают сигнал после них. - Конец. Когда всё остановлено, kubelet удаляет под с grace period 0 и условием на UID.
Действие sleep стабильно с 1.34 и принимает ноль. API сверяет его с grace period; в
sleep-too-long.yaml у пода terminationGracePeriodSeconds: 30 и sleep: { seconds: 40 }:
$ kubectl apply --dry-run=server -f sleep-too-long.yaml
The Pod "billing" is invalid: spec.containers[0].lifecycle.preStop.sleep: Invalid value: 40: must be non-negative and less than terminationGracePeriodSeconds (30)
$ kubectl get endpointslice -n kube-system -l kubernetes.io/service-name=kube-dns -o jsonpath='{range .items[*].endpoints[*]}{.targetRef.name} {.conditions}{"\n"}{end}'
coredns-559f6c778d-tdsh4 {"ready":true,"serving":true,"terminating":false}
coredns-559f6c778d-gx9h8 {"ready":true,"serving":true,"terminating":false}
Сообщение говорит «less than», но validateSleepAction пропускает и равное: sleep: 30 при
30 секундах проходит. Второй вывод показывает условия живых адресов CoreDNS; у удаляемого пода они сменятся, как в
шаге 2.
Хук exec: { command: ["sleep", "10"] } требует бинарника sleep в образе. В scratch и
distroless его нет: хук падает сразу, kubelet пишет событие FailedPreStopHook и без паузы шлёт SIGTERM,
так что гонка с балансировкой возвращается. Образ CoreDNS как раз такой:
# ls /proc/4704/root/bin /proc/4704/root/sbin /proc/4704/root/usr/bin
/proc/4704/root/bin:
/proc/4704/root/sbin:
/proc/4704/root/usr/bin:
Для них и сделано действие sleep.
Что делать при остановке самому Go-сервису, разобрано в главе 10.2 статьи «Веб-сервисы». Со стороны пода важно,
что сигнал получает только первый процесс контейнера, а Go-бинарь без обработчика умирает по SIGTERM сразу, с
кодом 143. Бюджет считают от хука:
terminationGracePeriodSeconds ≥ preStop + самый долгий запрос + закрытие ресурсов + запас, у billing
10 + 25 + 5 + 5 = 45.
Вопросы
3Как собрано сообщение
Шаблон «0/%v nodes are available» лежит в FitError.Error(). К нему дописываются пары «число
причина», отсортированные как строки, и сообщение вытеснения с приставкой «preemption:». Один узел может дать
несколько причин, поэтому сумма чисел бывает больше числа узлов. Живой пример нашёлся в событиях CoreDNS с
первых секунд кластера, когда узел ещё не был Ready:
$ kubectl events -n kube-system --for pod/coredns-559f6c778d-gx9h8 | head -3
LAST SEEN TYPE REASON OBJECT MESSAGE
81m Warning FailedScheduling Pod/coredns-559f6c778d-gx9h8 0/1 nodes are available: 1 node(s) had untolerated taint(s). preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.
80m Normal Scheduled Pod/coredns-559f6c778d-gx9h8 Successfully assigned kube-system/coredns-559f6c778d-gx9h8 to dv-k8s-control-plane
Какой именно taint не подошёл, сообщение в 1.37 не говорит.
| Причина в 1.37 | Что проверить |
|---|---|
Insufficient cpu, Insufficient memory | requests пода с init-контейнерами и sidecars против Allocated resources в describe node |
Too many pods | maxPods узла, по умолчанию 110 |
node(s) had untolerated taint(s) | spec.taints узлов и tolerations пода |
node(s) didn't match Pod's node affinity/selector | nodeSelector, nodeAffinity, метки узлов |
node(s) didn't match pod topology spread constraints | maxSkew; с «(missing required label)» у узлов нет метки topologyKey |
node(s) were unschedulable | узлы в cordon, например во время drain |
Что происходит с подом дальше
Pending-под ждёт в очереди и идёт на новую попытку, когда в кластере меняется что-то для него важное (новый
узел, удалённый под, снятый taint), с паузой от 1 до 10 секунд и не реже раза в 5 минут. Под с priority выше,
чем у подов на узле, может их вытеснить (DefaultPreemption), тогда у него появится
status.nominatedNodeName. Под такие поды Cluster Autoscaler добавляет узлы, если это поможет.
kubectl describe node <узел> | sed -n '/Allocated resources/,/Events/p' # сколько уже расписано
«Узлы пустые, а Insufficient memory»: фильтр складывает requests, и поды, запросившие по 4 ГиБ при потреблении 300 МиБ, занимают узел целиком. Тяжёлый init-контейнер раздувает запрос всего пода.
oom_score с поправкой от
QoS, а если упёрлась cgroup контейнера, то только внутри неё. Первым часто успевает ядро: kubelet проверяет раз в
10 секунд.Как выбирает kubelet
На узле кэш с requests.memory: 4Gi тратит 3,5 ГиБ, а API с requests.memory: 256Mi
тратит 600 МиБ. Кэш ест больше, но в свои requests укладывается; API превысил их на 344 МиБ и уходит первым. За
одну проверку kubelet выселяет один под и снова смотрит на сигналы. У пода будет reason: Evicted и
сообщение «The node was low on resource: memory. Threshold quantity: …, available: …. Container api was using …,
request is …», а на узле — событие EvictionThresholdMet.
Откуда OOMKilled без Evicted
- Контейнер упёрся в свой
limits.memory. Это OOM внутри его cgroup, память узла ни при чём: вlastStatereason OOMKilled, растётrestartCount, а под остаётся (глава 3.1). - Память узла кончилась быстрее проверки kubelet. Глобальный OOM killer решает по
oom_score_adj: процесс BestEffort или Burstable с маленьким запросом (поправка около 1000) и 300 МиБ проиграет Guaranteed-процессу с −997 и 3 ГиБ. kubelet пишет на узел событиеSystemOOM.
kubectl get pod api-7d9f8b6c5d-xk2wq -o jsonpath='{.status.reason} {.status.containerStatuses[*].lastState.terminated.reason}{"\n"}'
kubectl events --for node/<узел> | grep -E 'EvictionThresholdMet|SystemOOM'
requests.memory ставь по рабочему потреблению под нагрузкой: заниженный запрос делает сервис первой
жертвой и выселения, и OOM killer. GOMEMLIMIT влияет на выселение только через фактическое
потребление: kubelet сравнивает working set с requests, про лимит он не знает.
Куда уходят 30 секунд
| Время | Что происходит |
|---|---|
| 0 с | kubelet начал остановку, идёт sleep 10; адрес уже ready: false |
| 10 с | SIGTERM; StopContainer получил остаток, 20 секунд |
| 10–30 с | readiness 503, своя пауза в коде, srv.Shutdown(ctx), закрытие ресурсов |
| 30 с | SIGKILL всем, кто не вышел; exit code 137 |
Типичная ошибка: таймаут Shutdown в 30 секунд из примера без preStop или своя пауза в коде поверх
preStop. Пример из главы 10.2 спит 5 секунд и даёт Shutdown 20: с preStop на 10 секунд ему нужно
не меньше 35 секунд grace period, лучше 40.
Сигнал не доходит или не тот
- Первым процессом оказался shell.
ENTRYPOINTв shell-форме запускает/bin/sh -c, и dash в debian остаётся PID 1: SIGTERM забирает он, а приложение через 20 секунд умирает от SIGKILL (глава 2.1 статьи «Docker»). STOPSIGNALв образе. containerd шлёт его вместо SIGTERM. Go-процесс без обработчика наSTOPSIGNAL SIGQUITпечатает стеки горутин и выходит с кодом 2.- Упал хук.
execсоsleepв distroless падает мгновенно, SIGTERM приходит сразу (событиеFailedPreStopHook).
Shutdown не укладывается
srv.Shutdown ждёт активные запросы, и потоковая выдача или long polling держат его до таймаута, если
хендлеры не слушают общий контекст остановки (подробно в главе 10.2 статьи «Веб-сервисы»).
kubectl delete --grace-period=5 и drain с тем же флагом заменяют значение из spec: хук на 10 секунд
получит 5, а между SIGTERM и SIGKILL пройдут минимальные 2 секунды.
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. Шаги ниже посчитаны по этим формулам в
предположении, что новые поды становятся доступными одновременно.
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 не повторяет.
Такой бюджет запрещает любое выселение, и drain узла не закончится: документация это допускает. Для нескольких
реплик удобнее maxUnavailable: 1 и AlwaysAllow, чтобы узел не держали поды в CrashLoopBackOff.
HPA изнутри
Контроллер (pkg/controller/podautoscaler/horizontal.go) раз в 15 секунд берёт наибольшее из
предложений по метрикам. Для Resource (GetResourceReplicas):
- потребление подов с метриками делится на сумму их requests, отношение к цели даёт
ratio; - при
0,9 ≤ ratio ≤ 1,1число реплик не меняется; порог задаёт флаг--horizontal-pod-autoscaler-tolerance, а с 1.37 стабильно полеtoleranceвbehavior.scaleUpиscaleDown; - поды без метрик при росте считаются нулём, при уменьшении полным request (или целью, если она выше 100 %), неготовые поды для CPU при росте тоже нулём; если поправка меняет направление, масштаба нет;
- результат:
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.9 | metrics.k8s.io | Resource, ContainerResource | CPU и память от kubelet; память в виде working set: memory.current − inactive_file |
| prometheus-adapter | custom.metrics.k8s.io, external.metrics.k8s.io | Pods, Object, External | PromQL в метрики; последний релиз 0.12.0, май 2024 |
| KEDA 2.20 | external.metrics.k8s.io | HPA создаёт сам из ScaledObject | 0 ↔ 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".
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 после удаления остаются.
Если новая версия не становится 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.3 | Kustomize 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-ом |
| Где уместен | распространяемый чарт, сторонние системы | свои сервисы с небольшой разницей окружений |
Вопросы
4disruptionsAllowed равен нулю, а 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 продолжит сам |
Флаг удаляет поды без бюджета: сервис с одной репликой получит простой, сервис с неготовыми подами может
потерять последние живые. В скриптах обслуживания 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 признаёт, что активную Job контроллер может не увидеть. Под
внутри Job тоже иногда стартует дважды (глава 4.3 статьи «CI/CD»). Задачу делают идемпотентной: отчёт за
дату перезаписывается, рассылки защищены ключом или блокировкой в базе.
Арифметика контроллера
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.memory даёт ratio выше, и HPA растёт без нагрузки, как с CPU
в главе 3.1. Подбором requests и цели это не лечится: память не падает с числом реплик.
--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.yaml | 1 | 6 | api:1.4.2 |
upgrade --set image.tag=1.4.3 | 2 | 2 | api:1.4.3 |
upgrade без -f и --set | 3 | 2 | api:1.4.3 |
upgrade -f values-prod.yaml --set image.tag=1.4.3 | 4 | 6 | api: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.4 | 5 | 6 | api: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.
helm upgrade без флагов сохранил values, и легко решить, что Helm их всегда помнит. Следующий
--set опровергнет это в prod. Перед выкаткой запускают helm diff upgrade: он показал бы
replicas: 6 → 2 до записи в кластер.
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).
Случайность видна по счётчикам. 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"]
| Запрос | Обычный Service | Headless Service |
|---|---|---|
A orders.shop.svc.cluster.local | ClusterIP | адреса готовых эндпоинтов; нет готовых — 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
Шесть запросов из восьми ушли впустую, и каждый прошёл DNAT и conntrack. Отказы CoreDNS даёт сам, наружу уходят последние два. Число запросов зависит от библиотеки:
| Резолвер, тот же стенд | api.example.com | api.example.com. |
|---|---|---|
| Go 1.27.1, чистый резолвер | 8: A и AAAA на четыре имени | 2 |
glibc 2.41, getent ahosts (Debian 13) | 4: только A (AI_ADDRCONFIG, IPv6-адреса нет); с --no-addrconfig 8 | 1 |
musl 1.2.6, wget из busybox (Alpine 3.24) | 8 | 2 (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, то есть шлюз видит отдельные вызовы, а не соединение.
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.36 | SYS_PTRACE | сеть, PID и IPC узла, корень узла в /host |
baseline, restricted | ничего; у restricted не root и drop: ALL | обычный под на узле |
netadmin | NET_ADMIN, NET_RAW | namespace узла без /host |
sysadmin | privileged | namespace узла, /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 лишь выбирает один под по селектору.
Сервис на 127.0.0.1:8080 через port-forward доступен: containerd подключается к localhost
внутри пода. Соседи и Service ходят на IP пода и получают отказ (адрес слушателя разобран в главе 1.3 статьи «Linux и ОС»).
Вопросы
4no such host указывает на DNS, мгновенный отказ на эндпоинты или порт, таймаут на политику
или сеть.Звено за звеном
- Имя. Короткое
ordersнаходится только из своего namespace, из другого нужноorders.shop. Проверяй тем же резолвером, что у сервиса (GODEBUG=netdns=2,getent hosts), а неdig(глава 1.3 статьи «Linux и ОС»). - Эндпоинты.
kubectl get endpointslices -n shop -l kubernetes.io/service-name=orders: если пусто, селектор не совпал с метками, еслиreadyложно, под не проходит readiness. Сверь иtargetPort. - Узел.
iptables-save -t nat | grep 'shop/orders'покажет цепочку сервиса. - Политика.
kubectl get networkpolicyв обоих namespace: Egress клиента, Ingress сервера. - Процесс. Эфемерный контейнер с
--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, сеть между узлами |
Когда пакеты до сервера имён не доходят, 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иadmin1.37 этого подресурса нет, хотяpods/execиpods/portforwardесть (grepпо всем ClusterRole кластера). Для копии и узла нужно право создавать поды. - Pod Security Admission проверяет и
ephemeralcontainers(его нет вignoredPodSubresources), так чтоsysadminв namespace уровняbaselineотклонят, как и профиль по умолчанию.
Почему упало всё
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"].
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 secrets — no. 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.