Раздел 3 · Kubernetes — глава 3.1

Kubernetes: Deployment, StatefulSet и почему балансировка не round robin

Две трети вопросов про Kubernetes на собеседовании — это на самом деле один вопрос: «понимаешь ли ты, что под — это не виртуалка». Всё остальное — StatefulSet, Service, балансировка — следствия. Начнём с этого, а закончим тем, почему твой gRPC-клиент ходит в один под из десяти.

§0Карта Kubernetes, если основа пока плавает

Kubernetes — это API желаемого состояния и набор контроллеров. Ты не говоришь системе «запусти контейнер на node-2». Ты создаёшь объект: «хочу три готовые копии приложения с такими ресурсами и таким образом». Контроллеры замечают разницу между желаемым и фактическим состоянием и непрерывно её устраняют.

kubectl / CI / GitOps │ HTTPS ▼ ┌──────────────────── control plane ────────────────────┐ │ kube-apiserver ── etcd │ │ │ │ │ ├─ scheduler: выбирает узел для нового Pod │ │ └─ controller-manager: создаёт/чинит объекты │ └──────┼────────────────────────────────────────────────┘ │ watch ▼ ┌──────────────────────── worker node ──────────────────┐ │ kubelet → container runtime → контейнеры Pod │ │ CNI → сеть CSI → тома │ └────────────────────────────────────────────────────────┘

kube-apiserver — единственная точка изменения состояния кластера: проверяет аутентификацию, RBAC, admission и схему, затем сохраняет объект в etcd. Scheduler выбирает узел для Pod с учётом requests, selectors, affinity, taints и доступности томов. Controller manager запускает reconcile loops. Kubelet на узле следит, чтобы назначенные ему Pod действительно работали.

ОбъектЗачем нуженЧто запомнить сначала
PodОдна совместно запущенная группа контейнеровМинимальная единица, эфемерная; обычно не создаётся руками
DeploymentStateless-приложение и rolling updateУправляет ReplicaSet, Pod взаимозаменяемы
StatefulSetСтабильные имена и персональные PVCПоды имеют идентичность и порядок
ServiceСтабильный адрес поверх меняющихся PodОтбирает endpoints по labels
Ingress / GatewayHTTP(S)-вход и маршрутизацияНужен работающий controller, одного YAML мало
ConfigMap / SecretКонфигурация отдельно от образаSecret в etcd не становится безопасным только из-за base64
PV / PVC / StorageClassХранилище и запрос на негоPVC просит ресурс, PV представляет выданный том
Job / CronJobЗавершаемая и периодическая работаУспех — процесс завершился с кодом 0
NamespaceЛогическая область имён и политикНе является полной границей безопасности сам по себе

Labels связывают объекты. Service не «принадлежит Deployment»: он независимо выбирает Pod по selector. Если labels Pod и selector Service не совпали, Service существует, но endpoints пусты. Это одна из первых вещей, которые проверяют при недоступном приложении.

Requests участвуют в планировании: scheduler ищет узел, где заявленный ресурс помещается. Limits ограничивают использование после запуска. CPU limit приводит к throttling, memory limit — к OOMKill. Если requests не заданы, scheduler может уплотнить узел сильнее, чем выдержит реальная нагрузка.

Пять шагов диагностики вместо случайных команд

# 1. Что существует и где запущено
$ kubectl get pod -n shop -o wide

# 2. Почему объект в этом состоянии: Events, probes, mounts, image
$ kubectl describe pod api-7d9f8-x4k2b -n shop

# 3. Что сказал текущий и предыдущий процесс
$ kubectl logs api-7d9f8-x4k2b -n shop --all-containers
$ kubectl logs api-7d9f8-x4k2b -n shop --previous

# 4. Есть ли у Service готовые endpoints
$ kubectl get service,endpointslice -n shop

# 5. Последние события по времени
$ kubectl get events -n shop --sort-by=.lastTimestamp

Сначала всегда фиксируй namespace и имя объекта. Затем разделяй проблему по границам: объект не создался → Pod не запланирован → image не скачался → контейнер не стартует → probe не проходит → Service не выбрал Pod → входной controller не маршрутизирует. Так диагностика превращается из перебора в дерево решений.

Практический минимум перед продолжением

На локальном кластере самостоятельно: создать Deployment из двух реплик; сломать image tag и найти причину; добавить readiness; создать Service и намеренно испортить selector; выполнить rolling update и rollback; задать requests/limits и получить OOMKilled; смонтировать ConfigMap; удалить Pod и объяснить, кто его восстановил. После этого переходи к §1.

§1Копия или личность

Представь два разных приложения. Первое — stateless-бэкенд на Java: принял HTTP-запрос, сходил в базу, отдал JSON, забыл. Второе — узел кластера PostgreSQL, который держит на диске данные и знает, что он именно второй узел, реплика от такого-то мастера.

Для первого приложения совершенно неважно, как называется процесс и на какой машине он живёт. Их можно запустить три, десять или ноль, перезапустить в любом порядке, убить любой — разницы нет. Каждый экземпляр — взаимозаменяемая копия. Именно на это рассчитан Deployment.

Для второго — важно всё. Реплика должна каждый раз просыпаться со своим диском (чужой ей бесполезен), другие узлы должны уметь до неё дозвониться по стабильному имени, а поднимать их надо по порядку: сначала первый, потом второй, иначе второму не к кому подключаться. Это идентичность, а не копия. Для этого есть StatefulSet.

Всё дальнейшее — просто механика, которая обеспечивает эту разницу. Если на собесе тебя спросят «в чём разница между Deployment и StatefulSet», худший ответ — начать перечислять поля YAML. Хороший ответ начинается ровно с этого абзаца: один управляет неразличимыми копиями, второй — набором пронумерованных личностей, у каждой свой диск и своё DNS-имя.

Спросят

«Можно ли запустить базу в Deployment?» — Технически да, и она даже заработает. Но при обновлении Deployment спокойно поднимет второй под до того, как умрёт первый (стратегия RollingUpdate), оба схватятся за один PVC — и если это ReadWriteOnce, второй просто не запустится, а если ReadWriteMany, ты получишь два процесса на одних файлах и разрушенную базу. Плюс после перезапуска под получит новое имя, и все, кто ходил к нему по имени, промахнутся.

§2Как живёт Deployment

Deployment сам по себе ничего не запускает. Он создаёт ReplicaSet — объект, вся работа которого сводится к одной фразе: «подов с такими метками должно быть N штук». ReplicaSet смотрит на текущее количество, и если их меньше — создаёт, если больше — удаляет. Всё.

Deployment (что катим и как катим) │ ├── ReplicaSet v2 ← 3 пода активный └── ReplicaSet v1 ← 0 подов оставлен для отката │ app-7d9f8-x4k2b app-7d9f8-mn7qp app-7d9f8-hs3vz

Сам Deployment нужен ровно для того, чтобы аккуратно переключаться между версиями. Когда ты меняешь образ, он не трогает старый ReplicaSet — он создаёт новый, с новым хешем в имени, и начинает перекладывать: добавить под в новый, дождаться готовности, убрать под из старого. Скорость этого перекладывания задают maxSurge (сколько подов сверх желаемого разрешено на время выкатки) и maxUnavailable (сколько можно временно недосчитаться).

Старый ReplicaSet остаётся с нулём реплик — это и есть механизм kubectl rollout undo. Никакой магии: команда просто масштабирует старый ReplicaSet обратно вверх, а новый вниз.

Имена подов — <deployment>-<хеш ReplicaSet>-<случайные 5 символов>. Случайный суффикс здесь не мелочь, а декларация: имя пода бессмысленно, не привязывайся к нему. Перезапустился — стал другим.

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

§3Как живёт StatefulSet

StatefulSet даёт четыре гарантии, которых нет у Deployment. Их удобно запоминать не списком, а как одну историю про базу данных.

Стабильное имя. Поды называются pg-0, pg-1, pg-2 — по порядковому номеру, без случайного хвоста. Убил pg-1 — на его месте поднимется pg-1. Это тот же самый узел кластера, а не новый.

Стабильный сетевой адрес. В паре со StatefulSet всегда идёт headless-сервис (о нём в §8), и каждый под получает собственное DNS-имя вида pg-1.pg-headless.prod.svc.cluster.local. Оно переживает перезапуск и переезд на другой узел. Именно это позволяет прописать в конфиге реплики «мой мастер — pg-0.pg-headless» и не переписывать конфиг после каждого рестарта.

Стабильный диск. Через volumeClaimTemplates каждый под получает свой PVC — data-pg-0, data-pg-1. Это не общий том, это персональный. Про это отдельно в §4, потому что тут больше всего граблей.

Порядок. При создании поды поднимаются строго последовательно: pg-0 должен стать Ready, только потом стартует pg-1. При удалении и масштабировании вниз — в обратном порядке, с конца. При обновлении — тоже с конца: сначала pg-2, дождались готовности, потом pg-1, потом pg-0. Логика понятная: мастер обычно нулевой, и трогать его надо последним.

Deployment, RollingUpdate StatefulSet, RollingUpdate ────────────────────────── ──────────────────────────── + new ─┐ pg-2 ✕ → pg-2 new → Ready - old ─┤ вперемешку, ↓ + new ─┤ параллельно pg-1 ✕ → pg-1 new → Ready - old ─┘ порядок не важен ↓ pg-0 ✕ → pg-0 new → Ready строго по одному, с конца

Последовательность можно отключить: podManagementPolicy: Parallel заставит StatefulSet создавать и удалять поды одновременно, сохранив имена и диски. Это разумно, когда узлы равноправны и не зависят друг от друга при старте — скажем, шарды или Kafka-брокеры, которые сами договорятся через ZooKeeper/KRaft.

А ещё у StatefulSet есть встроенная канарейка: updateStrategy.rollingUpdate.partition: 2 означает «обновляй только поды с номером ≥ 2». Меняешь образ — обновится один pg-2, остальные останутся на старой версии. Посмотрел, что живой — опустил partition до 0, поехало дальше. Простой и рабочий механизм поэтапного обновления без всяких Argo Rollouts.

Грабли

StatefulSet застревает намертво, если под не может стать Ready. Скажем, pg-1 не проходит readiness-пробу из-за проблем с диском. StatefulSet не пойдёт дальшеpg-0 так и останется на старом образе, а выкатка будет висеть часами. Deployment в такой ситуации хотя бы остановится с частично обновлённым набором и отдаст ошибку в rollout status. У StatefulSet выкатка просто замирает. Всегда проверяй kubectl rollout status sts/pg, а не «ну я применил манифест».

§3.1Kubernetes Operators: когда одного StatefulSet мало

StatefulSet умеет дать подам стабильные имена, диски и порядок запуска, но он ничего не знает о PostgreSQL, Kafka или Elasticsearch. Он не выберет нового мастера, не сделает backup, не проверит совместимость версий и не выполнит безопасное обновление схемы. Operator добавляет в Kubernetes именно такую предметную операционную логику — то, что без него делал бы опытный администратор по инструкции.

Оператор — не особый встроенный процесс Kubernetes. Обычно это обычный контроллер, запущенный как Deployment, со своим ServiceAccount и RBAC. Он использует Kubernetes API и чаще всего состоит из двух частей:

  • CRD (CustomResourceDefinition) регистрирует в API новый тип объекта и его схему, например PostgresCluster. После установки CRD с объектом можно работать через обычный kubectl.
  • Controller наблюдает за объектами этого типа и выполняет их смысл. CRD без контроллера — только валидируемые данные в API, никакой кластер базы сам по себе не появится.
apiVersion: database.example.io/v1
kind: PostgresCluster
metadata:
  name: orders-db
spec:                              # желаемое состояние
  replicas: 3
  version: "17"
  storage: 100Gi
  backup:
    schedule: "0 2 * * *"

Пользователь создаёт один понятный объект, а оператор уже создаёт и связывает низкоуровневые ресурсы: StatefulSet, Services, Secrets, ConfigMaps, PVC и Jobs для backup. В поле status он записывает фактическое состояние: готовые реплики, текущего primary, последнюю резервную копию и ошибки.

PostgresCluster/orders-db spec: replicas=3, version=17 │ watch ▼ ┌─────────────────┐ │ Postgres │ │ Operator │ └────────┬────────┘ │ reconcile через apiserver ┌───────┼────────┬──────────┐ ▼ ▼ ▼ ▼ StatefulSet Service Secrets backup Job │ └── status: Ready, primary=orders-db-0

Reconcile loop — сердце оператора

Контроллер работает не как скрипт «создай кластер один раз», а как бесконечный цикл сверки:

  1. прочитать желаемое состояние из spec;
  2. прочитать реальное состояние ресурсов и самого приложения;
  3. вычислить разницу и сделать один или несколько шагов к цели;
  4. обновить status и Conditions, затем ждать следующего события или повторной проверки.

Reconcile должен быть идемпотентным: десять запусков с одним состоянием дают тот же результат, что один. Контроллеру нельзя полагаться только на событие «объект создан» — события могут повториться или потеряться, а после рестарта он обязан восстановить картину из текущего состояния API.

metadata.generation увеличивается при изменении spec, а status.observedGeneration показывает, какую версию желаемого состояния уже обработал контроллер. Если generation больше observedGeneration, оператор ещё не дошёл до последнего изменения или застрял. Conditions вроде Ready, Progressing и Degraded дают причину понятнее, чем один общий phase.

Operator, Helm и StatefulSet — не конкуренты

ИнструментЧто он знаетКогда работает
StatefulSetИмена, диски и порядок подовПостоянно поддерживает заданное число подов, но не понимает приложение
HelmКак отрендерить и установить набор YAMLВо время install и upgrade; сам не следит за failover и backup
OperatorПредметную логику конкретной системыПостоянно сверяет состояние и выполняет обновления, failover, backup и recovery

На практике Helm часто устанавливает сам оператор, его CRD, RBAC и Deployment. После установки уже оператор создаёт StatefulSet и управляет жизненным циклом приложения. Поэтому фраза «мы используем Helm вместо оператора» обычно сравнивает разные уровни.

OwnerReferences, finalizers и ручные изменения

Оператор обычно ставит созданным ресурсам ownerReferences на Custom Resource, чтобы garbage collector мог удалить зависимые объекты вместе с владельцем. Для действий, которые Kubernetes сам выполнить не может, используют finalizer: при удалении объект получает deletionTimestamp, но остаётся в API, пока контроллер, например, не сделает финальный snapshot и не уберёт finalizer. Если оператор сломан, объект с finalizer может навсегда застрять в Terminating.

Грабли

Не редактируй созданный оператором StatefulSet как постоянное решение. Следующий reconcile почти наверняка вернёт значение из Custom Resource. Менять нужно поле в CR, которое оператор считает источником истины. Если такого поля нет — проверить документацию оператора, а не бороться с контроллером через kubectl edit.

Когда оператор нужен, а когда нет

Оператор оправдан, когда у приложения есть повторяющиеся операции, требующие знания его внутренностей: выбор лидера, безопасная замена узла, backup/restore, изменение топологии, ротация сертификатов, обновление с миграцией данных. Для обычного stateless-сервиса из Deployment, Service и ConfigMap оператор чаще добавит лишний CRD, широкие RBAC-права и ещё один компонент, который может сломаться. Там достаточно стандартных контроллеров и Helm.

Как диагностировать оператор

kubectl get crd                              # установлен ли новый тип
kubectl api-resources | grep -i postgres    # как называется ресурс
kubectl get postgrescluster orders-db -o yaml
                                              # spec, status, conditions
kubectl describe postgrescluster orders-db   # события
kubectl logs -n operators deploy/postgres-operator
                                              # ошибки reconcile

Порядок проверки простой: существует ли CRD, запущен ли Pod оператора, хватает ли ему RBAC, видит ли он нужный namespace, совпали ли generation и observedGeneration, что написано в Conditions, Events и логах контроллера. При обновлении оператора отдельно проверяй совместимость версии CRD: Helm обрабатывает CRD особым образом — подробнее в главе 3.2.

Короткий ответ на собеседовании

«Operator — это custom controller с предметной логикой приложения. CRD добавляет в Kubernetes API новый тип, Custom Resource хранит желаемое состояние в spec, а контроллер в идемпотентном reconcile loop создаёт обычные ресурсы, выполняет backup, failover и upgrades и пишет результат в status. StatefulSet знает про поды и диски, но не знает, как администрировать конкретную базу; Helm может установить оператор, но не заменяет его постоянный control loop.»

§4Диски: главная разница на практике

Здесь ломаются чаще всего, потому что поведение неочевидное и асимметричное.

Если ты в Deployment пропишешь persistentVolumeClaim, то все реплики получат один и тот же том. Для ReadWriteOnce это означает, что при трёх репликах запустится одна, а две останутся в Pending с сообщением «volume is already used by pod…» (или, в лучшем случае, все три уедут на один узел). Deployment просто не умеет выдавать каждому поду по диску.

StatefulSet с volumeClaimTemplates — умеет. Он создаёт PVC по шаблону, подставляя имя пода. И вот ключевое: этот PVC живёт дольше пода. Удалил под — PVC остался. Удалил весь StatefulSet — PVC всё равно остались. Уменьшил реплики с 3 до 1 — data-pg-1 и data-pg-2 так и лежат в кластере и продолжают занимать место (и деньги, если диски облачные).

# масштабировали вниз, поды ушли — а тома остались
$ kubectl get pvc
NAME        STATUS   VOLUME     CAPACITY   AGE
data-pg-0   Bound    pvc-3f1..  50Gi       41d
data-pg-1   Bound    pvc-9c7..  50Gi       41d   ← пода нет, диск есть
data-pg-2   Bound    pvc-1ab..  50Gi       41d   ← пода нет, диск есть

Это сделано намеренно и, если подумать, правильно: данные ценнее контроллера. Случайно набранная команда kubectl delete sts pg не должна стирать продовую базу. Обратная сторона — за томами надо следить самому, иначе они копятся годами.

В относительно свежих версиях появилось поле persistentVolumeClaimRetentionPolicy с настройками whenDeleted и whenScaled — оно как раз позволяет сказать «при масштабировании вниз удаляй тома». Проверь по своей версии кластера, доступно ли оно и в каком статусе; для боевой базы включать whenDeleted: Delete я бы всё равно не стал.

В бою

Расширить диск у StatefulSet — отдельный квест: volumeClaimTemplates в живом StatefulSet иммутабельны, поле просто не даст себя изменить. Рабочая последовательность такая: сначала правишь размер прямо в каждом PVC (если StorageClass поддерживает allowVolumeExpansion), затем удаляешь сам StatefulSet с --cascade=orphan — объект уходит, поды и PVC остаются живыми, — и применяешь манифест с новым размером заново. Поды при этом не перезапускаются, простоя нет.

§5Сеть кластера, если смотреть снизу

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

  • у каждого пода свой IP-адрес, и он видит его сам (внутри пода ip a покажет ровно тот адрес, который видят снаружи — никакого NAT);
  • любой под может достучаться до любого другого пода на любом узле напрямую по этому IP, без NAT;
  • процессы на узле могут достучаться до подов на этом узле и наоборот.

Как это реализовано — дело CNI-плагина. Он либо роутит подсети подов между узлами по-настоящему (Calico в режиме BGP, Cilium с нативным роутингом), либо заворачивает трафик в туннель поверх существующей сети (VXLAN, Geneve, IPIP). Второй вариант проще разворачивать, но у него есть цена — оверхед заголовков туннеля и, как следствие, уменьшенный MTU внутри подов. Об этом подробно в главе 1.1, и это одна из самых мерзких проблем в проде.

Важно понять: под-IP эфемерен. Под перезапустился — адрес другой. Поэтому обращаться к подам по IP нельзя ни в каком виде, и именно ради этого существует Service.

§6Service и то самое «round robin»

Service — это не процесс и не прокси. Это запись в API, у которой есть виртуальный IP (ClusterIP) из отдельной подсети, и селектор по меткам. Контроллер эндпоинтов следит за подами, подходящими под селектор и прошедшими readiness, и складывает их адреса в объект EndpointSlice. Всё, объект закончился — дальше начинается магия на узлах.

На каждом узле работает kube-proxy. Он читает Service и EndpointSlice и программирует ядро так, чтобы пакет, отправленный на ClusterIP, оказался у одного из подов. У этой программируемости есть несколько режимов, и вот тут-то и живёт ответ на вопрос про round robin.

Режим iptables — это не round robin, а случайный выбор

Классический и до сих пор самый распространённый режим. kube-proxy создаёт цепочку правил, где выбор пода делается модулем statistic с вероятностями:

$ iptables -t nat -L KUBE-SVC-XZY... -n

# 3 пода за сервисом:
statistic mode random probability 0.33333  → KUBE-SEP-под-1
statistic mode random probability 0.50000  → KUBE-SEP-под-2
                                           → KUBE-SEP-под-3

Читается так: с вероятностью 1/3 уходим в первый под. Если не ушли (осталось два кандидата) — с вероятностью 1/2 во второй. Если и там не сработало — в третий. Математика честная, каждый под получает ровно 1/3 соединений в среднем на длинной дистанции. Но это случайное распределение, а не циклический перебор. Три подряд соединения вполне могут уйти в один и тот же под.

Дальше выбранное правило делает DNAT: подменяет адрес назначения с ClusterIP на IP пода. Ядро запоминает эту трансляцию в таблице conntrack, и все последующие пакеты того же соединения летят туда же, уже не проходя через правила выбора. Ключевой вывод: решение принимается один раз, на первом пакете соединения.

Режим IPVS — вот здесь настоящий round robin

IPVS — это встроенный в ядро балансировщик четвёртого уровня, живущий в хеш-таблице, а не в линейном списке правил. Он и работает быстрее на больших кластерах (тысячи сервисов не превращаются в тысячи правил, которые ядро проходит последовательно), и умеет разные алгоритмы:

АлгоритмЧто делает
rrRound robin по кругу. Режим по умолчанию.
lcLeast connections — в под с наименьшим числом активных соединений.
shSource hashing — один клиент всегда на один под.
wrr, wlcТо же с весами.

Проверить режим у себя: kubectl -n kube-system logs ds/kube-proxy | grep -i "proxy mode" или посмотреть --proxy-mode в конфиге. Если IPVS — есть ipvsadm -Ln, который покажет реальные счётчики соединений на каждый под.

Есть и более свежий режим — nftables, идейный наследник iptables без его проблем с производительностью. И совсем отдельная история — Cilium, который заменяет kube-proxy на eBPF-программы и делает выбор бэкенда прямо в хуке сетевого стека. Проверяй, что у тебя в кластере, — на собесе фраза «зависит от режима kube-proxy, у нас был такой-то» стоит дороже любого заученного определения.

Спросят

«Как Service балансирует нагрузку? Round robin?» — Ответ, который отличает человека от читателя документации: «Смотря в каком режиме kube-proxy. В iptables — не round robin, а случайный выбор с равными вероятностями через модуль statistic. В IPVS — да, честный rr, и это дефолтный алгоритм. Но важнее другое: балансируются не запросы, а соединения. Решение принимается один раз, на SYN-пакете, дальше conntrack держит поток на выбранном поде.»

§7Почему keep-alive и gRPC ломают балансировку

Вот следствие, которое сто́ит понимать не ради собеса, а ради жизни. Если балансировка происходит на уровне соединения, то приложение, которое открывает соединение один раз и держит его часами, будет ходить в один и тот же под всё это время.

HTTP/1.1 без keep-alive gRPC / HTTP/2 / пул keep-alive ─────────────────────── ────────────────────────────── запрос → новое TCP → под-1 один TCP ──────────────→ под-1 запрос → новое TCP → под-3 ├─ запрос запрос → новое TCP → под-2 ├─ запрос всё сюда, запрос → новое TCP → под-1 ├─ запрос всегда распределяется └─ ... часами

С gRPC это выражено максимально ярко: он поверх HTTP/2, где все вызовы мультиплексируются в одно долгоживущее соединение. Ты выкатываешь десять реплик сервиса, а нагрузка приходит на две — те, к которым клиенты успели подключиться. Более того, после выкатки новые поды остаются пустыми: старые клиенты держат соединения со старыми... нет, старые поды умерли, клиенты переподключились — и все скопом заново прилипли к тем, кто ответил первым. Классическая картина: график CPU, где два пода в потолок, восемь в нуле.

Что с этим делают:

  • Балансировка на седьмом уровне. Envoy, Istio, nginx-ingress, linkerd — прокси, который понимает HTTP/2 и раскидывает отдельные запросы внутри одного соединения по разным бэкендам. Самое честное решение и главная причина, по которой service mesh вообще существует.
  • Клиентская балансировка. Headless-сервис (§8) отдаёт клиенту список всех IP, и gRPC-библиотека сама открывает соединение к каждому и раскидывает вызовы (round_robin в качестве loadBalancingPolicy). Работает, но требует поддержки со стороны кода.
  • Ограничение времени жизни соединения. На сервере ставится максимальный возраст соединения (в gRPC-Go это MaxConnectionAge + MaxConnectionAgeGrace), сервер сам аккуратно закрывает старые, клиент переподключается — и попадает, возможно, уже в другой под. Дёшево и часто достаточно.

Грабли

Ровно та же история — с пулами соединений к базе. Ты ставишь перед PostgreSQL Service, приложение открывает пул из 20 соединений при старте и держит его. Если за Service стоит несколько реплик для чтения, весь пул может уйти в одну. Балансировка соединений и балансировка нагрузки — не одно и то же, и это стоит проговаривать вслух на собесе.

§8DNS в кластере: полный разбор

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

Что вообще резолвится внутри кластера

За DNS отвечает CoreDNS — обычный деплоймент в kube-system, стоящий за Service с именем kube-dns. Kubelet прописывает его адрес в /etc/resolv.conf каждого пода. Записи бывают четырёх видов, и путаница обычно именно здесь:

ЧтоИмяКуда указывает
Обычный Servicemyapp.prod.svc.cluster.localОдин ClusterIP. Кто за ним — не видно
Headless Servicepg-h.prod.svc.cluster.localВсе IP готовых подов, списком A-записей
Под за headlesspg-1.pg-h.prod.svc.cluster.localIP конкретного пода. Только если у пода задан hostname
Под по IP10-244-1-17.prod.pod.cluster.localТот же самый IP. Практически бесполезно
SRV-запись_web._tcp.myapp.prod.svc...Порт и хост — для клиентов, которые умеют искать сервис по имени порта

Тот самый вопрос: можно ли обращаться по DNS к подам Deployment

Ответ звучит «формально да, практически бессмысленно», и вся ценность — в объяснении почему.

Через обычный Service — нельзя в принципе. Имя сервиса резолвится в один ClusterIP, за которым конкретный под выбирается уже в ядре, на уровне iptables/IPVS. Никакого способа сказать «дай мне второй под» через DNS тут нет — DNS про поды вообще ничего не знает.

Через headless Service — получишь список всех IP. Это работает и для Deployment тоже: сделай ему clusterIP: None, и запрос имени вернёт адреса всех готовых подов. Но это адреса, а не имена: если под перезапустился, в списке будет другой IP, и понять, «тот же это экземпляр или новый», невозможно.

Персональные DNS-имена подов в headless-сервисе создаются только для тех подов, у которых заданы поля spec.hostname и spec.subdomain. У StatefulSet контроллер выставляет их автоматически: hostname = имя пода (pg-1), subdomain = имя управляющего сервиса. Отсюда и берётся pg-1.pg-h.prod.svc.cluster.local.

А в Deployment их можно прописать руками — но толку не будет, и вот тут главное:

Почему это не работает для Deployment

У всех подов одного Deployment одинаковый шаблон. Задав в нём hostname: myapp, ты получишь три пода с одним и тем же DNS-именем — имя будет резолвиться в три разных адреса, то есть ровно то же самое, что и имя сервиса. Дать каждому поду своё имя шаблон не позволяет физически: он один на всех, и подставлять в него порядковый номер некому. Именно этим StatefulSet и отличается — у него есть контроллер, который знает порядковый номер каждого пода и проставляет hostname индивидуально. Плюс имена подов Deployment случайны (myapp-7d9f8-x4k2b), так что даже если бы имя генерировалось из имени пода, оно менялось бы при каждом перезапуске и как адрес было бы непригодно.

Отсюда формулировка, которую и хотят услышать: дело не в том, что DNS «не выдают» подам Deployment, а в том, что у них нет устойчивой идентичности, которую можно было бы записать в DNS. Стабильное имя требует стабильного номера, а Deployment по своей природе оперирует безымянными взаимозаменяемыми копиями. Обращаться к конкретному экземпляру там просто не предполагается — и если приложению это нужно, значит, оно stateful и ему нужен StatefulSet.

# StatefulSet — имена есть и они предсказуемы
$ nslookup pg-h.prod.svc.cluster.local
Address: 10.244.1.17
Address: 10.244.2.31
Address: 10.244.3.9
$ nslookup pg-1.pg-h.prod.svc.cluster.local
Address: 10.244.2.31          ← адресуемся к конкретному узлу кластера БД

# Deployment за headless — только список адресов, имён нет
$ nslookup myapp-h.prod.svc.cluster.local
Address: 10.244.1.44
Address: 10.244.2.15
$ nslookup myapp-7d9f8-x4k2b.myapp-h.prod.svc.cluster.local
** server can't find ... NXDOMAIN

Спросят дальше

«А если я всё-таки хочу постучаться в конкретный под Deployment?» — Для отладки: kubectl port-forward pod/myapp-7d9f8-x4k2b 8080:8080 или напрямую по IP пода из другого пода. Для продакшена — правильного способа нет, и это не ограничение, а сигнал: если бизнес-логика требует адресовать конкретный экземпляр, выбран не тот контроллер. Ответ «переделал бы на StatefulSet или вынес состояние наружу» гораздо лучше, чем попытка придумать обходной путь.

Как выглядит resolv.conf и почему ndots:5 портит жизнь

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

$ kubectl exec -it myapp -- cat /etc/resolv.conf
nameserver 10.96.0.10
search prod.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

Список search — это то, ради чего внутри кластера можно писать просто postgres вместо полного имени: резолвер сам переберёт суффиксы. Из-за него же в одном namespace работает короткое имя, а в соседний надо обращаться как svc.namespace.

А вот ndots:5 означает: «если в имени меньше пяти точек, сначала попробуй его со всеми суффиксами из search, и только потом — как есть». Для внутренних имён это удобно. Но для внешних адресов получается беда: запрос к api.stripe.com (две точки) сначала породит четыре заведомо провальных запроса — api.stripe.com.prod.svc.cluster.local, ...svc.cluster.local, ...cluster.local, плюс суффикс из настроек узла, — и только пятым уйдёт правильный. Умножь на два (A и AAAA) — восемь лишних запросов к CoreDNS на каждый вызов внешнего API.

Симптомы: CoreDNS неожиданно нагружен, у внешних вызовов плавающие задержки, в тяжёлых случаях — таймауты резолва под нагрузкой. Лечится двумя способами: точкой в конце имени (api.stripe.com. — резолвер поймёт, что имя полное, и не будет ничего подставлять) либо переопределением параметра в самом поде:

spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"

Headless как механизм обнаружения

Возвращаясь к самому headless-сервису: clusterIP: None отключает всю машинерию — виртуального IP нет, iptables-правил нет, DNAT нет. CoreDNS просто отдаёт адреса. Это ровно то, что нужно кластерным приложениям (PostgreSQL, Kafka, Elasticsearch, Consul), где узлы должны видеть друг друга поимённо и сами решать, кто лидер.

Иногда список адресов называют «DNS round robin» и пытаются использовать как балансировку. Не стоит: клиенты кешируют ответы, многие библиотеки берут просто первую запись, а порядок записей в ответе меняется не всегда. Как механизм обнаружения — отлично. Как балансировщик — ненадёжно.

Полезная деталь для баз: у headless-сервиса обычно ставят publishNotReadyAddresses: true. Иначе поды, ещё не прошедшие readiness, не попадают в DNS — а узлам кластера БД нужно найти друг друга до того, как они станут готовы, иначе стартовать не из чего.

§9Пробы: startup, readiness и liveness

Пробы выполняет kubelet на узле. У них три разных вопроса и три разных последствия — это главное, что нужно понять и произнести на собеседовании.

ПробаВопросЧто делает Kubernetes при ошибке
startupProbeПриложение уже закончило долгий запуск?Ждёт до порога ошибок, затем перезапускает контейнер. Пока startup не успешна, readiness и liveness не выполняются.
readinessProbeМожно ли сейчас отправлять сюда трафик?Помечает под как NotReady, а его endpoint — как неготовый. Сервис перестаёт слать туда трафик, контейнер не перезапускается.
livenessProbeЗавис ли процесс так, что поможет перезапуск?Kubelet перезапускает контейнер внутри того же пода.
startup успешна? │ нет → ждём; после failureThreshold → restart ▼ да readiness успешна? ── нет → убрать из трафика, не перезапускать │ да ▼ под получает трафик liveness работает параллельно после startup └── провалена → restart контейнера

Минимальный рабочий пример

containers:
  - name: api
    image: registry.local/api:1.7.3
    ports:
      - name: http
        containerPort: 8080

    startupProbe:
      httpGet:
        path: /health/startup
        port: http
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 60    # на запуск даём примерно 5 минут

    readinessProbe:
      httpGet:
        path: /health/ready
        port: http
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 2

    livenessProbe:
      httpGet:
        path: /health/live
        port: http
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3

periodSeconds — интервал проверок, timeoutSeconds — сколько ждать одну проверку, failureThreshold — сколько последовательных ошибок считать провалом. successThreshold задаёт число успешных проверок для восстановления; для startup и liveness оно должно быть 1, а у readiness может быть больше. initialDelaySeconds задерживает первую проверку, но для медленного старта лучше отдельная startup-проба: она даёт большой запас только при запуске и не ослабляет liveness во время работы.

Кроме httpGet, доступны tcpSocket (TCP-соединение установилось), exec (команда завершилась с кодом 0) и grpc по стандартному gRPC Health Checking Protocol. TCP-проверка видит открытый порт, но не знает, способно ли приложение обработать запрос; частый exec создаёт лишние процессы. Обычно HTTP- или gRPC-проба понятнее и дешевле.

Что именно должны проверять эндпоинты

  • /health/live — отвечает ли сам процесс и не завис ли его главный цикл. Не надо ходить отсюда в базу, Redis и внешние API.
  • /health/ready — способен ли экземпляр обслужить запрос. Здесь допустимы только действительно критичные зависимости и локальные признаки: загрузилась конфигурация, прогрелся кеш, есть нужное соединение.
  • /health/startup — завершилась ли инициализация: миграции, прогрев JVM, загрузка модели или большого кеша.

Главная ловушка

Нельзя делать liveness зависимой от базы. Если база моргнёт, все реплики одновременно провалят проверку, перезапустятся и лавиной новых соединений добьют зависимость. Внешняя проблема обычно должна временно закрыть readiness, а не запускать массовый restart.

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

Как диагностировать

kubectl describe pod api-7d9f8-x4k2b       # Events: Unhealthy и причина
kubectl get pod api-7d9f8-x4k2b -w        # Ready и число рестартов
kubectl get endpointslice \
  -l kubernetes.io/service-name=api        # есть ли IP пода в сервисе
kubectl logs api-7d9f8-x4k2b --previous   # логи до рестарта

Провал readiness ищи в состоянии Ready=False, событиях и EndpointSlice — счётчик рестартов не обязан меняться. При провале liveness увеличится RESTARTS, а в Last State и событиях будет видно предыдущее завершение. Во время rolling update именно readiness не даёт сервису отправить трафик в новую реплику раньше времени.

Короткий ответ на собеседовании

«Startup защищает медленный запуск и до успеха отключает остальные пробы. Readiness управляет участием пода в трафике и не перезапускает контейнер. Liveness лечит зависший локальный процесс перезапуском, поэтому в неё нельзя включать внешние зависимости. Проверки выполняет kubelet, а readiness через состояние пода меняет признак готовности endpoint в EndpointSlice.»

§9.1Отказоустойчивость, PodDisruptionBudget и масштабирование

Отказоустойчивость в Kubernetes не включается одним полем. Это цепочка: несколько реплик должны быть готовы, находиться в разных доменах отказа, переживать плановые работы по одной, иметь место для пересоздания и успевать масштабироваться до перегрузки.

несколько реплик + readiness │ ├── topology spread → не сложить всё на один узел / в одну зону ├── PDB → не выселить слишком много при drain ├── requests → scheduler и autoscaler понимают нужную ёмкость ├── HPA → добавить Pod при росте нагрузки └── node autoscaler → добавить узел, если Pod некуда поставить любое звено отсутствует → «реплики есть», а доступности всё равно нет

Добровольные и аварийные нарушения

ТипПримерыЧто помогает
Добровольноеkubectl drain, обслуживание узла, консолидация node autoscaler, Eviction APIPDB ограничивает число одновременных eviction
Аварийноеузел умер, kernel panic, сеть разделилась, OOM или нехватка ресурсов узлареплики на разных узлах/зонах, requests, запас ёмкости; PDB остановить аварию не может
Изменение приложенияrolling update Deployment или StatefulSetmaxUnavailable/maxSurge самого workload; PDB rollout не ограничивает

PodDisruptionBudget: сколько можно выселить

PDB не держит поды живыми и не создаёт новые. Он отвечает Eviction API: «можно ли сейчас добровольно выселить ещё один под?». kubectl drain использует eviction и будет ждать, пока бюджет разрешит продолжить.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
spec:
  maxUnavailable: 1
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels:
      app: api

При трёх здоровых репликах этот бюджет разрешает вывести одну. minAvailable: 2 дал бы тот же результат, но maxUnavailable: 1 удобнее при изменении числа реплик. Для кворума из пяти узлов можно явно поставить minAvailable: 3. В одном PDB указывают либо minAvailable, либо maxUnavailable; значения бывают числом или процентом, а проценты округляются вверх.

unhealthyPodEvictionPolicy: AlwaysAllow позволяет убрать уже неготовый или зависший Pod при drain. Без этого проблемный Pod может заблокировать обслуживание узла именно потому, что бюджет ждёт его восстановления. Поле доступно в современных версиях Kubernetes; на старом кластере сначала проверь kubectl explain pdb.spec.unhealthyPodEvictionPolicy.

Чего PDB не делает

Прямой kubectl delete pod и удаление Deployment обходят PDB. Падение узла тоже никто не спрашивает, хотя уже недоступные поды учитываются в бюджете. Rolling update управляется стратегией workload, а не PDB. И бюджет maxUnavailable: 0 не даёт «абсолютную доступность» — он лишь способен навсегда заблокировать drain.

kubectl get pdb
kubectl describe pdb api              # смотри DisruptionsAllowed
kubectl get pdb api -o yaml            # currentHealthy / desiredHealthy
kubectl drain worker-2 --ignore-daemonsets
                                      # ждёт, если PDB запрещает eviction

Реплики надо разнести

Три реплики на одном worker — это защита от падения процесса, но не узла. topologySpreadConstraints просит scheduler распределять поды по доменам: узлам, зонам или стойкам.

spec:
  template:
    metadata:
      labels:
        app: api
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          minDomains: 3
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: api
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: api

Первая политика требует три подходящих домена hostname и не допускает разницу больше одного Pod между ними; если подходящих узлов не хватает, лишний Pod останется Pending. Вторая старается равномерно распределить реплики по зонам. DoNotSchedule задаёт жёсткое ограничение, ScheduleAnyway — мягкое предпочтение. Anti-affinity решает похожую задачу «не клади рядом», а topology spread лучше выражает «распредели примерно поровну».

PriorityClass определяет, кто получит дефицитный ресурс первым: высокоприоритетный Pending Pod может вытеснить менее важные. Это аварийный порядок, а не новая ёмкость — если все сервисы объявить critical, приоритета фактически не останется.

HPA: горизонтальное масштабирование Pod

HPA не создаёт поды напрямую. Контроллер периодически читает метрики и меняет поле replicas у Deployment или StatefulSet, а уже их контроллеры создают или удаляют поды.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3                 # минимум для отказоустойчивости
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300

Для CPU в режиме Utilization HPA считает отношение фактического потребления к CPU request. Если request не задан, процент не имеет смысла и HPA не сможет нормально посчитать рекомендацию. Ресурсные метрики обычно приходят из Metrics Server; для RPS, длины очереди и других сигналов нужны custom/external metrics adapter или KEDA.

Упрощённая формула: желаемые реплики = ceil(текущие × текущая метрика / целевая). При 4 Pod и среднем CPU 130% от request при цели 65% получится примерно 8 Pod. Scale-up обычно должен быть быстрым, а scale-down — медленным: окно стабилизации не даёт удалить только что добавленные реплики из-за короткого провала метрики.

Грабли HPA

  • Не меняй вручную spec.replicas и не перезаписывай его каждым Helm/GitOps apply — этим полем уже владеет HPA.
  • CPU — плохой сигнал для I/O-bound сервиса: очередь запросов растёт, а CPU остаётся низким. Масштабируйся по метрике, связанной с реальной нагрузкой.
  • HPA реагирует после появления метрики и запускает новые Pod не мгновенно. Нужны запас ёмкости, быстрая startup/readiness и разумный minReplicas.

Pod стало больше — но где их запускать?

HPA масштабирует workload, а не кластер. Если на worker нет свободных ресурсов, новые Pod останутся Pending. Node autoscaler (например, Cluster Autoscaler или Karpenter в поддерживаемой среде) замечает unschedulable Pod и добавляет узел. Он принимает решение по requests, а не по фактическому потреблению. Поэтому заниженные requests ломают и планирование, и HPA, и расчёт нужной ёмкости.

нагрузка ↑ → метрика ↑ → HPA: replicas 3 → 8 │ ├── место есть → scheduler размещает Pod └── места нет → Pod Pending │ ▼ node autoscaler │ ▼ новый worker

VPA решает другую задачу: меняет requests/limits одного Pod, а не число реплик, и устанавливается отдельно. HPA и VPA можно сочетать только понимая ownership метрик и ресурсов: если оба контроллера реагируют на один CPU, изменение request меняет процент для HPA и контуры начинают влиять друг на друга.

kubectl get hpa -w                    # TARGETS, replicas
kubectl describe hpa api             # метрики, Conditions, события
kubectl top pod -l app=api
kubectl get pod -l app=api -o wide   # по каким узлам разложены
kubectl get events --sort-by=.lastTimestamp

Короткий ответ на собеседовании

«Для HA я держу минимум несколько Ready-реплик, распределяю их по узлам и зонам, задаю requests и оставляю запас ёмкости. PDB через Eviction API ограничивает только добровольные disruptions вроде drain, но не спасает от падения узла и не управляет rollout. HPA меняет replicas по метрикам, а если поды некуда ставить, нужен node autoscaler. Всё это дополняют корректные probes и graceful shutdown.»

§9.2RBAC, rootless и привилегии контейнера

Здесь важно не смешивать два независимых слоя. RBAC решает, какие запросы пользователь или Pod может отправлять в Kubernetes API. SecurityContext решает, что процесс контейнера может делать в Linux на worker-узле. Pod может не иметь никаких прав в API, но работать как privileged root; или иметь мощный ServiceAccount, оставаясь обычным непривилегированным Linux-процессом.

кто делает запрос? что может процесс? User / Group / ServiceAccount UID / GID │ capabilities ▼ seccomp / AppArmor / SELinux kube-apiserver │ │ ▼ ├── RBAC authorization ядро worker-узла └── admission (Pod Security) ▲ │ securityContext

RBAC: субъекту привязывают набор разрешений

ОбъектНазначение
RoleПравила внутри одного namespace
ClusterRoleПравила для cluster-scoped ресурсов или переиспользуемый набор правил для namespace
RoleBindingПривязывает Role или ClusterRole к субъекту, но права действуют только в namespace binding
ClusterRoleBindingПривязывает ClusterRole на весь кластер

Role содержит apiGroups, resources и verbs, а binding отвечает на вопрос «кому». RBAC только складывает разрешения: правила deny в нём нет. Если один binding дал доступ, второй не сможет его отнять — нужно удалить или сузить разрешающее правило.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: log-reader
  namespace: prod
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: log-reader
  namespace: prod
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]
  - apiGroups: [""]
    resources: ["pods/log"]       # subresource
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: log-reader
  namespace: prod
subjects:
  - kind: ServiceAccount
    name: log-reader
    namespace: prod
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: log-reader

Этот ServiceAccount получит права только в prod. Даже если RoleBinding ссылается на ClusterRole, область доступа всё равно задаёт namespace RoleBinding. ClusterRoleBinding понадобился бы для всех namespace или ресурсов уровня кластера, например Nodes.

Verbs и права, которые выглядят невинно

  • get читает один объект, list — коллекцию, watch — поток изменений, create/update/patch/delete изменяют состояние.
  • pods/log — отдельный subresource с verb get; pods/exec и pods/portforward обычно требуют create.
  • get/list/watch secrets фактически раскрывают значения Secret. Base64 здесь ничего не защищает.
  • Право создавать Pod или Deployment почти равно доступу ко многим данным namespace: можно смонтировать Secret, PVC или запустить Pod под более сильным ServiceAccount, если admission это не запрещает.
  • bind, escalate, impersonate, доступ к nodes/proxy, wildcard * и cluster-admin — права повышения привилегий, а не обычная эксплуатация.

Главная ошибка RBAC

«Дадим *, потом сузим» обычно остаётся навсегда. Wildcard автоматически захватит и новые ресурсы или subresources, появившиеся после обновления кластера. Начинай с Role в одном namespace и конкретных verbs; ClusterRoleBinding — осознанное исключение.

Pod обращается к API от имени ServiceAccount. Если приложению API не нужен, токен вообще не следует монтировать:

spec:
  serviceAccountName: api
  automountServiceAccountToken: false

Иначе короткоживущий токен доступен внутри Pod по стандартному пути. Уязвимость приложения тогда может превратиться в компрометацию Kubernetes API в пределах прав ServiceAccount.

kubectl auth can-i get secrets -n prod
kubectl auth can-i create pods/exec -n prod
kubectl auth can-i --list \
  --as=system:serviceaccount:prod:log-reader -n prod

kubectl get role,rolebinding -n prod
kubectl get clusterrolebinding

Безопасный securityContext для обычного приложения

spec:
  automountServiceAccountToken: false
  securityContext:                    # настройки всего Pod
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    fsGroupChangePolicy: OnRootMismatch
    seccompProfile:
      type: RuntimeDefault

  containers:
    - name: api
      image: registry.local/api:1.7.3
      securityContext:                # настройки контейнера
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}
ПолеЧто ограничивает
runAsNonRootНе даёт запустить контейнер с эффективным UID 0
runAsUser/runAsGroupЯвно задают UID и GID процесса; образ и его каталоги должны иметь подходящие права
fsGroupДаёт группе доступ к поддерживаемым подключённым volumes; не чинит права файлов внутри слоя образа
allowPrivilegeEscalation: falseВключает Linux no_new_privs: процесс не получит больше прав через setuid/setcap
capabilities.drop: ALLУбирает отдельные полномочия root, которые runtime оставляет по умолчанию
seccomp: RuntimeDefaultФильтрует опасные системные вызовы профилем container runtime
readOnlyRootFilesystemЗапрещает запись в writable layer; нужные каталоги вроде /tmp выносят в volume

Pod-level настройки наследуются контейнерами, а совпадающие container-level поля их переопределяют. fsGroupChangePolicy: OnRootMismatch не делает рекурсивный chown большого диска на каждом старте, если корень volume уже имеет правильные права.

Capabilities: дать одну возможность вместо полного root

Linux разбивает полномочия root на capabilities. Если процессу действительно нужно слушать порт ниже 1024, можно после drop: ALL добавить только NET_BIND_SERVICE. NET_ADMIN позволяет менять сетевую конфигурацию, SYS_PTRACE — трассировать процессы, а SYS_ADMIN объединяет слишком много опасных операций и часто близок к «почти privileged».

securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]       # только если нельзя слушать 8080

allowPrivilegeEscalation: false не работает как защита у privileged-контейнера и при CAP_SYS_ADMIN: для них повышение привилегий фактически всегда разрешено.

Non-root, user namespace и rootless — три разных вещи

  1. Non-root process. В образе есть USER 10001, а манифест требует runAsNonRoot. Это базовый и предпочтительный вариант для приложения.
  2. User namespace Pod. При hostUsers: false UID 0 внутри Pod отображается в непривилегированный UID на узле, а capabilities действуют только внутри user namespace. В Kubernetes 1.36 эта возможность стала GA, но нужны поддержка runtime, kernel и файловой системы.
  3. Rootless runtime. Сам container runtime и его вспомогательные процессы работают без host root. Это настройка платформы на worker, а не поле отдельного контейнера. runAsNonRoot не превращает containerd в rootless.
spec:
  hostUsers: false                    # отдельный user namespace Pod
  containers:
    - name: legacy-app
      image: registry.local/legacy:4.2
      securityContext:
        runAsUser: 0                  # root внутри, non-root UID на host

User namespace снижает последствия container escape, но не делает Pod всесильным и одновременно безопасным. Такой Pod нельзя совмещать с hostNetwork, hostPID или hostIPC; есть ограничения файловых систем и volumes. Для нового приложения всё равно проще сначала научить образ работать обычным non-root.

Что действительно приближает контейнер к root на узле

НастройкаРиск
privileged: trueВсе Linux capabilities; seccomp становится Unconfined, AppArmor игнорируется, SELinux снимает обычные ограничения
hostPID/hostIPC/hostNetworkPod делит соответствующий namespace узла и видит больше host-ресурсов
hostPathМонтирует путь узла; запись в /, kubelet-конфиг или runtime socket может дать контроль над host
container runtime socketДоступ к containerd.sock, crio.sock или docker.sock практически равен администрированию контейнеров узла
CAP_SYS_ADMIN, CAP_NET_ADMIN, devicesТочечные, но очень сильные операции ядра, сети и оборудования

Privileged оправдан для узкого класса системных компонентов: некоторых CNI, CSI, node-агентов и инструментов работы с железом. Обычному web-приложению он не нужен. Если контейнер падает с Permission denied, правильная реакция — выяснить конкретную операцию, починить владельца каталога или добавить одну capability, а не сразу включать privileged.

Pod Security Admission: не дать команде ослабить Pod

SecurityContext — просьба автора манифеста. Чтобы небезопасный Pod вообще не прошёл в кластер, встроенный Pod Security Admission применяет к namespace один из профилей: privileged, baseline или самый строгий restricted. Режим enforce отклоняет Pod, warn предупреждает пользователя, audit пишет нарушение в audit log.

kubectl label namespace prod \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

В production версию профиля лучше закрепить на minor-версии кластера после проверки, чтобы обновление Kubernetes неожиданно не изменило правила. Старый PodSecurityPolicy удалён; в актуальном Kubernetes встроенная замена — Pod Security Admission, а для собственных правил используют admission policies или внешние движки.

Как слои связаны

RBAC может разрешить ServiceAccount создать Pod, но admission решает, можно ли этому Pod запросить privileged, hostPath и host namespaces. После допуска securityContext и профили ядра ограничивают уже запущенный процесс. NetworkPolicy отдельно ограничивает сетевые направления. Один механизм не заменяет остальные.

kubectl get namespace prod --show-labels
kubectl describe pod api-7d9f8-x4k2b       # admission и runtime errors
kubectl exec api-7d9f8-x4k2b -- id
kubectl exec api-7d9f8-x4k2b -- \
  sh -c 'grep -E "Cap(Eff|Bnd)|NoNewPrivs" /proc/1/status'

Глубже про namespaces, capabilities и границу контейнера — в главе 2.2. В OpenShift похожую задачу жёстче решают SCC — в главе 3.3.

Короткий ответ на собеседовании

«RBAC — аддитивная авторизация запросов к Kubernetes API: Role/ClusterRole задают ресурсы и verbs, binding привязывает их к User, Group или ServiceAccount. Права даю в namespace и без wildcard, токен ServiceAccount не монтирую, если API не нужен. Права Linux — отдельный слой: запускаю non-root, запрещаю privilege escalation, drop ALL capabilities, включаю RuntimeDefault seccomp и read-only rootfs. Privileged, host namespaces и hostPath требуют отдельного обоснования и блокируются Pod Security Admission. User namespace с hostUsers=false отображает root контейнера в непривилегированный UID хоста, а rootless runtime — уже настройка самого worker.»

§10Что происходит с трафиком во время выкатки

Тема, на которой ловят почти всех, потому что она требует держать в голове две независимые системы одновременно.

Когда под удаляют, происходят параллельно две вещи. Первая: kubelet отправляет процессу SIGTERM и запускает отсчёт terminationGracePeriodSeconds. Вторая: контроллер эндпоинтов убирает под из EndpointSlice, и эта новость должна доехать до kube-proxy на каждом узле, который перепишет свои iptables-правила.

Вторая цепочка — асинхронная и сетевая, она занимает сотни миллисекунд, а на нагруженном кластере и секунды. Первая — мгновенная. Итог: приложение уже начало гасить себя и закрывать слушающий сокет, а правила на соседнем узле ещё направляют в него новые соединения. Клиент получает connection refused, ingress отдаёт 502 — и это происходит при каждой выкатке.

t=0 удаление пода ├── kubelet → SIGTERM ────→ приложение закрывает сокет ✕ └── endpoint-контроллер ──→ apiserver ──→ kube-proxy на узлах ──→ iptables ~ сотни мс — секунды ┌────────────────────────────────────────┐ │ здесь трафик всё ещё летит в мёртвый │ ← 502 │ под │ └────────────────────────────────────────┘

Лечится примитивно и надёжно: preStop-хук со сном на 5–15 секунд. Kubelet выполняет preStop до SIGTERM, так что приложение эти секунды продолжает нормально обслуживать запросы — а правила за это время успевают обновиться везде. И только потом приходит SIGTERM, и начинается корректное завершение.

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 40   # должно быть больше preStop
                                    # + времени на долив запросов

Вторая половина той же истории — приложение обязано корректно обрабатывать SIGTERM: перестать принимать новые запросы, доработать текущие, закрыть соединения с базой, выйти. Если оно сигнал игнорирует, через terminationGracePeriodSeconds прилетит SIGKILL и всё оборвётся посреди транзакции. Почему приложение в контейнере запросто может не получать сигналы вообще — в главе 2.1, это прямое следствие того, что оно работает как PID 1.

В бою

Проверить, что выкатка действительно бесшовная, можно за пять секунд без всяких инструментов нагрузочного тестирования: while true; do curl -s -o /dev/null -w "%{http_code}\n" https://app/health; sleep 0.2; done и в соседнем окне kubectl rollout restart deploy/app. Если в потоке двухсоток мелькают нули и 502 — preStop и readiness настроены неправильно.

§11Сводная таблица и типовые доп-вопросы

Разницу Deployment и StatefulSet любят раскручивать вглубь, поэтому вот всё в одном месте.

DeploymentStatefulSet
Промежуточный объектReplicaSetНет, управляет подами напрямую
Имена подовapp-7d9f8-x4k2b, случайныеpg-0, pg-1, по порядку
DNS-имя подаНет (нечего записывать)pg-1.svc.ns.svc.cluster.local
ТомаОдин общий PVC на всех либо без томаСвой PVC на каждый под через volumeClaimTemplates
Судьба PVCЖивёт отдельно, ты создал — ты и удаляешьПереживает под и сам StatefulSet
Порядок созданияВсе разомПо одному, следующий после Ready предыдущего
Порядок удаленияПроизвольныйС конца, от старшего номера
Порядок обновленияВперемешку, по maxSurge/maxUnavailableПо одному, с конца
Частичное обновлениеНетЕсть: partition — канарейка из коробки
Поведение при незапустившемся подеВыкатка останавливается, остальные живыВыкатка замирает целиком и ждёт
СервисОбычный ClusterIPОбычно headless (clusterIP: None)
Откатrollout undo — старый ReplicaSet поднимается обратноТоже есть, но реплей идёт по одному поду

Куда обычно уводят дальше

«Зачем StatefulSet нужен headless-сервис, если он и так даёт стабильные имена?» Имена берутся из него. Сам StatefulSet только проставляет подам hostname и subdomain; превращает это в DNS-записи именно headless-сервис, указанный в serviceName. Без него никаких имён не будет.

«Что будет, если удалить под StatefulSet?» Контроллер создаст под с тем же именем и подключит к нему тот же PVC. С точки зрения приложения — это перезапуск того же узла, а не появление нового. У Deployment на месте удалённого появится под с другим именем.

«Можно ли уменьшить реплики StatefulSet до нуля и обратно?» Можно, и данные не потеряются: PVC остаются на месте и подключатся обратно при масштабировании вверх. Это, кстати, рабочий способ остановить кластер БД на обслуживание.

«А если приложению нужны и стабильные диски, и произвольный порядок старта?» podManagementPolicy: Parallel. Имена и персональные PVC сохраняются, а последовательность старта отключается. Подходит для шардов и брокеров, которые не зависят друг от друга при запуске.

«Как обновлять StatefulSet без простоя, если это база?» Никак «без простоя» в полном смысле — переключение лидера всё равно происходит. Практика: обновлять реплики с конца (что StatefulSet и делает), а перед тем, как очередь дойдёт до нулевого пода, вручную сделать switchover лидера на уже обновлённую реплику. В операторах вроде Patroni это встроено.

«DaemonSet — это что и когда?» Третий контроллер: по одному поду на каждый узел (или на узлы с определённой меткой), без реплик как понятия. Для всего, что должно быть на каждой машине: сборщики логов, экспортеры метрик, CNI, драйверы хранилища.

§12Как это звучит в ответе

Соберём главу в то, что реально стоит произнести вслух.

Про Deployment и StatefulSet. «Deployment — про взаимозаменяемые копии: случайные имена, общий или отсутствующий том, порядок обновления не гарантирован. StatefulSet — про идентичность: порядковые имена, персональный PVC на каждый под через volumeClaimTemplates, стабильное DNS-имя через headless-сервис, строгий порядок при старте и обратный при обновлении. И важная практическая деталь — PVC у StatefulSet переживает и под, и сам StatefulSet, за томами приходится следить руками.»

Про DNS подов. «К подам Deployment по DNS обратиться нельзя не потому, что записи кому-то жалко, а потому что записывать нечего: имена случайные и меняются при каждом перезапуске, а шаблон пода один на все реплики — задать в нём индивидуальный hostname физически невозможно. Через headless-сервис можно получить список IP всех подов, но это адреса, а не идентичности. У StatefulSet контроллер знает порядковый номер каждого пода и проставляет ему hostname и subdomain, а headless-сервис превращает это в имя вида pg-1.pg-h.ns.svc.cluster.local, которое переживает перезапуск и переезд на другой узел. Если приложению нужно адресовать конкретный экземпляр — это признак того, что оно stateful, и контроллер выбран неправильно.»

Про балансировку. «Service — это не прокси, а правила в ядре, которые ставит kube-proxy. В режиме iptables выбор бэкенда случайный с равными вероятностями, в IPVS — настоящий round robin по умолчанию. Но балансируются соединения, а не запросы: DNAT делается на первом пакете, дальше поток фиксируется в conntrack. Поэтому HTTP/2, gRPC и пулы соединений через L4-балансировку нормально не размазываются — нужен L7-прокси, клиентская балансировка через headless или принудительное ограничение времени жизни соединения.»

Про пробы. «Startup даёт приложению спокойно запуститься и до успеха блокирует остальные проверки. Readiness отвечает, можно ли направлять в под трафик: при ошибке endpoint помечается неготовым без рестарта контейнера. Liveness отвечает, завис ли сам процесс, и при ошибке перезапускает контейнер. Поэтому база и внешние API не должны входить в liveness — их сбой иначе вызовет каскадные рестарты.»

Про операторы. «CRD добавляет новый тип в Kubernetes API, а custom controller реализует его поведение. Вместе это Operator: он постоянно сравнивает spec с реальностью, идемпотентно приводит систему к цели и пишет результат в status. В отличие от StatefulSet, оператор знает предметную логику — например, как выбрать primary, сделать backup или безопасно обновить базу. Helm при этом часто нужен, чтобы установить сам оператор.»

Про отказоустойчивость и масштабирование. «Несколько реплик сами по себе мало что гарантируют: их нужно разнести по узлам и зонам, защитить плановые eviction через PDB, задать requests и оставить ёмкость для пересоздания. PDB не защищает от аварии узла и не управляет rollout. HPA меняет replicas по CPU или прикладным метрикам, а node autoscaler добавляет узлы для Pending-подов — это два разных контура.»

Про RBAC и права контейнера. «RBAC ограничивает запросы к Kubernetes API и не управляет UID процесса. Role и ClusterRole содержат аддитивные rules, bindings назначают их субъектам; по возможности использую RoleBinding в namespace и отдельный ServiceAccount без wildcard. Linux-права задаю securityContext: non-root, no privilege escalation, drop capabilities, seccomp и read-only rootfs. Запросы privileged, hostPath и host namespaces должен блокировать admission, потому что они резко приближают контейнер к правам на worker.»

Дальше почти наверняка спросят «а как ты это ловил?» — и вот тут выигрывает конкретика: график, на котором два пода из десяти в потолке, и то, чем это чинили.