Зачем и как хранить объекты на примере MinIO

Наша биг дата проанализировала 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 от ответа сервиса
Изображение полученное при переходе по 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. У всех инструментов свои плюсы и минусы. 

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

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

Читайте так же: