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 одна для всех драйверов: PVC, класс, провизионер, attach, stage, publish, права через fsGroup. Меняется только то, что именно драйвер делает на каждом шаге и где лежат данные.

Дальше — NFS-сервер для кластера: как открыть экспорт узлам правильно и безопасно, почему ограничение по адресам узлов не защищает от подов и чем заменить популярный no_root_squash.

Оставить комментарий могут только зарегистрированные пользователи.

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