Kubernetes для самых маленьких. Часть 25: Какие ещё бывают хранилища для кластера?

Все части серии «Kubernetes для самых маленьких»

Три прошлые части были о сетевых файловых системах: NFS-сервер для кластера (часть 22), csi-driver-nfs (часть 23) и csi-driver-smb (часть 24). Они хороши для общих файлов, но мы уже видели, чего они не умеют: базы данных на них не кладут, размер тома ничего не ограничивает (df в поде показывает весь экспорт), а при недоступности сервера встают все поды с его томами разом. Сегодня — обзор остального, что встречается в кластерах без облака: локальные диски узлов (local-path-provisioner, том local, TopoLVM), объектное хранилище S3, подключённое как файловая система, и распределённые хранилища Longhorn, Ceph и OpenEBS. В конце — ответ на вопрос, к которому сводятся все пять частей блока: где держать базы данных и какие данные куда класть. Механизмы PV, PVC, StorageClass и CSI — из части 21.

Локальные тома и S3 проверены на учебном кластере: local-path-provisioner, статический том local на отдельном диске узла, k8s-csi-s3 с сервером S3 Garage. Распределённые хранилища на нём не помещаются даже в минимальные требования (о них ниже), поэтому их раздел — по документации проектов, без вывода.


Локальные диски узлов

Самый быстрый диск для пода — тот, что стоит в его узле: ни сети, ни сервера, ни протокола, задержка fsync — задержка SSD. Цена одна, но большая: данные живут на одном узле. Под, привязанный к такому тому, может запускаться только там, а если узел умер навсегда — вместе с ним умерли и данные, если их не реплицирует кто-то выше.

local-path-provisioner

local-path-provisioner — небольшой контроллер от разработчиков k3s, который реализует динамическое выделение томов на локальных каталогах узлов. Это не CSI-драйвер, а внешний провизионер старого образца: он сам следит за PVC своего класса (rancher.io/local-path) и создаёт PV, а монтирует их kubelet встроенными средствами. Ставится одним манифестом выпуска:

$ kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.37/deploy/local-path-storage.yaml
namespace/local-path-storage created
serviceaccount/local-path-provisioner-service-account created
role.rbac.authorization.k8s.io/local-path-provisioner-role created
clusterrole.rbac.authorization.k8s.io/local-path-provisioner-role created
rolebinding.rbac.authorization.k8s.io/local-path-provisioner-bind created
clusterrolebinding.rbac.authorization.k8s.io/local-path-provisioner-bind created
deployment.apps/local-path-provisioner created
storageclass.storage.k8s.io/local-path created
configmap/local-path-config created
$ kubectl get storageclass local-path
NAME         PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  25s

Как работает. Когда под с PVC этого класса назначен на узел, контроллер запускает на этом узле короткоживущий вспомогательный под helper-pod-create-<имя PV> (в неймспейсе local-path-storage), который выполняет скрипт setup из ConfigMap local-path-config: mkdir -m 0777 каталога /opt/local-path-provisioner/<имя PV>_<неймспейс>_<имя PVC>. Затем контроллер создаёт PV — по умолчанию типа hostPath, а с аннотацией volumeType: local у PVC (или defaultVolumeType: local у класса) типа local — с nodeAffinity на этот узел. kubelet монтирует каталог в под bind-монтированием. При удалении PVC (политика Delete) такой же под helper-pod-delete-… выполняет teardown — rm -rf каталога.

Всё это настраивается в том же ConfigMap: nodePathMap задаёт каталоги для каждого узла (их стоит вынести на отдельный диск, а не оставлять на корневом разделе), helperPod.yaml — шаблон вспомогательного пода. В шаблоне выпуска образ docker.io/library/busybox без тега, то есть latest с Docker Hub: в кластере без доступа в интернет или с правилом «только закреплённые версии» его нужно заменить на образ из своего реестра.

WaitForFirstConsumer. Режим из части 21: класс откладывает создание тома до момента, когда планировщик выбрал узел для пода. Для локального тома иначе нельзя: если бы том создался сразу на k8s-node-1, а поду по ресурсам подходил только k8s-node-2, под не запустился бы никогда. После создания связь навсегда: PV получает nodeAffinity, и все следующие запуски пода — только на этом узле.

Ограничения.

  • Размер PVC не соблюдается: в документации проекта прямо сказано, что ограничение ёмкости игнорируется. Том — каталог на общей файловой системе, и он растёт, пока есть место на диске узла. Расширения тоже нет (ALLOWVOLUMEEXPANSION false) — его нечем делать.
  • Режимы доступа — только ReadWriteOnce и ReadWriteOncePod: каталог на одном узле. PVC с ReadWriteMany контроллер отклоняет (NodePath only supports ReadWriteOnce and ReadWriteOncePod (1.22+) access modes). Исключение — sharedFileSystemPath: если на всех узлах в одном и том же каталоге смонтирована общая файловая система, провизионер выдаёт тома в ней с любыми режимами, но это уже не локальное хранилище.
  • Узел недоступен или выведен из работы — под не перезапустится на другом, он останется в Pending до возвращения узла. kubectl drain выселит под, но новый будет ждать uncordon. Если узел потерян навсегда, PVC придётся удалить и создать заново, а данные восстановить из резервной копии или реплики.

Когда уместен. Учебные и тестовые кластеры; кэши и временные данные, которые не жалко; и — главное — приложения, которые сами реплицируют данные между экземплярами: кластер PostgreSQL с потоковой репликацией, брокер сообщений RabbitMQ с кворумными очередями (очередь хранится на нескольких узлах брокера и согласуется голосованием), поисковый движок Elasticsearch со своими копиями шардов. Им не нужна надёжность от хранилища, им нужна скорость локального диска, а надёжность они обеспечивают сами.

Том local и статическое выделение

Встроенный в Kubernetes тип тома local — то же самое, но без контроллера: администратор сам создаёт PV на каждый диск или раздел, обычно отдельно смонтированный (например, NVMe-диск под базу данных).

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-disk
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: k8s-node-1-disk1
spec:
  capacity:
    storage: 200Mi
  accessModes: ["ReadWriteOncePod"]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-disk
  local:
    path: /mnt/disks/disk1
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values: ["k8s-node-1"]

kubernetes.io/no-provisioner — у класса нет провизионера: PVC этого класса связываются только с заранее созданными PV. WaitForFirstConsumer здесь означает другое, чем у local-path: планировщик учитывает, на каких узлах есть свободные подходящие PV, выбирает узел и только потом связывает PVC с PV на этом узле. nodeAffinity у PV local обязательна — это и есть привязка к узлу. Размер здесь настоящий: том — отдельная файловая система, и запись сверх неё кончается No space left on device. ReadWriteOncePod (режим из части 21: том использует ровно один под во всём кластере) у статического PV нужно объявить в самом PV — PVC с ReadWriteOncePod к PV только с ReadWriteOnce не привяжется.

Отличие от hostPath, которое важно: local — полноценный PV, который планировщик понимает, а hostPath в поде — просто путь на том узле, куда под случайно попал. Ручное создание PV на каждый диск автоматизирует sig-storage-local-static-provisioner — DaemonSet, который находит смонтированные диски в заданном каталоге узла (например, /mnt/disks/) и сам создаёт для них PV, а после освобождения тома очищает диск и создаёт PV заново.

TopoLVM

TopoLVM — CSI-драйвер, который нарезает тома как логические тома LVM (менеджера логических томов Linux) из группы томов на каждом узле. В отличие от local-path, том получает реальный размер (это отдельное блочное устройство с файловой системой ext4, XFS или Btrfs), поддерживает расширение и блочный режим, снапшоты — для тонких логических томов (thin provisioning). Свободное место каждого узла планировщик узнаёт через отслеживание ёмкости хранилища (CSIStorageCapacity, часть 21) или расширение планировщика TopoLVM и не назначит под туда, где места нет. Если локальных томов в кластере много, это следующий шаг после local-path.


S3 через CSI

S3 — HTTP-протокол объектного хранилища, предложенный Amazon и ставший стандартом: данные лежат в бакетах как объекты с ключом-именем, их кладут (PUT), читают (GET, в том числе частично), удаляют и перечисляют по префиксу (ListObjectsV2). Каждый запрос подписывается ключом доступа: клиент вычисляет по секретному ключу подпись запроса (схема AWS Signature Version 4, HMAC от метода, пути, заголовков и времени), а сам секрет по сети не передаётся. Каталогов в S3 нет: reports/2026/10.pdf — ключ с косыми чертами, а «каталог» — общий префикс или пустой объект с именем, оканчивающимся на /.

Совместимые серверы, которые ставят у себя: Ceph RGW (шлюз S3 распределённого хранилища Ceph, о нём ниже), Garage (лёгкий распределённый сервер S3 с открытым кодом, рассчитанный на небольшие установки на нескольких машинах) и другие. MinIO, долго бывший самым популярным из них, свой открытый репозиторий больше не поддерживает: он переведён в архив, а компания предлагает вместо него отдельный продукт со своей лицензией.

Подключить бакет как том можно через CSI-драйверы на основе FUSE (Filesystem in Userspace) — механизма ядра, который передаёт операции с файловой системой процессу в пространстве пользователя. Процесс на узле превращает open, read, write в запросы S3.

  • k8s-csi-s3 — драйвер, который монтирует бакеты через GeeseFS, быструю FUSE-файловую систему для S3 (умеет и s3fs и rclone, но авторы настоятельно рекомендуют GeeseFS). Умеет динамическое выделение: новый PVC — новый бакет или префикс pvc-<uid> в бакете, указанном в классе; при удалении PVC префикс стирается. Режим доступа — любой, включая ReadWriteMany. Права и владельцы файлов на сторонних серверах S3 не хранит: всё выглядит принадлежащим одному пользователю с одними правами из опций монтирования.
  • Mountpoint for Amazon S3 CSI Driver — драйвер на основе Mountpoint, FUSE-клиента от AWS, оптимизированного под большие последовательные чтения. Только статическое выделение: PV указывает на существующий бакет. Адрес совместимого сервера задаётся опцией --endpoint-url, но рассчитан он прежде всего на Amazon S3 и ставится чартом Helm или дополнением EKS.

k8s-csi-s3 ставится тремя манифестами выпуска; адрес сервера и ключи лежат в Secret, а не в классе:

apiVersion: v1
kind: Secret
metadata:
  name: csi-s3-secret
  namespace: storage-credentials
stringData:
  accessKeyID: GK51446d5c5b4635172249f367
  secretAccessKey: <секретный ключ>
  endpoint: http://s3.example.com:3900
  region: garage
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: s3
provisioner: ru.yandex.s3.csi
parameters:
  mounter: geesefs
  options: "--memory-limit 1000 --dir-mode 0770 --file-mode 0660 --uid 10001 --gid 10001"
  bucket: k8s-volumes
  csi.storage.k8s.io/provisioner-secret-name: csi-s3-secret
  csi.storage.k8s.io/provisioner-secret-namespace: storage-credentials
  csi.storage.k8s.io/controller-publish-secret-name: csi-s3-secret
  csi.storage.k8s.io/controller-publish-secret-namespace: storage-credentials
  csi.storage.k8s.io/node-stage-secret-name: csi-s3-secret
  csi.storage.k8s.io/node-stage-secret-namespace: storage-credentials
  csi.storage.k8s.io/node-publish-secret-name: csi-s3-secret
  csi.storage.k8s.io/node-publish-secret-namespace: storage-credentials

Secret лежит в отдельном неймспейсе storage-credentials — по той же логике, что пароль SMB в части 24: у ключа должны быть права только на этот бакет, а не на всё хранилище. --uid, --gid, --dir-mode, --file-mode задают владельца и права сразу для всех файлов тома; --memory-limit — потолок памяти GeeseFS под кэш данных в мегабайтах, и он действует на каждый том на каждом узле: десяток томов S3 на узле с умолчанием 1000 — это до 10 ГБ памяти вне учёта подов.

Где работает GeeseFS. По умолчанию node-плагин запускает GeeseFS не в своём контейнере, а на самом узле — временной службой systemd geesefs-<имя тома>.service (для этого в контейнер смонтирован /run/systemd, а бинарник копируется в /var/lib/kubelet/plugins/ru.yandex.s3.csi/). Так обновление или перезапуск драйвера не обрывает смонтированные тома. С опцией --no-systemd GeeseFS живёт в контейнере драйвера, и при его перезапуске все тома узла отвечают Transport endpoint is not connected — оба случая проверены ниже.

Безопасность. Контроллер драйвера получает ClusterRole с get и list на все секреты кластера, node-плагин работает privileged с доступом к systemd узла. Ключи S3 служба получает через переменные окружения, а свойство Environment временной службы systemd отдаёт по D-Bus любому пользователю узла: systemctl show без sudo печатает секретный ключ (показано ниже). Поэтому ключ тома — с правами на один бакет, а на узлах не должно быть посторонних пользователей. И ещё отличие от NFS и SMB: к серверу S3 ходит из сети подов контроллер драйвера (его под не в hostNetwork), так что закрыть подам путь к серверу сетевой политикой по адресу, как в части 22, можно только с исключением для kube-system; главная граница здесь — права ключа.

Это не POSIX

Бакет, смонтированный как каталог, выглядит как файловая система, но ведёт себя иначе:

  • Нет атомарного rename. В S3 нет операции переименования; FUSE-клиент копирует объект под новым ключом и удаляет старый, а переименование каталога — копирование всех объектов с его префиксом. Приложения, которые пишут во временный файл и затем атомарно переименовывают его в рабочий (так делают почти все базы данных и многие конфигурационные системы), на S3 теряют эту гарантию. Mountpoint переименование файлов поддерживает только в классе хранения S3 Express One Zone, а каталоги не переименовывает вовсе.
  • Нет блокировок между узлами. GeeseFS блокировки не поддерживает: flock действует только среди процессов одного узла (его обслуживает ядро), а под на другом узле получает ту же блокировку одновременно. Два писателя одного объекта не узнают друг о друге, и выиграет последний.
  • Нет жёстких ссылок, а символьные ссылки, владельцы и права GeeseFS сохраняет только в Yandex Object Storage: на другом сервере S3 символьная ссылка на соседнем узле выглядит пустым файлом, а chmod и chown завершаются успешно и ничего не меняют.
  • Перезапись по частям дорога. Объект в S3 записывается целиком. Изменить 4 КиБ в середине файла на 1 ГиБ — значит собрать объект заново (GeeseFS делает это копированием на стороне сервера, но объём тот же). Mountpoint по умолчанию умеет только последовательную запись новых файлов; перезапись — с --allow-overwrite и только с усечением, удаление — с --allow-delete.
  • Согласованность метаданных. Список объектов и атрибуты кэшируются клиентом (у GeeseFS --stat-cache-ttl по умолчанию минута), и другой узел может минуту не видеть только что созданный файл.
  • Размер тома ничего не значит. df в поде показывает петабайт, а capacity PVC — просто число в объекте.

Когда уместно. Большие файлы, которые пишут один раз и потом читают: медиа, архивы, выгрузки, наборы данных для обработки; передача файлов между системами, одна из которых работает с S3 по API, а другая умеет только файлы. Не уместно: базы данных, журналы с дозаписью, всё, что опирается на rename и блокировки.

Лучше — без файловой системы. Если приложение пишете вы, правильнее говорить с S3 по его API через SDK (библиотеку для языка программирования): приложение само решает, что и когда выгружать, получает явные ошибки, умеет многочастную загрузку и предподписанные ссылки, а слой FUSE с его неочевидным поведением исчезает. Тогда в Kubernetes нужен не том, а Secret с ключами и адрес хранилища в конфигурации.


Распределённые хранилища

Следующий уровень — хранилище, которое само живёт в кластере: использует диски узлов и реплицирует данные между ними по сети. Под получает блочный том, который переживает потерю узла: реплика есть на другом.

      узел 1                узел 2                узел 3
  ┌────────────┐        ┌────────────┐        ┌────────────┐
  │ под        │        │            │        │            │
  │  │         │        │            │        │            │
  │  ▼         │        │            │        │            │
  │ том ───────┼───────►│ реплика 2  │        │ реплика 3  │
  │ реплика 1  │◄───────┼────────────┼───────►│            │
  │ диск узла  │        │ диск узла  │        │ диск узла  │
  └────────────┘        └────────────┘        └────────────┘
    запись подтверждается, когда её сохранили все работающие реплики
  • Longhorn — распределённое блочное хранилище от авторов Rancher (выпуск 1.13). На каждый том — свой процесс engine, который синхронно пишет данные в несколько реплик на дисках разных узлов. В основном движке (V1) engine отдаёт том узлу как блочное устройство по iSCSI — протоколу, который передаёт команды SCSI к дискам поверх TCP, — поэтому на всех узлах нужны open-iscsi с работающим iscsid; новый движок V2 построен на SPDK (библиотеке драйверов хранилища в пространстве пользователя) и отдаёт тома по NVMe/TCP. Есть веб-интерфейс, снапшоты, резервное копирование в S3 или NFS, ReadWriteMany через отдельный под share-manager с сервером NFSv4 для тома (на узлах нужен клиент NFSv4). Минимум по документации — три узла по 4 vCPU и 4 ГиБ памяти, SSD или NVMe и сеть 10 Гбит/с между узлами. Самый простой в установке из распределённых.
  • Ceph — распределённая система хранения, которая отдаёт блочные устройства (RBD, RADOS Block Device), файловую систему (CephFS) и объектное хранилище, совместимое с S3 (RGW). Данные распределяются по демонам OSD (по одному на диск) алгоритмом CRUSH, который вычисляет место объекта по карте кластера, без центрального каталога; состояние кластера хранят мониторы (MON), которым для кворума нужно минимум три. В Kubernetes его разворачивает оператор Rook (выпуск 1.21; оператор — контроллер, который управляет сложным приложением через свои ресурсы, подробно — в части 28), а тома подключает драйвер ceph-csi. OSD нужен диск, раздел или логический том LVM без файловой системы (или PVC в блочном режиме), на узлах — модуль ядра rbd. Самый мощный и самый сложный вариант: промышленное хранилище, на котором держатся целые облака.
  • OpenEBS (выпуск 4.6) — набор движков под общим именем: локальные тома (на каталогах, LVM или ZFS — файловой системе со встроенным менеджером томов; аналог local-path и TopoLVM) и реплицируемые тома Replicated PV Mayastor, который создаёт для каждого тома цель NVMe over TCP — протокол NVMe-oF, передающий команды NVMe-дисков по сети, — и требует выделенных ядер процессора и больших страниц памяти (huge pages). Позволяет начать с локальных томов и добавить репликацию там, где нужна.

Все три ставятся чартами Helm (о Helm — в части 27) или своими манифестами; на учебном кластере из двух воркеров по 2 vCPU и 4 ГБ ни один не укладывается даже в минимальные требования, поэтому здесь только описание.

Цена эксплуатации одинакова по сути и разная по величине:

  • Ресурсы. Минимум три узла с выделенными дисками, память и процессор под демоны хранилища на каждом узле, сеть: каждая запись уходит по сети в 2–3 реплики, и 1 Гбит/с быстро становится узким местом — Longhorn прямо называет базовым требованием 10 Гбит/с и рекомендует отдельную сеть хранилища.
  • Задержка. Синхронная репликация добавляет сетевой круг к каждой записи. Распределённый том медленнее локального диска.
  • Сложность отказов. Хранилище, которое живёт в кластере, падает вместе с кластером: ошибка обновления Kubernetes, сбой сети узлов или нехватка памяти на узлах задевают и данные. Восстановление после двойного отказа, ребалансировка, замена дисков — отдельная квалификация.
  • Обновления. У хранилища свой цикл версий, совместимость с версиями Kubernetes и ядра, и обновлять его надо аккуратнее, чем приложения.

Если в организации нет людей, готовых отвечать за Ceph, распределённое хранилище в маленьком кластере часто приносит больше рисков, чем снимает. Разумный путь для трёх–пяти узлов: локальные тома для того, что реплицируется само, NFS или SMB для общих файлов, S3 для больших объектов, а Longhorn — если действительно нужен переживающий отказ узла блочный том для приложения, которое реплицировать себя не умеет.


Базы данных в кластере

К этому месту блок хранилищ сводится так: базе данных нужны быстрый fsync, корректные блокировки, POSIX-семантика и защита от потери узла. Сетевые файловые системы дают последнее ценой первых трёх (как разобрано в части 23: Prometheus прямо не поддерживает NFS, SQLite предупреждает о сломанных блокировках на сетевых ФС, задержка каждого fsync растёт на сетевой круг). Распределённое блочное хранилище даёт всё, но ценой сложности и ещё одной копии данных поверх тех, что делает сама база.

Поэтому общепринятая схема другая: локальные диски и репликация средствами самой СУБД — архитектура без общего хранилища (shared-nothing), которую для PostgreSQL прямо рекомендует документация CloudNativePG.

  k8s-node-1                       k8s-node-2
 ┌─────────────────────────┐      ┌─────────────────────────┐
 │ postgres-1 (primary)    │ WAL  │ postgres-2 (replica)    │
 │ PVC local-disk ──► диск ├─────►│ PVC local-disk ──► диск │
 └─────────────────────────┘      └─────────────────────────┘
        оператор: выбор primary, переключение, бэкапы в S3

Каждый экземпляр — под со своим локальным томом; основной сервер передаёт журнал изменений (WAL, write-ahead log у PostgreSQL) репликам по сети; при потере узла оператор переводит одну из реплик в основные и создаёт новую реплику на другом узле с нуля. Резервные копии и журнал уходят в S3 — вне кластера и вне узлов. Управляют этим операторы: для PostgreSQL — например, CloudNativePG (оператор PostgreSQL с открытым кодом; копии в S3 он делает через подключаемый модуль Barman Cloud, встроенная выгрузка устарела с версии 1.26), для RabbitMQ — его официальный оператор кластера; о том, как устроены операторы, — в части 28.

Что получаем: скорость локального NVMe, гарантии согласованности самой СУБД, переживание потери узла без распределённого хранилища и восстановление на любой момент времени из S3. Чего стоит: экземпляров минимум два-три, и каждый держит полную копию данных.


Какие данные куда

Тип данных Хранилище Access mode Почему
Базы данных с репликацией (PostgreSQL, MySQL, кластеры брокеров) local PV, local-path, TopoLVM RWO (лучше RWOP) скорость локального диска; надёжность даёт репликация СУБД
Базы данных без репликации, которые нельзя потерять распределённое блочное (Longhorn, Ceph RBD) RWO переживает потерю узла
Метрики Prometheus (системы мониторинга), локальная часть журналов Loki (хранилища логов, часть 33) локальный диск RWO TSDB требует POSIX; долгосрочное хранение — в S3 средствами самого приложения
etcd локальный SSD — (не в PV кластера) задержка fsync; подробнее в части 34
Общие файлы нескольких реплик: загрузки, отчёты, статика NFS (csi-driver-nfs) RWX естественная общая ФС, права по UID
Файлы, которые нужны и пользователям Windows SMB (csi-driver-smb) RWX та же шара, что у людей; аутентификация и шифрование SMB3
Готовые справочные данные, только чтение статический PV NFS или SMB ROX ro на сервере и в PV
Большие объекты, медиа, архивы, бэкапы S3 по API; при необходимости CSI S3 RWX (через FUSE) дёшево и масштабируемо; не POSIX
Кэши, промежуточные данные emptyDir или local-path RWO не жалко потерять
Конфигурация и секреты ConfigMap, Secret — это не тома с данными (часть 12)

RWOP (ReadWriteOncePod) для баз данных надёжнее RWO: RWO ограничивает том одним узлом, но два пода на этом узле смонтируют его оба, а RWOP исключает второй под совсем. local-path его поддерживает, у статического PV его надо объявить в самом PV.


Смотрим руками

Опыты идут в неймспейсе demo из части 11, поды — на busybox.

local-path: каталог на узле

PVC на 100 МиБ и Deployment, который пишет в него время запуска:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: cache
  namespace: demo
spec:
  storageClassName: local-path
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 100Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cache
  namespace: demo
  labels:
    app.kubernetes.io/name: cache
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: cache
  template:
    metadata:
      labels:
        app.kubernetes.io/name: cache
    spec:
      containers:
        - name: app
          image: busybox:1.37
          command: ["sh", "-c", "date >> /data/started.log; sleep infinity"]
          volumeMounts:
            - name: data
              mountPath: /data
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: cache

strategy: Recreate — потому что том RWO на одном узле: при обновлении старый под должен уйти раньше, чем придёт новый. Пока пода нет, PVC ждёт — так и задумано:

$ kubectl -n demo get pvc cache
NAME    STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
cache   Pending                                      local-path     <unset>                 5s
$ kubectl -n demo describe pvc cache | sed -n '/Events/,$p'
Events:
  Type    Reason                Age              From                         Message
  ----    ------                ----             ----                         -------
  Normal  WaitForFirstConsumer  2s (x2 over 6s)  persistentvolume-controller  waiting for first consumer to be created before binding

Появился под — планировщик выбрал k8s-node-2, и на нём запустился вспомогательный под:

$ kubectl -n local-path-storage get pods -o wide
NAME                                                         READY   STATUS              RESTARTS   AGE   IP              NODE         NOMINATED NODE   READINESS GATES
helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531   0/1     ContainerCreating   0          2s    <none>          k8s-node-2   <none>           <none>
local-path-provisioner-54745b98cf-rj4p5                      1/1     Running             0          50s   172.20.211.62   k8s-node-1   <none>           <none>
$ kubectl -n demo get pvc cache
NAME    STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
cache   Bound    pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531   100Mi      RWO            local-path     <unset>                 34s
$ kubectl -n demo describe pvc cache | sed -n '/Events/,$p'
Events:
  Type    Reason                 Age                From                                                                                                Message
  ----    ------                 ----               ----                                                                                                -------
  Normal  WaitForFirstConsumer   30s (x2 over 34s)  persistentvolume-controller                                                                         waiting for first consumer to be created before binding
  Normal  Provisioning           22s                rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc  External provisioner is provisioning volume for claim "demo/cache"
  Normal  ExternalProvisioning   15s (x2 over 22s)  persistentvolume-controller                                                                         Waiting for a volume to be created either by the external provisioner 'rancher.io/local-path' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
  Normal  ProvisioningSucceeded  9s                 rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc  Successfully provisioned volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531

Из 13 секунд между Provisioning и ProvisioningSucceeded вспомогательный под почти всё время провёл в ContainerCreating: на узле скачивался busybox:latest. Журнал провизионера показывает, что произошло:

$ kubectl -n local-path-storage logs deploy/local-path-provisioner | grep -v event.go
time="2026-10-30T13:19:15Z" level=info msg="Health server started on :8080"
time="2026-10-30T13:19:15Z" level=debug msg="Applied config: {\"nodePathMap\":[{\"node\":\"DEFAULT_PATH_FOR_NON_LISTED_NODES\",\"paths\":[\"/opt/local-path-provisioner\"]}],\"storageClassConfigs\":null}"
time="2026-10-30T13:19:15Z" level=debug msg="Provisioner started"
I1030 13:19:15.347581       1 controller.go:838] "Starting provisioner controller" component="rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc"
I1030 13:19:15.448643       1 controller.go:887] "Started provisioner controller" component="rancher.io/local-path_local-path-provisioner-54745b98cf-rj4p5_27abeaf4-ef01-47f0-885a-be13148abbdc"
time="2026-10-30T13:19:52Z" level=debug msg="config doesn't contain node k8s-node-2, use DEFAULT_PATH_FOR_NON_LISTED_NODES instead"
time="2026-10-30T13:19:52Z" level=info msg="Creating volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 at k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
time="2026-10-30T13:19:52Z" level=info msg="create the helper pod helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 into local-path-storage"
time="2026-10-30T13:20:05Z" level=info msg="Volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 has been created on k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
time="2026-10-30T13:20:05Z" level=info msg="Start of helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 logs"
time="2026-10-30T13:20:05Z" level=info msg="End of helper-pod-create-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 logs"

PV — обычный hostPath с привязкой к узлу:

$ kubectl get pv pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 -o yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  annotations:
    local.path.provisioner/selected-node: k8s-node-2
    pv.kubernetes.io/provisioned-by: rancher.io/local-path
...
spec:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 100Mi
...
  hostPath:
    path: /opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache
    type: DirectoryOrCreate
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - k8s-node-2
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-path
  volumeMode: Filesystem

С аннотацией volumeType: local у PVC тот же каталог оформляется как PV local (spec.local.path вместо spec.hostPath) — проверено вторым PVC, больше ничего не меняется.

Пишем в том на 100 МиБ 200 МиБ:

$ kubectl -n demo exec deploy/cache -- sh -c 'dd if=/dev/zero of=/data/big bs=1M count=200 2>&1 | tail -1; ls -l /data; df -h /data'
209715200 bytes (200.0MB) copied, 0.036870 seconds, 5.3GB/s
total 204804
-rw-r--r--    1 root     root     209715200 Oct 30 13:20 big
-rw-r--r--    1 root     root            29 Oct 30 13:20 started.log
Filesystem                Size      Used Available Use% Mounted on
/dev/sda2                30.3G      5.9G     22.8G  21% /data

Ни ошибки, ни предупреждения: в поде виден весь корневой раздел узла. На узле — обычный каталог с правами 0777:

user@k8s-node-2:~$ sudo ls -la /opt/local-path-provisioner/
total 12
drwxr-xr-x 3 root root 4096 Oct 30 13:20 .
drwxr-xr-x 5 root root 4096 Oct 30 13:19 ..
drwxrwxrwx 2 root root 4096 Oct 30 13:20 pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache
user@k8s-node-2:~$ sudo du -sh /opt/local-path-provisioner/*
201M	/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache
user@k8s-node-2:~$ df -h /opt/local-path-provisioner
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda2        31G  6.0G   23G  21% /

Каталог на корневом разделе: так local-path работает по умолчанию, и именно поэтому его путь переносят на отдельный диск через nodePathMap. Один под без ограничений может заполнить корневой раздел и вызвать давление на диск у всего узла.

Что будет, если узел выведен из работы. Закрываем k8s-node-2 для новых подов и удаляем под — так же поступает kubectl drain:

$ kubectl cordon k8s-node-2
node/k8s-node-2 cordoned
$ kubectl -n demo delete pod -l app.kubernetes.io/name=cache
pod "cache-7dc5df85bc-ktqpg" deleted from demo namespace
$ kubectl -n demo get pods -l app.kubernetes.io/name=cache -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP       NODE     NOMINATED NODE   READINESS GATES
cache-7dc5df85bc-9lhkb   0/1     Pending   0          40s   <none>   <none>   <none>           <none>
$ kubectl -n demo describe pod cache-7dc5df85bc-9lhkb | sed -n '/Events/,$p'
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  39s   default-scheduler  0/3 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) had untolerated taint(s), 1 node(s) were unschedulable. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

Три узла — три причины: k8s-node-1 не подходит по nodeAffinity тома, у мастера taint control-plane (часть 13), k8s-node-2 закрыт. Под будет ждать сколько угодно; возвращаем узел:

$ kubectl uncordon k8s-node-2
node/k8s-node-2 uncordoned
$ kubectl -n demo get pods -l app.kubernetes.io/name=cache -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP              NODE         NOMINATED NODE   READINESS GATES
cache-7dc5df85bc-9lhkb   1/1     Running   0          51s   172.20.172.63   k8s-node-2   <none>           <none>
$ kubectl -n demo exec deploy/cache -- cat /data/started.log
Fri Oct 30 13:20:09 UTC 2026
Fri Oct 30 13:22:41 UTC 2026

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

Удаление PVC запускает второй вспомогательный под, и каталог исчезает:

$ kubectl delete -f cache.yaml
persistentvolumeclaim "cache" deleted from demo namespace
deployment.apps "cache" deleted from demo namespace
$ kubectl -n local-path-storage logs deploy/local-path-provisioner --since=2m | grep -v event.go
time="2026-10-30T13:23:29Z" level=info msg="Deleting volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 at k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
time="2026-10-30T13:23:29Z" level=info msg="create the helper pod helper-pod-delete-pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 into local-path-storage"
time="2026-10-30T13:23:35Z" level=info msg="Volume pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531 has been deleted on k8s-node-2:/opt/local-path-provisioner/pvc-4b0f1a3d-1c17-4468-b06c-dde209e63531_demo_cache"
...
user@k8s-node-2:~$ sudo ls -la /opt/local-path-provisioner/
total 8
drwxr-xr-x 2 root root 4096 Oct 30 13:23 .
drwxr-xr-x 5 root root 4096 Oct 30 13:19 ..

Том local на отдельном диске

Для опыта вместо NVMe — файл-образ на 256 МиБ, подключённый как loop-устройство (блочное устройство поверх файла) и смонтированный на k8s-node-1:

user@k8s-node-1:~$ sudo fallocate -l 256M /var/tmp/disk1.img
user@k8s-node-1:~$ LOOP=$(sudo losetup --find --show /var/tmp/disk1.img); echo $LOOP
/dev/loop5
user@k8s-node-1:~$ sudo mkfs.ext4 -q -L disk1 $LOOP
user@k8s-node-1:~$ sudo mkdir -p /mnt/disks/disk1 && sudo mount $LOOP /mnt/disks/disk1
user@k8s-node-1:~$ df -h /mnt/disks/disk1
Filesystem      Size  Used Avail Use% Mounted on
/dev/loop5      224M  152K  206M   1% /mnt/disks/disk1

Класс и PV — манифест из начала части. Ловушка с режимом доступа: в первый раз PV был объявлен с ReadWriteOnce, а PVC с подом просили ReadWriteOncePod:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db
  namespace: demo
spec:
  storageClassName: local-disk
  accessModes: ["ReadWriteOncePod"]
  resources:
    requests:
      storage: 200Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: db
  namespace: demo
spec:
  containers:
    - name: app
      image: busybox:1.37
      command: ["sleep", "infinity"]
      volumeMounts:
        - name: data
          mountPath: /data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: db
$ kubectl -n demo describe pod db | sed -n '/Events/,$p'
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  68s   default-scheduler  0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't find available persistent volumes to bind. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

didn't find available persistent volumes to bind — планировщик не нашёл подходящего свободного PV ни на одном воркере; PVC при этом в Pending с тем же событием WaitForFirstConsumer. Тот же ответ будет, если PV меньше запроса. Добавляем PV нужный режим — связывание проходит:

$ kubectl patch pv k8s-node-1-disk1 --type=merge -p '{"spec":{"accessModes":["ReadWriteOncePod"]}}'
persistentvolume/k8s-node-1-disk1 patched
$ kubectl -n demo get pvc db
NAME   STATUS   VOLUME             CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
db     Bound    k8s-node-1-disk1   200Mi      RWOP           local-disk     <unset>                 89s
$ kubectl -n demo get pod db -o wide
NAME   READY   STATUS    RESTARTS   AGE   IP             NODE         NOMINATED NODE   READINESS GATES
db     1/1     Running   0          89s   172.20.211.9   k8s-node-1   <none>           <none>

Здесь размер настоящий — у тома своя файловая система:

$ kubectl -n demo exec db -- df -h /data
Filesystem                Size      Used Available Use% Mounted on
/dev/loop5              223.7M    152.0K    205.7M   0% /data
$ kubectl -n demo exec db -- dd if=/dev/zero of=/data/big bs=1M count=300
dd: error writing '/data/big': No space left on device
219+0 records in
218+0 records out
228589568 bytes (218.0MB) copied, 2.515043 seconds, 86.7MB/s
command terminated with exit code 1
user@k8s-node-1:~$ findmnt -n -o TARGET,SOURCE,FSTYPE -S /dev/loop5
/mnt/disks/disk1                                                                                               /dev/loop5 ext4
/var/lib/kubelet/pods/9f3f0845-d7d9-4b30-ac5a-0ba986ac6e7f/volumes/kubernetes.io~local-volume/k8s-node-1-disk1 /dev/loop5 ext4

kubelet bind-монтирует каталог диска в каталог тома пода (плагин kubernetes.io~local-volume). После удаления пода и PVC срабатывает Retain: PV переходит в Released с прежним claimRef, данные остаются на диске, и новый PVC к нему не привяжется, пока администратор не очистит диск и не уберёт claimRef (kubectl patch pv k8s-node-1-disk1 --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]') — это ровно та работа, которую берёт на себя sig-storage-local-static-provisioner:

$ kubectl get pv
NAME               CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS     CLAIM     STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
k8s-node-1-disk1   200Mi      RWOP           Retain           Released   demo/db   local-disk     <unset>                          2m21s
user@k8s-node-1:~$ sudo ls -la /mnt/disks/disk1
total 223260
drwxr-xr-x 3 root root      4096 Oct 30 13:26 .
drwxr-xr-x 3 root root      4096 Oct 30 13:24 ..
-rw-r--r-- 1 root root 228589568 Oct 30 13:26 big
drwx------ 2 root root     16384 Oct 30 13:24 lost+found

S3: сервер Garage и k8s-csi-s3

Сервер S3 для опыта — Garage 2.5 на отдельной машине s3.example.com (192.168.10.33): один узел, одна копия данных (replication_factor = 1 — документация прямо говорит, что это только для пробных установок), метаданные в SQLite. Garage — один статический бинарник; конфигурация:

# /etc/garage.toml
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1

rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "<вывод openssl rand -hex 32>"

[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"

[admin]
api_bind_addr = "127.0.0.1:3903"

Порт 3900 — API S3, 3901 — связь узлов Garage между собой (у одного узла — только локально), 3903 — административный API. Garage сам TLS не делает: в учебной сети он отвечает по HTTP, подпись запросов секрет не раскрывает, но данные идут открытыми. В рабочей установке перед ним ставят обратный прокси с TLS и пишут в endpoint адрес https://.

Для опыта хватает временной службы systemd-run (для постоянной установки в документации Garage есть unit-файл); дальше узлу назначается роль в раскладке кластера, создаются бакет и ключ с правами только на него:

user@s3:~$ sudo systemd-run --unit=garage /usr/local/bin/garage -c /etc/garage.toml server
Running as unit: garage.service; invocation ID: 5b947ec15de847bfb9ed5764c294f9c2
user@s3:~$ sudo garage -c /etc/garage.toml status
==== HEALTHY NODES ====
ID                Hostname     Address         Tags  Zone  Capacity          DataAvail  ResyncQueue  Version
42df63bf219b0dad  s3           127.0.0.1:3901              NO ROLE ASSIGNED             0            v2.5.0
user@s3:~$ sudo garage -c /etc/garage.toml layout assign -z dc1 -c 2G 42df63bf219b0dad
user@s3:~$ sudo garage -c /etc/garage.toml layout apply --version 1
...
New cluster layout with updated role assignment has been applied in cluster.
Data will now be moved around between nodes accordingly.
user@s3:~$ sudo garage -c /etc/garage.toml bucket create k8s-volumes
user@s3:~$ sudo garage -c /etc/garage.toml key create k8s-csi-s3
==== ACCESS KEY INFORMATION ====
Key ID:              GK51446d5c5b4635172249f367
Key name:            k8s-csi-s3
Created:             2026-10-30 13:28:25.940 +00:00
Validity:            valid
Expiration:          never

Can create buckets:  false
...
user@s3:~$ sudo garage -c /etc/garage.toml bucket allow --read --write --owner k8s-volumes --key k8s-csi-s3
==== KEYS FOR THIS BUCKET ====
Permissions  Access key                              Local aliases
RWO          GK51446d5c5b4635172249f367  k8s-csi-s3
user@s3:~$ for ip in 192.168.10.17 192.168.10.20 192.168.10.21; do
>   sudo ufw allow proto tcp from $ip to any port 3900 comment 'S3: k8s nodes'
> done
Rule added
Rule added
Rule added

Секретный ключ печатается один раз при создании (и потом по garage key info --show-secret); в текст он не попадает. Регион входит в подпись SigV4, и Garage принимает только запросы, адресованные региону из s3_region (на остальные отвечает AuthorizationHeaderMalformed с подсказкой правильного региона), поэтому в Secret драйвера region: garage.

Драйвер k8s-csi-s3 v0.43.7 — три манифеста выпуска (контроллер-провизионер, объект CSIDriver, DaemonSet node-плагина), всё в kube-system:

$ U=https://raw.githubusercontent.com/yandex-cloud/k8s-csi-s3/v0.43.7/deploy/kubernetes
$ kubectl apply -f $U/provisioner.yaml -f $U/driver.yaml -f $U/csi-s3.yaml
serviceaccount/csi-s3-provisioner-sa created
clusterrole.rbac.authorization.k8s.io/csi-s3-external-provisioner-runner created
clusterrolebinding.rbac.authorization.k8s.io/csi-s3-provisioner-role created
service/csi-s3-provisioner created
statefulset.apps/csi-s3-provisioner created
csidriver.storage.k8s.io/ru.yandex.s3.csi created
serviceaccount/csi-s3 created
clusterrole.rbac.authorization.k8s.io/csi-s3 created
clusterrolebinding.rbac.authorization.k8s.io/csi-s3 created
daemonset.apps/csi-s3 created
$ kubectl -n kube-system get pods -l 'app in (csi-s3,csi-s3-provisioner)' -o wide
NAME                   READY   STATUS    RESTARTS      AGE   IP              NODE         NOMINATED NODE   READINESS GATES
csi-s3-nntbp           2/2     Running   0             97s   172.20.172.20   k8s-node-2   <none>           <none>
csi-s3-provisioner-0   2/2     Running   1 (23s ago)   99s   172.20.172.15   k8s-node-2   <none>           <none>
csi-s3-v7j98           2/2     Running   0             97s   172.20.211.7    k8s-node-1   <none>           <none>
$ kubectl get csidriver ru.yandex.s3.csi
NAME               ATTACHREQUIRED   PODINFOONMOUNT   STORAGECAPACITY   TOKENREQUESTS   REQUIRESREPUBLISH   MODES        AGE
ru.yandex.s3.csi   false            true             false             <unset>         false               Persistent   61s

Образы драйвера и sidecar'ов (provisioner v6.2.0, registrar v2.16.0) лежат в реестре cr.yandex. node-плагин встал только на воркеры — toleration для taint мастера в манифесте нет, и подам с томами S3 на мастер дороги нет. Перезапуск csi-provisioner при первом старте — тот же, что у csi-driver-nfs: sidecar стартовал раньше, чем драйвер открыл сокет. Поля fsGroupPolicy в манифесте нет, API подставляет ReadWriteOnceWithFSType, а при ней kubelet применяет fsGroup только к томам RWO с указанным fsType — том S3 fsGroup пода не меняет, владельца задают опции --uid/--gid.

Secret создаём, подставляя ключ из файла, чтобы он не попал ни в вывод, ни в историю оболочки ($(…) заодно отрезает перевод строки в конце файла — с ним подпись не сойдётся, поэтому --from-file здесь не годится), затем класс из начала части и PVC с Deployment на двух узлах:

$ kubectl create namespace storage-credentials
namespace/storage-credentials created
$ kubectl -n storage-credentials create secret generic csi-s3-secret \
    --from-literal=accessKeyID=GK51446d5c5b4635172249f367 \
    --from-literal=secretAccessKey="$(cat ./garage-k8s-csi-s3.secret)" \
    --from-literal=endpoint=http://s3.example.com:3900 \
    --from-literal=region=garage
secret/csi-s3-secret created
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: media
  namespace: demo
spec:
  storageClassName: s3
  accessModes: ["ReadWriteMany"]
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: media
  namespace: demo
  labels:
    app.kubernetes.io/name: media
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: media
  template:
    metadata:
      labels:
        app.kubernetes.io/name: media
    spec:
      securityContext:
        runAsUser: 10001
        runAsGroup: 10001
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: media
      containers:
        - name: app
          image: busybox:1.37
          command: ["sleep", "infinity"]
          volumeMounts:
            - name: media
              mountPath: /media
      volumes:
        - name: media
          persistentVolumeClaim:
            claimName: media
$ kubectl -n demo get pvc media
NAME    STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
media   Bound    pvc-f2b79471-30fc-4c02-9008-49899e70f87e   1Gi        RWX            s3             <unset>                 26s
$ kubectl -n demo get pods -l app.kubernetes.io/name=media -o wide
NAME                     READY   STATUS    RESTARTS   AGE   IP              NODE         NOMINATED NODE   READINESS GATES
media-7f79bbf67d-4jr2n   1/1     Running   0          25s   172.20.172.18   k8s-node-2   <none>           <none>
media-7f79bbf67d-cghcr   1/1     Running   0          25s   172.20.211.8    k8s-node-1   <none>           <none>
$ kubectl get pv pvc-f2b79471-30fc-4c02-9008-49899e70f87e -o yaml
...
  csi:
...
    driver: ru.yandex.s3.csi
...
    volumeAttributes:
      bucket: k8s-volumes
      capacity: "1073741824"
      mounter: geesefs
      options: --memory-limit 1000 --dir-mode 0770 --file-mode 0660 --uid 10001 --gid
        10001
      storage.kubernetes.io/csiProvisionerIdentity: 1793367013849-1750-ru.yandex.s3.csi
    volumeHandle: k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e
  persistentVolumeReclaimPolicy: Delete

volumeHandle — бакет/префикс. На узле — два монтирования FUSE (staging и каталог тома пода), а за ними служба systemd:

user@k8s-node-1:~$ findmnt -t fuse.geesefs -o TARGET,SOURCE,FSTYPE,OPTIONS
TARGET                                                                                                                                   SOURCE      FSTYPE       OPTIONS
/var/lib/kubelet/plugins/kubernetes.io/csi/ru.yandex.s3.csi/a0978cd9ba03768c44659165dd0bc3f0a40595d4597aeb9a3c3484fdce698bbd/globalmount k8s-volumes fuse.geesefs rw,nosuid,nodev,relatime,user_id=65534,group_id=65534,default_permissions,allow_other
/var/lib/kubelet/pods/152f88ad-2f11-473d-b3ca-c0f0f0e87ab5/volumes/kubernetes.io~csi/pvc-f2b79471-30fc-4c02-9008-49899e70f87e/mount      k8s-volumes fuse.geesefs rw,nosuid,nodev,relatime,user_id=65534,group_id=65534,default_permissions,allow_other
user@k8s-node-1:~$ systemctl status 'geesefs-*' --no-pager -n 0
● geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service - GeeseFS mount for Kubernetes volume k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e
     Loaded: loaded (/run/systemd/transient/geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service; transient)
  Transient: yes
    Drop-In: /run/systemd/system/geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service.d
             └─50-StopProps.conf
     Active: active (running) since Fri 2026-10-30 13:30:52 UTC; 44s ago
 Invocation: abd7fcbeb574439e985b9aeaa847c6a9
   Main PID: 957629 (geesefs)
      Tasks: 7 (limit: 1699)
     Memory: 6.9M (peak: 7.4M)
        CPU: 17ms
     CGroup: /system.slice/geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service
             └─957629 /var/lib/kubelet/plugins/ru.yandex.s3.csi/geesefs -f -o allow_other --endpoint http://s3.example.com:3900 --region garage --setuid 65534 --setgid 65534 --memory-limit 1000 --dir-mode 0770 --file-mode 0660 --uid 10001 --gid 10001 k8s-volumes:pvc-f2b79471-30fc-4c02-9008-49899e70f87e /var/lib/kubelet/plugins/kubernetes.io/csi/ru.yandex.s3.csi/a0978cd9ba03768c44659165dd0bc3f0a40595d4597aeb9a3c3484fdce698bbd/globalmount

GeeseFS работает от nobody (--setuid 65534), каталог staging — тот же SHA-256 от volumeHandle, что в части 21. А вот что видит обычный пользователь узла без sudo (секретный ключ — 64 символа — я заменил):

user@k8s-node-1:~$ systemctl show 'geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service' -p Environment
Environment=AWS_ACCESS_KEY_ID=GK51446d5c5b4635172249f367 AWS_SECRET_ACCESS_KEY=<секретный ключ>

Теперь поведение. Под на k8s-node-1 создаёт файлы и каталоги:

$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'id; df -h /media; echo hello > /media/a.txt; mkdir -p /media/reports/2026; echo r > /media/reports/2026/10.txt; ls -la /media'
uid=10001 gid=10001 groups=10001
Filesystem                Size      Used Available Use% Mounted on
k8s-volumes               1.0P         0      1.0P   0% /media
total 9
drwxrwx---    2 10001    10001         4096 Oct 30 13:31 .
drwxr-xr-x    1 root     root          4096 Oct 30 13:30 ..
-rw-rw----    1 10001    10001            6 Oct 30 13:31 a.txt
drwxrwx---    2 10001    10001            0 Oct 30 13:31 reports
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'chmod 0600 /media/a.txt; echo chmod=$?; ls -l /media/a.txt; ln /media/a.txt /media/hard.txt; echo ln=$?; ln -s a.txt /media/sym.txt; echo symlink=$?'
chmod=0
-rw-rw----    1 10001    10001            6 Oct 30 13:31 /media/a.txt
ln: /media/hard.txt: Operation not supported
ln=1
symlink=0
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'mv /media/a.txt /media/b.txt; mv /media/reports /media/archive; ls -R /media'
/media:
archive
b.txt
sym.txt

/media/archive:
2026

/media/archive/2026:
10.txt

Петабайт в df, chmod «успешен», но права прежние, жёсткая ссылка не поддерживается. На соседнем узле символьная ссылка оказывается пустым файлом — на Garage GeeseFS её не восстанавливает:

$ kubectl -n demo exec media-7f79bbf67d-4jr2n -- ls -l /media
total 1
drwxrwx---    2 10001    10001            0 Oct 30 13:32 archive
-rw-rw----    1 10001    10001            6 Oct 30 13:32 b.txt
-rw-rw----    1 10001    10001            0 Oct 30 13:32 sym.txt

Согласованность: под на k8s-node-2 уже прочитал каталог, под на k8s-node-1 создаёт c.txt в 13:33:06, а второй узел опрашивает каталог раз в пять секунд:

13:33:06 archive b.txt sym.txt
13:33:11 archive b.txt sym.txt
...
13:34:01 archive b.txt sym.txt
13:34:06 archive b.txt c.txt sym.txt

Ровно минута — --stat-cache-ttl. Блокировки: под на k8s-node-1 берёт flock на 20 секунд, в это время пробуют под на другом узле и второй процесс на том же узле:

$ kubectl -n demo exec media-7f79bbf67d-cghcr -- flock -n /media/lock -c 'echo A got lock; sleep 20' &
A got lock
$ kubectl -n demo exec media-7f79bbf67d-4jr2n -- sh -c 'flock -n /media/lock -c "echo B got lock too"; echo rc=$?'
B got lock too
rc=0
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'flock -n /media/lock -c "echo A2 got lock"; echo rc=$?'
rc=1

На одном узле блокировку держит ядро, и второй процесс получает отказ; под на другом узле о ней не знает.

Перезапуск драйвера. Удаляем под node-плагина на k8s-node-1 — DaemonSet создаёт новый, а GeeseFS продолжает работать в своей службе:

$ kubectl -n kube-system delete pod csi-s3-v7j98
pod "csi-s3-v7j98" deleted from kube-system namespace
$ kubectl -n demo exec media-7f79bbf67d-cghcr -- sh -c 'echo after-restart > /media/d.txt; cat /media/d.txt'
after-restart
user@k8s-node-1:~$ ps -o pid,etime,cmd -C geesefs | cut -c1-80
    PID     ELAPSED CMD
 957629       04:37 /var/lib/kubelet/plugins/ru.yandex.s3.csi/geesefs -f -o allo

Тот же опыт с классом, где в options добавлен --no-systemd, и подом nosd на k8s-node-1:

$ kubectl -n kube-system delete pod csi-s3-nlqqr
pod "csi-s3-nlqqr" deleted from kube-system namespace
$ kubectl -n demo exec nosd -- ls /data
ls: /data: Transport endpoint is not connected
command terminated with exit code 1
$ kubectl -n demo exec nosd -- sh -c 'echo y > /data/y.txt'
sh: can't create /data/y.txt: Transport endpoint is not connected
command terminated with exit code 1
$ kubectl -n demo get pod nosd
NAME   READY   STATUS    RESTARTS   AGE
nosd   1/1     Running   0          38s

Процесс GeeseFS умер вместе с контейнером драйвера, том мёртв, а под остаётся Running — у него нет пробы, которая бы это заметила. Более того, после удаления такого пода kubelet пишет GetMountRefs found corrupted mount … treating as unmounted path, драйвер на NodeUnstageVolume мёртвое монтирование не снимает, и staging приходится размонтировать на узле руками (sudo umount …/globalmount). Поэтому --no-systemd — только если systemd на узлах нет.

Что лежит в бакете — запросом ListObjectsV2 с подписью SigV4 прямо из curl (ключ подставляется из файла, чтобы не попасть в историю и список процессов):

user@s3:~$ printf 'user = "GK51446d5c5b4635172249f367:%s"\n' "$(sudo cat /root/garage-k8s-csi-s3.secret)" \
    | curl -sS -K - --aws-sigv4 aws:amz:garage:s3 'http://127.0.0.1:3900/k8s-volumes?list-type=2' \
    | grep -oE '<Key>[^<]*</Key>|<Size>[^<]*</Size>' | paste - -
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/</Key>	<Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/archive/</Key>	<Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/archive/2026/</Key>	<Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/archive/2026/10.txt</Key>	<Size>2</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/b.txt</Key>	<Size>6</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/c.txt</Key>	<Size>4</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/d.txt</Key>	<Size>14</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/lock</Key>	<Size>0</Size>
<Key>pvc-f2b79471-30fc-4c02-9008-49899e70f87e/sym.txt</Key>	<Size>0</Size>

Каталоги — пустые объекты с / на конце, переименованный каталог reports стал набором новых ключей archive/…, символьная ссылка — пустой объект (её цель GeeseFS хранит в метаданных объекта, которые Garage в списке не возвращает). d.txt — запись после перезапуска драйвера, префикса тома nosd уже нет: его PVC удалён, и драйвер стёр префикс.

Остановка тома. При уменьшении Deployment до нуля драйвер на NodeUnstageVolume останавливает службу; GeeseFS от nobody сам размонтироваться не может, через 20 секунд (TimeoutStopSec из 50-StopProps.conf) systemd его убивает, а ExecStopPost выполняет umount -f -l:

user@k8s-node-2:~$ sudo journalctl -u 'geesefs-*' -o cat --since '-5min'
...
2026/10/30 13:38:30.580733 main.INFO Received terminated, attempting to unmount...
Stopping geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service - GeeseFS mount for Kubernetes volume k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e...
2026/10/30 13:38:50.617082 main.ERROR Failed to unmount in response to terminated: exit status 1: /usr/bin/fusermount3: failed to unmount /var/lib/kubelet/plugins/kubernetes.io/csi/ru.yandex.s3.csi/a0978cd9ba03768c44659165dd0bc3f0a40595d4597aeb9a3c3484fdce698bbd/globalmount: Permission denied
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: State 'stop-sigterm' timed out. Killing.
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: Killing process 329074 (geesefs) with signal SIGKILL.
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: Main process exited, code=killed, status=9/KILL
geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service: Failed with result 'timeout'.
Stopped geesefs-k8s_2dvolumes_2fpvc_2df2b79471_2d30fc_2d4c02_2d9008_2d49899e70f87e.service - GeeseFS mount for Kubernetes volume k8s-volumes/pvc-f2b79471-30fc-4c02-9008-49899e70f87e.

Монтирование снято, служба исчезла, но каталог с drop-in /run/systemd/system/geesefs-….service.d остаётся до перезагрузки узла. Здесь файлы были записаны задолго до остановки, но GeeseFS выгружает данные асинхронно, и SIGKILL через 20 секунд опасен для тома, где запись ещё не ушла в S3: приложение должно закрыть файлы (fsync, close) до остановки пода. Удаление PVC стирает префикс в бакете:

$ kubectl -n kube-system logs csi-s3-provisioner-0 -c csi-s3 | grep -E 'DeleteVolume|removed'
I1030 13:39:22.351104       1 driver.go:119] GRPC call: /csi.v1.Controller/DeleteVolume
I1030 13:39:22.359356       1 controllerserver.go:130] Prefix pvc-f2b79471-30fc-4c02-9008-49899e70f87e removed
user@s3:~$ sudo garage -c /etc/garage.toml bucket info k8s-volumes | sed -n '/Size/,/Objects/p'
Size:            0 B (0 B)
Objects:         0

Уборка

Драйверы удаляются теми же манифестами (kubectl delete -f), вместе с ними уходят их StorageClass, неймспейсы local-path-storage и storage-credentials; PV с Retain (k8s-node-1-disk1) удаляется вручную. На узлах после k8s-csi-s3 остаются каталог /var/lib/kubelet/plugins/ru.yandex.s3.csi с копией бинарника GeeseFS и drop-in'ы служб в /run/systemd/system, после local-path — пустой /opt/local-path-provisioner, образы драйверов и busybox:latest — всё это убирается руками (crictl rmi по точным именам). Loop-диск: umount, losetup -d, удаление файла-образа.


Источники


Дальше — Kustomize: как перестать копировать манифесты между средами, base и overlays, патчи, генераторы ConfigMap и Secret и kubectl apply -k.

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

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