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, держат поды, которые должны работать всегда. Но есть задачи, которые должны выполниться и закончиться: миграция схемы базы, ночной дамп, пересчёт отчёта.

Источники


Дальше — Job и CronJob: как Kubernetes запускает задачу до успешного завершения, сколько раз повторяет при сбое, как выполняет её параллельно по частям и по расписанию, и почему задания по расписанию должны быть идемпотентными.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.