Наша биг дата проанализировала 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}
Кейс 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. У всех инструментов свои плюсы и минусы.
Желаю вам успехов в создании и масштабировании ваших приложений и надеюсь, что объектные хранилища вам будут в этом помогать!
Делитесь в комментариях о вашем опыте работы с объектными хранилищами и задавайте вопроы!