Хранение изображений в Kubernetes: CSI-драйверы или S3-совместимые объектные бэкенды | AdminWiki

Хранение изображений в Kubernetes: CSI-драйверы или S3-совместимые объектные бэкенды

12 сентября 2026 15 мин. чтения
Содержание статьи

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
Режимы PVCRWO, 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 по задержке отдачи мелких файлов и требованиям к железу приведено в отдельном разборе систем хранения изображений.

Поделиться:
Сохранить гайд? В закладки браузера