CSI или S3: что выбрать для хранения изображений в Kubernetes
Две модели закрывают одну задачу по-разному. CSI-драйвер отдаёт поду блочное или сетевое файловое устройство: приложение видит каталог, пишет файлы системными вызовами, читает через mmap и sendfile. S3-совместимый бэкенд отдаёт бакет и HTTP API: объект кладётся через PUT и забирается через GET, а носитель скрыт за несколькими шлюзами и пулами.
Правило выбора: приложение работает с файлами как с файлами (Nginx раздаёт статику из каталога, legacy-сервис сохраняет превью на диск), значит нужен PersistentVolumeClaim на CSI. Приложение общается только по HTTP, а число изображений растёт, значит нужен S3. Для медиахранилища на миллионы объектов объектный бэкенд дешевле в эксплуатации: версии объектов, lifecycle-политики, presigned URL и раздача через CDN входят в стандартный набор API.
Ниже собраны критерии, по которым сравнивают оба подхода перед развёртыванием.
| Критерий | CSI (Longhorn, Rook-Ceph) | S3 (MinIO, Ceph RGW) |
|---|---|---|
| Модель доступа | Блочное (RBD) или файловое (CephFS, Longhorn) устройство, смонтированное в под | Объект и HTTP API, SDK или CLI |
| Режимы PVC | RWO, RWX для CephFS и NFS, ReadWriteOncePod | Не применяется, доступ идёт через бакет |
| Масштабирование | Размер тома, число реплик, число узлов и дисков | Горизонтальное: добавляются диски и шлюзы |
| Задержка мелких операций | Доли миллисекунды при локальных репликах | 5-30 мс на PUT и GET небольшого объекта |
| Версионирование | Снапшоты и клоны тома | Версии объектов, Object Lock, lifecycle-правила |
| Раздача контента | Только через само приложение | Напрямую через CDN и presigned URL |
| Сложность эксплуатации | Низкая у Longhorn, высокая у Ceph | Средняя у MinIO, высокая у Ceph RGW |
Когда CSI-драйверы (Longhorn, Rook-Ceph) оправданы
Longhorn разворачивается в самом кластере, реплицирует каждый том на три узла по умолчанию, умеет снапшоты, клоны и выгрузку бэкапов в S3 или NFS. Требует установленных open-iscsi и iscsid на всех узлах, а для репликации нужно минимум три worker-ноды. Это самый быстрый способ получить PersistentVolume в небольшом кластере: одна установка Helm, StorageClass с тремя репликами и всё.
Rook-Ceph выигрывает там, где нужны высокая производительность и разные типы доступа: RBD для блочных томов с низкой задержкой, CephFS для режима RWX, когда один каталог монтируют несколько подов. Production-инсталляция требует минимум трёх узлов и трёх OSD, репликация пулов обычно ставится в size=3, а каждому OSD нужно от 2 ГБ памяти, на практике чаще 4 ГБ.
Классический сценарий для CSI: сервис загрузки сохраняет файл в каталог /var/www/uploads, Nginx раздаёт его оттуда. Перевод такого сервиса на S3 требует переписать код загрузки и раздачи, а PersistentVolumeClaim на базе Longhorn подключается без изменений в приложении.
Главная ловушка CSI связана с режимами доступа. Том в режиме ReadWriteOnce монтируется только на одном узле, поэтому при трёх репликах Deployment на разных нодах второй и третий поды зависнут в статусе ContainerCreating. Варианты решения: перейти на RWX через CephFS, использовать StatefulSet с volumeClaimTemplates (тогда у каждого пода свой том, но общий каталог изображений не собирается), либо вынести файлы в объектное хранилище. Горизонтальное масштабирование приложения на PVC с RWO не работает без внешней файловой системы.
Когда S3-совместимое объектное хранилище выигрывает
Объектный бэкенд даёт приложению один endpoint и набор ключей, а вопрос «на каком узле лежит файл» исчезает. Бакет доступен любому поду в любом namespace, масштабирование приложения сводится к увеличению числа реплик. Для изображений это критично: микросервис, генерирующий превью, и сервис модерации читают одни и те же объекты, не деля один том.
MinIO закрывает потребности среднего кластера: одиночный бинарник, S3 API, режим распределённого кластера с erasure coding, IAM-пользователи, bucket policies, presigned URL, Object Lock и lifecycle. Ceph RGW выбирают, когда Ceph уже развёрнут для блочных томов: шлюз ставится поверх RADOS, поддерживает S3 и Swift, умеет multi-site репликацию между площадками.
Presigned URL стоит отдельного упоминания. Браузер получает временную ссылку и загружает изображение прямо в бакет, минуя сервис приложения. Это снимает нагрузку с подов и убирает двойной трафик через кластер. Для отдачи готовых изображений бакет подключают к CDN, и запросы пользователей вообще не доходят до Kubernetes.
Ограничение тоже есть: приложение должно уметь работать с S3 API. Для Go, Python, Java, Node.js есть официальные SDK, для legacy-кода на C или старого PHP придётся добавлять прослойку. Если сервис безнадёжно умеет только писать в файл, поможет CSI-драйвер вроде JuiceFS или csi-s3, который монтирует бакет как файловую систему. Подробное сравнение этого варианта с прямым S3 API разобрано в материале о подключении объектного хранилища к Kubernetes.
Развёртывание MinIO в Kubernetes: пошаговая инструкция
Для production берут распределённый режим: несколько подов с независимыми дисками и erasure coding. Минимальная рабочая конфигурация - четыре пода с четырьмя дисками, схема кодирования по умолчанию выдерживает потерю одного диска без потери данных. Одиночный под с одним PVC годится только для тестов.
Шаг первый: namespace и Secret с учётными данными. Поле stringData принимает открытый текст и кодирует его в base64 автоматически, что удобнее для чтения манифеста.
apiVersion: v1 kind: Namespace metadata: name: media --- apiVersion: v1 kind: Secret metadata: name: minio-root namespace: media type: Opaque stringData: root-user: minio-admin root-password: ChangeMe-Long-Random-Value
Шаг второй: headless Service и StatefulSet. Каждый под получает свой PVC через volumeClaimTemplates, поэтому тома не пересекаются, а адреса подов предсказуемы.
apiVersion: v1
kind: Service
metadata:
name: minio-headless
namespace: media
spec:
clusterIP: None
selector:
app: minio
ports:
- name: api
port: 9000
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: minio
namespace: media
spec:
serviceName: minio-headless
replicas: 4
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
containers:
- name: minio
image: minio/minio:latest
args:
- server
- http://minio-{0...3}.minio-headless.media.svc.cluster.local/data
- --console-address
- ":9001"
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: minio-root
key: root-user
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: minio-root
key: root-password
ports:
- containerPort: 9000
- containerPort: 9001
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2
memory: 4Gi
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 100Gi
Шаг третий: обычный ClusterIP Service для приложений и, при необходимости, Ingress для консоли. Обращаться к MinIO изнутри кластера нужно по внутреннему адресу http://minio.media.svc.cluster.local:9000, внешний домен здесь только добавляет задержку и точку отказа.
Тег latest в примере заменяют на конкретный релиз и фиксируют его в Git. Обновление MinIO между мажорными релизами иногда требует перезапуска всех подов одновременно, и на фиксированной версии это планируется заранее. Если поднимать и обслуживать пул самостоятельно не хочется, управляемый Kubernetes с объектным хранилищем доступен в Timeweb Cloud: там кластер, тома и S3-хранилище выдаются как сервис, а не как набор манифестов для круглосуточной поддержки.
Создание Secret и настройка RBAC для MinIO
Разделяйте два уровня прав. RBAC в Kubernetes управляет доступом к объектам API: чтению Secret с ключами, списку подов, работе с ConfigMap. Права на бакеты выдаёт IAM-политика самого MinIO через mc admin policy или встроенные политики readwrite и readonly.
ServiceAccount и Role нужны приложению, которое, к примеру, читает свой Secret или создаёт Job для обработки изображений.
apiVersion: v1 kind: ServiceAccount metadata: name: image-api namespace: media --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: image-api-secrets namespace: media rules: - apiGroups: [""] resources: ["secrets"] resourceNames: ["minio-app-keys"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: image-api-secrets namespace: media subjects: - kind: ServiceAccount name: image-api namespace: media roleRef: kind: Role name: image-api-secrets apiGroup: rbac.authorization.k8s.io
Поле resourceNames ограничивает Role одним конкретным секретом. Это проще и безопаснее, чем выдавать доступ ко всем секретам namespace. Учётные данные лучше держать в Sealed Secrets, External Secrets Operator или Vault, а стандартный Secret использовать как промежуточное звено. Секреты в base64 в открытом репозитории равны утечке ключей.
Подключение приложения к MinIO через S3 API
Приложение получает ключи из Secret через переменные окружения, поэтому в манифесте не остается ни одного пароля. Endpoint указывает на внутренний Service, регион можно указать любой, MinIO его игнорирует.
apiVersion: apps/v1
kind: Deployment
metadata:
name: image-api
namespace: media
spec:
replicas: 3
selector:
matchLabels:
app: image-api
template:
metadata:
labels:
app: image-api
role: image-api
spec:
serviceAccountName: image-api
containers:
- name: api
image: registry.example.com/image-api:1.4.0
env:
- name: S3_ENDPOINT
value: "http://minio.media.svc.cluster.local:9000"
- name: S3_BUCKET
value: "images"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: minio-app-keys
key: access-key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: minio-app-keys
key: secret-key
Бакет и пользователя создают через клиент mc до запуска приложения:
mc alias set media http://minio.media.svc.cluster.local:9000 minio-admin ChangeMe-Long-Random-Value mc mb media/images --with-lock mc admin user add media image-api-access image-api-secret-value mc admin policy attach media readwrite --user image-api-access mc anonymous set none media/images
Политику readwrite заменяют на кастомную с ограничением по префиксу, если приложению нужен только один каталог. Ограничение по IP или времени действия выдают через условия bucket policy. Расширенный сценарий с распределённым режимом, CDN и метаданными изображений разобран в отдельном руководстве по настройке S3-совместимого хранилища изображений на MinIO.
Ceph RGW как S3-бэкенд для изображений в Kubernetes
RADOS Gateway это шлюз объектного доступа поверх Ceph: он принимает HTTP-запросы и раскладывает объекты по RADOS-пулам. В кластере с Rook-Ceph шлюзом управляет оператор, вручную собирать radosgw не нужно.
Настройка CephObjectStore и получение учётных данных
Объектное хранилище описывается ресурсом CephObjectStore. Пул метаданных обычно реплицируется, пул данных можно собрать на erasure coding и получить большую ёмкость при меньшем расходе дисков.
apiVersion: ceph.rook.io/v1
kind: CephObjectStore
metadata:
name: media-store
namespace: rook-ceph
spec:
metadataPool:
replicated:
size: 3
dataPool:
erasureCoded:
dataChunks: 4
codingChunks: 2
preservePoolsOnDelete: true
gateway:
port: 80
instances: 2
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
healthCheck:
bucket:
interval: 60s
После создания CephObjectStore оператор поднимает два пода radosgw и создаёт служебный Secret. Пользователя для приложения заводят отдельным ресурсом:
apiVersion: ceph.rook.io/v1
kind: CephObjectStoreUser
metadata:
name: media-app
namespace: rook-ceph
spec:
store: media-store
displayName: media-app
capabilities:
user: "*"
bucket: "*"
Ключи достают из секрета, имя которого собрано из названия хранилища и пользователя:
kubectl -n rook-ceph get secret rook-ceph-object-user-media-store-media-app -o jsonpath='{.data.AccessKey}' | base64 -d
kubectl -n rook-ceph get secret rook-ceph-object-user-media-store-media-app -o jsonpath='{.data.SecretKey}' | base64 -d
Внутренний endpoint шлюза выглядит как http://rook-ceph-rgw-media-store.rook-ceph.svc:80. Обращаться к внешнему Ingress-адресу из подов не стоит: трафик уйдёт наружу кластера и вернётся обратно, что добавляет задержку и ломается при недоступности внешнего балансировщика.
Требования к ресурсам жёстче, чем у MinIO. Для production нужны минимум три узла с OSD и запас по дисковому пространству, потому что реплицированный пул метаданных занимает трёхкратный объём, а erasure coding 4+2 расходует 1,5 ёмкости на полезные данные. Мониторинг состояния идёт через ceph -s и дашборд Rook.
Интеграция Ceph RGW с приложениями: секреты, RBAC, сетевые политики
Ключи RGW переносят в namespace приложения своим Secret, чтобы приложение не имело прав читать секреты rook-ceph.
apiVersion: v1 kind: Secret metadata: name: rgw-app-keys namespace: media type: Opaque stringData: access-key: REPLACE_WITH_ACCESS_KEY secret-key: REPLACE_WITH_SECRET_KEY
Проверка доступности из пода занимает одну команду. Если RGW отвечает, клиент получает список бакетов, если нет, видно код ошибки и текст сообщения.
kubectl -n media run s3check --rm -it --image=amazon/aws-cli --command -- sh aws --endpoint-url http://rook-ceph-rgw-media-store.rook-ceph.svc s3 ls
NetworkPolicy должна разрешать исходящий трафик из namespace media в namespace rook-ceph на порт 80 или 443. Без этого правила pod зависнет на попытке соединения и выдаст timeout, который легко принять за проблему с ключами. Разбор типовой ошибки с внешним endpoint приведён в конце статьи.
Безопасность доступа к изображениям: secrets, RBAC и NetworkPolicy
Доступ к медиаданным защищают на трёх уровнях: учётные данные в Secret, права Kubernetes через RBAC и сетевой периметр через NetworkPolicy. Каждый уровень закрывает свой класс рисков, и пропуск любого из них оставляет брешь.
Шаблоны Secret и ServiceAccount для доступа к S3
Секрет с ключами подключают либо переменными окружения, либо projected volume. Второй способ удобнее: файл обновляется автоматически при изменении Secret, и приложению не нужно перезапускаться, если оно умеет перечитывать файл.
apiVersion: v1
kind: Pod
metadata:
name: image-worker
namespace: media
spec:
serviceAccountName: image-api
containers:
- name: worker
image: registry.example.com/image-worker:2.1.0
volumeMounts:
- name: s3-keys
mountPath: /etc/s3
readOnly: true
volumes:
- name: s3-keys
projected:
sources:
- secret:
name: minio-app-keys
items:
- key: access-key
path: access-key
- key: secret-key
path: secret-key
Ключи ротируют минимум раз в квартал, а при подозрении на утечку немедленно. В MinIO смена ключа делается командой mc admin user enable с новым паролем, старый ключ отключают. В Ceph новый CephObjectStoreUser создаётся за минуту, после чего старый удаляют.
Настройка NetworkPolicy для изоляции трафика к хранилищу
Правило по умолчанию: к MinIO или RGW обращаются только поды с нужной меткой, всё остальное отбрасывается. NetworkPolicy работает лишь при CNI с поддержкой политик, например Calico или Cilium, у стандартного flannel правила молча игнорируются.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: minio-ingress
namespace: media
spec:
podSelector:
matchLabels:
app: minio
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: media
podSelector:
matchLabels:
role: image-api
ports:
- protocol: TCP
port: 9000
Обратное правило ограничивает исходящий трафик пода: разрешают соединения только с DNS и с адресом хранилища. Такой egress закрывает попытки слить данные на сторонние адреса при компрометации контейнера. Для mTLS между сервисами поверх политик ставят service mesh. Общие принципы сегментации и настройки RBAC в кластере собраны в руководстве по проектированию промышленного Kubernetes-кластера.
Типичные ошибки этого уровня: правило с пустым podSelector, которое разрешает весь входящий трафик, отсутствие egress-политики, доступ к бакету по публичной ссылке из-за mc anonymous set download. Публичный доступ к изображениям иногда нужен, но тогда его выдают только на конкретный префикс с превью.
Типовые ошибки конфигурации и способы их устранения
Ошибки при работе с PersistentVolumeClaim и StorageClass
PVC в статусе Pending почти всегда означает одно из четырёх. StorageClass с нужным именем не существует или не помечен как default. Режим доступа не поддерживается драйвером, например RWX запрошен у Longhorn, который такие тома не отдаёт. Свободного места в пуле меньше запрошенного объёма. Провайдер не успел создать том.
Порядок диагностики: kubectl get storageclass, затем kubectl describe pvc в namespace приложения, затем логи CSI-контроллера через kubectl logs -n longhorn-system или kubectl logs -n rook-ceph. События в describe обычно прямо указывают причину, например failed to provision volume with StorageClass.
Для динамического выделения ставят volumeBindingMode: WaitForFirstConsumer. Без этого параметра том создаётся сразу, привязывается к случайной зоне, а под потом не может его смонтировать. Ещё одна частая проблема: расширение тома через allowVolumeExpansion работает не у всех драйверов, а у некоторых требует перезапуска пода, чтобы файловая система увидела новый размер.
Проблемы с доступом к S3: неверные ключи, endpoint, политики
Симптомы различаются по коду ответа, и это ускоряет поиск. 403 Forbidden указывает на ключи или политику. 404 NoSuchBucket означает, что бакет не создан или указан неверный регион и путь. Connection refused или timeout говорит о сети, DNS или NetworkPolicy, а не о правах.
Быстрая проверка из кластера:
kubectl -n media exec deploy/image-api -- sh -c "curl -sS -o /dev/null -w '%{http_code}' http://minio.media.svc.cluster.local:9000/minio/health/live"
mc admin trace media --verbose
aws --endpoint-url http://rook-ceph-rgw-media-store.rook-ceph.svc s3 ls s3://images/
Самая распространённая ошибка в RGW-инсталляциях: в переменной окружения приложения прописан внешний адрес вида https://s3.example.com вместо внутреннего Service. Внутри кластера такой запрос уходит через балансировщик и часто блокируется NetworkPolicy, хотя ключи верны.
Вторая по частоте причина отказа - политика пользователя. Пользователь с политикой readonly получает 403 на PUT, хотя GET работает. Третий случай: бакет создан, но с включённым Object Lock, и приложение пытается перезаписать версию объекта без корректного заголовка. Четвёртый: неправильно закодированный Secret, где в base64 попал символ перевода строки.
Сравнение производительности и масштабирования: CSI vs S3
Метрики и бенчмарки для хранилищ изображений
Для хранилища изображений важны четыре метрики: задержка на одну операцию, пропускная способность на последовательных операциях, IOPS на случайном доступе и время до первого байта при отдаче файла. Мелкие объекты по 50-200 КБ упираются в задержку, крупные архивы упираются в пропускную способность.
Тома CSI проверяют утилитой fio, запуская её внутри пода с подключённым PVC. Для S3 используют warp от MinIO, s3bench или собственный нагрузочный скрипт на SDK с несколькими потоками.
fio --name=randread --ioengine=libaio --rw=randread --bs=4k --numjobs=4 --size=1G --runtime=60 --group_reporting warp mixed --host=minio.media.svc.cluster.local:9000 --access-key=... --secret-key=... --bucket=images --duration=2m --obj.size=128KiB
Ориентировочные значения на NVMe и сети 10 Гбит/с: Longhorn с тремя репликами отдаёт 1-3 тыс. IOPS случайного чтения 4 КБ и 200-500 МБ/с на последовательном чтении. Ceph RBD на достаточном числе OSD показывает 10-50 тыс. IOPS на том. MinIO из четырёх нод со схемой 4+2 выдаёт 1-3 ГБ/с на последовательных операциях и 10-30 мс задержки на PUT объекта 128 КБ. Ceph RGW близок к MinIO и сильно зависит от числа шлюзов и OSD. Любые цифры проверяют на своём железе: результаты меняются от модели дисков, схемы сети и размера объекта.
Стратегии масштабирования для растущего объёма изображений
Тома CSI растут двумя способами: увеличением размера PVC при allowVolumeExpansion и добавлением узлов, если драйвер умеет перебалансировку. У Longhorn реплики распределяются по нодам, но один том остаётся привязанным к своему набору реплик, а верхний предел ёмкости задан пулом дисков кластера. Распределённые файловые системы поверх CSI решают часть проблем, но заметно усложняют эксплуатацию.
S3 масштабируется ровнее. В MinIO добавляют диски и ноды в пул, кластер расширяется без остановки отдачи. В Ceph увеличивают число OSD и шлюзов RGW. Старые изображения переносят в холодный класс хранения через lifecycle-правила, а горячие отдают через CDN. Для медиахранилища на десятки терабайт заранее считают capacity: репликация 3 копий съедает трёхкратный объём, erasure coding 4+2 снижает расход до 1,5 объёма плюс накладные расходы.
Отдельно планируют защиту данных. Снапшоты тома не заменяют резервную копию, а версионирование бакета не спасает от удаления всего бакета. Рабочая схема и команды для MinIO, Ceph RGW и ZFS собраны в материале о резервном копировании хранилища изображений.
Если после загрузки изображения попадают в конвейер обработки, например распознавание содержимого, генерация превью или модерация через внешние модели, доступ к ним удобно получать через AiTunnel: единый ключ к нескольким десяткам моделей и учёт расходов по проектам. Сам сервис обработки при этом читает объекты из бакета по внутреннему endpoint и не зависит от того, где физически лежат файлы.
Итог: как выбрать и внедрить хранилище для изображений
Для небольшого кластера и приложений, которые работают с файлами напрямую, хватит Longhorn: три реплики, снапшоты, минимальная настройка. Для баз данных и задач с режимом RWX берут Rook-Ceph с RBD и CephFS, если в наличии минимум три узла и запас по дискам.
Для медиасервисов с ростом числа объектов, раздачей через CDN и горизонтальным масштабированием приложений выбирают S3. MinIO разворачивается за один вечер, Ceph RGW имеет смысл, когда Ceph уже работает в кластере или нужна репликация между площадками.
Порядок действий при внедрении: подготовить Secret с ключами и внешнее хранилище секретов, создать ServiceAccount и Role с ограничением по resourceNames, настроить NetworkPolicy на входящий и исходящий трафик, зафиксировать версии образов в Git, проверить доступность через curl и нагрузочный тест, затем продублировать конфигурацию в staging и только после этого переносить в production. Сравнение MinIO, Ceph RGW, SeaweedFS и TrueNAS SCALE по задержке отдачи мелких файлов и требованиям к железу приведено в отдельном разборе систем хранения изображений.