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в поде показывает петабайт, аcapacityPVC — просто число в объекте.
Когда уместно. Большие файлы, которые пишут один раз и потом читают: медиа, архивы, выгрузки, наборы данных для обработки; передача файлов между системами, одна из которых работает с 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, удаление файла-образа.
Источники
- Kubernetes: Volumes — local и Persistent Volumes — том
local, обязательнаяnodeAffinity, режимы доступа,RetainиReleased. - Kubernetes: Storage Classes — Local и Storage Capacity —
kubernetes.io/no-provisioner,WaitForFirstConsumer, учёт свободного места планировщиком. - Local Path Provisioner (выпуск v0.0.37) — установка,
nodePathMap,sharedFileSystemPath,volumeType, вспомогательный под, игнорирование ёмкости. - sig-storage-local-static-provisioner — автоматическое создание PV
localдля дисков узла. - TopoLVM — CSI-драйвер на LVM, учёт ёмкости, снапшоты тонких томов.
- Amazon S3 API Reference: ListObjectsV2 и Signature Version 4 — перечисление по префиксу и подпись запросов; curl: --aws-sigv4 — подпись из командной строки.
- Linux kernel: FUSE — файловая система в пространстве пользователя.
- k8s-csi-s3 (выпуск v0.43.7) и GeeseFS — установка, бакет и префиксы, запуск GeeseFS через systemd и
--no-systemd, таблица совместимости с POSIX и список отступлений. - Mountpoint for Amazon S3 CSI Driver и Mountpoint file system behavior — только статическое выделение, последовательная запись,
renameтолько в S3 Express One Zone. - Garage: Quick Start и Configuration file format — установка, раскладка кластера, ключи и бакеты,
s3_region; репозиторий MinIO — статус проекта. - Longhorn: Architecture and Concepts, Installation Requirements и Best Practices — engine и реплики, iSCSI и NVMe/TCP, минимальные требования.
- Rook: Prerequisites и Ceph: Architecture — устройства для OSD, модуль
rbd, CRUSH, мониторы. - OpenEBS Documentation — локальные и реплицируемые тома, NVMe over TCP.
- CloudNativePG: Storage и Backup — локальное хранилище и shared-nothing, копии в S3 через Barman Cloud Plugin; RabbitMQ Cluster Operator и Quorum Queues.
- Prometheus: Storage и SQLite: How To Corrupt An SQLite Database File — почему базы данных не кладут на сетевые и не-POSIX файловые системы.
Дальше — Kustomize: как перестать копировать манифесты между средами, base и overlays, патчи, генераторы ConfigMap и Secret и kubectl apply -k.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.