Раздел 3 · Kubernetes — глава 3.1
Kubernetes: Deployment, StatefulSet и почему балансировка не round robin
Две трети вопросов про Kubernetes на собеседовании — это на самом деле один вопрос: «понимаешь ли ты, что под — это не виртуалка». Всё остальное — StatefulSet, Service, балансировка — следствия. Начнём с этого, а закончим тем, почему твой gRPC-клиент ходит в один под из десяти.
§0Карта Kubernetes, если основа пока плавает
Kubernetes — это API желаемого состояния и набор контроллеров. Ты не говоришь системе «запусти контейнер на node-2». Ты создаёшь объект: «хочу три готовые копии приложения с такими ресурсами и таким образом». Контроллеры замечают разницу между желаемым и фактическим состоянием и непрерывно её устраняют.
kube-apiserver — единственная точка изменения состояния кластера: проверяет аутентификацию, RBAC, admission и схему, затем сохраняет объект в etcd. Scheduler выбирает узел для Pod с учётом requests, selectors, affinity, taints и доступности томов. Controller manager запускает reconcile loops. Kubelet на узле следит, чтобы назначенные ему Pod действительно работали.
| Объект | Зачем нужен | Что запомнить сначала |
|---|---|---|
| Pod | Одна совместно запущенная группа контейнеров | Минимальная единица, эфемерная; обычно не создаётся руками |
| Deployment | Stateless-приложение и rolling update | Управляет ReplicaSet, Pod взаимозаменяемы |
| StatefulSet | Стабильные имена и персональные PVC | Поды имеют идентичность и порядок |
| Service | Стабильный адрес поверх меняющихся Pod | Отбирает endpoints по labels |
| Ingress / Gateway | HTTP(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 — он создаёт новый, с новым хешем в имени, и начинает перекладывать: добавить под в новый, дождаться готовности, убрать под из старого. Скорость этого перекладывания задают 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. Логика понятная: мастер обычно нулевой, и трогать его надо последним.
Последовательность можно отключить: 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, последнюю резервную копию и ошибки.
Reconcile loop — сердце оператора
Контроллер работает не как скрипт «создай кластер один раз», а как бесконечный цикл сверки:
- прочитать желаемое состояние из
spec; - прочитать реальное состояние ресурсов и самого приложения;
- вычислить разницу и сделать один или несколько шагов к цели;
- обновить
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 — это встроенный в ядро балансировщик четвёртого уровня, живущий в хеш-таблице, а не в линейном списке правил. Он и работает быстрее на больших кластерах (тысячи сервисов не превращаются в тысячи правил, которые ядро проходит последовательно), и умеет разные алгоритмы:
| Алгоритм | Что делает |
|---|---|
rr | Round robin по кругу. Режим по умолчанию. |
lc | Least connections — в под с наименьшим числом активных соединений. |
sh | Source 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 ломают балансировку
Вот следствие, которое сто́ит понимать не ради собеса, а ради жизни. Если балансировка происходит на уровне соединения, то приложение, которое открывает соединение один раз и держит его часами, будет ходить в один и тот же под всё это время.
С 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 каждого пода. Записи бывают четырёх видов, и путаница обычно именно здесь:
| Что | Имя | Куда указывает |
|---|---|---|
| Обычный Service | myapp.prod.svc.cluster.local | Один ClusterIP. Кто за ним — не видно |
| Headless Service | pg-h.prod.svc.cluster.local | Все IP готовых подов, списком A-записей |
| Под за headless | pg-1.pg-h.prod.svc.cluster.local | IP конкретного пода. Только если у пода задан hostname |
| Под по IP | 10-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 перезапускает контейнер внутри того же пода. |
Минимальный рабочий пример
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 не включается одним полем. Это цепочка: несколько реплик должны быть готовы, находиться в разных доменах отказа, переживать плановые работы по одной, иметь место для пересоздания и успевать масштабироваться до перегрузки.
Добровольные и аварийные нарушения
| Тип | Примеры | Что помогает |
|---|---|---|
| Добровольное | kubectl drain, обслуживание узла, консолидация node autoscaler, Eviction API | PDB ограничивает число одновременных eviction |
| Аварийное | узел умер, kernel panic, сеть разделилась, OOM или нехватка ресурсов узла | реплики на разных узлах/зонах, requests, запас ёмкости; PDB остановить аварию не может |
| Изменение приложения | rolling update Deployment или StatefulSet | maxUnavailable/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, и расчёт нужной ёмкости.
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-процессом.
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 с verbget;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 — три разных вещи
- Non-root process. В образе есть
USER 10001, а манифест требуетrunAsNonRoot. Это базовый и предпочтительный вариант для приложения. - User namespace Pod. При
hostUsers: falseUID 0 внутри Pod отображается в непривилегированный UID на узле, а capabilities действуют только внутри user namespace. В Kubernetes 1.36 эта возможность стала GA, но нужны поддержка runtime, kernel и файловой системы. - 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/hostNetwork | Pod делит соответствующий 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 — и это происходит при каждой выкатке.
Лечится примитивно и надёжно: 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 любят раскручивать вглубь, поэтому вот всё в одном месте.
| Deployment | StatefulSet | |
|---|---|---|
| Промежуточный объект | 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.»
Дальше почти наверняка спросят «а как ты это ловил?» — и вот тут выигрывает конкретика: график, на котором два пода из десяти в потолке, и то, чем это чинили.