Kubernetes для самых маленьких. Часть 8: Как работают StatefulSet и DaemonSet?
Все части серии «Kubernetes для самых маленьких»В прошлой части Deployment держал четыре копии hello, менял их при обновлении и откатывал назад. Для него все поды одинаковы: имя случайное (hello-85854d9fd8-4mjwb), адрес новый после каждого пересоздания, и если удалить любой из них, ReplicaSet создаст замену, ничем не хуже. Так устроены приложения без состояния, и их в кластере большинство. Но есть два класса нагрузок, которым такая взаимозаменяемость мешает. Базам данных, брокерам сообщений и любым кластерам с выборами лидера нужны постоянные имена и свои диски: реплика номер два должна после перезапуска остаться номером два и найти свои данные. А агентам сбора логов и метрик, сетевым и дисковым драйверам нужно ровно по одной копии на каждом узле, сколько бы узлов ни было. Для первого в Kubernetes есть StatefulSet, для второго — DaemonSet. Оба — такие же контроллеры в kube-controller-manager, как ReplicaSet из части 7, с тем же циклом согласования из части 1; отличаются правила, по которым они создают и удаляют поды.
Что не так с Deployment для приложения с состоянием
Представим, что hello — не веб-сервер, а узел трёхузловой базы. Что сломается, если запустить её Deployment-ом:
- Имена. Узлы базы знают друг друга по именам или адресам: «реплика
db-1следует за лидеромdb-0». У пода Deployment имя содержит случайный хвост и меняется при каждом пересоздании, адрес тоже. Записать список участников в конфигурацию нельзя. - Диски. В шаблоне Deployment один набор томов на все реплики. Если там PersistentVolumeClaim (заявка на постоянный том, подробно в части 21), то все три пода ссылаются на один и тот же том — а базе нужно три разных, по одному на узел, и после пересоздания пода он должен получить свой том обратно.
- Порядок. ReplicaSet создаёт и удаляет поды пачкой, в любом порядке. Многим кластерным системам важно, чтобы первым поднялся инициатор кластера, а остальные присоединялись к нему по одному.
- Обновления. Rolling update Deployment сначала создаёт новый под (
maxSurge), потом убивает старый. Для базы это значит, что на минуту существуют два пода, претендующих на одну роль.
StatefulSet решает все четыре пункта и ничего сверх них.
StatefulSet: имена по порядку
Манифест почти не отличается от Deployment. Возьмём наш образ из части 5: он отвечает hello from <имя пода>, и на нём удобно видеть, кто есть кто. Доступ к частному пакету в ghcr.io настроен там же; учебный неймспейс до части 11 — default.
apiVersion: v1
kind: Service
metadata:
name: web
labels:
app: web
spec:
clusterIP: None # headless: без виртуального адреса
selector:
app: web
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: web # сервис, который даёт подам DNS-имена
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
terminationGracePeriodSeconds: 30
containers:
- name: hello
image: ghcr.io/example/hello:1.0
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: http
$ kubectl apply -f web.yaml
service/web created
statefulset.apps/web created
$ kubectl get pods -l app=web -w
NAME READY STATUS RESTARTS AGE
web-0 0/1 Pending 0 0s
web-0 0/1 Pending 0 0s
web-0 0/1 ContainerCreating 0 1s
web-0 0/1 ContainerCreating 0 2s
web-0 0/1 ContainerCreating 0 3s
web-0 0/1 Running 0 4s
web-0 1/1 Running 0 4s
web-1 0/1 Pending 0 0s
web-1 0/1 Pending 0 0s
web-1 0/1 ContainerCreating 0 0s
web-1 0/1 ContainerCreating 0 1s
web-1 0/1 ContainerCreating 0 2s
web-1 0/1 Running 0 3s
web-1 1/1 Running 0 3s
web-2 0/1 Pending 0 1s
web-2 0/1 Pending 0 1s
web-2 0/1 ContainerCreating 0 1s
web-2 0/1 ContainerCreating 0 2s
web-2 0/1 ContainerCreating 0 3s
web-2 0/1 Running 0 4s
web-2 1/1 Running 0 4s
kubectl get -w печатает строку на каждое изменение объекта, поэтому одинаковые строки идут подряд, а колонки выравнены только по первой. Видно сразу две вещи. Имена — web-0, web-1, web-2: имя StatefulSet и порядковый номер (ordinal), без случайных хвостов. И поды появляются строго по одному: web-1 не создаётся, пока web-0 не стал Ready, то есть пока его readiness-проба из части 6 не прошла.
Если удалить web-1, контроллер создаст новый под с тем же именем web-1, а не web-3. Адрес у него будет другой — IP пода по-прежнему выдаёт CNI при каждом создании, — но имя, DNS-запись и диск останутся прежними. Это и называется стабильной идентичностью: номер, сетевое имя и тома привязаны не к конкретному экземпляру пода, а к позиции в наборе.
Контроллер ставит на каждый под метки, по которым его можно выбрать отдельно:
| Метка | Значение | Зачем |
|---|---|---|
statefulset.kubernetes.io/pod-name |
web-1 |
сервис на один конкретный под (например, только на лидера) |
apps.kubernetes.io/pod-index |
1 |
фильтр в логах и метриках, маршрутизация по номеру |
controller-revision-hash |
web-867b65b898 |
ревизия шаблона, из которой создан под; о ней ниже |
Нумерация по умолчанию начинается с нуля. Поле .spec.ordinals.start (стабильно с 1.31) сдвигает начало: при start: 1 поды будут web-1, web-2, web-3. Это нужно в основном при переносе набора между кластерами по частям.
Headless-сервис и DNS-имена
Обычный Service из части 15 получает виртуальный адрес из 10.96.0.0/12, и kube-proxy раскидывает соединения на этот адрес по подам. Headless-сервис (clusterIP: None) адреса не получает: kube-proxy его не обслуживает вовсе, а DNS-сервер кластера CoreDNS на запрос имени сервиса отвечает списком адресов самих подов. Для StatefulSet важнее другое: контроллер записывает в спецификацию каждого пода hostname: web-1 и subdomain: web (значение serviceName), и CoreDNS по этой паре создаёт отдельное имя для каждого пода:
web-0.web.default.svc.cluster.local → 172.20.211.19
web-1.web.default.svc.cluster.local → 172.20.172.23
web-2.web.default.svc.cluster.local → 172.20.211.20
│ │ │
│ │ └─ неймспейс
│ └─ serviceName (headless-сервис)
└─ имя пода = hostname
Внутри неймспейса хватает короткого web-1.web: остальное допишет список поиска в /etc/resolv.conf пода. Когда web-1 пересоздаётся, запись указывает уже на новый адрес. Именно это имя участники кластера и пишут в конфигурацию друг друга. Сервис нужно создать самому — StatefulSet его не создаёт, а только ссылается на него по имени.
Кроме A-записей с адресами, CoreDNS публикует для headless-сервиса SRV-записи. SRV — тип DNS-записи, который отвечает на вопрос «какие узлы предоставляют такую-то службу и на каком порту»: в ответе приоритет, вес, порт и имя узла. По имени сервиса приходит по SRV-записи на каждый готовый под — с его портом 8080 и персональным именем web-N.web.default.svc.cluster.local, а для именованного порта есть ещё имя вида _http._tcp.web.default.svc.cluster.local (_<имя порта>._<протокол>.<сервис>). Так клиент узнаёт и список участников, и порт, не зная заранее ни того ни другого.
Две тонкости. По умолчанию DNS отдаёт только поды, прошедшие readiness-пробу; кластерным системам, которым нужно найти соседей ещё до готовности (чтобы собраться в кластер, а уже потом стать Ready), в сервисе ставят publishNotReadyAddresses: true. И о кэше: сам CoreDNS в kubeadm ответы об именах cluster.local не кэширует — ни положительные, ни отрицательные (в Corefile disable success cluster.local и disable denial cluster.local), поэтому имя web-2.web начинает разрешаться, как только под стал готов. Но «нет такого имени», полученное до этого, может запомнить кто-то на стороне клиента — резолвер приложения или кэширующий агент на узле, — так что программы, ищущие соседей по DNS, должны повторять запрос, а не сдаваться после первого отказа. Как устроены CoreDNS, список поиска и ndots, разобрано в части 16; сам протокол DNS — в части 12 сетевой серии.
Свой диск у каждого пода: volumeClaimTemplates
Главное, ради чего StatefulSet существует, — поле volumeClaimTemplates. Это шаблон не тома, а заявки на том (PersistentVolumeClaim, PVC): контроллер создаёт по заявке на каждый номер и подставляет её в под с тем же номером.
spec:
# ... всё как выше
template:
spec:
containers:
- name: hello
# ...
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
Имя заявки складывается из имени шаблона, имени набора и номера: data-web-0, data-web-1, data-web-2. Когда под web-1 пересоздаётся — после сбоя, обновления, переезда на другой узел, — контроллер не создаёт новую заявку, а находит существующую data-web-1 и монтирует её. Данные возвращаются к тому же номеру.
StatefulSet web
├─ web-0 ── PVC data-web-0 ── PV (диск 1) web-0.web.default.svc
├─ web-1 ── PVC data-web-1 ── PV (диск 2) web-1.web.default.svc
└─ web-2 ── PVC data-web-2 ── PV (диск 3) web-2.web.default.svc
под пересоздан → новое имя пода? нет, то же: web-1
→ новая PVC? нет, та же: data-web-1
→ новый IP? да, но DNS-имя то же
На нашем стенде такой манифест пока застрянет: в кластере ещё нет StorageClass — описания того, где и как выделять тома, — и заявки останутся в Pending:
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-web-0 Pending <unset> 40s
$ kubectl get pods -l app=web
NAME READY STATUS RESTARTS AGE
web-0 0/1 Pending 0 40s
$ kubectl describe pvc data-web-0 | tail -3
Type Reason Age From Message
---- ------ ---- ---- -------
Normal FailedBinding 9s (x4 over 40s) persistentvolume-controller no persistent volumes available for this claim and no storage class is set
Обратите внимание: заявка одна, data-web-0, и под один. web-1 не создаётся, потому что web-0 не готов, — порядок работает и здесь. Под ждёт с событием pod has unbound immediate PersistentVolumeClaims от планировщика. Тома, PV, StorageClass и CSI-драйверы — тема частей 21–23; там этот пример заработает на NFS.
Чтобы попробовать пример уже сейчас, тома можно создать вручную. PersistentVolume (PV) — объект, описывающий конкретный том; заявка без storageClassName в кластере без StorageClass по умолчанию привязывается к любому свободному PV без класса подходящего размера и режима доступа. Простейший вариант для опыта — локальный каталог на узле: том типа local с привязкой к узлу, чтобы планировщик ставил под туда, где лежат его данные. Каталог /srv/pv/a нужно заранее создать на k8s-node-1, а таких PV понадобится по одному на реплику:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-node-1-a
spec:
capacity:
storage: 1Gi
accessModes: ["ReadWriteOnce"]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
local:
path: /srv/pv/a
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: ["k8s-node-1"]
Как только свободные PV появились, контроллер томов привязывает к ним заявки, и набор поднимается дальше:
$ kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-web-0 Bound pv-node-1-b 1Gi RWO <unset> 87s
data-web-1 Bound pv-node-1-a 1Gi RWO <unset> 22s
data-web-2 Bound pv-node-2-a 1Gi RWO <unset> 18s
Под с таким томом навсегда привязан к узлу каталога: если узел умрёт, данные останутся на нём. Для опыта это годится, для настоящих данных — нет; после опыта PV и каталоги удаляют руками.
volumeClaimTemplates после создания набора менять нельзя: API разрешает править в StatefulSet только replicas, ordinals, template, updateStrategy, revisionHistoryLimit, persistentVolumeClaimRetentionPolicy и minReadySeconds, а на попытку изменить остальное отвечает Forbidden: updates to statefulset spec for fields other than ... are forbidden. Увеличить размер дисков можно, расширив сами PVC (если StorageClass это позволяет), а шаблон останется старым — новые реплики получат старый размер, это надо помнить при масштабировании.
Что происходит с дисками при удалении и уменьшении
По умолчанию — ничего. Удалили StatefulSet, уменьшили replicas с трёх до одного — заявки data-web-1 и data-web-2 и тома за ними остаются. Это сделано сознательно: потеря данных из-за опечатки в replicas хуже, чем забытый диск. Вернули три реплики — web-1 и web-2 получили свои старые данные.
Поведение задаёт persistentVolumeClaimRetentionPolicy (стабильно с 1.32):
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain # удалили StatefulSet
whenScaled: Delete # уменьшили replicas
У обоих полей значения Retain (по умолчанию) и Delete. Механизм — те же ссылки на владельца (ownerReferences) и сборщик мусора, что удаляют поды вместе с ReplicaSet. При whenDeleted: Delete контроллер прописывает в каждую заявку владельцем сам StatefulSet, и после его удаления сборщик мусора удаляет заявки. При whenScaled: Delete контроллер перед удалением лишнего пода делает владельцем заявки этот под, и заявка исчезает вслед за ним — уже после того, как под остановился и отмонтировал том. Что станет с самим диском, решает reclaimPolicy тома (часть 21): Delete удалит данные, Retain оставит их администратору.
Это видно по самим заявкам. С обоими Delete у каждой заявки владелец — StatefulSet, а при уменьшении replicas с трёх до двух владелец заявки data-web-2 меняется на под:
$ kubectl get pvc -w -o custom-columns=NAME:.metadata.name,STATUS:.status.phase,OWNER-KIND:.metadata.ownerReferences[*].kind,OWNER:.metadata.ownerReferences[*].name
NAME STATUS OWNER-KIND OWNER
data-web-0 Bound StatefulSet web
data-web-1 Bound StatefulSet web
data-web-2 Bound StatefulSet web
data-web-2 Bound Pod web-2
Через несколько секунд, когда под остановился, заявки data-web-2 уже нет, а её PV с Retain перешёл в статус Released: данные на месте, но заново к новой заявке такой том сам не привяжется — администратор решает, что с ним делать.
Политики срабатывают только при удалении набора и уменьшении числа реплик. Если под пересоздаётся из-за сбоя узла, заявка всегда сохраняется. Для баз данных разумное сочетание — оба Retain, а для кэшей и временных реплик, которые при каждом запуске всё равно синхронизируются заново, — whenScaled: Delete.
Порядок запуска и podManagementPolicy
Правила по умолчанию (политика OrderedReady):
- при создании и увеличении поды создаются по возрастанию номера, и каждый следующий — только когда все предыдущие Running и Ready (а если задан
minReadySeconds— готовы не меньше этого времени); - при уменьшении поды удаляются по убыванию номера, и каждый следующий — только когда предыдущий полностью остановился;
- если во время масштабирования упал более ранний под, процесс останавливается, пока тот не поднимется.
scale 0 → 3: web-0 ──Ready──► web-1 ──Ready──► web-2
scale 3 → 1: web-2 ──удалён──► web-1 ──удалён──► (web-0 остаётся)
Для системы с инициатором это ровно то, что нужно: web-0 поднимает кластер, остальные по одному в него входят и по одному выходят. Но многим приложениям с состоянием порядок не важен — каждый узел сам находит соседей, — а ждать готовности по одному долго: сто реплик по десять секунд — это почти 17 минут. Для них есть podManagementPolicy: Parallel: поды создаются и удаляются все сразу, без ожидания. Имена, DNS и диски остаются такими же стабильными; отменяется только очередь. Поле задаётся при создании набора и потом не меняется.
terminationGracePeriodSeconds: 0 в StatefulSet ставить нельзя ни в коем случае. Набор гарантирует, что под с данным именем существует не больше одного, и на эту гарантию опирается всё остальное: два web-1 одновременно писали бы в один диск. Нулевой срок и принудительное удаление (kubectl delete pod --force --grace-period=0) снимают под из API, не дожидаясь подтверждения от kubelet, что процессы остановлены.
Отсюда же поведение при падении узла. Если k8s-node-2 выключился, его поды переходят в Unknown или зависают в Terminating: API-сервер не может получить подтверждение, что они остановлены, ведь kubelet недоступен. Deployment в такой ситуации просто создаёт замену на другом узле. StatefulSet не создаёт — с его точки зрения web-1 всё ещё может работать и писать на диск, а второй web-1 недопустим. Под останется в этом состоянии, пока узел не вернётся или администратор не подтвердит, что узел действительно мёртв: удалит объект Node или поставит на него taint node.kubernetes.io/out-of-service (о taints — в части 13). Только после этого контроллер создаст web-1 на другом узле, а тома будут отсоединены от мёртвого. Это не недоработка, а цена гарантии.
Обновления: RollingUpdate, partition и OnDelete
Стратегию задаёт .spec.updateStrategy.type, и в 1.36 вариантов два (в 1.37 добавляется третий, Recreate, — альфа за feature gate StatefulSetRecreateStrategy).
RollingUpdate (по умолчанию). После изменения шаблона контроллер удаляет и пересоздаёт поды по одному, от старшего номера к младшему: web-2, затем web-1, затем web-0, каждый раз дожидаясь готовности нового пода. В отличие от Deployment, новый под не создаётся рядом со старым: сначала старый удаляется, потом под тем же именем появляется новый. Лишних экземпляров нет, но на время пересоздания одна реплика недоступна. Старший номер идёт первым потому, что у кластерных систем web-0 чаще всего инициатор или лидер, и его логично обновлять последним.
Поэтапное обновление через partition. Поле .spec.updateStrategy.rollingUpdate.partition задаёт номер, начиная с которого поды обновляются. Поды с номером меньше partition остаются на старой версии — и даже если их удалить, они будут пересозданы из старого шаблона. Это встроенный механизм канареечного выпуска:
spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 2 # обновится только web-2
$ kubectl set image statefulset/web hello=ghcr.io/example/hello:1.1
statefulset.apps/web image updated
$ kubectl rollout status statefulset/web
Waiting for partitioned roll out to finish: 0 out of 1 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
partitioned roll out complete: 1 new pods have been updated...
$ kubectl get pods -l app=web -L controller-revision-hash
NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH
web-0 1/1 Running 0 24s web-867b65b898
web-1 1/1 Running 0 20s web-867b65b898
web-2 1/1 Running 0 5s web-846985d455
Посмотрели на web-2, убедились, что новая версия работает с данными и соседями, — опускаем partition до 0 (kubectl patch statefulset web -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'), и обновляются остальные: сначала web-1, потом web-0, каждый проходит Terminating, Completed и создаётся заново. Если partition больше числа реплик, изменения шаблона не доходят ни до одного пода. Пока partition не опущен, удалённый вручную web-0 возвращается с прежним образом hello:1.0. Слово «partitioned» kubectl rollout status пишет всегда, даже без partition в манифесте: API подставляет в стратегию partition: 0 по умолчанию.
maxUnavailable — сколько подов набора обновлять одновременно (.spec.updateStrategy.rollingUpdate.maxUnavailable, число или процент, по умолчанию 1). В 1.36 это поле в статусе beta, но выключено по умолчанию: оно было включено в первых выпусках 1.35 и снова выключено в 1.35.4. Чтобы им пользоваться, нужно включить feature gate (флаг включения функции) MaxUnavailableStatefulSet на API-сервере и в kube-controller-manager. Без него API-сервер молча выбрасывает поле maxUnavailable из стратегии — ошибки нет, но и в сохранённом объекте поля нет, — а обновление идёт строго по одному: для набора из сотни реплик это медленно, но предсказуемо. Включён ли gate, видно по метрикам API-сервера: kubectl get --raw /metrics | grep 'kubernetes_feature_enabled{name="MaxUnavailableStatefulSet"' на нашем стенде показывает stage="BETA"} 0. С podManagementPolicy: Parallel и maxUnavailable больше единицы контроллер пересоздаёт поды пачками.
OnDelete. Контроллер вообще не трогает поды при изменении шаблона. Новый под с новой версией появится только тогда, когда старый удалит кто-то другой — администратор или оператор (часть 28). kubectl rollout status для такого набора не работает и отвечает error: rollout status is only available for RollingUpdate strategy type; какие поды уже на новой версии, видно по метке controller-revision-hash. Так обновляют системы, где порядок и момент перезапуска каждой реплики решает человек или внешняя логика: сначала реплики, потом переключение лидера, потом бывший лидер.
Ревизии и откат. Каждое изменение шаблона контроллер сохраняет в объекте ControllerRevision — снимке шаблона с номером. Хеш ревизии и есть метка controller-revision-hash на подах; по ней контроллер понимает, какой под какой версии. Хранится до revisionHistoryLimit ревизий (по умолчанию 10), и kubectl rollout history statefulset/web и kubectl rollout undo statefulset/web работают так же, как с Deployment.
Одна известная ловушка при OrderedReady. Если новая версия никогда не становится Ready (ошибка в образе или конфигурации), обновление останавливается на первом же поде и ждёт. Откат шаблона в этой ситуации не помогает: контроллер продолжает ждать, пока сломанный под станет готовым, и только потом пересоздал бы его из исправленного шаблона. Документация описывает это как известную проблему: после отката шаблона нужно вручную удалить поды, которые успели запуститься со сломанной версией, — тогда контроллер пересоздаст их из исправленного шаблона. На стенде это воспроизводится тегом, которого нет в реестре:
$ kubectl set image statefulset/web hello=ghcr.io/example/hello:1.2
statefulset.apps/web image updated
$ kubectl get pods -l app=web -L controller-revision-hash
NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH
web-0 1/1 Running 0 95s web-846985d455
web-1 1/1 Running 0 48s web-846985d455
web-2 0/1 ErrImagePull 0 36s web-7697f865bd
$ kubectl rollout undo statefulset/web
Warning: resource statefulsets/web was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
statefulset.apps/web rolled back
$ kubectl get pods -l app=web -L controller-revision-hash
NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH
web-0 1/1 Running 0 2m20s web-846985d455
web-1 1/1 Running 0 93s web-846985d455
web-2 0/1 ImagePullBackOff 0 81s web-7697f865bd
$ kubectl delete pod web-2
pod "web-2" deleted from default namespace
$ kubectl get pods -l app=web -L controller-revision-hash
NAME READY STATUS RESTARTS AGE CONTROLLER-REVISION-HASH
web-0 1/1 Running 0 2m33s web-846985d455
web-1 1/1 Running 0 106s web-846985d455
web-2 1/1 Running 0 4s web-846985d455
Шаблон откатился сразу, но web-2 полторы минуты оставался на сломанной ревизии, пока его не удалили руками. И ещё одна деталь отката: новой ревизии он не создаёт, а переносит существующую ControllerRevision с тем же шаблоном на следующий номер, поэтому kubectl rollout history после него показывает ревизии 1, 3 и 4 — вторая стала четвёртой.
StatefulSet не делает приложение кластерным
Это самое частое недоразумение. StatefulSet даёт три вещи: постоянное имя, постоянный диск и порядок запуска. Он не знает, что внутри пода база данных, и не делает ничего из того, что делает базу отказоустойчивой:
- не настраивает репликацию — три пода PostgreSQL в StatefulSet с
replicas: 3это три независимые базы с тремя пустыми дисками, а не кластер; - не выбирает лидера и не переключает его при сбое — если упал под с основной базой, StatefulSet пересоздаст его, когда сможет, но не сделает основной другую реплику;
- не знает, какой из подов сейчас принимает запись, и не направляет туда трафик — обычный Service раскидает клиентов по всем трём, включая реплики только для чтения;
- не делает резервных копий и не проверяет, что данные на диске целы после переезда.
Всё это должно уметь само приложение, или это делает оператор — отдельный контроллер, который знает, как управлять конкретной системой: создаёт StatefulSet, настраивает репликацию, переключает лидера, делает резервные копии. Пример первого — etcd, хранилище, на котором держится сам Kubernetes: его узлы по протоколу согласования Raft выбирают лидера и подтверждают каждую запись большинством, и StatefulSet ему нужен только ради имён и дисков (подробно в части 34). Пример второго — операторы СУБД PostgreSQL и брокера сообщений RabbitMQ, которые разбираются в части 28. На практике базы в кластере почти всегда ставят оператором, и StatefulSet оказывается деталью внутри него.
DaemonSet: по поду на каждый узел
DaemonSet решает обратную задачу: не «N подов где угодно», а «по одному поду на каждом подходящем узле». Добавили узел — на нём появился под; убрали узел — под исчез вместе с ним. Поля replicas у DaemonSet нет: число подов равно числу узлов.
Типичные жильцы DaemonSet:
- сетевой плагин. У Calico на каждом узле работает
calico-node: он программирует маршруты и правила для подов этого узла и, в режиме BGP, обменивается маршрутами с соседями (BGP — протокол, которым маршрутизаторы сообщают друг другу, какие сети через них доступны, см. часть 22 сетевой серии); в нашем стенде его поставил Tigera operator — контроллер, который устанавливает и обновляет Calico, — в части 4. Там жеkube-proxy, который превращает Service в правила nftables на каждом узле (часть 15); - узловая часть CSI-драйвера. CSI (Container Storage Interface) — стандартный интерфейс, через который Kubernetes подключает внешние хранилища; у каждого драйвера есть node-плагин, который монтирует тома на конкретном узле, и он всегда DaemonSet (часть 21);
- агенты логов. Контейнеры пишут в stdout, containerd складывает это в файлы
/var/log/pods/...на узле, а агент вроде Fluent Bit (лёгкий сборщик и пересыльщик логов) или Grafana Alloy (агент Grafana для логов, метрик и трасс) читает эти файлы и отправляет в хранилище, например в Loki — систему хранения и поиска логов от Grafana Labs (часть 33); - агенты метрик. node-exporter — программа проекта Prometheus, которая отдаёт по HTTP метрики самого узла: загрузку процессора, память, диски, сеть. Prometheus — система мониторинга, которая периодически забирает такие метрики и хранит их как временные ряды (часть 32).
Пример: node-exporter
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
labels:
app: node-exporter
spec:
selector:
matchLabels:
app: node-exporter
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app: node-exporter
spec:
hostNetwork: true # слушать порт 9100 на адресе узла
hostPID: true # видеть процессы узла
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule # пустить и на мастер
containers:
- name: node-exporter
image: quay.io/prometheus/node-exporter:v1.12.1
args:
- --path.rootfs=/host
- --path.udev.data=/host/run/udev/data
ports:
- name: metrics
containerPort: 9100
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
memory: 64Mi
securityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
volumeMounts:
- name: root
mountPath: /host
readOnly: true
mountPropagation: HostToContainer
volumes:
- name: root
hostPath:
path: /
Агенты узла почти всегда просят что-то от узла: сеть узла (hostNetwork), его процессы (hostPID), его файлы (hostPath). Это прямой доступ мимо изоляции контейнера, и давать его нужно по минимуму: здесь корень узла смонтирован только для чтения, процесс работает не от root (UID 65534 — nobody), файловая система контейнера неизменяема, повышение привилегий запрещено. mountPropagation: HostToContainer нужен, чтобы диски, смонтированные на узле позже, были видны и внутри контейнера. --path.rootfs=/host говорит node-exporter, где искать корень узла при подсчёте места на файловых системах, а путь к базе udev (сведения о дисках) этот флаг не меняет — без --path.udev.data в журнале пода будет Failed to open directory, disabling udev device properties. Рецепты из интернета с privileged: true для node-exporter не нужны: ему достаточно читать /proc и /sys. Подробно о securityContext — в части 30, о requests и limits — в следующей.
$ kubectl apply -f node-exporter.yaml
daemonset.apps/node-exporter created
$ kubectl get daemonset node-exporter
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
node-exporter 3 3 3 3 3 <none> 52s
$ kubectl get pods -l app=node-exporter -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
node-exporter-ns8mn 1/1 Running 0 52s 192.168.10.21 k8s-node-2 <none> <none>
node-exporter-nslxs 1/1 Running 0 52s 192.168.10.20 k8s-node-1 <none> <none>
node-exporter-snhlc 1/1 Running 0 52s 192.168.10.17 k8s-master-1 <none> <none>
Первые полминуты READY будет 0: на каждом узле kubelet скачивает образ (Successfully pulled image … in 20.42s в событиях пода).
У подов адреса узлов, а не из 172.20.0.0/16: при hostNetwork: true под живёт в сетевом пространстве имён узла, и CNI ему адрес не выдаёт.
Как DaemonSet размещает поды
Долгое время контроллер DaemonSet сам выбирал узел и записывал его в spec.nodeName, минуя планировщик. Сейчас он работает иначе: для каждого подходящего узла создаёт под с жёсткой привязкой к этому узлу через node affinity — правило размещения по свойствам узла, — а дальше под размещает обычный планировщик kube-scheduler, как любой другой:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchFields:
- key: metadata.name
operator: In
values:
- k8s-node-1
Так DaemonSet-поды проходят через те же проверки ресурсов и приоритетов, что и остальные. Если на узле не хватает места, планировщик может вытеснить менее приоритетные поды; поэтому критичным агентам (CNI, CSI) ставят priorityClassName: system-node-critical — встроенный класс с наивысшим приоритетом для компонентов узла.
Какие узлы «подходящие», задают nodeSelector и affinity в шаблоне — например, агент GPU только на узлах с меткой gpu=true. Без них — все узлы, кроме тех, где мешают taints. Taint — пометка на узле «сюда нельзя», и под попадёт на такой узел, только если у него есть соответствующая toleration (терпимость). На мастере kubeadm ставит taint node-role.kubernetes.io/control-plane:NoSchedule, поэтому в примере выше toleration для него прописана явно: без неё на мастере node-exporter не появился бы, и DESIRED был бы 2.
Часть tolerations контроллер добавляет всем DaemonSet-подам сам:
| Taint | Эффект | Что даёт |
|---|---|---|
node.kubernetes.io/not-ready |
NoExecute | под ставится на неготовый узел и не выселяется с него |
node.kubernetes.io/unreachable |
NoExecute | не выселяется с узла, потерявшего связь с control plane |
node.kubernetes.io/disk-pressure |
NoSchedule | ставится на узел, где кончается диск |
node.kubernetes.io/memory-pressure |
NoSchedule | ставится на узел, где кончается память |
node.kubernetes.io/pid-pressure |
NoSchedule | ставится на узел, где кончаются номера процессов |
node.kubernetes.io/unschedulable |
NoSchedule | ставится на узел, закрытый для новых подов командой kubectl cordon |
node.kubernetes.io/network-unavailable |
NoSchedule | только для подов с hostNetwork: true: ставится на узел без сети |
Смысл в первой строке и последней. Новый узел не станет Ready, пока на нём не заработает сетевой плагин, а сетевой плагин — DaemonSet-под. Без автоматической toleration для not-ready получилась бы взаимная блокировка. Поэтому же kubectl drain — команда, которая закрывает узел для новых подов и выселяет с него существующие перед обслуживанием, — по умолчанию отказывается выселять DaemonSet-поды и требует флага --ignore-daemonsets: выселять их бессмысленно, контроллер тут же вернёт их на этот узел. Подробно о taints, tolerations, affinity и drain — в части 13.
Обновление DaemonSet
У DaemonSet те же две стратегии, RollingUpdate (по умолчанию) и OnDelete, но параметры у RollingUpdate свои:
maxUnavailable(по умолчанию 1) — на скольких узлах одновременно старый под может быть остановлен до появления нового. Число или процент от числа узлов. Для кластера в сотню узлов 1 — это сотня последовательных шагов, и агентам метрик обычно ставят 10–20 %.maxSurge(по умолчанию 0, стабильно с 1.25) — на скольких узлах можно сначала запустить новый под рядом со старым и убрать старый, только когда новый готов. Так узел не остаётся без агента ни на секунду, что важно для сетевых и дисковых плагинов.
У DaemonSet ненулевым должно быть ровно одно из двух полей. Оба нуля API отвергает (maxUnavailable: Required value: cannot be 0 when maxSurge is 0), но и оба ненулевых тоже: spec.updateStrategy.rollingUpdate.maxSurge: Invalid value: 1: may not be set when maxUnavailable is non-zero. Поэтому, включая maxSurge, надо явно написать maxUnavailable: 0 — иначе подставится умолчание 1 и манифест не примется.
maxSurge работает не всегда. Если под занимает порт на узле — через hostNetwork или hostPort, — второй экземпляр на том же узле не запустится: порт 9100 уже занят старым. При hostNetwork: true API сам записывает в каждый порт контейнера hostPort с тем же номером, и планировщик не находит для нового пода узла:
$ kubectl get pods -l app=node-exporter -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
node-exporter-dhvjz 0/1 Pending 0 20s <none> <none> <none> <none>
node-exporter-ns8mn 1/1 Running 0 45s 192.168.10.21 k8s-node-2 <none> <none>
...
$ kubectl describe pod node-exporter-dhvjz | tail -1
Warning FailedScheduling 20s default-scheduler 0/3 nodes are available: 1 node(s) didn't have free ports for the requested pod ports, 2 node(s) didn't satisfy plugin(s) [NodeAffinity]. no new claims to deallocate, preemption: 0/3 nodes are available: 1 No preemption victims found for incoming pod, 2 Preemption is not helpful for scheduling.
Один узел занят портом, два других не подходят по привязке к узлу, о которой выше, — и выкатка стоит, пока стратегию не вернут на maxUnavailable. Для нашего node-exporter с hostNetwork: true поэтому подходит только maxUnavailable: старый под на узле останавливается (node-exporter завершается по SIGTERM с ненулевым кодом, поэтому в get -w мелькает Error), и лишь потом на этот узел ставится новый. То же с агентами, которые держат эксклюзивную блокировку файла на узле.
minReadySeconds (по умолчанию 0) добавляет паузу: новый под считается доступным, только пробыв Ready указанное время. Это дешёвая защита от версии, которая стартует, проходит пробу и падает через десять секунд.
OnDelete у DaemonSet используют для самых чувствительных компонентов — сетевого плагина и CSI: администратор сам выводит узел из работы (kubectl drain), удаляет старый под, проверяет новый и переходит к следующему узлу. Ревизии DaemonSet тоже хранятся в ControllerRevision, и kubectl rollout history daemonset/node-exporter и kubectl rollout undo работают как обычно.
Смотрим руками
Начнём со StatefulSet web из начала части. Что контроллер записал в спецификацию пода:
$ kubectl get pod web-1 -o jsonpath='{.spec.hostname}{" "}{.spec.subdomain}{"\n"}'
web-1 web
$ kubectl get pod web-1 -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
StatefulSet/web
$ kubectl get pods -l app=web -o wide --show-labels
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS
web-0 1/1 Running 0 34s 172.20.211.19 k8s-node-1 <none> <none> app=web,apps.kubernetes.io/pod-index=0,controller-revision-hash=web-846985d455,statefulset.kubernetes.io/pod-name=web-0
web-1 1/1 Running 0 41s 172.20.172.23 k8s-node-2 <none> <none> app=web,apps.kubernetes.io/pod-index=1,controller-revision-hash=web-846985d455,statefulset.kubernetes.io/pod-name=web-1
web-2 1/1 Running 0 49s 172.20.211.20 k8s-node-1 <none> <none> app=web,apps.kubernetes.io/pod-index=2,controller-revision-hash=web-846985d455,statefulset.kubernetes.io/pod-name=web-2
Владелец пода — сам StatefulSet, без промежуточного ReplicaSet, как у Deployment. Все три пода уже на второй ревизии после обновления до hello:1.1, и по возрасту видно его порядок: web-2 пересоздан первым, web-0 — последним. Теперь DNS. Запустим временный под с busybox и будем спрашивать имена из него:
$ kubectl run dns-test --restart=Never --image=busybox:1.37 -- sleep 3600
pod/dns-test created
$ kubectl exec dns-test -- nslookup web.default.svc.cluster.local
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: web.default.svc.cluster.local
Address: 172.20.211.19
Name: web.default.svc.cluster.local
Address: 172.20.172.23
Name: web.default.svc.cluster.local
Address: 172.20.211.20
$ kubectl exec dns-test -- nslookup web-1.web.default.svc.cluster.local
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: web-1.web.default.svc.cluster.local
Address: 172.20.172.23
$ kubectl exec dns-test -- nslookup -type=SRV web.default.svc.cluster.local
Server: 10.96.0.10
Address: 10.96.0.10:53
web.default.svc.cluster.local service = 0 33 8080 web-2.web.default.svc.cluster.local
web.default.svc.cluster.local service = 0 33 8080 web-1.web.default.svc.cluster.local
web.default.svc.cluster.local service = 0 33 8080 web-0.web.default.svc.cluster.local
$ kubectl exec dns-test -- wget -qO- http://web-1.web:8080/
hello from web-1
Имя сервиса возвращает все три адреса (headless: виртуального адреса нет), имя web-1.web… — ровно один. В SRV-ответе приоритет 0, вес 33 (CoreDNS делит 100 поровну между записями), порт 8080 и персональное имя пода. Пустая строка перед Name: — пустой ответ на второй запрос, за IPv6-адресом (AAAA), который busybox отправляет вместе с запросом A.
Полные имена в nslookup здесь не случайно. nslookup из busybox дописывает домены из search только к именам без точки: nslookup web перебирает весь список и печатает ** server can't find web.cluster.local: NXDOMAIN на каждый неудачный вариант вперемешку с ответом, а nslookup web-1.web сразу отвечает NXDOMAIN, хотя такое имя в поде работает — wget идёт через обычный резолвер, который учитывает search и ndots:5, и короткое web-1.web находит. Теперь удалим web-1 и сразу спросим его имя ещё раз:
$ kubectl delete pod web-1
pod "web-1" deleted from default namespace
$ kubectl exec dns-test -- nslookup web-1.web.default.svc.cluster.local
Server: 10.96.0.10
Address: 10.96.0.10:53
** server can't find web-1.web.default.svc.cluster.local: NXDOMAIN
** server can't find web-1.web.default.svc.cluster.local: NXDOMAIN
$ kubectl get pod web-1 -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
web-1 1/1 Running 0 8s 172.20.172.25 k8s-node-2 <none> <none>
$ kubectl exec dns-test -- nslookup web-1.web.default.svc.cluster.local
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: web-1.web.default.svc.cluster.local
Address: 172.20.172.25
$ kubectl exec dns-test -- wget -qO- http://web-1.web:8080/
hello from web-1
Пока новый web-1 не прошёл readiness-пробу, имени нет вовсе — на стенде шесть-семь секунд; потом оно появляется уже с новым адресом. Адрес сменился, имя осталось. Под dns-test после опытов удаляем (kubectl delete pod dns-test); sleep в нём работает с PID 1 и SIGTERM не обрабатывает, поэтому удаление ждёт все 30 секунд срока завершения. Ревизии шаблона:
$ kubectl get controllerrevisions -l app=web
NAME CONTROLLER REVISION AGE
web-846985d455 statefulset.apps/web 2 79s
web-867b65b898 statefulset.apps/web 1 94s
$ kubectl rollout history statefulset/web
statefulset.apps/web
REVISION CHANGE-CAUSE
1 <none>
2 <none>
Имя ControllerRevision — имя набора и хеш шаблона, тот же, что в метке controller-revision-hash подов; одинаковый шаблон даёт одинаковый хеш, поэтому после пересоздания набора из того же манифеста хеши не меняются.
Теперь порядок удаления. Уменьшим набор до одного и посмотрим, как уходят поды:
$ kubectl scale statefulset web --replicas=1
statefulset.apps/web scaled
$ kubectl get pods -l app=web -w
NAME READY STATUS RESTARTS AGE
web-0 1/1 Running 0 12s
web-1 1/1 Running 0 26s
web-2 1/1 Running 0 59s
web-2 1/1 Terminating 0 60s
web-2 1/1 Terminating 0 61s
web-2 1/1 Terminating 0 61s
web-2 0/1 Completed 0 62s
web-2 0/1 Completed 0 62s
web-2 0/1 Completed 0 62s
web-1 1/1 Terminating 0 30s
web-1 1/1 Terminating 0 30s
web-1 1/1 Terminating 0 31s
web-1 0/1 Completed 0 32s
web-1 0/1 Completed 0 32s
web-1 0/1 Completed 0 32s
web-1 начал останавливаться только после того, как web-2 завершился полностью и исчез из API. Completed — фаза пода, контейнер которого вышел с кодом 0: hello по SIGTERM завершается чисто.
Перейдём к DaemonSet. Какие DaemonSet уже работают в кластере:
$ kubectl get daemonsets -A
NAMESPACE NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
calico-system calico-node 3 3 3 3 3 kubernetes.io/os=linux 2d11h
calico-system csi-node-driver 3 3 3 3 3 kubernetes.io/os=linux 2d11h
default node-exporter 3 3 3 3 3 <none> 11m
kube-system kube-proxy 3 3 3 3 3 kubernetes.io/os=linux 2d11h
calico-node и kube-proxy уже знакомы; csi-node-driver — узловой CSI-плагин, который Tigera operator ставит вместе с Calico для собственных служебных томов. Все стоят на трёх узлах, включая мастер: в их шаблонах есть toleration на любой taint (operator: Exists без ключа). Посмотрим, что контроллер добавил в под node-exporter:
$ kubectl get pod node-exporter-nslxs -o yaml | grep -A 24 '^ tolerations:'
tolerations:
- effect: NoSchedule
key: node-role.kubernetes.io/control-plane
operator: Exists
- effect: NoExecute
key: node.kubernetes.io/not-ready
operator: Exists
- effect: NoExecute
key: node.kubernetes.io/unreachable
operator: Exists
- effect: NoSchedule
key: node.kubernetes.io/disk-pressure
operator: Exists
- effect: NoSchedule
key: node.kubernetes.io/memory-pressure
operator: Exists
- effect: NoSchedule
key: node.kubernetes.io/pid-pressure
operator: Exists
- effect: NoSchedule
key: node.kubernetes.io/unschedulable
operator: Exists
- effect: NoSchedule
key: node.kubernetes.io/network-unavailable
operator: Exists
Первая — наша, остальные семь добавил контроллер, включая network-unavailable, потому что под использует сеть узла. Метрики узла — прямо на его адресе:
$ curl -s http://192.168.10.20:9100/metrics | grep -E '^node_(load1|memory_MemAvailable_bytes|filesystem_avail_bytes\{.*mountpoint="/"\}) '
node_filesystem_avail_bytes{device="/dev/sda2",device_error="",fstype="ext4",mountpoint="/"} 2.5520345088e+10
node_load1 0.14
node_memory_MemAvailable_bytes 2.789015552e+09
Пробел в конце выражения отсекает node_load15, которое иначе тоже совпало бы с node_load1.
И что будет, если узел выводят из работы:
$ kubectl drain k8s-node-1 --ignore-daemonsets --delete-emptydir-data
node/k8s-node-1 cordoned
Warning: ignoring DaemonSet-managed Pods: calico-system/calico-node-6d2qb, calico-system/csi-node-driver-q6t25, default/node-exporter-nslxs, kube-system/kube-proxy-xmjz2
evicting pod default/web-0
pod/web-0 evicted
node/k8s-node-1 drained
$ kubectl get pods -o wide | grep -E 'NAME|k8s-node-1|web-0'
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
node-exporter-nslxs 1/1 Running 0 12m 192.168.10.20 k8s-node-1 <none> <none>
web-0 1/1 Running 0 10s 172.20.172.30 k8s-node-2 <none> <none>
web-0 выселен и пересоздан с тем же именем на k8s-node-2, DaemonSet-поды остались на месте: узел помечен taint node.kubernetes.io/unschedulable:NoSchedule, но у них есть toleration на это. Без --ignore-daemonsets drain отказывается работать и перечисляет их: cannot delete DaemonSet-managed Pods (use --ignore-daemonsets to ignore): calico-system/calico-node-6d2qb, …. Если на узле окажется под без контроллера, вроде нашего dns-test, drain тоже остановится — cannot delete Pods that declare no controller (use --force to override): такой под после выселения никто не создаст заново. Вернём узел: kubectl uncordon k8s-node-1.
Состав выселяемых подов зависит от того, что работает на узле: если там окажется реплика calico-typha, drain выселит и её. Typha — посредник Calico между API-сервером и calico-node: она держит одно подключение к API и раздаёт изменения агентам на узлах, чтобы API-сервер не обслуживал отдельный watch от каждого узла; это Deployment, и Tigera operator держит её реплики на части узлов. Оператор к тому же считает число Typha по узлам, открытым для планирования, и на время cordon одного из трёх узлов уменьшает её реплики с двух до одной, а после uncordon возвращает.
StatefulSet и DaemonSet, как и Deployment, держат поды, которые должны работать всегда. Но есть задачи, которые должны выполниться и закончиться: миграция схемы базы, ночной дамп, пересчёт отчёта.
Источники
- Kubernetes: StatefulSet (порядковые номера и
ordinals, политики управления подами,partition,maxUnavailable, принудительный откат, ControllerRevision,persistentVolumeClaimRetentionPolicy), основы StatefulSet по шагам, принудительное удаление подов StatefulSet, DaemonSet (размещение через node affinity, автоматические tolerations), обновление DaemonSet (maxSurge,maxUnavailable,OnDelete). - Справочник API: StatefulSet, DaemonSet, ControllerRevision; известные метки, аннотации и taints (
statefulset.kubernetes.io/pod-name,apps.kubernetes.io/pod-index,node.kubernetes.io/out-of-service), feature gates (MaxUnavailableStatefulSet). - Сеть и DNS: Service (headless-сервисы), DNS для сервисов и подов (
hostname,subdomain, SRV-записи), плагины CoreDNS kubernetes и cache, RFC 2782 (SRV). - Хранилище: PersistentVolume и PersistentVolumeClaim, тома (
local,hostPath). - Узлы и планирование: taints и tolerations, приоритет и вытеснение подов, выселение через API, выключение узла (taint
out-of-service), безопасный вывод узла,kubectl drain. - node-exporter (запуск в контейнере,
--path.rootfs,--path.udev.data), Typha в Calico.
Дальше — Job и CronJob: как Kubernetes запускает задачу до успешного завершения, сколько раз повторяет при сбое, как выполняет её параллельно по частям и по расписанию, и почему задания по расписанию должны быть идемпотентными.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.