Привет, Хабр! Я Анна Мелкомукова — инженер команды Spirit Compute в T-Банке. Наша команда отвечает за создание и полную поддержку Kubernetes-кластеров. 

Раньше трава была зеленее и небо голубее, а в Kubernetes крутились только легкие и беззаботные stateless-микросервисы. Поды появлялись и исчезали, как будто так будет всегда, но затем в архитектуре появился Stateful.

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

В статье я постараюсь ответить на вопрос «Как научить Kubernetes работать с приложениями, которым необходимо стабильное и надежное локальное хранилище».

Как Kubernetes хранит важное: PV, PVC и StorageClass

Когда под говорит: «Мне нужно 10 ГБ, чтобы записать туда светлое будущее… или хотя бы логи», — Kubernetes кивает, но сам по себе ничего не хранит. Желания пода воплощают в жизнь несколько героев:

  • Persistent Volume (PV) — заранее подготовленная квартира для данных. Kubernetes знает, где она расположена, какой у нее размер и какие правила пользования.

  • Persistent Volume Claim (PVC) — заявка на аренду. Под как бы говорит: «Мне нужна квартира на 10 ГБ для хранения файлов». Kubernetes ищет подходящий PV и «заселяет» туда под, связывая их друг с другом, а если такого PV нет — строит его.

  • StorageClass — агент по недвижимости. Он знает, как и где создавать новые PV на лету, если готовых нет. В нем описаны условия: какой диск, какая скорость и тип хранилища, какие параметры доступа, тип провайдера. Когда появляется PVC без готового PV, StorageClass говорит: «Не проблема! Сейчас все построим», — и создает PV автоматически.

  • Container Storage Interface (CSI) — скромный работяга, строительная компания и служба ЖКХ в одном лице. Именно он строит дом, проводит коммуникации и отдает ключи от квартир подам.

CSI-драйвер знает:

  • как создать нужный том в реальном хранилище (облаке, локальном диске, сетевом NAS);

  • как подключить его к кластеру так, чтобы поду было удобно;

  • как обслуживать его — например, делать снапшоты или расширять объем.

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

Все вместе наши герои работают как организованный ЖК: никто не останется без крыши над головой, место для хранения данных найдется. Все знают, где хранятся их вещи, — даже если подов стало больше, меньше или они вообще решили уехать.

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

Как выбрать решение для Persistent Volumes

Выбор подходящего решения для организации Persistent Volume — стратегический шаг, от которого напрямую завис��т надежность, производительность и удобство эксплуатации всего кластера, особенно когда в игре stateful-приложения.

Наша отправная точка:

  • K8s-кластер на базе ОС Talos или Ubuntu.

  • Железные физические серверы, каждый сервер — отдельная нода.

  • Оборудование от разных вендоров, каждый со своим RAID-контроллером.

  • Хранилище — локальные диски на нодах, объединенные в RAID.

Перед нами стояла задача понять реальные сценарии использования и сформировать требования исходя из них. Для этого мы пошли к командам, которые активно работают с хранилищем, и вместе сформировали набор требований и ключевых задач, которые должен был покрывать выбранный подход работы с PV. Получилось четыре основных пункта:

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

  2. Хранение и обработка метрик внутри кластера, включая работу с VictoriaMetrics и Elasticsearch: высокая нагрузка на диск, большой объем данных и необходимость масштабируемости.

  3. Возможность запуска задач с использованием Generic Ephemeral Volumes и поддержка больших Logical Volumes — для тех случаев, когда подам нужно много временного, но очень быстрого хранилища.

  4. Поддержка процессов обслуживания K8S-кластера: вывод одной неработоспособной ноды, перекатка всего кла��тера (всех нод) через Cluster API (CAPI), обновление CNI, приоритизация размещения подов с PV, мониторинг состояния здоровья дисков на нодах.

После определения основных пунктов мы перешли к более глубокой проработке функциональных требований и проанализировали несколько решений. В итоге остановились на OpenEBS Local PV LVM.

OpenEBS Local PV LVM — CSI-плагин, который позволяет Kubernetes работать с томами на основе LVM. Он предоставляет механизм создания локальных LV (PV), где хранилище и приложение находятся на одном и том же узле.

Архитектурно решение следует стандарту CSI:

  • CSI Controller — принимает входящие запросы от Kubernetes (создание или удаление тома) и инициирует операции. Поставляется в виде Deployment.

  • CSI Node Plugin — выполняет команды на каждом узле: работает с LVM, подключает тома, делает их доступными для подов. Поставляется в виде DaemonSet, состоит из двух обязательных контейнеров: csi-node-driver-registrar и lvm-driver. CSI Node Driver Registrar — sidecar‑контейнер, отвечающий за регистрацию CSI‑драйвера в kubelet. Компонент проекта sig-storage. LVM Driver — основной контейнер OpenEBS.

Подробная установка OpenEBS Local PV LVM описана в документации разработчиков, не будем заострять на ней внимание. Важно, что для работы решения нужны готовые VolumeGroup, мы создаем их в дополнительном initContainer в DaemonSet. Для создания используем bash-скрипт.

У нас есть сервер с несколькими физическими дисками:

# storcli /c0  show
PD LIST :
----------------------------------------------------------------------------------------
EID:Slt DID State DG   	Size Intf Med SED PI SeSz Model                  	Sp Type
----------------------------------------------------------------------------------------
252:0 	0 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:1 	9 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:2 	7 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:3 	4 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:6 	8 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:7 	3 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:8 	1 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
252:9 	5 Onln   1   3.492 TB SAS  SSD Y   N  512B model1  	U  -
----------------------------------------------------------------------------------------

Мы собираем свободные диски со статусом Onln в нужный нам RAID, например RAID1. Тип RAID мы проставляем в виде лейбла на ноду при ее создании в CAPI.

VD LIST :
----------------------------------------------------------------
DG/VD TYPE  State Access Consist Cache Cac sCC   	Size Name
----------------------------------------------------------------
1/238 RAID1 Optl  RW 	No  	RWTD  -   ON   13.969 TB RAID1
----------------------------------------------------------------

Видим собранный RAID как блочное устройство, создаем Physical Volume и Volume Group.

# lsblk
NAME	MAJ:MIN RM   SIZE RO TYPE MOUNTPOINT
sdb   	8:16   0	14T  0 disk
# pvs
  PV       	VG   Fmt  Attr PSize  PFree
  /dev/sdb          lvm2 ---  13.97t 13.97t
# vgs
  VG            #PV #LV #SN Attr   VSize  VFree
  lvm-ssd-raid1   1   0   0 wz--n- 13.97t 13.97t

В StorageClass указываем созданную VG (volgroup) и тип файловой системы (fsType).

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: openebs-lvm-ssd-raid1-xfs
parameters:
  fsType: xfs
  shared: "no"
  storage: lvm
  thinProvision: "no"
  volgroup: lvm-ssd-raid1
provisioner: local.csi.openebs.io
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true

Казалось бы, все готово: OpenEBS установлен, настроен, можно спокойно выдохнуть и наслаждаться работой. Но на самом деле это только начало. Расскажу, с какими проблемами можно столкнуться при эксплуатации K8S с PV.

Основные вызовы и проблемы при работе с Persistent Volumes в Kubernetes

Проблема 1: квотирование I/O-операций с дисками. Мы используем локальные SSD, у которых есть лимит по количеству I/O-операций. Этот лимит зависит от типа памяти, возможностей контроллера SSD, интерфейса подключения, размера блоков в операциях, типов операций и так далее.

OpenEBS из коробки предоставляет механизм установки IOPS и Bps Read/Write лимитов доступа с использованием cgroupv2 на 1 ГБ дискового пространства. Для его включения устанавливаем в DS OpenEBS лимиты. В примере используем ограничение на read per second и vg lvm-ssd-raid1.

containers:
- name: openebs-lvm-plugin
  args:
    - --setiolimits=true
    - --riops-per-gb=lvm-ssd-raid1:100
Ограничение read iops для PV
Ограничение read iops для PV

В логах видим 400 riops по 100 на 1 ГБ, в PVC запросили 4.

[~]$ kubectl logs openebs-lvm-localpv -n openebs-system -c openebs-lvm-plugin | grep -i pvc-fafcf670-1dc4-4409-894b-96d0a735ce64
I0305 10:44:14.882032   	1 mount.go:282] Setting iolimits for podUId 3bdcff32-7ce2-468e-989a-0c4ae1375694, device /dev/gev/pvc-fafcf670-1dc4-4409-894b-96d0a735ce64: riops=400, wiops=0, rbps=0, wbps=0
I0305 10:44:14.882148   	1 mount.go:215] lvm: io limits set for podUid 3bdcff32-7ce2-468e-989a-0c4ae1375694, device /dev/gev/pvc-fafcf670-1dc4-4409-894b-96d0a735ce64
root@node:/sys/fs/cgroup/kubepods/besteffort# cat pod*/io.max
251:1 rbps=max wbps=max riops=400 wiops=max

Проводим тест с помощью fio, видим read iops = 400.

fio-2.2.10
Starting 8 processes
....
readio: (groupid=0, jobs=8): err= 0: pid=127: Wed Mar  5 13:45:09 2025
  read : io=196256KB, bw=1600.2KB/s, iops=400, runt=122584msec

Недостаток встроенного в OpenEBS квотирования — строгая привязка квоты на IOPS и Bps Read/Write к размеру PV. Приложениям, создающим высокую нагрузку на диск, придется запрашивать большие по размеру PV, даже если им это не нужно.

Проблема 2: изменение размера PV. На момент запроса Persistent Volume пользователи редко могут точно предсказать, насколько вырастет объем их данных. Поэтому процедура увеличения PV становится базовой и обязательной для любого кластера K8S с PV.

На первый взгляд кажется, что проблема решена: любой провиженер предоставляет возможность расширения PV по умолчанию. Но, как обычно, всегда есть одно большое «но».

Не будем обсуждать проблемы изменения размеров в самих манифестах StatefulSet-ов, обсудим процесс увеличения LogicalVolume, на которое ссылается PV.

Расширение PVC при недостатке свободного места на ноде
Расширение PVC при недостатке свободного места на ноде

Представим StatefulSet из одного пода с PVC, он запущен на ноде со свободным местом в 10 ГБ. Пользователь решает увеличить размер PV и смело меняет в PVC запрос с 15 ГБ на 30 ГБ.

kind: PersistentVolumeClaim
spec:
  resources:
    requests:
      storage: 30Gi

Что же произойдет с PV? При выполнении обычного get-запроса мы не видим никаких проблем и в статусе отображается успешное увеличение.

$ k get pvc -n test fio-vol-testing1-0                                                        
NAME             	STATUS   VOLUME                                 	CAPACITY   ACCESS MODES   STORAGECLASS  	AGE
fio-vol-testing1-0   Bound	pvc-a1656632-a45d-4f02-8b2b-3ef474ec334d   30Gi 	RWO        	openebs-lvm-gev   24m

[~]$ k get pv pvc-a1656632-a45d-4f02-8b2b-3ef474ec334d
NAME                                   	CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                STORAGECLASS  	REASON   AGE
pvc-a1656632-a45d-4f02-8b2b-3ef474ec334d   30Gi	RWO        	Delete       	Bound    test/fio-vol-testing1-0   openebs-lvm           	25m

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

[~]$ k describe  pvc -n test fio-vol-testing1-0
Events:
  Type 	Reason                  	Age            	From  Message
  ---- 	------                  	----           	----	-------
  Normal   FileSystemResizeSuccessful  20m (x3 over 29m)  kubelet                                                                                                	MountVolume.NodeExpandVolume succeeded for volume "pvc-a1656632-a45d-4f02-8b2b-3ef474ec334d" ix-m4-sm4-24bb00119
  Warning  ExternalExpanding       	10m (x4 over 30m)  volume_expand                                                                                          	waiting for an external controller to expand this PVC
  Normal   Resizing                	10m (x4 over 30m)  external-resizer local.csi.openebs.io                                                                  	External resizer is resizing volume pvc-a1656632-a45d-4f02-8b2b-3ef474ec334d
  Normal   FileSystemResizeRequired	10m (x4 over 30m)  external-resizer local.csi.openebs.io                                                                  	Require file system resize of volume on node
  E1126 13:46:10.713326   	1 lvm_util.go:431] lvm: could not resize the volume lvm-ssd-raid5/pvc-a1656632-a45d-4f02-8b2b-3ef474ec334d

Пользователь попадает в безвыходную ситуацию: он не может вернутся к старому размеру PV и не может увеличить PV. Для предотвращения этого обратимся к Gatekeeper и OPA rules.

Напишем правило, в котором узнаем новый размер PV в PVC и сравним его со свободным пространством на ноде и суммарным размером всех PVs в реальном времени. Если места нет, то отбросим такой запрос пользователя.

Схематичное изображение работы правила ОРА
Схематичное изображение работы правила ОРА

Проблема 3: зависшие PV. Для создания кластеров мы используем CAPI, который позволяет нам быстро и без участия администратора пересоздавать ноды. Появление PV в кластере сильно меняет этот процесс:

  1. Pod привязан к PV (через PVC) и не может съехать на другую ноду.

  2. Удаление PVC и PV контролируется функциональностью storage protection: на PVC и PV стоит финалайзер, который не дает удалить эти объекты.
    Если существует pod, ссылающийся на PVC, — PVC нельзя удалить.
    Если существует PVC и связанный с ним PV — PV нельзя удалить.

  3. PV закреплен за конкретной нодой в поле nodeAffinity.

kind: PersistentVolume
metadata:
  finalizers:
  - kubernetes.io/pv-protection
  name: pvc-87fd2b05-33e5-452c-b92f-84292a697d1c
spec:
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: openebs.io/nodename
          operator: In
          values:
          - my-node

С учетом всех особенностей PV/PVC при удалении ноды мы получаем:

  • Нода удалена из кластера.

  • Бывшие на этой ноде PV/PVC не удалились и зависли.

  • Поды с PV перешли в состояние Pending.

Для решения проблемы зависших PV мы написали контроллер, который выполняет две функции:

  1. Следит за жизненным циклом ноды. Помечает ноды с хранилищем finalizer-ом, а при удалении ноды удаляет PVC на ней и после этого удаляет finalizer.

  2. Следит за PVC. Если видит появление DeletionTimestamp на PVC, то удаляет поды с этим PVC — и далее PVC удаляется по умолчанию. PV удаляется по persistentVolumeReclaimPolicy: Delete.

Алгоритм работы контроллера
Алгоритм работы контроллера

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

Проблема 4: общий подход к запуску нагрузок с PV. Когда мы составляли требования для разработчиков, исходили из базовых принципов поставки и работы K8S:

  • K8S-нода – объект с коротким временем жизни, и она может быть пересоздана в любой момент. Мы часто проводим работы по изменению конфигурации кластера, в процессе которых последовательно пересоздаем все ноды кластера.

  • Критически важно время, за которое мы сможем пересоздать ноду. Оно складывается из времени drain-а ноды + время на удаление инфраструктуры (очистка физического сервера) + время на создание новой инфраструктуры (подготовка ОС и сети на сервере) + настройка ноды.

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

  • При удалении машин средствами CAPI выполняется drain ноды, связанной с машиной. Drain не завершается успешно и машина не удаляется, если на ноде есть нарушающие PodDisruptionBudget поды.

Уделю особое внимание базовым параметрам, которые становятся критичными для работающих с PV K8S-приложений. Список составлен от лица администратора K8S, а не от разработчика:

  • Все приложения должны иметь как минимум две реплики, лучше больше.

  • Реплики приложения должны быть распределены по разным нодам, для этого можно использовать механизм podAntiAffinity.

  • Приложения должны уметь восстанавливать данные в PV из сторонних источников или из PV оставшихся реплик при создании новой реплики.

  • Разработчики приложения с PV рассчитывают и устанавливают время, необходимое приложению для корректного завершения работы, в terminationGracePeriodSeconds.

  • В PodDisruptionBudget разработчики определяют, при каком количестве реплик приложение будет выполнять свои функции без сбоев.

  • Стоит потратить время на проработку readinessProbe и livenessProbe. Они должны отмечать поды notReady, если данные его PV не синхронизированы и не готовы к работе, тем самым приводя к нарушению PDB.

  • Стоит стремиться к уменьшению размеров PV для уменьшения времени копирования данных из сторонних источников или из PV оставшихся реплик при создании новой реплики.

Выводы

Развертывание и сопровождение PV в Kubernetes — задача непростая, требующая внимательной настройки и понимания деталей, но она вполне решаема. В статье я разобрала ключевые сложности и показала подход, который используем в нашей компании для надежной работы с хранилищем.