Kubernetes для самых маленьких. Часть 21: Как устроены тома и CSI?
Все части серии «Kubernetes для самых маленьких»Сетевой блок закончился в прошлой части, начинается хранилище. До сих пор все наши поды были без состояния: hello ничего не пишет на диск, и если под пересоздать, ничего не потеряется. С базой данных так нельзя. В части 6 мы видели, что файловая система контейнера — это слои образа плюс записываемый слой overlayfs, который живёт ровно столько, сколько контейнер; в части 12 монтировали ConfigMap и Secret как файлы; в части 8 StatefulSet просил тома через volumeClaimTemplates. Сегодня разберём, как всё это устроено: какие бывают тома, как PersistentVolumeClaim находит свой PersistentVolume, кто создаёт тома по запросу и что делает драйвер CSI, чтобы на узле появился смонтированный каталог. Сетевые хранилища — NFS и SMB — в следующих частях; здесь механика, показанная на локальных дисках узлов.
Опыты идут в неймспейсе demo из части 11 — с его LimitRange, который даёт каждому контейнеру без resources лимит памяти 128 МиБ. Это пригодится при разговоре о tmpfs.
Тома пода
Том (volume) в Kubernetes — каталог, который kubelet готовит до запуска контейнеров и монтирует в них по путям из volumeMounts. Том описывается в spec.volumes пода и живёт либо столько же, сколько под (эфемерные тома), либо независимо от него (постоянные). В отличие от записываемого слоя контейнера, том переживает перезапуск контейнера внутри пода и виден всем контейнерам пода, которые его смонтировали.
Все тома пода kubelet держит в каталоге /var/lib/kubelet/pods/<uid пода>/volumes/<плагин>/<имя тома>, а в контейнер они попадают bind-монтированием этого каталога по нужному пути. Плагин в имени каталога пишется через ~: kubernetes.io~empty-dir, kubernetes.io~projected, kubernetes.io~csi.
emptyDir
Пустой каталог, который создаётся при запуске пода на узле и удаляется вместе с подом. Подходит для временных файлов, кэшей и обмена файлами между контейнерами пода (например, init-контейнер скачивает данные, основной их читает). Падение контейнера данные не стирает: под остаётся на узле, и перезапущенный контейнер видит те же файлы.
volumes:
- name: cache
emptyDir:
sizeLimit: 1Gi
- name: scratch
emptyDir:
medium: Memory
sizeLimit: 256Mi
Обычный emptyDir лежит на диске узла, в каталоге kubelet, и занимает эфемерное хранилище узла — то же, куда пишутся журналы контейнеров и записываемые слои. sizeLimit — не квота файловой системы: внутри пода виден весь диск узла, писать можно сколько угодно, а kubelet периодически измеряет размер каталога и при превышении вытесняет под. Поэтому лимит обязателен: под без него может заполнить диск узла и вызвать вытеснение соседей.
medium: Memory монтирует tmpfs — файловую систему в оперативной памяти. Она быстрая, но всё записанное в неё учитывается в памяти контейнера, который писал: в cgroup эти байты видны как shmem, и они идут в счёт лимита, как обычная память процесса. Размер самой tmpfs kubelet выбирает как наименьшее из трёх: sizeLimit, лимит памяти пода и allocatable-память узла. Сверх размера tmpfs записать нельзя — No space left on device; а файлы в ней не освобождает ни OOM-killer, ни вытеснение, пока под существует. Это хорошее место для секретов, которые не должны попасть на диск, и для мелких горячих файлов, но не для больших объёмов.
configMap, secret, downwardAPI и projected
Эти тома показывают в виде файлов данные из API: ключи ConfigMap и Secret, метаданные пода (downwardAPI — имя, неймспейс, метки, аннотации, запросы и лимиты ресурсов). projected собирает несколько таких источников в один каталог и добавляет свои: serviceAccountToken — короткоживущий токен ServiceAccount с заданной аудиторией и сроком (не меньше 10 минут, по умолчанию час), который kubelet сам продлевает, и clusterTrustBundle — набор сертификатов доверия из кластерных объектов ClusterTrustBundle. Последний в 1.36 — бета-функция, выключенная по умолчанию (gate ClusterTrustBundleProjection), на нашем кластере его нет.
volumes:
- name: app-config
projected:
sources:
- configMap:
name: hello-config
- secret:
name: hello-db
items:
- key: password
path: db-password
- downwardAPI:
items:
- path: pod-name
fieldRef:
fieldPath: metadata.name
- serviceAccountToken:
audience: vault
expirationSeconds: 3600
path: vault-token
Механика обновления, о которой говорили в части 12: kubelet пишет файлы в скрытый каталог с отметкой времени, а символическая ссылка ..data атомарно переключается на новый. Приложение никогда не видит наполовину обновлённый набор файлов. Исключение — монтирование с subPath: оно привязывается к конкретному файлу и обновлений не получает. Тома secret и projected kubelet кладёт в tmpfs, поэтому секреты на диск узла не попадают. Каждый под получает такой том и без нашей просьбы: kube-api-access-<суффикс> с токеном ServiceAccount, сертификатом CA кластера и именем неймспейса.
image
С Kubernetes 1.36 стабилен ещё один источник — image: kubelet скачивает OCI-образ (или другой OCI-артефакт) так же, как образ контейнера, и монтирует его содержимое в под только для чтения. Так доставляют модели, словари и другие большие данные, которые удобно версионировать тегом в реестре, не встраивая их в образ приложения. Политика скачивания задаётся pullPolicy, как у контейнера.
hostPath и почему он опасен
hostPath монтирует в под файл или каталог файловой системы узла:
volumes:
- name: host-logs
hostPath:
path: /var/log
type: Directory
Поле type проверяет, что лежит по пути: Directory, File, Socket, CharDevice, BlockDevice — должно существовать; DirectoryOrCreate, FileOrCreate — создать, если нет; пустое значение — не проверять ничего.
Документация Kubernetes прямо пишет: если можно обойтись без hostPath, обходитесь. Причины:
- Это дыра в изоляции. Под с записью в
/,/etc,/var/lib/kubeletили в сокет containerd фактически получает права root на узле: может подменить конфигурацию, прочитать чужие тома и секреты, запустить контейнер мимо Kubernetes. Даже ограничение «разрешены только вот эти каталоги» держится, лишь если монтирование только для чтения: при записи из разрешённого каталога можно выбраться символическими ссылками. - Данные привязаны к узлу. Под переехал на другой узел — данные остались на старом, а на новом он видит пустой (или чужой) каталог.
- Никакого учёта. Запись в hostPath не считается эфемерным хранилищем пода: Kubernetes не знает размер этих данных, не вытесняет за них и не чистит их.
Где hostPath законен — в системных DaemonSet'ах, которым по роли нужен узел: сборщики журналов читают /var/log, агенты мониторинга — /proc и /sys, node-плагины CSI (о них ниже) — каталоги kubelet. Для приложений его не используют. Pod Security Admission, о котором в части 30, в профиле baseline запрещает hostPath совсем. Если приложению действительно нужен локальный диск узла, правильный путь — локальный PersistentVolume с привязкой к узлу или драйвер, который выделяет такие тома сам.
PersistentVolume и PersistentVolumeClaim
Постоянное хранилище в Kubernetes разделено на два объекта, и разделение то же, что у Gateway и HTTPRoute: одно описывает инфраструктуру, другое — потребность приложения.
PersistentVolume (PV) — кластерный объект: «вот конкретный том такого размера, с такими режимами доступа, вот как к нему подключиться». Его создаёт администратор вручную или провизионер автоматически.
PersistentVolumeClaim (PVC) — объект неймспейса: «приложению нужен том не меньше 10 ГиБ, с записью с одного узла, такого класса». Под ссылается на PVC, а не на PV, и ничего не знает о том, NFS это, локальный диск или облачный том.
apiVersion: v1
kind: PersistentVolume
metadata:
name: manual-pv-1
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath:
path: /srv/volumes/manual-pv-1
type: DirectoryOrCreate
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- k8s-node-1
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
namespace: demo
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
PV здесь — каталог на k8s-node-1, и nodeAffinity сообщает планировщику, что под с этим томом можно запускать только там. Это учебный пример механики; для настоящих данных hostPath-PV не годится по причинам из предыдущего раздела. Объекта StorageClass с именем manual в кластере нет, и он не нужен: при статическом связывании storageClassName — просто метка, которая должна совпасть у PV и PVC.
Связывание
Связыванием (binding) занимается контроллер PersistentVolume в kube-controller-manager. Он следит за PVC без тома и ищет подходящий PV:
- тот же
storageClassName(пустая строка""— «только PV без класса»; отсутствие поля — «класс по умолчанию», об этом ниже); - все запрошенные режимы доступа есть у PV;
- размер PV не меньше запрошенного — из подходящих выбирается самый маленький (на стенде из PV на 3 и 5 ГиБ PVC на 2 ГиБ получил 3 ГиБ);
- совпадает
selectorпо меткам PV, если он задан в PVC, илиvolumeName, если PVC просит конкретный PV.
Найдя пару, контроллер записывает ссылку в обе стороны: в PV — spec.claimRef (неймспейс, имя и UID claim'а), в PVC — spec.volumeName. Связь один к одному и эксклюзивная: PV на 5 ГиБ, связанный с PVC на 2 ГиБ, целиком принадлежит этому PVC, и остальные 3 ГиБ никому не достанутся. PVC сообщает в status.capacity реальный размер — 5 ГиБ. Контроллер перебирает PVC и по событиям, и периодически, раз в 15 секунд, поэтому связывание иногда случается не мгновенно.
Фазы:
| Объект | Фаза | Значит |
|---|---|---|
| PV | Available |
свободен |
| PV | Bound |
связан с PVC |
| PV | Released |
PVC удалён, но данные ещё на месте, повторно не связывается |
| PV | Failed |
автоматическое освобождение не удалось |
| PVC | Pending |
подходящего PV нет (или ждём первого потребителя) |
| PVC | Bound |
связан |
| PVC | Lost |
связанный PV исчез |
От случайного удаления защищают финализаторы: kubernetes.io/pvc-protection не даёт удалить PVC, пока его использует хоть один под (объект повиснет в Terminating), kubernetes.io/pv-protection — удалить связанный PV.
StorageClass и динамическое выделение
Заводить PV руками на каждый запрос неудобно. StorageClass описывает «сорт» хранилища и того, кто умеет создавать тома этого сорта:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: csi-hostpath
provisioner: hostpath.csi.k8s.io
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
provisioner— имя драйвера, который создаёт тома; для CSI это имя драйвера из объекта CSIDriver;parameters— параметры, которые передаются драйверу как есть (адрес сервера, тип диска, путь на сервере) и которые Kubernetes не понимает; демонстрационному драйверу ниже они не нужны;reclaimPolicy— политика освобождения, которую получат созданные PV;volumeBindingMode— когда создавать том;allowVolumeExpansion— можно ли увеличивать тома;mountOptions— опции монтирования для всех PV класса (понадобятся для NFS и SMB);allowedTopologies— где разрешено создавать тома.
Динамическое выделение: PVC ссылается на класс, PV нет — контроллер PV ставит на PVC аннотацию volume.kubernetes.io/storage-provisioner с именем провизионера, провизионер видит «свой» PVC, создаёт том в хранилище и объект PV, уже связанный с этим PVC. Имя такого PV — pvc-<UID claim'а>.
Класс по умолчанию помечается аннотацией storageclass.kubernetes.io/is-default-class: "true". PVC без поля storageClassName получит его автоматически (это делает admission-плагин DefaultStorageClass), причём задним числом: PVC, созданный, пока класса по умолчанию не было, висит в Pending, а когда такой класс появится, контроллер PV впишет его имя в PVC. Удобно, но коварно: опечатка в имени класса или забытое поле — и данные молча поедут в хранилище по умолчанию. Класс по умолчанию должен быть ровно один, и выбирать его стоит осознанно. В нашем кластере после установки его нет вовсе.
WaitForFirstConsumer против Immediate
Immediate создаёт том сразу, как только появился PVC. Провизионер при этом не знает, где будет работать под, и создаёт том там, где решит сам. Для сетевого хранилища, доступного со всех узлов, это неважно. Для топологически ограниченного — локального диска узла, диска в одной зоне облака — важно: том создан на k8s-node-2, а под из-за нехватки памяти или taints может работать только на k8s-node-1. Под навсегда остаётся в Pending.
WaitForFirstConsumer откладывает выделение до появления пода, который этот PVC использует. Планировщик выбирает узел с учётом всего — ресурсов, affinity, taints и топологии хранилища (а у драйверов, публикующих объекты CSIStorageCapacity, ещё и свободного места), — и записывает выбор в аннотацию PVC volume.kubernetes.io/selected-node. Провизионер создаёт том именно там, и в PV появляется nodeAffinity. Пока пода нет, PVC висит в Pending с событием WaitForFirstConsumer — это нормально.
Правило: для любого хранилища, у которого есть топология, — WaitForFirstConsumer. Для сетевого Immediate допустим, но и там WaitForFirstConsumer ничего не ломает.
Режимы доступа и политики освобождения
| Режим | Сокращение | Значит |
|---|---|---|
ReadWriteOnce |
RWO | чтение и запись с одного узла (несколько подов на этом узле — можно) |
ReadOnlyMany |
ROX | только чтение со многих узлов |
ReadWriteMany |
RWX | чтение и запись со многих узлов |
ReadWriteOncePod |
RWOP | чтение и запись одним подом во всём кластере |
Режимы — это обещания драйвера и критерий связывания, а не механизм блокировки внутри файловой системы. RWO ограничивает узел, а не под: два пода на одном узле спокойно смонтируют один RWO-том и могут испортить друг другу файлы. Если нужна гарантия «ровно один писатель», есть RWOP (стабилен с 1.29, поддерживается только CSI-драйверами): второй под с тем же PVC не будет запланирован. RWX умеют сетевые файловые системы вроде NFS и SMB; блочные тома обычно только RWO.
Политика освобождения (persistentVolumeReclaimPolicy) решает, что будет с данными, когда PVC удалён:
Delete— провизионер удаляет том в хранилище вместе с PV. Значение по умолчанию у динамически созданных томов. Удалили PVC по ошибке — данных нет.Retain— PV переходит вReleased, данные остаются. Вернуть их можно только вручную: убратьclaimRefиз PV и связать его с новым PVC черезvolumeName. Удалить такой PV можно, но сам том в хранилище останется — чистит его администратор.Recycle— устарела: раньше выполнялаrm -rfна томе и возвращала его вAvailable. Не используется; вместо неё — динамическое выделение.
Для важных данных правильнее класс с reclaimPolicy: Retain: случайный kubectl delete pvc или удаление неймспейса не уничтожит базу. Политику можно поменять и у существующего PV — kubectl patch pv <имя> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'.
Расширение, снапшоты и общие эфемерные тома
Расширение. Если в классе allowVolumeExpansion: true, том увеличивают правкой spec.resources.requests.storage в PVC. Дальше два шага: контроллерная часть драйвера увеличивает сам том в хранилище, затем kubelet на узле просит драйвер расширить файловую систему — для большинства драйверов на смонтированном томе, без остановки пода. Пока второй шаг не выполнен, у PV уже новый размер, а на PVC висит условие FileSystemResizePending и старый status.capacity. Уменьшать тома нельзя. Если запрошенный размер хранилище дать не может, запрос можно повторить с меньшим значением, но всё равно больше текущего.
Снапшоты — снимки тома на момент времени, средствами самого хранилища. Это три CRD, которые в Kubernetes не встроены и ставятся отдельно, из проекта external-snapshotter:
- VolumeSnapshotClass — аналог StorageClass для снимков: драйвер и
deletionPolicy(DeleteилиRetain); - VolumeSnapshot — запрос на снимок в неймспейсе: «снять PVC
data»; - VolumeSnapshotContent — кластерный объект, сам снимок в хранилище, аналог PV.
Обслуживает их snapshot-controller — Deployment в kube-system, связывающий VolumeSnapshot с VolumeSnapshotContent, а саму команду хранилищу отдаёт sidecar драйвера (о нём ниже). Из снимка создают новый PVC, указав его в dataSource; так же через dataSource с другим PVC делается клон. С Kubernetes 1.36 стабилен и VolumeGroupSnapshot — согласованный снимок сразу нескольких томов (данные и журнал базы в один момент), если хранилище это умеет; это ещё три CRD того же проекта.
Снимок, сделанный на том же хранилище, — не резервная копия: при потере хранилища пропадут и том, и снимки. Он хорош для быстрого отката перед обновлением и как источник для копирования в другое место.
Общие эфемерные тома (generic ephemeral volumes) — PVC, который создаётся вместе с подом и удаляется вместе с ним:
volumes:
- name: work
ephemeral:
volumeClaimTemplate:
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: csi-hostpath
resources:
requests:
storage: 1Gi
Контроллер создаёт PVC с именем <под>-<том> и владельцем-подом; когда под удалён, сборщик мусора удаляет и PVC. Это как emptyDir, но на любом хранилище с любыми возможностями — размером, классом, снапшотами, — и без расхода эфемерного хранилища узла. Подходит для больших временных данных.
Как устроен драйвер CSI
CSI (Container Storage Interface) — спецификация интерфейса между оркестратором и хранилищем. Раньше драйверы хранилищ жили прямо в коде Kubernetes (in-tree), и добавить новое хранилище или исправить ошибку в старом можно было только выпуском Kubernetes. CSI вынес их наружу: драйвер — отдельная программа, которая реализует gRPC-сервисы (gRPC — фреймворк удалённого вызова процедур: клиент вызывает метод сервера по имени, запрос и ответ передаются сообщениями Protocol Buffers поверх HTTP/2) и слушает unix-сокет. Старые in-tree плагины облачных дисков переведены на CSI-драйверы.
Основу спецификации составляют три сервиса:
| Сервис | Вызовы | Кто вызывает |
|---|---|---|
| Identity | GetPluginInfo, GetPluginCapabilities, Probe |
все: кто ты, что умеешь, жив ли |
| Controller | CreateVolume, DeleteVolume, ControllerPublishVolume, ControllerUnpublishVolume, ControllerExpandVolume, ControllerModifyVolume, CreateSnapshot, DeleteSnapshot |
sidecar-контейнеры controller-плагина |
| Node | NodeStageVolume, NodeUnstageVolume, NodePublishVolume, NodeUnpublishVolume, NodeExpandVolume, NodeGetInfo, NodeGetCapabilities |
kubelet |
Controller-вызовы работают с хранилищем «издалека» и могут выполняться где угодно; Node-вызовы монтируют том на конкретном узле и поэтому выполняются на нём. Кроме них, в спецификации есть сервис GroupController для групповых снимков и SnapshotMetadata — для получения списка изменённых блоков между снимками (по нему системы резервного копирования делают инкрементальные копии). Какие вызовы драйвер поддерживает, он сообщает возможностями (capabilities): GetPluginCapabilities, ControllerGetCapabilities, NodeGetCapabilities — и sidecar'ы с kubelet подстраиваются под ответ.
Отсюда типичная раскладка драйвера:
┌──────────────── controller-плагин (Deployment, 1–2 реплики) ─────────────────┐
│ external-provisioner PVC → CreateVolume / DeleteVolume → объект PV │
│ external-attacher VolumeAttachment → ControllerPublishVolume │
│ external-resizer PVC больше → ControllerExpandVolume │
│ external-snapshotter VolumeSnapshotContent → CreateSnapshot │
│ livenessprobe Probe → /healthz │
│ │ unix-сокет (emptyDir, общий для контейнеров пода) │
│ ▼ │
│ драйвер: Identity + Controller │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────── node-плагин (DaemonSet, на каждом узле) ─────────────────────┐
│ node-driver-registrar регистрирует драйвер в kubelet │
│ livenessprobe Probe → /healthz │
│ │ /var/lib/kubelet/plugins/<драйвер>/csi.sock (hostPath) │
│ ▼ │
│ драйвер: Identity + Node ◄── kubelet: NodeStage / NodePublish │
│ privileged, монтирует в /var/lib/kubelet с распространением Bidirectional │
└──────────────────────────────────────────────────────────────────────────────┘
Sidecar-контейнеры — общие для всех драйверов программы проекта kubernetes-csi. Драйверу не нужно ничего знать про API Kubernetes: sidecar следит за объектами через watch и переводит их изменения в вызовы gRPC.
- external-provisioner следит за PVC своего класса, вызывает
CreateVolumeи создаёт PV; при удалении PV с политикойDelete—DeleteVolume. - external-attacher следит за объектами VolumeAttachment и вызывает
ControllerPublishVolume— «подключить том к узлу» (для облачного диска — присоединить его к виртуальной машине). Если у драйвера нет возможностиPUBLISH_UNPUBLISH_VOLUME, attacher ничего не вызывает и просто отмечает VolumeAttachment подключённым. - external-resizer следит за увеличением размера в PVC и вызывает
ControllerExpandVolume. - external-snapshotter (sidecar
csi-snapshotter) следит за VolumeSnapshotContent своего драйвера и вызываетCreateSnapshot. - node-driver-registrar при старте узнаёт у драйвера имя и кладёт регистрационный сокет в
/var/lib/kubelet/plugins_registry/; kubelet следит за этим каталогом, находит новый драйвер и с этого момента знает, по какому сокету его звать. - livenessprobe превращает gRPC-вызов
Probeв HTTP-эндпоинт/healthzдля проб liveness пода.
Node-плагин работает привилегированным: ему нужно выполнять mount в пространстве имён узла. Каталоги kubelet — /var/lib/kubelet/pods и /var/lib/kubelet/plugins — монтируются в него с mountPropagation: Bidirectional: так монтирования, сделанные внутри контейнера, становятся видны kubelet и другим подам на узле (распространение монтирований — механизм общих поддеревьев mount namespace ядра).
Один CSI-драйвер в нашем кластере уже есть, хотя мы его не ставили: csi.tigera.io от Calico. Его node-плагин — DaemonSet csi-node-driver в calico-system с контейнерами calico-csi и csi-node-driver-registrar, controller-части нет вовсе: драйвер обслуживает только встроенные эфемерные тома, через которые под может получить сокет для связи с агентом Calico на узле. У нас этим не пользуется ни один под, но регистрацию драйвера видно в объектах ниже.
Объекты CSI в API
CSIDriver — кластерный объект, которым драйвер сообщает Kubernetes о своих особенностях. У демонстрационного драйвера, который мы поставим ниже, он такой (без меток и комментариев):
apiVersion: storage.k8s.io/v1
kind: CSIDriver
metadata:
name: hostpath.csi.k8s.io
spec:
volumeLifecycleModes:
- Persistent
- Ephemeral
podInfoOnMount: true
fsGroupPolicy: File
attachRequired— нужен ли шаг attach (VolumeAttachment иControllerPublishVolume); не указано — значитtrue. Для NFS и SMB подключать к узлу нечего, у таких драйверовfalse, и шаг пропускается.podInfoOnMount— передавать ли драйверу вNodePublishVolumeимя, неймспейс и UID пода.volumeLifecycleModes— постоянные тома (Persistent) и/или встроенные в под эфемерные тома CSI (Ephemeral).fsGroupPolicy— как kubelet поступает сfsGroupпода на этом томе. Три значения, и разница между ними понадобится в частях про NFS и SMB:
fsGroupPolicy |
Что делает kubelet |
|---|---|
ReadWriteOnceWithFSType (по умолчанию) |
меняет группу-владельца и права, только если у тома задан fsType и среди режимов доступа есть ReadWriteOnce. Сетевые тома без fsType и RWX-тома не трогает |
File |
меняет владельца и права всегда, независимо от типа файловой системы и режима доступа |
None |
не меняет никогда; том монтируется как есть, права — забота хранилища |
Логика у значения по умолчанию такая: блочный том с файловой системой, смонтированный на одном узле, принадлежит поду целиком, и менять права безопасно; сетевой том могут одновременно использовать другие клиенты, и рекурсивная смена владельца на нём — вмешательство в чужие данные. Есть и четвёртый вариант, не через это поле: драйвер с возможностью VOLUME_MOUNT_GROUP получает fsGroup в запросах NodeStageVolume/NodePublishVolume и применяет его сам — например, опцией монтирования gid=. Тогда kubelet права не меняет вовсе, и fsGroupPolicy с fsGroupChangePolicy игнорируются.
CSINode — объект на каждый узел, который создаёт kubelet: какие драйверы зарегистрированы на узле, под каким идентификатором узел известен драйверу (nodeID), какие у него ключи топологии и сколько томов драйвера можно подключить к узлу (allocatable.count). Ключи топологии kubelet заодно ставит меткой на объект Node — по ней провизионер и планировщик понимают, где том доступен.
VolumeAttachment — «том X подключён к узлу Y». Его создаёт контроллер AttachDetach в kube-controller-manager, когда под с томом запланирован на узел, а external-attacher выполняет и отмечает status.attached: true. Появляется только у драйверов с attachRequired: true.
Путь тома от PVC до каталога в контейнере
PVC создан ──► external-provisioner: CreateVolume ──► PV (pvc-<uid>), Bound
(при WaitForFirstConsumer — после выбора узла планировщиком)
│
под запланирован на k8s-node-2
│
├──► контроллер AttachDetach: VolumeAttachment ──► external-attacher:
│ ControllerPublishVolume ──► status.attached: true
│
▼ kubelet на k8s-node-2
NodeStageVolume один раз на узел и том:
│ /var/lib/kubelet/plugins/kubernetes.io/csi/<драйвер>/<sha256>/globalmount
▼
NodePublishVolume для каждого пода:
│ /var/lib/kubelet/pods/<uid пода>/volumes/kubernetes.io~csi/<pv>/mount
▼
containerd: bind-монтирование .../mount в контейнер по пути из volumeMounts
NodeStageVolume готовит том на узле один раз, сколько бы подов узла его ни использовали: для блочного диска — находит устройство, при необходимости создаёт файловую систему и монтирует её в globalmount; для SMB — подключает шару. <sha256> в пути — SHA-256 от идентификатора тома в хранилище (volumeHandle). Шаг необязательный: драйвер объявляет его возможностью STAGE_UNSTAGE_VOLUME, и без неё kubelet сразу вызывает публикацию.
NodePublishVolume делает том видимым конкретному поду — обычно bind-монтированием из globalmount в каталог пода. Рядом с каталогом mount kubelet кладёт vol_data.json с тем, что ему нужно для размонтирования после перезапуска.
Разбор в обратном порядке: NodeUnpublishVolume при удалении пода, NodeUnstageVolume — когда на узле не осталось подов с этим томом, ControllerUnpublishVolume — отключить от узла, DeleteVolume — при удалении PV с политикой Delete.
fsGroup и fsGroupChangePolicy
Процесс в контейнере работает от какого-то UID и GID, и на томе ему нужны права. securityContext.fsGroup пода задаёт дополнительную группу, которую получают все процессы пода; kubelet, если fsGroupPolicy драйвера это позволяет, рекурсивно делает эту группу владельцем всех файлов тома, добавляет группе права на чтение и запись и ставит каталогам бит setgid, чтобы новые файлы наследовали группу.
spec:
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 2000
fsGroupChangePolicy: OnRootMismatch
Рекурсивный обход — проблема на больших томах: миллион файлов на сетевом хранилище обходится минутами, и всё это время под не стартует. fsGroupChangePolicy управляет этим:
Always(по умолчанию) — обходить весь том при каждом монтировании;OnRootMismatch— проверить только корень тома: если владелец и права корня уже правильные, ничего не делать. Для томов, в которые пишет только это приложение, — правильный выбор.
Здесь же источник вопросов, к которым вернёмся в частях 23 и 24: на NFS смена владельца выполняется от root узла, и сервер с root_squash её не позволит; у SMB права файлов задаются опциями монтирования, а не chown. Поэтому для сетевых томов важно, какое fsGroupPolicy выставил драйвер и умеет ли он VOLUME_MOUNT_GROUP.
Демонстрационный драйвер
Чтобы увидеть механику CSI целиком, нужен драйвер со всеми sidecar-контейнерами, а настоящие сетевые хранилища появятся только в следующих частях. Возьмём csi-driver-host-path — демонстрационный драйвер проекта kubernetes-csi, который создаёт тома каталогами на узле в /var/lib/csi-hostpath-data/, умеет расширение и снапшоты и используется в тестах самого Kubernetes. Для данных он не годится: все тома живут на одном узле — там, где работает его единственный под StatefulSet, в котором собраны и controller-, и node-часть. Размер тома для него — только запись в объекте: каталог ничем не ограничен. У настоящих драйверов, как csi-driver-nfs в части 23, controller-часть — Deployment, а node-часть — DaemonSet.
Для снапшотов сначала ставим CRD и snapshot-controller официальными манифестами external-snapshotter v8.7.0. Файлы я скачал на рабочей станции и скопировал на мастер в ~/p21, а kubectl запускал оттуда:
$ V=v8.7.0
$ for f in client/config/crd/snapshot.storage.k8s.io_volumesnapshotclasses.yaml client/config/crd/snapshot.storage.k8s.io_volumesnapshotcontents.yaml client/config/crd/snapshot.storage.k8s.io_volumesnapshots.yaml deploy/kubernetes/snapshot-controller/rbac-snapshot-controller.yaml deploy/kubernetes/snapshot-controller/setup-snapshot-controller.yaml; do curl -sLO https://raw.githubusercontent.com/kubernetes-csi/external-snapshotter/$V/$f; done
$ kubectl apply -f snapshot.storage.k8s.io_volumesnapshotclasses.yaml -f snapshot.storage.k8s.io_volumesnapshotcontents.yaml -f snapshot.storage.k8s.io_volumesnapshots.yaml
customresourcedefinition.apiextensions.k8s.io/volumesnapshotclasses.snapshot.storage.k8s.io created
customresourcedefinition.apiextensions.k8s.io/volumesnapshotcontents.snapshot.storage.k8s.io created
customresourcedefinition.apiextensions.k8s.io/volumesnapshots.snapshot.storage.k8s.io created
$ kubectl apply -f rbac-snapshot-controller.yaml -f setup-snapshot-controller.yaml
serviceaccount/snapshot-controller created
clusterrole.rbac.authorization.k8s.io/snapshot-controller-runner created
clusterrolebinding.rbac.authorization.k8s.io/snapshot-controller-role created
role.rbac.authorization.k8s.io/snapshot-controller-leaderelection created
rolebinding.rbac.authorization.k8s.io/snapshot-controller-leaderelection created
deployment.apps/snapshot-controller created
$ kubectl -n kube-system get pods -l app.kubernetes.io/name=snapshot-controller -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
snapshot-controller-7fd654b88d-7pkfr 1/1 Running 0 71s 172.20.172.56 k8s-node-2 <none> <none>
snapshot-controller-7fd654b88d-wk69v 1/1 Running 0 71s 172.20.211.42 k8s-node-1 <none> <none>
Две реплики с выбором лидера; в манифесте выпуска v8.7.0 образ контроллера — snapshot-controller:v8.6.0.
Сам драйвер ставится скриптом из репозитория, который применяет набор манифестов: RBAC каждого sidecar'а (скрипт скачивает его из репозиториев sidecar'ов той версии, что указана в манифесте), CSIDriver, StatefulSet и VolumeSnapshotClass. Возьмём выпуск v1.19.0. Каталог hostpath содержит ещё csi-hostpath-testing.yaml — под с socat, который открывает сокет драйвера наружу через NodePort для тестовых утилит; нам он не нужен, и открывать сокет драйвера в сеть незачем, поэтому файл удаляем. Репозиторий клонируем там же, на рабочей станции, копируем на мастер в ~/p21 и запускаем скрипт там, где есть kubectl:
$ git clone --depth 1 --branch v1.19.0 https://github.com/kubernetes-csi/csi-driver-host-path.git
$ cd ~/p21/csi-driver-host-path
$ rm deploy/kubernetes-latest/hostpath/csi-hostpath-testing.yaml
$ deploy/kubernetes-latest/deploy.sh
applying RBAC rules
curl https://raw.githubusercontent.com/kubernetes-csi/external-provisioner/v6.4.0/deploy/kubernetes/rbac.yaml --output /tmp/tmp.5DbD3MoFpP/rbac.yaml --silent --location
kubectl apply --kustomize /tmp/tmp.5DbD3MoFpP
# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.
serviceaccount/csi-provisioner created
role.rbac.authorization.k8s.io/external-provisioner-cfg created
clusterrole.rbac.authorization.k8s.io/external-provisioner-runner created
rolebinding.rbac.authorization.k8s.io/csi-provisioner-role-cfg created
clusterrolebinding.rbac.authorization.k8s.io/csi-provisioner-role created
...
deploying hostpath components
/home/user/p21/csi-driver-host-path/deploy/kubernetes-latest/hostpath/csi-hostpath-driverinfo.yaml
csidriver.storage.k8s.io/hostpath.csi.k8s.io created
/home/user/p21/csi-driver-host-path/deploy/kubernetes-latest/hostpath/csi-hostpath-plugin.yaml
using image: registry.k8s.io/sig-storage/hostpathplugin:v1.18.0
using image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.18.0
using image: registry.k8s.io/sig-storage/livenessprobe:v2.20.0
using image: registry.k8s.io/sig-storage/csi-attacher:v4.13.0
using image: registry.k8s.io/sig-storage/csi-provisioner:v6.4.0
using image: registry.k8s.io/sig-storage/csi-resizer:v2.3.0
using image: registry.k8s.io/sig-storage/csi-snapshotter:v8.7.0
serviceaccount/csi-hostpathplugin-sa created
clusterrolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-attacher-cluster-role created
clusterrolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-provisioner-cluster-role created
clusterrolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-resizer-cluster-role created
clusterrolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-snapshotter-cluster-role created
clusterrolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-snapshot-metadata-cluster-role created
rolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-attacher-role created
rolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-provisioner-role created
rolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-resizer-role created
rolebinding.rbac.authorization.k8s.io/csi-hostpathplugin-snapshotter-role created
Warning: spec.template.spec.containers[1].ports[0]: duplicate port name "healthz" with spec.template.spec.containers[0].ports[0], services and probes that select ports by name will use spec.template.spec.containers[0].ports[0]
statefulset.apps/csi-hostpathplugin created
/home/user/p21/csi-driver-host-path/deploy/kubernetes-latest/hostpath/csi-hostpath-snapshotclass.yaml
volumesnapshotclass.snapshot.storage.k8s.io/csi-hostpath-snapclass created
17:42:25 waiting for hostpath deployment to complete, attempt #0
...
17:43:36 waiting for hostpath deployment to complete, attempt #7
Скрипт ставит драйвер в неймспейс default и ждёт, пока под StatefulSet станет готов (у нас — около 80 секунд, пока скачивались образы). Сам драйвер в выпуске v1.19.0 — образ hostpathplugin:v1.18.0. Предупреждение про повторяющееся имя порта healthz — особенность манифеста, на работу не влияет. Последний шаг — класс csi-hostpath из раздела про StorageClass: kubectl apply -f sc.yaml.
Смотрим руками
emptyDir и tmpfs
Под с двумя томами из примера выше — cache на диске с sizeLimit: 1Gi и scratch в памяти с sizeLimit: 256Mi:
apiVersion: v1
kind: Pod
metadata:
name: vols
namespace: demo
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "1d"]
volumeMounts:
- name: cache
mountPath: /cache
- name: scratch
mountPath: /scratch
volumes:
- name: cache
emptyDir:
sizeLimit: 1Gi
- name: scratch
emptyDir:
medium: Memory
sizeLimit: 256Mi
$ kubectl -n demo exec vols -- df -h /cache /scratch
Filesystem Size Used Available Use% Mounted on
/dev/sda2 30.3G 5.0G 23.7G 17% /cache
tmpfs 128.0M 0 128.0M 0% /scratch
$ kubectl -n demo get pod vols -o jsonpath='{.spec.containers[0].resources}'
{"limits":{"memory":"128Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}
Обе особенности видны сразу. cache — это просто корневой диск узла: sizeLimit: 1Gi внутри пода никак не виден. А tmpfs получила 128 МиБ, а не 256: LimitRange неймспейса demo дал контейнеру лимит памяти 128 МиБ, и kubelet взял наименьшее из sizeLimit и лимита пода. Под tmpfs-nolimit с единственным томом medium: Memory без sizeLimit в неймспейсе default, где LimitRange нет и лимита памяти у пода тоже нет, получает tmpfs размером с allocatable-память узла:
$ kubectl -n default exec tmpfs-nolimit -- df -h /scratch
Filesystem Size Used Available Use% Mounted on
tmpfs 3.2G 0 3.2G 0% /scratch
$ kubectl get node -o custom-columns=NAME:.metadata.name,ALLOC:.status.allocatable.memory
NAME ALLOC
k8s-master-1 3377260Ki
k8s-node-1 3377260Ki
k8s-node-2 3377252Ki
Запишем в tmpfs 8 МиБ и посмотрим на память контейнера в его cgroup:
$ kubectl -n demo exec vols -- sh -c 'grep -E "^(anon|file|shmem) " /sys/fs/cgroup/memory.stat; cat /sys/fs/cgroup/memory.current; dd if=/dev/zero of=/scratch/blob bs=1M count=8; grep -E "^(anon|file|shmem) " /sys/fs/cgroup/memory.stat; cat /sys/fs/cgroup/memory.current; df -h /scratch'
anon 413696
file 0
shmem 0
1556480
8+0 records in
8+0 records out
8388608 bytes (8.0MB) copied, 0.002622 seconds, 3.0GB/s
anon 417792
file 8388608
shmem 8388608
9879552
Filesystem Size Used Available Use% Mounted on
tmpfs 128.0M 8.0M 120.0M 6% /scratch
Файл в tmpfs — ровно 8 388 608 байт shmem в памяти контейнера, и memory.current вырос на столько же. Что будет, если упереться в размер tmpfs, показывает под tmpfs-small с sizeLimit: 16Mi (запись 32 МиБ):
$ kubectl -n demo exec tmpfs-small -- sh -c "dd if=/dev/zero of=/scratch/blob bs=1M count=32; df -h /scratch"
dd: error writing '/scratch/blob': No space left on device
17+0 records in
16+0 records out
16777216 bytes (16.0MB) copied, 0.003415 seconds, 4.6GB/s
Filesystem Size Used Available Use% Mounted on
tmpfs 16.0M 16.0M 0 100% /scratch
Вытеснение по sizeLimit
С томом на диске всё иначе: файловая система не мешает писать, следит kubelet. Под filler пишет 20 МиБ в emptyDir с sizeLimit: 10Mi:
apiVersion: v1
kind: Pod
metadata:
name: filler
namespace: demo
spec:
restartPolicy: Never
containers:
- name: app
image: busybox:1.37
command: ["sh", "-c", "dd if=/dev/zero of=/cache/blob bs=1M count=20; sleep 1d"]
volumeMounts:
- name: cache
mountPath: /cache
volumes:
- name: cache
emptyDir:
sizeLimit: 10Mi
Запись проходит сразу, а через 55 секунд после создания пода kubelet его вытесняет:
$ kubectl -n demo get pod filler
NAME READY STATUS RESTARTS AGE
filler 0/1 ContainerStatusUnknown 1 61s
$ kubectl -n demo get events --field-selector involvedObject.name=filler -o custom-columns=TYPE:.type,REASON:.reason,FROM:.source.component,MESSAGE:.message
TYPE REASON FROM MESSAGE
Normal Scheduled <none> Successfully assigned demo/filler to k8s-node-1
Normal Pulled kubelet Container image "busybox:1.37" already present on machine and can be accessed by the pod
Normal Created kubelet Container created
Normal Started kubelet Container started
Warning Evicted kubelet Usage of EmptyDir volume "cache" exceeds the limit "10Mi".
Normal Killing kubelet Stopping container app
Фаза пода — Failed, причина — Evicted, а колонка STATUS показывает ContainerStatusUnknown: контейнер убит при вытеснении, и kubelet не сохранил его код выхода. Размер каталогов kubelet измеряет периодически, поэтому вытеснение случается не в момент записи, а при очередной проверке; за это время под без лимита успел бы записать гораздо больше.
Каталоги томов на узле
Под vols работает на k8s-node-1. Находим его UID и смотрим каталог kubelet:
$ kubectl -n demo get pod vols -o jsonpath='{.metadata.uid}'
5e7efaf0-5e5f-42f3-a394-a995822ef630
user@k8s-node-1:~$ sudo find /var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes -maxdepth 2
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir/cache
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir/scratch
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~projected
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~projected/kube-api-access-bvh4v
user@k8s-node-1:~$ sudo findmnt -o TARGET,SOURCE,FSTYPE -T /var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir/cache
TARGET SOURCE FSTYPE
/ /dev/sda2 ext4
user@k8s-node-1:~$ findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS -T /var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir/scratch
TARGET SOURCE FSTYPE OPTIONS
/var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir/scratch tmpfs tmpfs rw,relatime,size=131072k,inode64,noswap
user@k8s-node-1:~$ sudo ls -l /var/lib/kubelet/pods/5e7efaf0-5e5f-42f3-a394-a995822ef630/volumes/kubernetes.io~empty-dir/scratch
total 8192
-rw-r--r-- 1 root root 8388608 Oct 29 17:34 blob
cache — обычный каталог на корневой файловой системе узла, отдельного монтирования у него нет. scratch — отдельная tmpfs с size=131072k (128 МиБ) и noswap: даже если на узле включат swap, страницы тома туда не уйдут. Рядом лежит kube-api-access-bvh4v — projected-том, который получает каждый под. В контейнере оба тома видны как монтирования:
$ kubectl -n demo exec vols -- grep -E ' /(cache|scratch) ' /proc/mounts
/dev/sda2 /cache ext4 rw,relatime 0 0
tmpfs /scratch tmpfs rw,relatime,size=131072k,inode64,noswap 0 0
projected
Под proj с томом из примера выше (ConfigMap hello-config с ключом LOG_LEVEL, Secret hello-db, имя пода и токен для аудитории vault), смонтированным в /etc/app:
$ kubectl -n demo exec proj -- ls -la /etc/app
total 4
drwxrwxrwt 3 root root 160 Oct 29 14:36 .
drwxr-xr-x 1 root root 4096 Oct 29 14:36 ..
drwxr-xr-x 2 root root 120 Oct 29 14:36 ..2026_10_29_14_36_35.2804868689
lrwxrwxrwx 1 root root 32 Oct 29 14:36 ..data -> ..2026_10_29_14_36_35.2804868689
lrwxrwxrwx 1 root root 16 Oct 29 14:36 LOG_LEVEL -> ..data/LOG_LEVEL
lrwxrwxrwx 1 root root 18 Oct 29 14:36 db-password -> ..data/db-password
lrwxrwxrwx 1 root root 15 Oct 29 14:36 pod-name -> ..data/pod-name
lrwxrwxrwx 1 root root 18 Oct 29 14:36 vault-token -> ..data/vault-token
$ kubectl -n demo exec proj -- cat /etc/app/pod-name
proj
$ kubectl -n demo exec proj -- cat /etc/app/vault-token | cut -d. -f2 | tr "_-" "/+" | base64 -d 2>/dev/null | jq -c "{aud,exp,iat,sub}"
{"aud":["vault"],"exp":1793288195,"iat":1793284595,"sub":"system:serviceaccount:demo:default"}
$ kubectl -n demo exec proj -- grep /etc/app /proc/mounts
tmpfs /etc/app tmpfs ro,relatime,size=131072k,inode64,noswap 0 0
Четыре источника в одном каталоге, все через ..data. Токен выписан для аудитории vault ровно на час (exp − iat = 3600) от имени ServiceAccount default неймспейса demo; kubelet обновит файл, когда пройдёт 80 % срока. Том — tmpfs только для чтения, и его размер снова равен лимиту памяти пода.
hostPath
Под hostlogs с томом /var/log из примера выше, смонтированным только для чтения в /host/var/log:
$ kubectl -n demo exec hostlogs -- ls /host/var/log | head -8
README
alternatives.log
apport.log
apt
auth.log
bootstrap.log
btmp
calico
$ kubectl -n demo exec hostlogs -- touch /host/var/log/x
touch: /host/var/log/x: Read-only file system
command terminated with exit code 1
Журналы узла видны поду как есть. Проверка type срабатывает при монтировании — под hostmissing просит type: Directory для несуществующего /srv/no-such-dir:
$ kubectl -n demo get pod hostmissing -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
hostmissing 0/1 ContainerCreating 0 12s <none> k8s-node-1 <none> <none>
$ kubectl -n demo get events --field-selector involvedObject.name=hostmissing -o custom-columns=TYPE:.type,REASON:.reason,MESSAGE:.message | tail -2
Normal Scheduled Successfully assigned demo/hostmissing to k8s-node-1
Warning FailedMount MountVolume.SetUp failed for volume "data" : hostPath type check failed: /srv/no-such-dir is not a directory
А в неймспейсе с профилем baseline такой под не создаётся вовсе. Неймспейс с меткой Pod Security Admission:
apiVersion: v1
kind: Namespace
metadata:
name: baseline-demo
labels:
pod-security.kubernetes.io/enforce: baseline
И тот же hostlogs, только в этом неймспейсе:
$ kubectl apply -f baseline-ns.yaml
namespace/baseline-demo created
$ kubectl apply -f hostlogs-baseline.yaml
Error from server (Forbidden): error when creating "hostlogs-baseline.yaml": pods "hostlogs" is forbidden: violates PodSecurity "baseline:latest": hostPath volumes (volume "host-logs")
Статический PV
Применяем manual-pv-1 и PVC data одним файлом:
$ kubectl apply -f static-pv.yaml
persistentvolume/manual-pv-1 created
persistentvolumeclaim/data created
$ kubectl get pv manual-pv-1
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
manual-pv-1 5Gi RWO Retain Bound demo/data manual <unset> 25s
$ kubectl -n demo get pvc data
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data Bound manual-pv-1 5Gi RWO manual <unset> 25s
$ kubectl get pv manual-pv-1 -o jsonpath='{.spec.claimRef}' | jq -c
{"apiVersion":"v1","kind":"PersistentVolumeClaim","name":"data","namespace":"demo","resourceVersion":"964210","uid":"072a2dcd-6a49-486b-9366-6d9a4c12c7da"}
$ kubectl -n demo describe pvc data | tail -6
VolumeMode: Filesystem
Used By: <none>
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning ProvisioningFailed 25s persistentvolume-controller storageclass.storage.k8s.io "manual" not found
Запросили 2 ГиБ, получили все 5: связь эксклюзивна. Событие ProvisioningFailed — след первой попытки: PVC пришёл к контроллеру раньше, чем PV попал в его кэш, подходящего тома не нашлось, и контроллер попробовал выделить том динамически по классу manual, которого нет. На следующем проходе он нашёл PV и связал их: через 3 секунды после apply PVC ещё был Pending, к 25-й — Bound. Колонка VOLUMEATTRIBUTESCLASS — класс изменяемых атрибутов тома (VolumeAttributesClass, стабилен с 1.34): через него у существующего тома меняют параметры вроде IOPS и пропускной способности, а драйвер получает вызов ControllerModifyVolume. У нас он не задан.
Под pv-writer пишет в том дату создания (date > /data/created.txt) и попадает, как и обещает nodeAffinity, на k8s-node-1. Удаляем PVC, пока под работает:
$ kubectl -n demo delete pvc data --wait=false
persistentvolumeclaim "data" deleted from demo namespace
$ kubectl -n demo get pvc data
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data Terminating manual-pv-1 5Gi RWO manual <unset> 40s
$ kubectl -n demo describe pvc data | grep -E "Status|Used By|Finalizers"
Status: Terminating (lasts 2s)
Finalizers: [kubernetes.io/pvc-protection]
Used By: pv-writer
kubectl честно пишет deleted, но объект держит финализатор kubernetes.io/pvc-protection, пока его использует pv-writer. Удаляем под — PVC исчезает, PV уходит в Released, а данные на узле остаются:
$ kubectl -n demo delete pod pv-writer
pod "pv-writer" deleted from demo namespace
$ kubectl -n demo get pvc
No resources found in demo namespace.
$ kubectl get pv manual-pv-1
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
manual-pv-1 5Gi RWO Retain Released demo/data manual <unset> 82s
user@k8s-node-1:~$ ls -l /srv/volumes/manual-pv-1; cat /srv/volumes/manual-pv-1/created.txt
total 4
-rw-r--r-- 1 root root 29 Oct 29 17:37 created.txt
Thu Oct 29 14:37:54 UTC 2026
Каталог /srv/volumes/manual-pv-1 создал kubelet при монтировании (type: DirectoryOrCreate). Вернём данные приложению: новый PVC data-again просит этот PV по имени (volumeName: manual-pv-1, тот же класс, 5 ГиБ), и под pv-reader его монтирует:
$ kubectl -n demo get pvc data-again
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-again Pending manual-pv-1 0 manual <unset> 20s
$ kubectl -n demo describe pvc data-again | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedBinding 12s (x2 over 21s) persistentvolume-controller volume "manual-pv-1" already bound to a different claim.
$ kubectl -n demo get events --field-selector involvedObject.name=pv-reader -o custom-columns=REASON:.reason,MESSAGE:.message | tail -2
REASON MESSAGE
FailedScheduling 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims. not found
В PV до сих пор claimRef на удалённый demo/data с его UID — для контроллера том занят. Убираем ссылку:
$ kubectl patch pv manual-pv-1 --type json -p '[{"op":"remove","path":"/spec/claimRef"}]'
persistentvolume/manual-pv-1 patched
$ kubectl get pv manual-pv-1
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
manual-pv-1 5Gi RWO Retain Bound demo/data-again manual <unset> 2m17s
$ kubectl -n demo exec pv-reader -- cat /data/created.txt
Thu Oct 29 14:37:54 UTC 2026
Сразу после patch PV ещё Released без CLAIM, а через 20 секунд он уже связан с data-again, и под видит старый файл. Так и выглядит ручное восстановление тома с Retain. После опыта PV удалён, а каталог /srv/volumes на узле пришлось удалить руками — Kubernetes за hostPath не убирает.
PVC без класса
Пока в кластере нет класса по умолчанию, PVC без storageClassName и без подходящих PV просто ждёт:
$ kubectl -n demo get pvc nosc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
nosc Pending <unset> 6s
$ kubectl -n demo describe pvc nosc | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal FailedBinding 5s persistentvolume-controller no persistent volumes available for this claim and no storage class is set
Вернёмся к нему, когда появится драйвер.
Драйвер на месте
Под драйвера — семь контейнеров: сам драйвер и sidecar'ы:
$ kubectl get pod -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
csi-hostpathplugin-0 7/7 Running 0 90s 172.20.172.61 k8s-node-2 <none> <none>
$ kubectl get pod csi-hostpathplugin-0 -o jsonpath='{range .spec.containers[*]}{.name}{"\n"}{end}'
hostpath
node-driver-registrar
liveness-probe
csi-attacher
csi-provisioner
csi-resizer
csi-snapshotter
$ kubectl get csidriver
NAME ATTACHREQUIRED PODINFOONMOUNT STORAGECAPACITY TOKENREQUESTS REQUIRESREPUBLISH MODES AGE
csi.tigera.io true true false <unset> false Ephemeral 5d4h
hostpath.csi.k8s.io true true false <unset> false Persistent,Ephemeral 92s
Рядом с нашим драйвером — csi.tigera.io от Calico, только для эфемерных томов. Оба зарегистрированы в CSINode:
$ kubectl get csinode
NAME DRIVERS AGE
k8s-master-1 1 5d4h
k8s-node-1 1 5d4h
k8s-node-2 2 5d3h
$ kubectl get csinode k8s-node-2 -o jsonpath='{.spec.drivers}' | jq
[
{
"name": "csi.tigera.io",
"nodeID": "k8s-node-2",
"topologyKeys": null
},
{
"name": "hostpath.csi.k8s.io",
"nodeID": "k8s-node-2",
"topologyKeys": [
"topology.hostpath.csi/node"
]
}
]
$ kubectl get node k8s-node-2 --show-labels | tr "," "\n" | grep -i topo
topology.hostpath.csi/node=k8s-node-2
Демонстрационный драйвер объявляет топологию «узел»: ключ topology.hostpath.csi/node есть только у k8s-node-2, где работает его под, и kubelet поставил узлу одноимённую метку. На узле — два сокета драйвера: регистрационный, который читает kubelet, и рабочий:
user@k8s-node-2:~$ sudo ls -l /var/lib/kubelet/plugins_registry/ /var/lib/kubelet/plugins/csi-hostpath/ /var/lib/kubelet/plugins/
/var/lib/kubelet/plugins/:
total 8
drwxr-xr-x 2 root root 4096 Oct 29 17:42 csi-hostpath
drwxr-xr-x 2 root root 4096 Oct 28 16:33 csi.tigera.io
/var/lib/kubelet/plugins/csi-hostpath/:
total 0
srwxr-xr-x 1 root root 0 Oct 29 17:42 csi.sock
/var/lib/kubelet/plugins_registry/:
total 0
srwx------ 1 root root 0 Oct 28 16:33 csi.tigera.io-reg.sock
srwx------ 1 root root 0 Oct 29 17:42 hostpath.csi.k8s.io-reg.sock
Что драйвер умеет, видно в журнале sidecar'ов при старте:
$ kubectl logs csi-hostpathplugin-0 -c csi-attacher | grep -i -E "capabilit|trivial|attach" | head -8
...
I1029 14:43:08.096659 1 main.go:260] "CSI driver does not support ControllerPublishUnpublish, using trivial handler" driver="hostpath.csi.k8s.io"
...
$ kubectl logs csi-hostpathplugin-0 -c csi-provisioner | grep -E "CreateVolume|provision" | head -6
...
I1029 14:43:22.446461 1 csi-provisioner.go:317] CSI driver does not support PUBLISH_UNPUBLISH_VOLUME, not watching VolumeAttachments
...
У драйвера attachRequired: true, поэтому Kubernetes будет создавать VolumeAttachment, но ControllerPublishVolume он не реализует, и attacher отмечает подключение сам, без вызова драйвера.
Динамический том с WaitForFirstConsumer
PVC hp-data на 1 ГиБ класса csi-hostpath без пода:
$ kubectl -n demo get pvc hp-data
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
hp-data Pending csi-hostpath <unset> 21s
$ kubectl -n demo describe pvc hp-data | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 14s (x2 over 21s) persistentvolume-controller waiting for first consumer to be created before binding
Под-потребитель writer с fsGroup: 2000 (как в разделе про fsGroup), сначала без привязки к узлу:
apiVersion: v1
kind: Pod
metadata:
name: writer
namespace: demo
spec:
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 2000
fsGroupChangePolicy: OnRootMismatch
containers:
- name: writer
image: busybox:1.37
command: ["sh", "-c", "echo hello > /data/hello.txt; sleep 1d"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: hp-data
$ kubectl -n demo get pod writer -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
writer 0/1 Pending 0 25s <none> <none> k8s-node-1 <none>
$ kubectl -n demo describe pvc hp-data | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 30s (x3 over 52s) persistentvolume-controller waiting for first consumer to be created before binding
Normal ExternalProvisioning 15s (x3 over 25s) persistentvolume-controller Waiting for a volume to be created either by the external provisioner 'hostpath.csi.k8s.io' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
Normal Provisioning 10s (x5 over 25s) hostpath.csi.k8s.io_csi-hostpathplugin-0_d11f0230-32e1-4686-897c-fd0e8af7bc28 External provisioner is provisioning volume for claim "demo/hp-data"
Warning ProvisioningFailed 10s (x5 over 25s) hostpath.csi.k8s.io_csi-hostpathplugin-0_d11f0230-32e1-4686-897c-fd0e8af7bc28 error generating accessibility requirements: no topology key found for node k8s-node-1
Вот обратная сторона WaitForFirstConsumer у драйвера, который не публикует CSIStorageCapacity: планировщик выбрал k8s-node-1 (NOMINATED NODE, аннотация selected-node), ничего не зная о том, что драйвера там нет, а провизионер не может создать том на узле без ключа топологии. Под ждёт тома, событий у него нет. Скрипт установки драйвера предупреждает об этом в комментарии: при позднем связывании поды нужно принудительно запускать на узле драйвера. Удаляем под и PVC, создаём PVC заново, а поду добавляем nodeSelector с kubernetes.io/hostname: k8s-node-2:
$ kubectl -n demo get pvc hp-data -o jsonpath='{.metadata.annotations}' | jq 'del(."kubectl.kubernetes.io/last-applied-configuration")'
{
"pv.kubernetes.io/bind-completed": "yes",
"pv.kubernetes.io/bound-by-controller": "yes",
"volume.beta.kubernetes.io/storage-provisioner": "hostpath.csi.k8s.io",
"volume.kubernetes.io/selected-node": "k8s-node-2",
"volume.kubernetes.io/storage-provisioner": "hostpath.csi.k8s.io"
}
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-8b17697d-7849-455d-af68-d08c71604723 1Gi RWO Delete Bound demo/hp-data csi-hostpath <unset> 14s
$ kubectl get pv pvc-8b17697d-7849-455d-af68-d08c71604723 -o jsonpath='{.spec.csi}' | jq
{
"driver": "hostpath.csi.k8s.io",
"volumeAttributes": {
"storage.kubernetes.io/csiProvisionerIdentity": "1793285002446-4963-hostpath.csi.k8s.io"
},
"volumeHandle": "665e490b-d3a7-11f1-8d4c-52a962bdf80a"
}
$ kubectl get pv pvc-8b17697d-7849-455d-af68-d08c71604723 -o jsonpath='{.spec.nodeAffinity}' | jq -c
{"required":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"topology.hostpath.csi/node","operator":"In","values":["k8s-node-2"]}]}]}}
$ kubectl -n demo describe pvc hp-data | sed -n '/Events/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 17s persistentvolume-controller waiting for first consumer to be created before binding
Normal Provisioning 14s hostpath.csi.k8s.io_csi-hostpathplugin-0_d11f0230-32e1-4686-897c-fd0e8af7bc28 External provisioner is provisioning volume for claim "demo/hp-data"
Normal ExternalProvisioning 14s (x2 over 14s) persistentvolume-controller Waiting for a volume to be created either by the external provisioner 'hostpath.csi.k8s.io' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
Normal ProvisioningSucceeded 14s hostpath.csi.k8s.io_csi-hostpathplugin-0_d11f0230-32e1-4686-897c-fd0e8af7bc28 Successfully provisioned volume pvc-8b17697d-7849-455d-af68-d08c71604723
Планировщик выбрал k8s-node-2 (selected-node), провизионер создал PV с volumeHandle — идентификатором тома в «хранилище» — и привязал его к узлу через nodeAffinity с ключом топологии драйвера. Подключение:
$ kubectl get volumeattachment
NAME ATTACHER PV NODE ATTACHED AGE
csi-05efdba6b22aa93a17bad6253a0b59e6ad7918b264e8044ce160c70f1b4b5380 hostpath.csi.k8s.io pvc-8b17697d-7849-455d-af68-d08c71604723 k8s-node-2 true 13s
Что на узле
user@k8s-node-2:~$ PV=pvc-8b17697d-7849-455d-af68-d08c71604723
user@k8s-node-2:~$ sudo find /var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io -maxdepth 2
/var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io
/var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/62cbbb6ca8ab41c5f487dcebe05d49f71f31c455abe62538185a0e1b932d92c7
/var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/62cbbb6ca8ab41c5f487dcebe05d49f71f31c455abe62538185a0e1b932d92c7/globalmount
/var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/62cbbb6ca8ab41c5f487dcebe05d49f71f31c455abe62538185a0e1b932d92c7/vol_data.json
user@k8s-node-2:~$ echo -n 665e490b-d3a7-11f1-8d4c-52a962bdf80a | sha256sum
62cbbb6ca8ab41c5f487dcebe05d49f71f31c455abe62538185a0e1b932d92c7 -
user@k8s-node-2:~$ POD_UID=$(sudo crictl inspectp $(sudo crictl pods --name writer -q) | jq -r .status.metadata.uid)
user@k8s-node-2:~$ sudo ls /var/lib/kubelet/pods/$POD_UID/volumes/kubernetes.io~csi/$PV/
mount
vol_data.json
user@k8s-node-2:~$ sudo findmnt -T /var/lib/kubelet/pods/$POD_UID/volumes/kubernetes.io~csi/$PV/mount
TARGET SOURCE FSTYPE OPTIONS
/var/lib/kubelet/pods/838f3069-261c-4b47-8f6d-b4e838e81f43/volumes/kubernetes.io~csi/pvc-8b17697d-7849-455d-af68-d08c71604723/mount /dev/sda2[/var/lib/csi-hostpath-data/665e490b-d3a7-11f1-8d4c-52a962bdf80a] ext4 rw,relatime
Имя каталога staging — SHA-256 от volumeHandle, как и обещано. Сам каталог globalmount пуст и ничем не смонтирован:
user@k8s-node-2:~$ sudo ls -la /var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/62cbbb6ca8ab41c5f487dcebe05d49f71f31c455abe62538185a0e1b932d92c7/globalmount
total 8
drwxr-x--- 2 root root 4096 Oct 29 17:45 .
drwxr-x--- 3 root root 4096 Oct 29 17:45 ..
user@k8s-node-2:~$ findmnt | grep -c globalmount
0
Демонстрационный драйвер объявляет шаг stage, но ничего на нём не делает, а монтирует только при публикации — bind-монтированием своего каталога данных /var/lib/csi-hostpath-data/<volumeHandle> в каталог пода. У драйвера блочных дисков в globalmount была бы смонтирована файловая система устройства, а в каталоге пода — bind из globalmount. Квадратные скобки в SOURCE у findmnt — это и есть bind: «подкаталог такой-то файловой системы».
vol_data.json — то, что kubelet запомнил о томе, в каталоге пода и в каталоге staging:
user@k8s-node-2:~$ sudo cat /var/lib/kubelet/pods/$POD_UID/volumes/kubernetes.io~csi/$PV/vol_data.json | jq
{
"attachmentID": "csi-05efdba6b22aa93a17bad6253a0b59e6ad7918b264e8044ce160c70f1b4b5380",
"driverName": "hostpath.csi.k8s.io",
"nodeName": "k8s-node-2",
"specVolID": "pvc-8b17697d-7849-455d-af68-d08c71604723",
"volumeHandle": "665e490b-d3a7-11f1-8d4c-52a962bdf80a",
"volumeLifecycleMode": "Persistent"
}
user@k8s-node-2:~$ G=$(sudo ls /var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/); sudo cat /var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/$G/vol_data.json | jq
{
"driverName": "hostpath.csi.k8s.io",
"volumeHandle": "665e490b-d3a7-11f1-8d4c-52a962bdf80a"
}
Вызовы драйвера
Драйвер запущен с --v=5 и пишет в журнал каждый вызов gRPC. Сводка за время опыта:
$ kubectl logs csi-hostpathplugin-0 -c hostpath | grep -E "GRPC call" | sed -E "s/.*GRPC call: //" | sort | uniq -c
6 /csi.v1.Controller/ControllerGetCapabilities
1 /csi.v1.Controller/CreateVolume
3 /csi.v1.Identity/GetPluginCapabilities
6 /csi.v1.Identity/GetPluginInfo
76 /csi.v1.Identity/Probe
7 /csi.v1.Node/NodeGetCapabilities
1 /csi.v1.Node/NodeGetInfo
1 /csi.v1.Node/NodeGetVolumeStats
1 /csi.v1.Node/NodePublishVolume
1 /csi.v1.Node/NodeStageVolume
Ровно та последовательность, что на схеме: CreateVolume от провизионера, затем через 11 секунд, когда под запустился, NodeStageVolume и NodePublishVolume от kubelet; ControllerPublishVolume нет — attacher обошёлся без него. Probe — это livenessprobe, NodeGetInfo — kubelet при регистрации драйвера узнаёт у него nodeID и ключи топологии для CSINode. Неудачные попытки на k8s-node-1 до драйвера не дошли: провизионер отказал раньше. Запрос публикации показывает, что дал podInfoOnMount:
$ kubectl logs csi-hostpathplugin-0 -c hostpath | grep -A6 "Node/NodePublishVolume" | head -8
I1029 14:45:46.387768 1 server.go:98] GRPC call: /csi.v1.Node/NodePublishVolume
I1029 14:45:46.387783 1 server.go:102] GRPC request: {"staging_target_path":"/var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io/62cbbb6ca8ab41c5f487dcebe05d49f71f31c455abe62538185a0e1b932d92c7/globalmount","target_path":"/var/lib/kubelet/pods/838f3069-261c-4b47-8f6d-b4e838e81f43/volumes/kubernetes.io~csi/pvc-8b17697d-7849-455d-af68-d08c71604723/mount","volume_capability":{"access_mode":{"mode":"SINGLE_NODE_MULTI_WRITER"},"mount":{}},"volume_context":{"csi.storage.k8s.io/ephemeral":"false","csi.storage.k8s.io/pod.name":"writer","csi.storage.k8s.io/pod.namespace":"demo","csi.storage.k8s.io/pod.uid":"838f3069-261c-4b47-8f6d-b4e838e81f43","csi.storage.k8s.io/serviceAccount.name":"default","storage.kubernetes.io/csiProvisionerIdentity":"1793285002446-4963-hostpath.csi.k8s.io"},"volume_id":"665e490b-d3a7-11f1-8d4c-52a962bdf80a"}
...
В volume_context — имя, неймспейс, UID и ServiceAccount пода. ReadWriteOnce пришёл к драйверу как SINGLE_NODE_MULTI_WRITER — режим CSI «один узел, много писателей»: драйвер объявил возможность SINGLE_NODE_MULTI_WRITER, и kubelet передаёт RWO именно так.
fsGroup
У драйвера fsGroupPolicy: File, поэтому kubelet сменил группу корня тома на 2000 и поставил setgid:
$ kubectl -n demo exec writer -- id
uid=1000 gid=1000 groups=1000,2000
$ kubectl -n demo exec writer -- ls -ln /data
total 4
-rw-r--r-- 1 1000 2000 6 Oct 29 14:45 hello.txt
$ kubectl -n demo exec writer -- stat -c '%A %u:%g %n' /data
drwxrwsr-x 0:2000 /data
$ kubectl -n demo exec writer -- df -h /data
Filesystem Size Used Available Use% Mounted on
/dev/sda2 30.3G 6.5G 22.2G 23% /data
Файл, созданный процессом с основной группой 1000, получил группу 2000 — так работает бит s на каталоге. Если бы у CSIDriver стояло None, корень остался бы root:root с правами 0755, и процесс от UID 1000 не смог бы туда писать. И df честно показывает, что «том на 1 ГиБ» — каталог на корневом диске узла.
Расширение
Увеличиваем PVC до 2 ГиБ:
$ kubectl -n demo patch pvc hp-data -p '{"spec":{"resources":{"requests":{"storage":"2Gi"}}}}'
persistentvolumeclaim/hp-data patched
$ kubectl -n demo get pvc hp-data
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
hp-data Bound pvc-8b17697d-7849-455d-af68-d08c71604723 1Gi RWO csi-hostpath <unset> 80s
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-8b17697d-7849-455d-af68-d08c71604723 2Gi RWO Delete Bound demo/hp-data csi-hostpath <unset> 77s
$ kubectl -n demo get pvc hp-data -o jsonpath='{.status.conditions}{"\n"}{.status.allocatedResources}{"\n"}{.status.allocatedResourceStatuses}'
[{"lastProbeTime":null,"lastTransitionTime":"2026-10-29T14:46:37Z","status":"True","type":"Resizing"},{"lastProbeTime":null,"lastTransitionTime":"2026-10-29T14:46:37Z","message":"Waiting for user to (re-)start a pod to finish file system resize of volume on node.","status":"True","type":"FileSystemResizePending"}]
{"storage":"2Gi"}
{"storage":"NodeResizePending"}
Первый шаг сделан: PV уже 2 ГиБ, а PVC — ещё 1 ГиБ с условием FileSystemResizePending. Текст условия предлагает перезапустить под, но драйвер умеет расширение на смонтированном томе, и ждать достаточно:
$ kubectl -n demo get pvc hp-data
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
hp-data Bound pvc-8b17697d-7849-455d-af68-d08c71604723 2Gi RWO csi-hostpath <unset> 97s
$ kubectl -n demo describe pvc hp-data | sed -n '/Events/,$p' | grep -E "Type|----|Resiz|FileSystem"
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Resizing 32s external-resizer hostpath.csi.k8s.io External resizer is resizing volume pvc-8b17697d-7849-455d-af68-d08c71604723
Normal FileSystemResizeRequired 32s external-resizer hostpath.csi.k8s.io Require file system resize of volume on node
Normal FileSystemResizeSuccessful 9s kubelet MountVolume.NodeExpandVolume succeeded for volume "pvc-8b17697d-7849-455d-af68-d08c71604723" k8s-node-2
$ kubectl logs csi-hostpathplugin-0 -c hostpath | grep "GRPC call" | grep -i expand
I1029 14:46:37.367330 1 server.go:98] GRPC call: /csi.v1.Controller/ControllerExpandVolume
I1029 14:47:00.332855 1 server.go:98] GRPC call: /csi.v1.Node/NodeExpandVolume
Два шага видны и в событиях, и в журнале драйвера: ControllerExpandVolume от external-resizer и через 23 секунды NodeExpandVolume от kubelet, без перезапуска пода.
Снапшот и восстановление
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: hp-data-snap-1
namespace: demo
spec:
volumeSnapshotClassName: csi-hostpath-snapclass
source:
persistentVolumeClaimName: hp-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hp-data-restored
namespace: demo
spec:
storageClassName: csi-hostpath
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 2Gi
dataSource:
apiGroup: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: hp-data-snap-1
Сначала снимок, затем PVC из него и под reader с nodeSelector на k8s-node-2, который его монтирует:
$ kubectl -n demo get volumesnapshot
NAME READYTOUSE SOURCEPVC SOURCESNAPSHOTCONTENT RESTORESIZE SNAPSHOTCLASS SNAPSHOTCONTENT CREATIONTIME AGE
hp-data-snap-1 true hp-data 2Gi csi-hostpath-snapclass snapcontent-f2003d1e-d2c5-4618-b33b-494453ab263f 8s 8s
$ kubectl get volumesnapshotcontent
NAME READYTOUSE RESTORESIZE DELETIONPOLICY DRIVER VOLUMESNAPSHOTCLASS VOLUMESNAPSHOT VOLUMESNAPSHOTNAMESPACE AGE
snapcontent-f2003d1e-d2c5-4618-b33b-494453ab263f true 2147483648 Delete hostpath.csi.k8s.io csi-hostpath-snapclass hp-data-snap-1 demo 8s
$ kubectl -n demo get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
hp-data Bound pvc-8b17697d-7849-455d-af68-d08c71604723 2Gi RWO csi-hostpath <unset> 2m9s
hp-data-restored Bound pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e 2Gi RWO csi-hostpath <unset> 10s
nosc Pending <unset> 7m26s
$ kubectl -n demo exec reader -- cat /data/hello.txt
hello
$ kubectl -n demo exec reader -- ls -ln /data
total 4
-rw-r--r-- 1 1000 2000 6 Oct 29 14:45 hello.txt
$ kubectl logs csi-hostpathplugin-0 -c hostpath | grep "GRPC call" | grep -E "Snapshot|CreateVolume" | grep -v List
I1029 14:45:35.564950 1 server.go:98] GRPC call: /csi.v1.Controller/CreateVolume
I1029 14:47:23.563740 1 server.go:98] GRPC call: /csi.v1.Controller/CreateSnapshot
I1029 14:47:32.341470 1 server.go:98] GRPC call: /csi.v1.Controller/CreateVolume
Пара VolumeSnapshot и VolumeSnapshotContent повторяет пару PVC и PV: запрос в неймспейсе и кластерный объект с самим снимком. Второй CreateVolume — восстановление: провизионер передал драйверу снимок как источник данных, и в новом томе лежит hello.txt с прежними владельцем и группой. В «хранилище» демонстрационного драйвера снимок — архив каталога тома:
user@k8s-node-2:~$ sudo ls -la /var/lib/csi-hostpath-data/
total 24
drwxr-xr-x 4 root root 4096 Oct 29 17:47 .
drwxr-xr-x 54 root root 4096 Oct 29 17:42 ..
drwxrwsr-x 2 root 2000 4096 Oct 29 17:45 665e490b-d3a7-11f1-8d4c-52a962bdf80a
-rw-r--r-- 1 root root 157 Oct 29 17:47 a6bd954c-d3a7-11f1-8d4c-52a962bdf80a.snap
drwxrwsr-x 2 root 2000 4096 Oct 29 17:45 abf8f915-d3a7-11f1-8d4c-52a962bdf80a
-rw------- 1 root root 1648 Oct 29 17:47 state.json
ReadWriteOncePod и ReadWriteOnce
PVC single с accessModes: ["ReadWriteOncePod"] и два пода с ним, оба с nodeSelector на k8s-node-2:
$ kubectl -n demo get pod single-1 single-2 -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
single-1 1/1 Running 0 18s 172.20.172.48 k8s-node-2 <none> <none>
single-2 0/1 Pending 0 10s <none> <none> <none> <none>
$ kubectl -n demo get events --field-selector involvedObject.name=single-2 -o custom-columns=REASON:.reason,FROM:.source.component,MESSAGE:.message
REASON FROM MESSAGE
FailedScheduling <none> 0/3 nodes are available: 1 node(s) didn't match Pod's node affinity/selector, 1 node(s) had untolerated taint(s), 1 node(s) unavailable due to PersistentVolumeClaim with ReadWriteOncePod access mode already in-use by another pod. 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.
Второй под не запланирован: RWOP проверяет планировщик. С обычным RWO всё иначе — второй под writer-2 с тем же hp-data на том же узле запускается и читает файл первого:
$ kubectl -n demo logs writer-2
hello
$ kubectl -n demo get pod writer writer-2 -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
writer 1/1 Running 0 2m58s 172.20.172.62 k8s-node-2 <none> <none>
writer-2 1/1 Running 0 8s 172.20.172.4 k8s-node-2 <none> <none>
Общий эфемерный том
Под scratchpod с томом work из примера в разделе про эфемерные тома (и nodeSelector на k8s-node-2):
$ kubectl -n demo get pvc scratchpod-work
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
scratchpod-work Bound pvc-4b0e7599-23cf-4b29-a0a1-1da7e57e5f8f 1Gi RWO csi-hostpath <unset> 7s
$ kubectl -n demo get pvc scratchpod-work -o jsonpath='{.metadata.ownerReferences}' | jq -c
[{"apiVersion":"v1","blockOwnerDeletion":true,"controller":true,"kind":"Pod","name":"scratchpod","uid":"d7feaf57-7e0b-4547-91d6-a76c6700972c"}]
$ kubectl -n demo delete pod scratchpod
pod "scratchpod" deleted from demo namespace
Через пять секунд после удаления пода ни PVC scratchpod-work, ни его PV в списках уже нет: сборщик мусора удалил PVC по ownerReferences, а политика Delete — том.
Класс по умолчанию задним числом
PVC nosc всё это время висит в Pending без класса. Делаем csi-hostpath классом по умолчанию:
$ kubectl annotate storageclass csi-hostpath storageclass.kubernetes.io/is-default-class=true
storageclass.storage.k8s.io/csi-hostpath annotated
$ kubectl get sc
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
csi-hostpath (default) hostpath.csi.k8s.io Delete WaitForFirstConsumer true 5m22s
$ kubectl -n demo get pvc nosc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
nosc Pending csi-hostpath <unset> 9m22s
Через 20 секунд в PVC, созданном девять минут назад, уже стоит csi-hostpath; он по-прежнему Pending, но теперь ждёт первого потребителя. Ровно так данные «молча едут» в класс по умолчанию.
Освобождение: Delete и Retain
Удаляем оба пода writer и PVC hp-data. У класса reclaimPolicy: Delete, поэтому исчезают VolumeAttachment, PV и каталог данных:
$ kubectl -n demo delete pod writer writer-2 && kubectl -n demo delete pvc hp-data
pod "writer" deleted from demo namespace
pod "writer-2" deleted from demo namespace
persistentvolumeclaim "hp-data" deleted from demo namespace
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e 2Gi RWO Delete Bound demo/hp-data-restored csi-hostpath <unset> 2m52s
pvc-b187bcc5-1c6d-456b-ac0d-690d24975e71 1Gi RWOP Delete Bound demo/single csi-hostpath <unset> 2m29s
$ kubectl logs csi-hostpathplugin-0 -c hostpath --since=2m | grep "GRPC call" | grep -v -E "Probe|GetCapabilities|GetPluginInfo|GetVolumeStats|NodeGetCapabilities|ControllerGetCapabilities"
...
I1029 14:50:18.400566 1 server.go:98] GRPC call: /csi.v1.Node/NodeUnpublishVolume
I1029 14:50:19.103586 1 server.go:98] GRPC call: /csi.v1.Node/NodeUnpublishVolume
I1029 14:50:19.205119 1 server.go:98] GRPC call: /csi.v1.Node/NodeUnstageVolume
I1029 14:50:19.964379 1 server.go:98] GRPC call: /csi.v1.Controller/DeleteVolume
Разбор в обратном порядке: два NodeUnpublishVolume — по одному на под, один NodeUnstageVolume — когда на узле не осталось подов с томом, и DeleteVolume после удаления PVC. Порядок команд важен: пока под жив, kubernetes.io/pvc-protection держит PVC в Terminating, и удаление завершится только после остановки пода.
Теперь то же с Retain: переключаем политику у восстановленного тома и удаляем его PVC, а затем и сам PV:
$ kubectl patch pv pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
persistentvolume/pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e patched
$ kubectl -n demo delete pod reader && kubectl -n demo delete pvc hp-data-restored
pod "reader" deleted from demo namespace
persistentvolumeclaim "hp-data-restored" deleted from demo namespace
$ kubectl get pv pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e 2Gi RWO Retain Released demo/hp-data-restored csi-hostpath <unset> 3m55s
$ kubectl delete pv pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e
persistentvolume "pvc-acfbd774-c3ae-4bd9-ac78-d85b90acee5e" deleted
$ kubectl logs csi-hostpathplugin-0 -c hostpath --since=1m | grep "GRPC call" | grep -E "Delete|Unstage|Unpublish"
I1029 14:51:21.579093 1 server.go:98] GRPC call: /csi.v1.Node/NodeUnpublishVolume
I1029 14:51:21.680692 1 server.go:98] GRPC call: /csi.v1.Node/NodeUnstageVolume
user@k8s-node-2:~$ sudo ls -la /var/lib/csi-hostpath-data/
total 24
drwxr-xr-x 4 root root 4096 Oct 29 17:50 .
drwxr-xr-x 54 root root 4096 Oct 29 17:42 ..
-rw-r--r-- 1 root root 157 Oct 29 17:47 a6bd954c-d3a7-11f1-8d4c-52a962bdf80a.snap
drwxrwsr-x 2 root 2000 4096 Oct 29 17:45 abf8f915-d3a7-11f1-8d4c-52a962bdf80a
drwxr-xr-x 2 root root 4096 Oct 29 17:47 b98c7c72-d3a7-11f1-8d4c-52a962bdf80a
-rw------- 1 root root 1648 Oct 29 17:51 state.json
DeleteVolume не было: объект PV удалён, а каталог тома abf8f915-… остался в хранилище — теперь он ничей, и убрать его может только администратор. Это и есть цена Retain: данные не теряются случайно, но и не убираются сами.
Уборка
Драйвер удаляется скриптом destroy.sh по меткам установки (StatefulSet, RBAC, CSIDriver), snapshot-controller и CRD — теми же манифестами через kubectl delete -f:
$ deploy/kubernetes-latest/destroy.sh
...
csidriver.storage.k8s.io "hostpath.csi.k8s.io" deleted
$ kubectl delete volumesnapshotclass csi-hostpath-snapclass --ignore-not-found
volumesnapshotclass.snapshot.storage.k8s.io "csi-hostpath-snapclass" deleted
$ kubectl delete -f setup-snapshot-controller.yaml -f rbac-snapshot-controller.yaml
deployment.apps "snapshot-controller" deleted from kube-system namespace
serviceaccount "snapshot-controller" deleted from kube-system namespace
clusterrole.rbac.authorization.k8s.io "snapshot-controller-runner" deleted
clusterrolebinding.rbac.authorization.k8s.io "snapshot-controller-role" deleted
role.rbac.authorization.k8s.io "snapshot-controller-leaderelection" deleted from kube-system namespace
rolebinding.rbac.authorization.k8s.io "snapshot-controller-leaderelection" deleted from kube-system namespace
$ kubectl delete -f snapshot.storage.k8s.io_volumesnapshots.yaml -f snapshot.storage.k8s.io_volumesnapshotcontents.yaml -f snapshot.storage.k8s.io_volumesnapshotclasses.yaml
customresourcedefinition.apiextensions.k8s.io "volumesnapshots.snapshot.storage.k8s.io" deleted
customresourcedefinition.apiextensions.k8s.io "volumesnapshotcontents.snapshot.storage.k8s.io" deleted
customresourcedefinition.apiextensions.k8s.io "volumesnapshotclasses.snapshot.storage.k8s.io" deleted
$ kubectl get csinode -o custom-columns=NAME:.metadata.name,DRIVERS:.spec.drivers[*].name
NAME DRIVERS
k8s-master-1 csi.tigera.io
k8s-node-1 csi.tigera.io
k8s-node-2 csi.tigera.io
$ kubectl get node --show-labels | tr "," "\n" | grep topology
topology.hostpath.csi/node=k8s-node-2
kubelet убрал драйвер из CSINode, но метку топологии на узле оставил — её снимаем сами (kubectl label node k8s-node-2 topology.hostpath.csi/node-). Скрипт не трогает и Lease snapshot-controller-leader в kube-system, и каталоги на узле драйвера: /var/lib/csi-hostpath-data с осиротевшим томом, /var/lib/kubelet/plugins/csi-hostpath и /var/lib/kubelet/plugins/kubernetes.io/csi/hostpath.csi.k8s.io удаляем руками, образы sidecar'ов — crictl rmi по точным именам.
Источники
- Kubernetes: Volumes — типы томов,
emptyDirи tmpfs,hostPathи его предупреждения,image, распространение монтирований. - Kubernetes: Projected Volumes — источники
projected, срок токена ServiceAccount,clusterTrustBundle. - Kubernetes: Ephemeral Volumes — общие эфемерные тома, встроенные эфемерные тома CSI.
- Kubernetes: Local ephemeral storage — эфемерное хранилище узла и лимиты
emptyDir. - Kubernetes: Persistent Volumes — связывание, фазы, финализаторы, политики освобождения, режимы доступа, расширение, класс по умолчанию задним числом.
- Kubernetes: Storage Classes и Dynamic Volume Provisioning — поля StorageClass,
volumeBindingMode, класс по умолчанию. - Kubernetes: Storage Capacity — CSIStorageCapacity и учёт свободного места планировщиком.
- Kubernetes: Volume Snapshots, Volume Snapshot Classes и Kubernetes v1.36: Moving Volume Group Snapshots to GA — снимки, восстановление, групповые снимки.
- Kubernetes: Volume Attributes Classes — изменяемые параметры тома и
ModifyVolume. - Kubernetes: Configure a Security Context for a Pod or Container —
fsGroup,fsGroupChangePolicy. - Kubernetes: Pod Security Standards — запрет
hostPathв профилеbaseline. - Kubernetes CSI Developer Documentation: Sidecar Containers, external-provisioner, external-attacher, external-resizer, external-snapshotter, node-driver-registrar, livenessprobe — что делает каждый sidecar.
- Kubernetes CSI: CSIDriver Object, CSINode Object, Topology и fsGroup Support — поля CSIDriver,
fsGroupPolicy,VOLUME_MOUNT_GROUP, ключи топологии. - Kubernetes CSI: Snapshot Controller и репозиторий external-snapshotter (выпуск v8.7.0) — CRD снимков и snapshot-controller.
- csi-driver-host-path (выпуск v1.19.0, инструкция по установке) — демонстрационный драйвер и его ограничения.
- Container Storage Interface Specification — сервисы Identity, Controller, Node, GroupController, SnapshotMetadata, возможности и жизненный цикл тома.
- gRPC: Introduction — удалённый вызов процедур и Protocol Buffers.
- Linux kernel: Shared Subtrees — распространение монтирований между mount namespace.
- Calico: pod2daemon — связь пода с агентом Calico на узле через том.
Механика хранилища в Kubernetes одна для всех драйверов: PVC, класс, провизионер, attach, stage, publish, права через fsGroup. Меняется только то, что именно драйвер делает на каждом шаге и где лежат данные.
Дальше — NFS-сервер для кластера: как открыть экспорт узлам правильно и безопасно, почему ограничение по адресам узлов не защищает от подов и чем заменить популярный no_root_squash.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.