Deployment ты уже знаешь. Он гениален для stateless: накатил три реплики nginx, поды называются как-нибудь вроде web-7d4f9c-x9q2, любой из них взаимозаменяем, упал - подняли новый, и плевать на порядок. Но как только ты пытаешься засунуть в кластер базу данных, очередь сообщений или распределенное хранилище - Deployment начинает рассыпаться.
Представь, что ты разворачиваешь реплику PostgreSQL: один primary и две standby-реплики. Им нужно три вещи, которых Deployment дать НЕ может. Первое - стабильное сетевое имя: standby должен знать, к кому подключаться, а не гадать, какой из подов сегодня primary. Второе - свой персональный диск на каждую реплику: данные primary и standby - это разные данные, их нельзя шарить. Третье - предсказуемый порядок: нельзя поднять реплику, пока не готов primary, и нельзя гасить primary, пока не остановлены реплики.
Вот ровно для этого и придуман statefulset. А когда тебе нужно обратное - не stateful-сервис, а агент на каждой ноде (сборщик логов, экспортер метрик, плагин сети) - в дело вступает daemonset. Разберем оба контроллера по косточкам: как они устроены под капотом, где грабли, и когда базу данных в Kubernetes тащить можно, а когда лучше десять раз подумать.

StatefulSet: механика стабильной идентичности
kubernetes statefulset - это контроллер из группы apps/v1, как и Deployment, но с тремя фундаментальными гарантиями, которые меняют все.
1. Стабильная сетевая идентичность. Поды называются не случайно, а строго по индексу: имя-0, имя-1, имя-2. Это ordinal index. Под с индексом 0 - это всегда тот же логический член кластера, даже если физически его пересоздали на другой ноде. Имя пода стабильно на протяжении всей жизни StatefulSet.
2. Стабильное хранилище. Через volumeClaimTemplates каждому поду создается СВОЙ PersistentVolumeClaim. Под pg-0 получает PVC data-pg-0, под pg-1 - data-pg-1 и так далее. Если pg-1 умрет и пересоздастся, он подцепит ровно свой старый PVC со своими данными. Диски не шарятся между репликами - у каждой свое состояние.
3. Упорядоченность. По умолчанию (политика OrderedReady) поды создаются строго последовательно: 0, потом ждем, пока 0 станет Ready, потом 1, ждем Ready, потом 2. Удаление - в обратном порядке: сначала гасим под с самым большим индексом. Это и есть тот самый предсказуемый порядок для primary/standby.
Чтобы стабильные имена работали на уровне DNS, StatefulSet требует headless-сервис - Service с clusterIP: None. Обычный Service дает один виртуальный IP и балансирует трафик по всем подам случайно. Headless не дает виртуального IP вообще - вместо этого DNS отдает A-записи на каждый под отдельно. Благодаря этому появляется стабильный FQDN на каждую реплику: pg-0.pg-headless.namespace.svc.cluster.local. Standby берет этот адрес и точно знает, куда стучаться.
Вот минимальный, но боевой манифест:
Код: Выделить всё
apiVersion: v1
kind: Service
metadata:
name: pg-headless
spec:
clusterIP: None # вот это делает сервис headless
selector:
app: pg
ports:
- port: 5432
name: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: pg
spec:
serviceName: pg-headless # обязательная привязка к headless-сервису
replicas: 3
podManagementPolicy: OrderedReady # по умолчанию; альтернатива - Parallel
selector:
matchLabels:
app: pg
template:
metadata:
labels:
app: pg
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
name: postgres
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates: # шаблон PVC на КАЖДУЮ реплику
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
Что реально происходит: разбор вывода
Применили манифест и смотрим, как поды рождаются по порядку:
Код: Выделить всё
$ kubectl get pods -l app=pg -w
NAME READY STATUS RESTARTS AGE
pg-0 0/1 Pending 0 0s
pg-0 1/1 Running 0 14s
pg-1 0/1 Pending 0 0s # pg-1 стартует ТОЛЬКО после Ready pg-0
pg-1 1/1 Running 0 12s
pg-2 1/1 Running 0 11s
Код: Выделить всё
$ kubectl get pvc -l app=pg
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
data-pg-0 Bound pvc-a1.. 20Gi RWO yc-network-ssd
data-pg-1 Bound pvc-b2.. 20Gi RWO yc-network-ssd
data-pg-2 Bound pvc-c3.. 20Gi RWO yc-network-ssd
Проверим DNS изнутри кластера:
Код: Выделить всё
$ kubectl run dns-test --rm -it --image=busybox:1.36 -- \
nslookup pg-0.pg-headless
Name: pg-0.pg-headless.default.svc.cluster.local
Address 1: 10.112.3.14 pg-0.pg-headless.default.svc.cluster.local
DaemonSet: по одному поду на узел
daemonset решает зеркально противоположную задачу. StatefulSet говорит "мне нужно N именованных реплик с дисками". DaemonSet говорит "мне нужен ровно один под на каждой подходящей ноде - сколько нод, столько и подов". Добавили ноду в кластер - DaemonSet автоматически выкатил на нее под. Убрали ноду - под уехал вместе с ней. Тебе не надо указывать replicas, контроллер сам считает количество по числу нод.
Кто это по жизни: агент сбора логов (Fluent Bit, Vector), экспортер метрик (node-exporter), плагин сети CNI (Cilium, Calico), агент безопасности, драйвер CSI. Все, что должно физически присутствовать на каждой машине, потому что собирает данные именно с этой машины. Сам kube-proxy в большинстве кластеров крутится как DaemonSet.
Код: Выделить всё
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: monitoring
spec:
selector:
matchLabels:
app: node-exporter
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # за раз обновляем не больше одной ноды
template:
metadata:
labels:
app: node-exporter
spec:
hostNetwork: true # агенту часто нужна сеть хоста
tolerations:
- operator: Exists # ехать даже на ноды с любыми taint, включая control-plane
containers:
- name: node-exporter
image: quay.io/prometheus/node-exporter:v1.8.2
ports:
- containerPort: 9100
hostPort: 9100
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
volumes:
- name: proc
hostPath:
path: /proc
Обновляется DaemonSet по стратегии RollingUpdate: maxUnavailable: 1 значит, что под обновляется по одной ноде за раз - выкатили новую версию на ноду, дождались готовности, пошли дальше. Есть и maxSurge - он позволяет на время обновления поднять новый под рядом со старым на той же ноде, чтобы агент не пропадал ни на секунду; но maxSurge несовместим с hostPort и hostNetwork, потому что два пода не займут один и тот же порт хоста. Это типичная ловушка: ставишь maxSurge на node-exporter с hostPort - и обновление встает колом.
Три контроллера рядом: когда что брать
- Deployment - stateless, взаимозаменяемые реплики, случайные имена, общий или вовсе никакой диск. Веб-приложения, API, воркеры. 95% твоих сервисов.
- StatefulSet - нужна стабильная идентичность, персональный диск на реплику, упорядоченный запуск. Базы данных, Kafka, ZooKeeper, Elasticsearch, Redis-кластер, etcd.
- DaemonSet - по одному агенту на узел, привязка к железу/ОС ноды. Логи, метрики, сеть, безопасность, CSI-драйверы.
Грабли StatefulSet, на которых горят все
volumeClaimTemplates не удаляются автоматически. Самая дорогая ловушка. Удалил StatefulSet - PVC и данные остаются. Это сделано специально, чтобы ты случайно не снес базу. Но это же значит, что diски тихо копятся и жгут деньги. С версии 1.23 есть persistentVolumeClaimRetentionPolicy, где можно задать поведение whenDeleted и whenScaled (Retain или Delete). По умолчанию Retain - и это правильный дефолт для БД, но за уборкой следить надо руками.
Масштабирование вниз не уменьшает данные. Уменьшил replicas с 5 до 3 - поды pg-4 и pg-3 удалились, но их PVC по умолчанию остались висеть. Снова увеличил до 5 - новые поды подцепят СТАРЫЕ диски со старыми данными. Иногда это спасает, иногда - источник коварных багов, когда реплика поднимается с протухшим состоянием.
Обновление идет в обратном порядке и может застрять. RollingUpdate обновляет поды от старшего индекса к младшему: pg-2, потом pg-1, потом pg-0. Если новый под не становится Ready (кривой образ, проблема с readinessProbe) - обновление встает намертво и не трогает остальные поды. Это защита от полного развала кластера, но первый раз пугает. Лечится через partition в rollingUpdate для канареечного выката: обновляются только поды с индексом не ниже partition.
Headless-сервис обязателен. Забыл clusterIP: None или serviceName - DNS-имена подов не поднимутся, и stateful-приложение, которое ждет своих собратьев по FQDN, зависнет на старте без внятной ошибки.
Parallel - острый нож. podManagementPolicy: Parallel ускоряет запуск, поднимая все поды разом, но убивает гарантию порядка. Для primary/standby-топологии, где порядок критичен, это прямой путь к расколу кластера (split-brain). Используй Parallel только если приложение реально не зависит от порядка реплик.
Kubernetes база данных: тащить или нет
Вопрос kubernetes база данных - религиозный, разберемся трезво. StatefulSet дает тебе примитивы (имена, диски, порядок), но НЕ дает саму логику БД: бэкапы, failover, репликацию, point-in-time recovery. Голый StatefulSet с PostgreSQL - это просто три пода с дисками, а не отказоустойчивый кластер.
Поэтому в проде БД в Kubernetes запускают через операторы: CloudNativePG или Zalando Postgres Operator для PostgreSQL, Strimzi для Kafka, оператор от Percona для MySQL/MongoDB. Оператор - это контроллер, который поверх StatefulSet добавляет мозги: сам делает failover, поднимает реплики, гоняет бэкапы в S3, чинит split-brain. Без оператора эксплуатация stateful-БД руками - это боль и риск потери данных.
Честный взгляд: сетевые диски (тот же yc-network-ssd) добавляют латентность по сравнению с локальным NVMe, а сетевые блипы внутри кластера для БД болезненнее, чем для stateless-сервиса. Если у тебя есть managed-БД от облака (Yandex Managed Service for PostgreSQL, VK Cloud Databases) и нет жесткой причины держать базу в кластере - чаще разумнее взять managed. В кластер БД тащат, когда нужна полная переносимость, GitOps-управление всем стейтом или специфические требования, которых managed не закрывает. И тогда - только через зрелый оператор, никогда не голым StatefulSet в продакшене.
Мини-лаба: собери и сломай руками
- Подними StatefulSet из трех реплик с volumeClaimTemplates (можно nginx с диском - суть в механике, не в БД). Запусти kubectl get pods -w и убедись своими глазами, что поды стартуют строго по порядку 0, 1, 2.
- Проверь kubectl get pvc - найди три отдельных PVC с именами по индексу. Запиши на диск пода-0 файл, удали под-0 (kubectl delete pod имя-0), дождись пересоздания и убедись, что файл на месте - PVC подцепился обратно.
- Уменьши replicas до 1, посмотри kubectl get pvc - убедись, что PVC удаленных подов остались висеть. Это и есть та самая грабля с дисками.
- Подними DaemonSet с node-exporter, выполни kubectl get pods -o wide и убедись, что подов ровно столько, сколько нод, и каждый на своей. Добавь toleration и проверь, доехал ли под до control-plane-ноды.
- Почему StatefulSet требует headless-сервис и что сломается, если задать обычный Service с clusterIP?
- Что произойдет с PVC при удалении StatefulSet и при scale down, и как это поведение настраивается?
- В каком порядке StatefulSet создает и удаляет поды при OrderedReady, и чем грозит переключение на Parallel для кластера БД?
- Почему для node-exporter с hostPort нельзя использовать maxSurge в стратегии обновления DaemonSet?
StatefulSet дает три вещи, которых нет у Deployment: стабильные имена подов по индексу, персональный диск на каждую реплику через volumeClaimTemplates и упорядоченный запуск/останов поверх headless-сервиса - это фундамент для БД и кластерных систем. DaemonSet решает обратную задачу: ровно один агент на каждую подходящую ноду для логов, метрик, сети и безопасности. Главные грабли StatefulSet - неудаляемые PVC и застревающее обновление. А базу данных в Kubernetes в проде запускают через оператор, а не голым контроллером - либо честно берут managed-сервис.