Как блочное хранилище попадает в Kubernetes: CSI, StorageClass и PVC
Блочная СХД подключается к Kubernetes через CSI-драйвер, и цепочка выглядит одинаково для массива iSCSI, NVMe-oF-таргета, локальных дисков ноды и реплицированного Longhorn. Драйвер регистрируется в кластере как provisioner, StorageClass описывает класс хранилища с набором параметров, PVC содержит заявку приложения, а монтирование в под выполняет kubelet вместе с node-частью драйвера.
Полный маршрут: PVC → StorageClass → external-provisioner (RPC CreateVolume) → PV → external-attacher (ControllerPublishVolume) → CSI node plugin (NodeStageVolume, NodePublishVolume) → kubelet. Каждый шаг оставляет запись в событиях объекта, поэтому сбой привязан к конкретному звену. Controller не смог создать том, и PVC висит в Pending. Node plugin не смог подключить устройство, и под получает FailedMount.
Участники процесса: kube-apiserver хранит объекты PV, PVC, StorageClass и VolumeAttachment; kube-controller-manager вместе с сайдкарами external-provisioner, external-attacher, external-resizer вызывает controller-часть драйвера; CSI controller plugin общается с API массива; CSI node plugin работает как DaemonSet на каждой ноде; kubelet инициирует монтирование и передаёт каталог в контейнер. Для stateful-приложений тома описывают в volumeClaimTemplates StatefulSet: каждый под получает собственный PVC с предсказуемым именем вида data-app-0.
Роли CSI-компонентов: controller и node plugin
CSI-драйвер состоит из двух частей, и путать их при диагностике дорого. Controller plugin вызывается по gRPC из сайдкаров external-provisioner, external-attacher, external-resizer и external-snapshotter. Он работает с API СХД: создаёт и удаляет тома (CreateVolume, DeleteVolume), публикует том на конкретной ноде (ControllerPublishVolume) и снимает публикацию, когда под переезжает.
Node plugin запускается как DaemonSet на каждой ноде и выполняет работу на месте. Сначала NodeStageVolume: подключение устройства и монтирование в staging-каталог внутри /var/lib/kubelet/plugins/kubernetes.io/csi. Затем NodePublishVolume: bind mount из staging-каталога в каталог конкретного пода.
Для iSCSI разделение видно на практике. Controller создаёт LUN на массиве и маппит его на IQN инициатора ноды. Node plugin выполняет iscsiadm -m node -T IQN -p PORTAL --login, ждёт появления /dev/disk/by-path/ip-...-lun-0 или /dev/mapper/mpathX, создаёт файловую систему и монтирует её. Если сессия инициатора не поднимается, том уже выделен на массиве, но приложение его не увидит.
Правило диагностики: Pending у PVC указывает на controller, внешний провижинер или API массива. FailedAttachVolume и FailedMount указывают на node plugin, kubelet, модули ядра или само устройство.
Чем CSI отличается от in-tree плагинов
Встроенные плагины Kubernetes (kubernetes.io/aws-ebs, kubernetes.io/gce-pd, kubernetes.io/iscsi, kubernetes.io/rbd) выведены из состава проекта и заменены CSI. Спецификация CSI версионируется отдельно от Kubernetes, драйвер устанавливается и обновляется независимо, поддержку нового массива можно добавить без ожидания релиза кластера.
В актуальных кластерах StorageClass ссылается на CSI provisioner, имя выглядит как driver.longhorn.io, rbd.csi.ceph.com или csi.iscsi.vendor.example. Манифесты из старых гайдов с provisioner: kubernetes.io/iscsi на новых версиях не заработают. Исключение одно: статические локальные тома по-прежнему описывают через kubernetes.io/no-provisioner, потому что том подготовлен вручную и провижинер для него не нужен.
Совместимость версий проверяют до установки: Kubernetes 1.24 и новее работают с CSI spec 1.5, свежие релизы кластера тянут spec 1.9 и выше. Сайдкары external-provisioner и external-attacher обновляют отдельно от драйвера, поэтому матрицу совместимости из документации драйвера стоит сверить с версией кластера до, а не после сбоя.
Выбор протокола и драйвера: iSCSI, NVMe-oF, LVM и Longhorn
Сравнение по критериям, которые влияют на эксплуатацию каждый день:
| Решение | Транспорт | Задержка | RWX | Падение ноды | Сложность |
|---|---|---|---|---|---|
| iSCSI к внешней СХД | блоковый, TCP | 0,5-2 мс в сети 10GbE | нет, только через NFS-шлюз массива | detach и attach к новой ноде, срок зависит от таймаутов | средняя |
| NVMe-oF (RDMA или TCP) | блоковый | 0,1-0,5 мс на RoCE или InfiniBand | нет | то же, что у iSCSI, переключение быстрее | высокая |
| Локальный LVM (TopoLVM, OpenEBS LVM) | локальные NVMe и SATA | десятки микросекунд | нет | том остаётся на упавшей ноде и недоступен | низкая |
| Longhorn | репликация поверх сети | 1-5 мс | да, через share-manager | переключение на живую реплику, нужен кворум | средняя |
| Ceph RBD | RADOS | 0,5-3 мс | да, через CephFS или NFS-Ganesha | быстрое переключение, зависит от состояния OSD | высокая |
Цифры зависят от железа и сети, но соотношение сохраняется: локальные тома быстрее сетевых, NVMe-oF быстрее iSCSI, распределённые решения платят задержкой за репликацию. Протокол выбирают тот, что реально поддерживает массив: если СХД отдаёт только iSCSI, драйвер NVMe-oF не поможет. Готовые конфигурации драйверов под базы данных собраны в материале про CSI-драйверы TrueNAS и Ceph/Rook для stateful-приложений.
Когда хватит локального LVM-драйвера
Local LVM (TopoLVM, OpenEBS LVM, local-path-provisioner от Rancher) даёт быстрые тома из локальных дисков ноды. Подходит для кэшей, очередей сообщений, single-node кластеров и приложений, которые реплицируют данные сами: PostgreSQL с потоковой репликацией, Kafka с replication.factor больше единицы, ClickHouse с шардированием.
Ограничение одно и жёсткое: том привязан к ноде. Упала нода, том остался на её дисках, под на другой ноде данные не получит. Для local-path-provisioner в StorageClass обязателен volumeBindingMode: WaitForFirstConsumer, иначе том создастся до планирования пода и окажется не на той ноде.
Longhorn: распределённое блочное хранилище в кластере
Longhorn устанавливается через Helm, разворачивает собственный CSI-драйвер и создаёт StorageClass с provisioner driver.longhorn.io. Тома реплицируются между нодами, поддерживаются снапшоты, бэкапы в S3 или NFS, восстановление из бэкапа через параметр fromBackup и режим RWX через share-manager на базе NFS.
Требования к окружению: минимум три ноды для кворума, отдельные диски под данные и пакет open-iscsi со службой iscsid на всех нодах. При потере кворума тома становятся недоступны для записи, поэтому Longhorn не размещают на кластере из двух нод. Репликация по умолчанию тройная (numberOfReplicas: "3"), для тестовых стендов её снижают до одной, теряя отказоустойчивость.
NVMe-oF: когда нужна минимальная задержка
NVMe-oF даёт задержку в разы ниже iSCSI, но требует поддержки на стороне массива, nvme-cli на нодах и загруженных модулей nvme-tcp или nvme-rdma. Подключение выполняет CSI node plugin командой nvme connect, после чего в системе появляется устройство /dev/nvme0n1.
Для RDMA нужен RoCE или InfiniBand с настроенными PFC и ECN, для TCP хватает обычной сети, но без jumbo frames и с потерями пакетов задержка вырастет до уровня iSCSI. Отдельная проблема, дублирующиеся устройства при неверном multipath: один namespace виден как два контроллера, и монтирование падает с ошибкой ввода-вывода.
Пошаговая настройка: от установки CSI-драйвера до монтирования тома
Порядок действий, проверенный на стендах с iSCSI, LVM и Longhorn:
- Проверить версии Kubernetes, драйвера и модули ядра на всех нодах.
- Установить CSI-драйвер через Helm или манифесты.
- Создать Secret с учётными данными СХД в нужном namespace.
- Создать StorageClass с явными reclaimPolicy и volumeBindingMode.
- Создать PVC и дождаться статуса Bound.
- Подключить PVC к поду, Deployment или StatefulSet.
- Проверить монтирование внутри контейнера и поведение при перезапуске пода.
StorageClass: параметры, которые важно выставить сразу
Перед установкой драйвера убедитесь, что ноды готовы к работе с блочным устройством:
kubectl version kubectl get nodes -o wide lsmod | grep -E 'iscsi_tcp|nvme_tcp|nvme_rdma|dm_mod|dm_multipath' systemctl status iscsid multipathd nvme version
Незагруженный модуль поднимают через modprobe и закрепляют в /etc/modules-load.d/. Пакеты iscsi-initiator-utils, multipath-tools и nvme-cli ставят на каждую ноду, включая те, что добавятся в кластер позже: новый узел без iscsid сломает расписание подов на себя.
Драйвер ставят через Helm, например helm install longhorn longhorn/longhorn -n longhorn-system --create-namespace. Controller-часть разворачивается как Deployment или StatefulSet, node-часть как DaemonSet. Проверка: kubectl get pods -n longhorn-system и kubectl get csidrivers.
Secret с логином, паролем или CHAP-ключами создают в том namespace, где появится PVC, если драйвер ждёт nodeStageSecret или nodePublishSecret в этом же namespace. Типовая ошибка: Secret лежит в kube-system, а PVC в app, и провижининг отвечает authentication failed.
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: longhorn-fast provisioner: driver.longhorn.io allowVolumeExpansion: true reclaimPolicy: Delete volumeBindingMode: Immediate parameters: numberOfReplicas: "3" staleReplicaTimeout: "30" fsType: "ext4"
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: iscsi-san provisioner: csi.iscsi.vendor.example reclaimPolicy: Retain volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true parameters: targetPortal: "10.10.20.5:3260" targetIQN: "iqn.2026-09.local.san:lun-01" fsType: "xfs" pool: "flash-pool"
Ключи внутри parameters задаёт драйвер, набор выше типовой для вендорских iSCSI-драйверов. Что критично выставить сразу: reclaimPolicy: Retain защищает данные при удалении PVC, а Delete освобождает место автоматически; volumeBindingMode: WaitForFirstConsumer нужен для томов с топологической привязкой, к которым относятся локальные LVM, Longhorn с дисками по нодам и любые зональные СХД; allowVolumeExpansion: true позволяет расширять том правкой PVC без пересоздания; mountOptions ограничены драйвером, обычно поддерживаются ext4 и xfs.
PVC и динамический провижининг: что происходит после kubectl apply
Жизненный цикл выглядит так: PVC создаётся в статусе Pending, external-provisioner видит storageClassName, вызывает CreateVolume в CSI controller, получает идентификатор тома, создаёт объект PV и связывает его с PVC, после чего PVC переходит в Bound. При volumeBindingMode: WaitForFirstConsumer том не создаётся, пока планировщик не выберет ноду для первого пода, которому этот PVC нужен.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-app-0
namespace: app
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: longhorn-fast
resources:
requests:
storage: 50Gi
Проверка и диагностика на этом этапе:
kubectl apply -f pvc.yaml kubectl get pvc,pv -n app -w kubectl describe pvc data-app-0 -n app kubectl logs -n longhorn-system deploy/csi-provisioner -c csi-provisioner --tail=100
События в describe pvc показывают, на чём остановился провижининг: waiting for first consumer to be created before binding, no capacity available, rpc error с текстом от СХД. Те же ошибки пишет external-provisioner, поэтому логи его контейнера читают первыми. Подробнее про связку объектов и политики очистки рассказано в руководстве по PV и PVC в Kubernetes.
Монтирование тома в под и проверка внутри контейнера
Для StatefulSet том описывают в volumeClaimTemplates, и каждый под получает PVC с именем из шаблона. Для одиночного пода достаточно volumes и volumeMounts:
spec:
containers:
- name: app
image: postgres:17
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: data-app-0
После запуска пода проверяют, что устройство реально доступно приложению:
kubectl get pod data-app-0 -n app -o wide kubectl exec -n app data-app-0 -- lsblk kubectl exec -n app data-app-0 -- df -h /var/lib/postgresql/data kubectl exec -n app data-app-0 -- mount | grep data
Режим RWO означает монтирование на одну ноду, поэтому две реплики Deployment с одним и тем же PVC на разных нодах не запустятся: вторая получит ошибку ввода-вывода или таймаут attach. Для RWX нужен драйвер с NFS или share-manager. Файловая система в fsType должна совпадать с тем, что создаёт драйвер: параметры xfs в mountOptions при ext4 на томе дают ошибку монтирования. Статические тома на локальных дисках и iSCSI без провижинера разобраны в статье про PersistentVolume, NFS и Rook/Ceph в Kubernetes.
Типовые ошибки при подключении блочных СХД и их диагностика
Почти все сбои укладываются в четыре группы: не сработал провижининг, не смонтировалось на ноде, истекли таймауты из-за несовпадения настроек, потерялась доступность тома при отказе ноды. Симптомы разные, точка поиска одна, события объекта и логи конкретного компонента.
PVC в Pending: провижининг не сработал
Причины по частоте: опечатка в имени StorageClass (storageclass.storage.k8s.io "fast" not found), отсутствие пода при WaitForFirstConsumer, нехватка свободного места в пуле (no capacity available), неверные учётные данные (rpc error: code = PermissionDenied, authentication failed), упавший pod csi-provisioner или нехватка прав у его ServiceAccount.
kubectl describe pvc data-app-0 -n app | tail -20 kubectl get events -n app --sort-by=.lastTimestamp | tail -20 kubectl get pods -n kube-system | grep -i csi kubectl logs -n kube-system deploy/csi-iscsi-controller -c csi-provisioner --tail=100
Если в событиях пусто и Pending висит без сообщений, проверяют kube-controller-manager и наличие CSI-драйвера в kubectl get csidrivers: без зарегистрированного драйвера провижининг не запустится вообще.
FailedMount и FailedAttachVolume: проблемы на ноде
Симптом в описании пода: Unable to attach or mount volumes: unmounted volumes=[data], unattached volumes=[data]: timed out waiting for the condition, либо MountVolume.WaitForAttach failed. Причины: не запущен iscsid, не загружен модуль iscsi_tcp, неверный portal или IQN, неверный CHAP, сломан multipath, том уже подключён к другой ноде, несовпадение fsType.
kubectl describe pod data-app-0 -n app | tail -30 journalctl -u kubelet --since "30 min ago" | grep -iE 'attach|mount|iscsi|nvme' iscsiadm -m session -P 3 multipath -ll dmesg | grep -iE 'iscsi|nvme|multipath|I/O error' ls -l /dev/disk/by-path
Порядок исправления: поднять iscsid и multipathd, загрузить модуль, сверить portal и IQN с конфигурацией массива, проверить CHAP-секрет, при дублирующихся путях пересобрать multipath и убедиться, что монтирование идёт через /dev/mapper, а не через /dev/sdX. Если ядро сообщает о повреждении суперблока или ошибках чтения, том требует ремонта файловой системы: алгоритм описан в материале про восстановление Persistent Storage в Kubernetes.
Таймауты и несовпадение протоколов
Классический случай: массив отдаёт iSCSI, а в кластер поставлен драйвер NVMe-oF. Node plugin вызывает nvme connect, получает ошибку подключения, под уходит в CrashLoopBackOff, а в логах видно failed to connect или no such device. Обратный вариант, драйвер iSCSI против NVMe-oF-таргета, даёт iscsiadm: No portals found.
Другие источники таймаутов: разные версии CSI-спецификации у драйвера и сайдкаров, секрет передан не в тот параметр (nodeStageSecret против controllerPublishSecret), MTU не согласован по всему пути. Проверка сети и MTU:
ping -M do -s 8972 -c 3 10.10.20.5 iperf3 -c 10.10.20.5 -t 10 ethtool -S eth0 | grep -iE 'err|drop' cat /sys/class/net/eth0/mtu
Если jumbo frames настроены на нодах, но не на коммутаторе, крупные блоки ретранслируются, и операции записи регулярно упираются в таймаут. Лечится приведением MTU к единому значению на всём пути либо увеличением таймаутов на стороне драйвера и массива: у external-attacher это флаг --timeout, у самой СХД, таймаут сессии iSCSI и login timeout.
Поведение хранилища при падении ноды
Нода перестаёт отвечать, kubelet не обновляет статус, и через node-monitor-grace-period (по умолчанию 40 секунд) узел получает статус NotReady. Дальше на него вешают taint node.kubernetes.io/unreachable:NoExecute с tolerationSeconds, стандартное значение 300 секунд, и через пять минут поды вытесняются на живые ноды.
Для RWO-тома новая нода не сможет подключить устройство, пока external-attacher не снимет публикацию со старой: появится сообщение Volume is already exclusively attached to one node and can't be attached to another. Detach может занять несколько минут, если массив недоступен по сети. Крайняя мера, удаление объекта VolumeAttachment (kubectl delete volumeattachment NAME), применяется только когда нода гарантированно мертва, иначе возможна запись в один том с двух сторон. Флаг --enable-force-detach поддерживают не все версии external-attacher, его наличие проверяют до аварии.
Локальный LVM в таком сценарии ведёт себя хуже всего: том физически остаётся на упавшей ноде, и данные недоступны до её возвращения. Longhorn переключает под на реплику, но если у томa осталась одна здоровая реплика на упавшей ноде, том деградирует до недоступного для записи. Для StatefulSet помогает podManagementPolicy: Parallel, который ускоряет пересоздание подов, и readinessProbe, не пускающий трафик в под до появления хранилища. Отказ ноды проверяют на staging: выключить узел и замерить время до готовности подов, это и есть ваш реальный RTO.
Как выбрать решение под свой сценарий и не сломать прод
Практические связки: одна нода или домашний стенд, local-path-provisioner либо локальный LVM; кластер из трёх и более нод без внешнего массива, Longhorn или Ceph RBD; максимальная производительность для баз данных, NVMe-oF или локальные NVMe с репликацией на уровне приложения; унаследованный массив SAN с iSCSI, iSCSI CSI-драйвер вендора. Если своего железа под блочные тома нет, облачный кластер с блочными дисками закрывает задачу быстрее: например, Timeweb Cloud даёт Kubernetes, блочное хранилище и серверы в одной панели, а CSI-драйвер провайдера ставится при создании кластера.
Чек-лист перед внедрением в продакшн
- Версия Kubernetes и версия CSI-драйвера совместимы, сайдкары обновлены вместе с драйвером.
- Модули iscsi_tcp, nvme_tcp, dm_mod, dm_multipath загружены на всех нодах, включая будущие.
- iscsid, multipathd, nvme-cli, iscsiadm установлены, службы включены в автозапуск.
- Secret с учётными данными создан в том namespace, где будут PVC.
- StorageClass протестирован на staging-кластере с той же версией СХД, включая расширение тома.
- Бэкапы настроены и хотя бы раз восстановлены: снапшоты Longhorn, Velero, штатные средства массива.
- Есть план отката: сохранённые манифесты, известное рабочее состояние драйвера, окно обслуживания и порядок drain нод.
- Для Longhorn минимум три ноды и отдельные диски под данные, для Ceph отдельный кластер хранения.
Миграция данных между StorageClass без простоя
Сменить storageClassName у существующего PVC нельзя, поле иммутабельно. Рабочий порядок такой: создать PVC нового класса, поднять один под с двумя томами (в одном поде можно смонтировать сразу два RWO-PVC, потому что они оказываются на одной ноде), скопировать содержимое через rsync с сохранением прав, перевести приложение на новый PVC, старый оставить в reclaimPolicy: Retain на время проверки. Для баз данных копирование заменяют репликацией: поднимают реплику на новом классе хранилища, ждут синхронизации и делают переключение роли. Снапшоты через VolumeSnapshot API подходят для быстрого переноса больших томов, если драйвер умеет создавать том из снапшота в другом StorageClass.
На проде менять StorageClass без переноса данных нельзя: Kubernetes не перемещает содержимое тома, и под получит пустую файловую систему. Сравнение моделей постоянных данных в разных оркестраторах, если рядом ещё живут кластеры Docker Swarm, приведено в материале про постоянные данные в Docker Swarm и Kubernetes. Начните с тестового кластера: один и тот же StorageClass и один и тот же сценарий падения ноды, прогнанные дважды, дают больше уверенности, чем любая теория.