Почему том - это абстракция, а не папка
В Kubernetes есть две разные сущности, которые новички вечно путают. PersistentVolume (PV) - это кусок реального хранилища в кластере: облачный диск Yandex, том Ceph RBD, шара NFS. Это ресурс кластерного уровня, его создаёт админ или, чаще, провижинер автоматически. PersistentVolumeClaim (PVC) - это заявка от приложения: "дай мне 20 гигабайт с доступом на запись". Приложение не знает и не должно знать, какой диск ему дадут. Это как розетка: ты втыкаешь вилку (PVC), а откуда там электричество (PV) - не твоя забота.
Зачем такое разделение? Чтобы разработчик описывал потребность, а инфраструктура решала, чем её закрыть. На одном кластере PVC превратится в диск Yandex Cloud, на другом - в Ceph, в локальном k3s - в local-path на диске ноды. Манифест пода при этом не меняется ни на байт. Вот минимальный persistentvolumeclaim:
Код: Выделить всё
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pg-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: yc-network-ssd
resources:
requests:
storage: 20Gi
Код: Выделить всё
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: pg-data

Binding: как PVC находит свой PV
Самое интересное - механика связывания. Контроллер PersistentVolume в kube-controller-manager постоянно сканирует PVC в статусе Pending и ищет PV, который подходит: размер не меньше запрошенного, совместимый accessMode, тот же storageClassName. Нашёл - связывает один к одному (binding) и пишет в обе стороны ссылки. Связь эксклюзивная: один PV - один PVC, даже если PV больше по объёму. Запросил 20Gi, а свободен PV на 100Gi - получишь все 100, остальное пропадёт впустую.
В реальном проде статических PV почти никто не держит. Их создаёт автоматика - dynamic provisioning. Запускаешь kubectl get pvc и в норме видишь так:
Код: Выделить всё
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
pg-data Bound pvc-7c3a...e91 20Gi RWO yc-network-ssd 12s
StorageClass и динамический провижининг: фабрика дисков
storageclass - это рецепт, по которому кластер создаёт диски на лету. Не нужно заранее нарезать PV руками: создал PVC со ссылкой на класс - провижинер тут же выписал реальный диск в облаке и связал с заявкой. Минимальный пример под Yandex Managed Kubernetes:
Код: Выделить всё
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: yc-network-ssd
provisioner: disk-csi-driver.mks.ycloud.io
parameters:
type: network-ssd
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
provisioner - какой CSI-драйвер физически создаёт диск. В Yandex это disk-csi-driver.mks.ycloud.io, в VK Cloud свой, в AWS ebs.csi.aws.com. Это точка входа в CSI.
parameters - параметры, которые драйвер передаёт облаку: тип диска (network-ssd, network-hdd, быстрый non-replicated), файловая система, шифрование. Список полей свой у каждого драйвера, наизусть не учат - смотрят в доке конкретного CSI.
reclaimPolicy - что станет с PV после удаления PVC. Delete - удалить и PV, и реальный диск в облаке (по умолчанию для динамики). Retain - оставить диск, чтобы спасти данные руками. Простое правило: на боевых базах ставь Retain. Снёс namespace одним kubectl delete - и при Delete диск с продакшен-базой растворится в облаке. Это самая дорогая ошибка в этом уроке.
allowVolumeExpansion: true - разрешает потом увеличивать диск. Без него PVC не расширишь, придётся пересоздавать. Включай сразу, выключить не получится без боли.
volumeBindingMode - тонкий, но критичный момент. Дефолт Immediate: диск создаётся сразу при появлении PVC, ещё до планирования пода. И вот ловушка зональности: в Yandex и любом облаке диск привязан к зоне доступности (ru-central1-a и т.д.). Создали диск в зоне a, а планировщик отправил под на ноду в зону b - под навсегда застрял в Pending, потому что сетевой диск нельзя примонтировать через зону. Лечит это WaitForFirstConsumer: провижинер ждёт, пока планировщик выберет ноду под этот PVC, и только потом создаёт диск - в той же зоне, что и нода. Для облачных дисков WaitForFirstConsumer - почти всегда правильный выбор. Запомни это как мантру.
CSI: почему хранилище живёт вне кода Kubernetes
csi kubernetes (Container Storage Interface) - это стандартный контракт между Kubernetes и любой системой хранения. Раньше код всех драйверов (AWS, GCE, Ceph) жил прямо в ядре Kubernetes - так называемые in-tree volume plugins. Это был ад: чтобы починить баг в драйвере диска, надо было ждать релиз всего Kubernetes. CSI вынес драйверы наружу: теперь это отдельные поды в кластере, которые ставятся как обычное приложение и обновляются независимо. Все in-tree плагины уже вырезаны через миграцию на CSI - в 2026 это пройденный этап, дискового кода вендоров в ядре больше нет.
Драйвер CSI обычно состоит из двух частей. Controller (как Deployment) общается с API облака: создать диск, удалить, снять снапшот, расширить. Node-плагин (как DaemonSet, по поду на каждой ноде) делает грязную работу на самой машине: подключает диск к ноде, форматирует, монтирует в под. Между ними - sidecar-контейнеры (external-provisioner, external-attacher, external-resizer, external-snapshotter), которые переводят события Kubernetes в вызовы драйвера.
Что под это есть в RU-реалиях:
- Yandex Managed Kubernetes - disk-csi-driver, сетевые SSD/HDD, снапшоты, расширение из коробки.
- VK Cloud - свой CSI поверх блочного хранилища.
- Deckhouse - умеет Ceph, LVM на локальных дисках, sds-replicated-volume для реплицируемого хранилища без облака.
- Ceph RBD через ceph-csi - блочные тома в своём железе, классика для on-prem.
- NFS через csi-driver-nfs - шара, которую можно монтировать сразу в много подов.
- local-path-provisioner (идёт в k3s) - PV прямо на диске ноды, идеально для дев-кластера и тестов, но без какой-либо отказоустойчивости.
Это поле порождает больше всего боли на практике. Режимов несколько:
- ReadWriteOnce (RWO) - том монтируется на запись на одной ноде. Внимание: именно на одной ноде, а не одним подом. Два пода на одной ноде RWO-том делить могут. Это режим всех облачных блочных дисков (Yandex, VK, EBS).
- ReadWriteOncePod (RWOP) - стабилен с версии 1.29, только для CSI. Гарантирует ровно один под на весь кластер. Нужен, когда даже два пода на одной ноде - недопустимо (например, чтобы случайно не запустить две реплики БД на одном диске).
- ReadOnlyMany (ROX) - много нод читают, никто не пишет.
- ReadWriteMany (RWX) - много нод пишут одновременно.
StatefulSet и volumeClaimTemplates: диск на каждую реплику
Deployment с PVC не масштабируется на состояние: все реплики цепляются к одному PVC, а RWO-диск пустит только одну ноду. Для баз, кафок, эластиков нужен StatefulSet. Его суперсила - volumeClaimTemplates: это не один PVC, а шаблон, из которого Kubernetes штампует персональный PVC для каждой реплики.
Код: Выделить всё
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: pg
spec:
serviceName: pg
replicas: 3
selector:
matchLabels: { app: pg }
template:
metadata:
labels: { app: pg }
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: yc-network-ssd
resources:
requests:
storage: 20Gi
Код: Выделить всё
$ kubectl get pvc -l app=pg
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
data-pg-0 Bound pvc-a1b2... 20Gi RWO yc-network-ssd 3m
data-pg-1 Bound pvc-c3d4... 20Gi RWO yc-network-ssd 2m
data-pg-2 Bound pvc-e5f6... 20Gi RWO yc-network-ssd 1m
Снапшоты, расширение и бэкап
Снапшоты (kubernetes pv можно снять как снимок) делаются через CSI, ресурс VolumeSnapshot, актуальный apiVersion - snapshot.storage.k8s.io/v1 (GA с давних времён). Сначала нужен VolumeSnapshotClass с указанием драйвера и deletionPolicy, потом сам снапшот:
Код: Выделить всё
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: pg-snap-2026-06-15
spec:
volumeSnapshotClassName: yc-snapclass
source:
persistentVolumeClaimName: data-pg-0
Расширение PVC: если в StorageClass стоит allowVolumeExpansion: true, диск растят на лету - правишь spec.resources.requests.storage в PVC на большее значение и применяешь. Современные CSI умеют online-расширение без перезапуска пода. Запомни два ограничения: уменьшить диск нельзя (только вверх), и расширять надо именно PVC, а не PV напрямую.
Бэкап снапшотами закрывает диски, но не сам объект Kubernetes - манифесты, секреты, конфиги. Для полноценного DR в экосистеме стандарт - Velero: он сохраняет ресурсы кластера в объектное хранилище (S3-совместимое, тот же Yandex Object Storage) и через свою CSI-интеграцию снимает снапшоты томов. Это даёт восстановление всего namespace, а не только данных одного диска. Снапшот без бэкапа манифестов восстановит диск, но не приложение вокруг него.
Грабли и антипаттерны
- RWO + несколько подов на запись - том примонтируется только на одной ноде, остальные поды зависнут на attach. Деплоить базу как Deployment с replicas больше 1 на одном PVC бессмысленно. Нужно состояние на много реплик - StatefulSet с volumeClaimTemplates.
- Зональность диска - Immediate binding в мультизональном кластере регулярно роняет поды в вечный Pending. Ставь WaitForFirstConsumer для облачных дисков.
- reclaimPolicy: Delete на проде - удалил PVC или namespace, и реальный диск с данными исчез вместе с PV. Для боевых данных - Retain.
- RWX там, где его нет - запросил ReadWriteMany на блочном диске и удивляешься, почему второй под не стартует. RWX дают NFS, CephFS, файловые хранилища - не блочные.
- Снапшот вместо бэкапа - снапшоты живут в том же облаке и том же аккаунте, что и диски. Накрылся аккаунт - накрылось всё. Бэкап обязан уезжать наружу.
- Брошенные PVC от удалённых StatefulSet тихо капают в счёт за облако. Заведи привычку чистить осиротевшие PVC.
Нужен любой кластер с динамическим провижинером (k3s с local-path или Minikube подойдут):
- Посмотри доступные классы: kubectl get storageclass. Найди, какой помечен как default (в имени звёздочка в выводе).
- Создай PVC на 1Gi со ссылкой на этот класс, примени, проверь kubectl get pvc. Если стоит WaitForFirstConsumer - заметь, что PVC висит Pending, пока нет пода.
- Подними под, который монтирует этот PVC, и убедись, что PVC стал Bound. Запиши файл в смонтированный каталог.
- Удали под, создай заново с тем же PVC - проверь, что файл на месте. Это и есть персистентность.
- Разверни StatefulSet с replicas: 2 и volumeClaimTemplates, посмотри, что создалось два отдельных PVC с именами по номеру пода.
- Если драйвер умеет снапшоты - сними VolumeSnapshot с одного PVC и подними из него новый PVC.
- Чем отличается PV от PVC и почему их разделили на два ресурса?
- Что делает volumeBindingMode: WaitForFirstConsumer и какую проблему зональности он решает для облачных дисков?
- Почему обычный облачный блочный диск не даёт ReadWriteMany и чем закрыть потребность в RWX?
- Что произойдёт с PVC при удалении StatefulSet и почему так сделано?
PVC - это заявка приложения, PV - реальный том, StorageClass - фабрика, которая выписывает диски на лету через CSI-драйвер. Для состояния на много реплик берём StatefulSet с volumeClaimTemplates: персональный диск на каждую реплику с стабильным именем. Три правила, которые спасают данные на проде: WaitForFirstConsumer для облачных дисков, reclaimPolicy Retain на боевых базах, и бэкап (Velero плюс снапшоты), который уезжает за пределы кластера. Запомни границу: снапшот - это быстрый откат, но не замена настоящему бэкапу.