Наша биг дата проанализировала Telegram-чаты, форумы и разговоры в кулуарах IT-мероприятий и пометила объектные хранилища как инструмент, который ещё не все осмеливаются использовать в своих проектах. Хочу поделиться с вами своим опытом в формате статьи-воркшопа. Если вы пока не знакомы с этой технологией и паттернами её применения, надеюсь, эта статья поможет вам начать использовать её в своих проектах. 

Зачем вообще говорить о хранении объектов?

С недавних пор я работаю Golang-разработчиком в Ozon. У нас в компании есть крутая команда админов и релиз-инженеров, которая построила инфраструктуру и CI вокруг неё. Благодаря этому я даже не задумываюсь о том, какие инструменты использовать для хранения файлов и как это всё поддерживать. 

Но до прихода в Ozon я сталкивался с довольно интересными кейсами, когда хранение разных данных (документов, изображений) было организовано не самым изящным образом. Мне попадались SFTP, Google Drive и даже монтирование PVC в контейнер! 

Использование всех этих решений сопряжено с проблемами, в основном связанными с масштабированием. Это и привело меня к знакомству с объектными хранилищами, ведь с их помощью можно красиво и удобно решать целый ряд задач. 

TL;DR

Объектное хранилище – это дополнительный слой абстракции над файловой системой и хостом, который позволяет работать с файлами (получать доступ, хранить) через API.

Объектное хранилище может помочь вам в кейсах, когда необходимо хранить файлы пользователей в ваших приложениях, складывать статику и предоставлять доступ к ней через Ingress или хранить кеши вашего CI

Все материалы к статье (исходники, конфиги, скрипты) лежат вот в этой репе

Что такое объектное хранилище

Хранить данные нашего приложения можно различными способами, от хранения данных просто на диске до блоба в нашей БД (если она это поддерживает, конечно). Но будет такое решение оптимальным? Часто есть нефункциональные требования, которые нам хотелось бы реализовать: масштабируемость, простота поддержки, гибкость. Тут уже хранением файлов в БД или на диске не обойтись. В этих случаях, например, масштабирование программных систем, в которых хранение данных построено на работе с файловой системой хоста, оказывается довольно проблематичной историей.

И на помощь приходят те самые объектные хранилища, о которых сегодня и пойдёт речь. Объектное хранилище – это способ хранить данные и гибко получать к ним доступ как к объектам (файлам). В данном контексте объект – это файл и набор метаданных о нём. 

Стоит ещё упомянуть, что в объектных хранилищах нет такого понятия, как структура каталогов. Все объекты находятся в одном «каталоге» – bucket. Структурирование данных предлагается делать на уровне приложения. Но никто не мешает назвать объект, например, так: objectScope/firstObject.dat .

Основное преимущество хранения данных в объектах – это возможность абстрагирования системы от технических деталей. Нас уже не интересует, какая файловая (или тем более операционная) система хранит наши данные. Мы не привязываемся к данным какими-то конкретными способами их представления, которые нам обеспечивает платформа. 

В этой статье мы не будем сравнивать типы объектных хранилищ, а обратим наше внимание на класс S3-совместимых стораджей, на примере MinIO. Выбор обусловлен тем, что MinIO имеет низкий порог входа (привет, Ceph), а ещё оно Kubernetes Native, что бы это ни значило

На мой взгляд, MinIO – это самый доступный способ начать использовать технологию объектного хранения данных прямо сейчас: его просто развернуть, легко управлять и его невозможно забыть. На протяжении долгого времени MinIO может удовлетворять таким требованиям, как доступность, масштабируемость и гибкость. 

Вообще S3-совместимых решений на рынке много. Всегда есть, из чего выбрать, будь то облачные сервисы или self-hosted-решения.  В общем случае мы всегда можем перенести наше приложение с одной платформы на другую (да, у некоторых провайдеров есть определённого рода vendor lock-in, но это уже детали конкретных реализаций).

Disclaimer: под S3 я буду иметь в виду технологию (S3-совместимые объектные хранилища), а не конкретный коммерческий продукт. Цель статьи – показать на примерах, как можно использовать такие решения в своих приложениях. 

Кейс 1: прокат самокатов

В рамках формата статьи-воркшопа знакомиться с S3 в общем и с MinIO в частности мы будем на практике. 

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

Давайте перейдём к кейсу. Представим, что мы пишем сервис для проката самокатов и у нас есть user story, когда клиент фотографирует самокат до и после аренды. Хранить медиаматериалы мы будем в объектном хранилище.

Для начала развернём наше хранилище.

Самый быстрый способ развернуть MinIO – это наш любимчик Docker, само собой.

С недавнего времени Docker – не такая уж и бесплатная штука, поэтому в репе на всякий случай есть альтернативные манифесты для Podman. 

Запускать «голый» контейнер из терминала – нынче моветон, поэтому начнём сразу с манифеста для docker-compose.

# docker-compose.yaml
version: '3.7'

services:
 minio:
   image: minio/minio:latest
   command: server --console-address ":9001" /data/
   ports:
     - "9000:9000"
     - "9001:9001"
   environment:
     MINIO_ROOT_USER: ozontech
     MINIO_ROOT_PASSWORD: minio123
   volumes:
     - minio-storage:/data
   healthcheck:
     test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
     interval: 30s
     timeout: 20s
     retries: 3
volumes:
 minio-storage:

Сохраняем манифест и делаем $ docker-compose up в директории с манифестом.

Теперь мы можем управлять нашим хранилищем с помощью web-ui. Но это не самый удобный способ для автоматизации процессов (например, для создания пайплайнов в CI/CD), поэтому сверху ещё поставим CLI-утилиту:

$ go get github.com/minio/mc

И да, не забываем про export PATH=$PATH:$(go env GOPATH)/bin.

Cоздадим алиас в mc (залогинимся):

$ mc alias set minio http://localhost:9000 ozontech minio123

Теперь создадим bucket – раздел, в котором мы будем хранить данные нашего пользователя (не стоит ассоциировать его с папкой). Это скорее раздел, внутри которого мы будем хранить данные.

Назовем наш бакет “usersPhotos”:

$ mc mb minio/usersPhot


$ mc ls minio > [0B] usersPhotos

Теперь можно приступать к реализации на бэке. Писать будем на Golang. MinIO любезно нам предоставляет пакетик для работы со своим API. 

Disclaimer: код ниже – лишь пример работы с объектным хранилищем; не стоит его рассматривать как набор best practices для использования в боевых проектах.

Начнём с подключения к хранилищу:

func (m *MinioProvider) Connect() error {
  var err error
  m.client, err = minio.New(m.url, &minio.Options{
     Creds:  credentials.NewStaticV4(m.user, m.password, ""),
     Secure: m.ssl,
  })
  if err != nil {
     log.Fatalln(err)
  }

  return err
}

Теперь опишем ручку добавления медиа:

func (s *Server) uploadPhoto(w http.ResponseWriter, r *http.Request) {
  // Убеждаемся, что к нам в ручку идут нужным методом
  if r.Method != "POST" {
     w.WriteHeader(http.StatusMethodNotAllowed)
     return
  }

  // Получаем ID сессии аренды, чтобы знать, в каком контексте это фото
  rentID, err := strconv.Atoi(r.Header.Get(HEADER_RENT_ID))
  if err != nil {
     logrus.Errorf("Can`t get rent id: %v\n", err)
     http.Error(w, "Wrong request!", http.StatusBadRequest)
     return
  }

  // Забираем фото из тела запроса
  src, hdr, err := r.FormFile("photo")
  if err != nil {
     http.Error(w, "Wrong request!", http.StatusBadRequest)
     return
  }

  // Получаем информацию о сессии аренды
  session, err := s.database.GetRentStatus(rentID)
  if err != nil {
     logrus.Errorf("Can`t get session: %v\n", err)
     http.Error(w, "Can`t upload photo!", http.StatusInternalServerError)
     return
  }

  // Складываем данные в объект, который является своего рода контрактом
  // между хранилищем изображений и нашей бизнес-логикой
  object := models.ImageUnit{
     Payload:     src,
     PayloadSize: hdr.Size,
     User:        session.User,
  }
  defer src.Close()

  // Отправляем фото в хранилище
  img, err := s.storage.UploadFile(r.Context(), object)
  if err != nil {
     logrus.Errorf("Fail update img in image strorage: %v\n", err)
     http.Error(w, "Can`t upload photo!", http.StatusInternalServerError)
     return
  }

  // Добавляем запись в БД с привязкой фото к сессии
  err = s.database.AddImageRecord(img, rentID)
  if err != nil {
     logrus.Errorf("Fail update img in database: %v\n", err)
     http.Error(w, "Can`t upload photo!", http.StatusInternalServerError)
  }
}

Загружаем фото:

func (m *MinioProvider) UploadFile(ctx context.Context, object models.ImageUnit) (string, error) {
  // Получаем «уникальное» имя объекта для загружаемого фото
  imageName := samokater.GenerateObjectName(object.User)

  _, err := m.client.PutObject(
     ctx,
     UserObjectsBucketName, // Константа с именем бакета
     imageName,
     object.Payload,
     object.PayloadSize,
     minio.PutObjectOptions{ContentType: "image/png"},
  )

  return imageName, err

Нам надо как-то разделять фото до и после, поэтому мы добавим записи в базу данных:

func (s *PGS) AddImageRecord(img string, rentID int) error {
  // Получаем информацию о сессии аренды
  rent, err := s.GetRentStatus(rentID)
  if err != nil {
     logrus.Errorf("Can`t get rent record in db: %v\n", err)
     return err
  }

  // В зависимости от того, были загружены фото до начала аренды
  // или после её завершения, добавляем запись в соответствующее поле в БД
  if rent.StartedAt.IsZero() {
     return s.updateImages(rent.ImagesBefore, img, update_images_before, rentID)
  }

  return s.updateImages(rent.ImagesAfter, img, update_images_after, rentID)
}

Ну и сам метод обновления записи в БД:

func (s *PGS) updateImages(old []string, new, req string, rentID int) error {
  // Добавляем в список старых записей
  // новую запись об изображении
  old = append(old, new)
  new = strings.Join(old, ",")

  _, err := s.db.Exec(req, new, rentID)
  if err != nil {
     logrus.Errorf("Can`t update image record in db: %v\n", err)
  }

  return err
}

Также мы могли бы напрямую через сервис вытаскивать и отдавать фото по запросу. Выглядело бы это примерно так: 

func (s *Server) downloadPhoto(w http.ResponseWriter, r *http.Request) {
  if r.Method != "GET" {
     w.WriteHeader(http.StatusMethodNotAllowed)
     return
  }

  rentID := r.URL.Query()["rid"][0]
  if rentID == "" {
     http.Error(w, "Can`t get rent-id from request", http.StatusBadRequest)
  }

  img, err := s.storage.DownloadFile(r.Context(), rentID)
  if err != nil {
     logrus.Errorf("Cant`t get image from image-storage: %v\n", err)
     http.Error(w, "Can`t get image", http.StatusBadRequest)
  }

  s.sendImage(w, img.Payload)
}

Ну и само получение файла из хранилища:

func (m *MinioProvider) DownloadFile(ctx context.Context, image string) (models.ImageUnit, error) {
  reader, err := m.client.GetObject(
     ctx,
     UserObjectsBucketName,
     image,
     minio.GetObjectOptions{},
  )
  if err != nil {
     logrus.Errorf("Cant`t get image from image-storage: %v\n", err)
  }
  defer reader.Close()

  return models.ImageUnit{}, nil
}

Но мы можем и просто проксировать запрос напрямую в MinIO, так как у нас нет причин этого не делать (на практике такими причинами могут быть требования безопасности или препроцессинг файлов перед передачей пользователю). Делать это можно, обернув всё в nginx:

server {
   listen 8080;
   underscores_in_headers on;
   proxy_pass_request_headers on;

   location / {
       proxy_pass http://docker-samokater;
   }

   location /samokater {
       proxy_pass http://docker-minio-api;
   }

}

server {
   listen 9090;

   location / {
       proxy_pass         http://docker-minio-console;
       proxy_redirect     off;
   }
}

Получать ссылки на изображения мы будем через ручку rent_info:

func (s *Server) rentInfo(w http.ResponseWriter, r *http.Request) {
  if r.Method != "GET" {
     w.WriteHeader(http.StatusMethodNotAllowed)
     return
  }

  rentID, err := strconv.Atoi(r.Header.Get(HEADER_RENT_ID))
  if err != nil {
     logrus.Errorf("Can`t get rent id: %v\n", err)
     http.Error(w, "Wrong request!", http.StatusBadRequest)
     return
  }

  session, err := s.database.GetRentStatus(rentID)
  if err != nil {
     logrus.Errorf("Can`t get session: %v\n", err)
     http.Error(w, "Can`t rent info!", http.StatusInternalServerError)
     return
  }

  // Обогащаем поля ссылками на изображения
  session = enrichImagesLinks(session)

  s.sendModel(w, session)
}

И сам метод обогащения:

func enrichImagesLinks(session models.Rent) models.Rent {
  for i, image := range session.ImagesBefore {
     session.ImagesBefore[i] = fmt.Sprintf("%s/%s", ImageStorageSVCDSN, image)
  }

  for i, image := range session.ImagesAfter {
     session.ImagesAfter[i] = fmt.Sprintf("%s/%s", ImageStorageSVCDSN, image)
  }

  return session
}

Упакуем всё в docker-compose.yaml:

docker-compose.yaml
version: '3.7'

services:
 minio:
   image: minio/minio:latest
   container_name: minio
   restart: unless-stopped
   command: server --console-address ":9001" /data/
   ports:
     - "9000:9000"
     - "9001:9001"
   environment:
     MINIO_ROOT_USER: ozontech
     MINIO_ROOT_PASSWORD: minio123
   volumes:
     - minio-storage:/data
   healthcheck:
     test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
     interval: 30s
     timeout: 20s
     retries: 3
   networks:
     - app-network

 samokater:
   image: samokater:latest
   container_name: samokater
   build:
     context: ./
     dockerfile: samokater.Dockerfile
   restart: unless-stopped
   ports:
     - 8080:8080
   networks:
     - app-network
   environment:
     SERVERPORT: :8080
     DBPATH: user=postgres password=devpass dbname=postgres host=db port=5432 sslmode=disable
     MINIOHOST: minio:9000
     MINIOUSER: ozontech
     MINIOPASS: minio123
   depends_on:
     - db
     - minio

 initDB:
   image: mingration:latest
   container_name: init
   environment:
     DBPATH: user=postgres password=devpass dbname=postgres host=db port=5432 sslmode=disable
   build:
     context: ./
     dockerfile: mingration.Dockerfile
   networks:
     - app-network
   depends_on:
     - db

 db:
   container_name: db
   image: postgres
   restart: always
   environment:
     POSTGRES_PASSWORD: devpass
   volumes:
     - pg-storage:/var/lib/postgresql/data
   ports:
     - 5432:5432
   networks:
     - app-network

 nginx:
   image: nginx-custom:latest
   build:
     context: ./
     dockerfile: nginx.Dockerfile
   restart: unless-stopped
   tty: true
   container_name: nginx
   volumes:
     - ./nginx.conf:/etc/nginx/nginx.conf
   ports:
     - 8000:80
     - 443:443
   networks:
     - app-network
   depends_on:
     - samokater

networks:
 app-network:
   driver: bridge

volumes:
 minio-storage:
 pg-storage:

Протестируем работу нашего приложения:

# Создаём сессию аренды
$ curl -i -X POST --header 'user_id:100' http://localhost:8080/api/v1/rent
HTTP/1.1 200 OK

{"ID":100,"Name":"","RentID":8674665223082153551}

# Добавляем пару фото до начала аренды
$ curl -i  -X POST --header 'rent_id:8674665223082153551' --form photo=@/Users/ktikhomirov/image_1.png  http://localhost:8080/api/v1/upload_photo --insecure
HTTP/1.1 200 OK

# Начинаем сессию аренды
$ curl -i -X POST  http://localhost:8080/api/v1/rent_start  -H  "Content-Type: application/json" -d '{"ID":100,"RentID":8674665223082153551}'
HTTP/1.1 200 OK

# Завершаем сессию аренды
$ curl -i -X POST  http://localhost:8080/api/v1/rent_stop  -H  "Content-Type: application/json" -d '{"ID":100,"RentID":8674665223082153551}'
HTTP/1.1 200 OK

# Добавляем фото после завершения аренды
$ curl -i  -X POST --header 'rent_id:8674665223082153551' --form photo=@/Users/ktikhomirov/image_2.png http://localhost:8080/api/v1/upload_photo --insecure

# Получаем информацию об аренде
curl -i -X GET -H "rent_id:8674665223082153551"  http://localhost:8080/api/v1/rent_info
HTTP/1.1 200 OK

{"ID":100,"Name":"","StartedAt":"2021-10-21T08:10:31.536028Z","CompletedAt":"2021-10-21T08:19:33.672493Z","ImagesBefore":["http://127.0.0.1:8080/samokater/100/2021-10-21T15:15:24.png","http://127.0.0.1:8080/samokater/100/2021-10-21T08:06:15.png"],"ImagesAfter":["http://127.0.0.1:8080/samokater/100/2021-10-21T08:21:06.png"],"RentID":8674665223082153551}
Изображение полученное при переходе по URL от ответа сервиса

Кейс 2: хранение и раздача фронта

Ещё одна довольно популярная задача, для решения которой можно использовать объектные хранилища, – хранение и раздача фронта. Объектные хранилища пригодятся нам тут, когда захотим повысить доступность нашего фронта или удобнее им управлять. Это актуально, например, если у нас несколько проектов и мы хотим упростить себе жизнь.

Небольшая предыстория. Однажды я встретил довольно интересную практику в компании, где в месяц релизили по несколько лендингов. В основном они были написаны на Vue.js, изредка прикручивался API на пару простеньких ручек. Но моё внимание больше привлекло то, как это всё деплоилось: там царствовали контейнеры с nginx, внутри которых лежала статика, а над всем этим стоял хостовый nginx, который выполнял роль маршрутизатора запросов. Как тебе такой cloud-native-подход, Илон? В качестве борьбы с этим монстром мной было предложено обмазаться кубами, статику держать внутри MinIO, создавая для каждого лендинга свой бакет, а с помощью Ingress уже всё это проксировать наружу. Но, как говорится, давайте не будем говорить о плохом, а лучше сделаем!

Представим, что перед нами стоит похожая задача и у нас уже есть Kubernetes. Давайте туда раскатаем MinIO Operator. Стоп, почему нельзя просто запустить MinIO в поде и пробросить туда порты? А потому, что MinIO-Operator любезно сделает это за нас, а заодно построит High Availability-хранилище. Для этого нам всего лишь надо три столовые ложки соды... воспользоваться официальной документацией.

Для простоты установки мы вооружимся смузи Krew, который всё сделает за нас:

$ kubectl krew update

$ kubectl krew install minio

$ kubectl minio init

Теперь надо создать tenant. Для этого перейдём в панель управления. Чтобы туда попасть, прокинем прокси:
$ kubectl minio proxy -n minio-operator

После прокидывания портов до нашего оператора мы получим в вывод терминала JWT-токен, с которым и залогинимся в нашей панели управления:

Интерфейс управления тенантами

Далее нажимаем на кнопку «Добавить тенант» и задаём ему имя и неймспейс:

Интерфейс настройки тенанта

После нажатия на кнопку «Создать» мы получим креденшиалы, которые стоит записать в какой-нибудь Vault:

Теперь для доступа к панели нашего кластера хранилищ, поднимем прокси к сервису minio-svc и его панели управления:

# Поднимаем прокси к дашборду minio-svc
kubectl -n minio-operator  port-forward service/minio-svc-console 9090:9090

# Поднимаем прокси к API minio-svc
kubectl -n minio-operator  port-forward service/minio-svc-hl 9000:9000

И вуаля! У нас есть высокодоступный отказоустойчивый кластер MinIO. Давайте прикрутим его к нашему GitLab CI и сделаем .gitlab_ci, чтобы в пару кликов деплоить фронт.

Вот так у нас будет выглядеть джоба для CI/CD на примере GitLab CI (целиком конфиг лежит в репе):

# gitlab-ci 
deploy-front:
 stage: deploy
 image: minio/mc
 script:
  # Логинимся в MinIO
  - mc config host add --insecure deploy $CI_OBJECT_STORAGE $CI_OBJECT_STORAGE_USER $CI_OBJECT_STORAGE_PASSWORD
  # И всё собранное ранее переносим в наш бакет
  - mc cp dist/* deploy/static --insecure -c -r
 dependencies:
   - build-front

Для того чтобы отдавать статику, добавим Ingress-манифест:

# static.yaml
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
 name: example-static
 labels:
   app.kubernetes.io/name: example-static
   app.kubernetes.io/version: "latest"
 annotations:
   cert-manager.io/cluster-issuer: letsencrypt-worker
   kubernetes.io/ingress.class: nginx
   kubernetes.io/tls-acme: "true"
   nginx.ingress.kubernetes.io/proxy-body-size: 100m
   nginx.ingress.kubernetes.io/secure-backends: "true"
   nginx.ingress.kubernetes.io/ssl-redirect: "true"
   nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
   nginx.ingress.kubernetes.io/rewrite-target: /$1
spec:
 tls:
   - hosts:
       - "domain.ru"
     secretName: ssl-letsencrypt-example
 rules:
   - host: "domain.ru"
     http:
       paths:
         - backend:
             serviceName: minio-svc
             servicePort: 9000
           path: /(.+)
           pathType: Prefix

А если вдруг потребуется доступ из других неймспейсов, то мы можем создать ресурс ExternalName:

---
apiVersion: v1
kind: Service
metadata:
 name: minio-svc
 namespace: deploy
spec:
 ports:
   - port: 9000
     protocol: TCP
     targetPort: 9000
 sessionAffinity: None
 type: ExternalName

Вместо вывода

Объектные хранилища – это класс инструментов, которые позволяют наделить систему высокодоступным хранилищем данных. Во времена cloud-native это незаменимый помощник в решении многих задач. Да, на практике могут случаться кейсы, в которых использование объектного хранения данных будет избыточным, но вряд ли это можно считать поводом совсем игнорировать этот инструментарий в других своих проектах.

Отдельно я бы посоветовал обратить внимание на S3-совместимые решения, если вы занимаетесь машинным обучением или BigData и у вас есть потребность в хранении большого количества медиаданных для их последующей обработки.

Рассмотренное в статье MinIO – это не единственный достойный инструмент, который позволяет работать с данной технологией. Существуют решения на основе Ceph и Riak CS и даже S3 от Amazon. У всех инструментов свои плюсы и минусы. 

Желаю вам успехов в создании и масштабировании ваших приложений и надеюсь, что объектные хранилища вам будут в этом помогать!

Делитесь в комментариях о вашем опыте работы с объектными хранилищами и задавайте вопроы!