
Привет, Хабр! Я Анна Мелкомукова — инженер команды 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. Получилось четыре основных пункта:
Поддержка развертывания баз данных со всеми их особенностями: требовательностью к дисковой подсистеме, надежностью и отказоустойчивостью.
Хранение и обработка метрик внутри кластера, включая работу с VictoriaMetrics и Elasticsearch: высокая нагрузка на диск, большой объем данных и необходимость масштабируемости.
Возможность запуска задач с использованием Generic Ephemeral Volumes и поддержка больших Logical Volumes — для тех случаев, когда подам нужно много временного, но очень быстрого хранилища.
Поддержка процессов обслуживания 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

В логах видим 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.

Представим 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 в кластере сильно меняет этот процесс:
Pod привязан к PV (через PVC) и не может съехать на другую ноду.
Удаление PVC и PV контролируется функциональностью storage protection: на PVC и PV стоит финалайзер, который не дает удалить эти объекты.
Если существует pod, ссылающийся на PVC, — PVC нельзя удалить.
Если существует PVC и связанный с ним PV — PV нельзя удалить.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 мы написали контроллер, который выполняет две функции:
Следит за жизненным циклом ноды. Помечает ноды с хранилищем finalizer-ом, а при удалении ноды удаляет PVC на ней и после этого удаляет finalizer.
Следит за 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 — задача непростая, требующая внимательной настройки и понимания деталей, но она вполне решаема. В статье я разобрала ключевые сложности и показала подход, который используем в нашей компании для надежной работы с хранилищем.
