Зачем и как хранить объекты на примере 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. У всех инструментов свои плюсы и минусы. 

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/586024/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *