Введение

В самом начале своей карьеры я имел честь в одно лицо разрабатывать проект, предназначенный для массового пользователя. Надо сказать, что почти все основополагающие принципы того, как провалить проект, были соблюдены, однако, он до сих пор жив. Проект был предназначен для принудительного использования определенной категорией работников бюджетной сферы. Технического задания, аналитики, дизайн-документов, макетов в Фигме, лавандового смузи, и прочих этих ваших модных слов, без которых N лет назад строили БАМ и Траннсиб не было от слова совсем. Зато, были процессы “в бумаге”, которые требовалось оцифровать. Поэтому то, что можно было принять за ТЗ, выглядело как “Эти (работники) заполняют вот это (бумаги) потом несут тем (проверяющим) а дальше все это хранится, сделай чтобы они с компьютера могли загрузить и отправить, у нас тут целый этаж бумагами занят, пожар начнется - всему хана”. Используя весь свой багаж знаний и опыта в построении высоконагруженных систем (на этом месте я отошел от написания статьи сначала проржаться а потом поплакать), я приступил к реализации.

Фатальный недостаток

Я видел как в некоторых “больших и взрослых” системах документооборота реализовано хранение файлов. По-умолчанию тела документов писались в базу данных, в специальную табличку. Также была возможность, используя специальный модуль “Файлового хранилища”, настроить трансфер файлов на диск, распределив документы по нескольким серверам. “Если в таких (серьезных) системах хранят файлы в БД, значит и тут прокатит” - подумалось мне, но масштаб трагедии был недооценен. Те люди, для которых предназначалась система, стали загружать вообще все что плохо лежит - видео, пухлые сканы не менее пухлых документов, архивы, и т.д. СУБД, в качестве которой выступал PostgreSQL, такое надругательство добросовестно выдерживала. База пухла, но держалась. Пока однажды не стало понятно, что загружать-то пользователи любят, а место на диске все же не резиновое. Да и автовакуум кто-то выключил, не иначе пранк это все. Автовакуум включили, запустили, часть места вернулась, хотя и было понятно что ненадолго. 

Собственно, если говорить про хранение файлов в базе данных, могу выделить только один плюс - все в одном месте. Делаем бэкап базы - получаем и бэкап файлов. Теряем базу - теряем все, беспокоиться больше не о чем.

В качестве достаточно очевидного минуса стоит выделить процесс скачивания файла. Чтобы файл из базы по запросу пользователя получить - нужно сделать SELECT, что приведет к вычитке файла в память вашего сервиса. Если размеры файлов и количество одновременных загрузок жестко не лимитировать, то пользователи имеют шанс призвать OOM-killer или OutOfMemoryError с падением контейнера в бездну.

Стандартным подходом является хранение файлов на файловой системе, однако в таком случае придется упражняться в rsync`ах и установке дополнительных дисков при исчерпании емкости хранилища.

Идеей на миллион долларов является хранение файлов в /dev/null. Места не надо, репликация не нужна. Есть, правда, проблема со скачиванием. Так что это решение на случай, если файлы никому и никогда не понадобятся.

Альтернатива

Хочу рассказать про один из вариантов решения проблемы хранения пользовательских файлов с помощью объектного хранилища (конкретно - с помощью MiniO).

MiniO является достаточно удобной альтернативой перечисленным выше способам хранения пользовательских файлов, так как многие вопросы решены из коробки:

  1. Резервное копирование

  2. Репликация

  3. Балансировка нагрузки

  4. API

  5. Некоторые фреймворки (например Laravel) из коробки умеют работать с объектным хранилищем.

Однако не все подряд фреймворки умеют работать с MiniO, поэтому, запустим MiniO и сделаем простой сервис для загрузки и скачивания по временной ссылке ранее сохраненных файлов.

Развертывание MiniO

Развертывание удобнее всего осуществить с помощью docker, в репозитории minio есть соответствующая документация с примерами.

Используем следующий скрипт для запуска:

docker run \
  -p 9000:9000 \
  -p 9001:9001 \
  --name minio1 \
  -e "MINIO_ROOT_USER=AKIAIOSFODNN7EXAMPLE" \
  -e "MINIO_ROOT_PASSWORD=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" \
  -v D:\data:/data \
  quay.io/minio/minio server /data --console-address ":9001"

После его выполнения в консоли увидим следующий вывод:

Formatting 1st pool, 1 set(s), 1 drives per set.
WARNING: Host local has more than 0 drives of set. A host failure will result in data becoming unavailable.
MinIO Object Storage Server
Copyright: 2015-2024 MinIO, Inc.
License: GNU AGPLv3 <https://www.gnu.org/licenses/agpl-3.0.html>
Version: RELEASE.2024-03-15T01-07-19Z (go1.21.8 linux/amd64)
API: http://172.17.0.4:9000  http://127.0.0.1:9000
WebUI: http://172.17.0.4:9001 http://127.0.0.1:9001
Docs: https://min.io/docs/minio/linux/index.html
Status:         1 Online, 0 Offline.
STARTUP WARNINGS:
- The standard parity is set to 0. This can lead to data loss.

Главным здесь является то, что теперь на порту 9001 нас ждет WebUI, а на порту 9000 - API для доступа к бакетам.

В WebUI можно зайти с использованием логина и пароля, указанных в команде запуска контейнера.

Нам потребуется создать бакет и ключи для доступа нашему приложению. Для этого переходим в Administration - Buckets и нажимаем Create bucket, задаем имя и сохраняем.

Создание бакетов
Создание бакетов

Далее - ключи для приложения в меню User - Access keys - Create access key.

Создание ключа
Создание ключа
Новый ключ
Новый ключ

Копируем себе Access key и Secret key, заполняем поля с именем и описанием (и датой окончания срока действия, если необходимо), нажимаем Create.

Отлично, теперь перейдем к демо-примеру приложения.

Приложение

Для реализации будем использовать Python (FastAPI). Реализуем следующую задумку:

  • Сервис будет поддерживать 4 метода:

    • Загрузка файла в хранилище

    • Получение списка файлов в хранилище

    • Скачивание файла по временной ссылке

    • Получение временной ссылки на файл

  • Для реализации временной ссылки используем JWT (может показаться что это не самое лучшее решение, однако оно позволяет не хранить никакого состояния, сам JWT-токен содержит всю информацию, альтернативно можно хранить информацию в Redis с установкой для записи TTL, по истечении которого ссылка будет удалена)

Сначала реализуем обертку для MiniO.

from typing import BinaryIO

import minio
from minio import Minio


class MinioHandler:
    def __init__(self, minio_endpoint: str, access_key: str, secret_key: str, bucket: str, secure: bool = False):
        self.client = Minio(
            minio_endpoint,
            access_key=access_key,
            secret_key=secret_key,
            secure=secure
        )
        self.bucket = bucket

    def upload_file(self, name: str, file: BinaryIO, length: int):
        return self.client.put_object(self.bucket, name, file, length=length)

    def list(self):
        objects = list(self.client.list_objects(self.bucket))
        return [{"name": i.object_name, "last_modified": i.last_modified} for i in objects]

    def stats(self, name: str) -> minio.api.Object:
        return self.client.stat_object(self.bucket, name)

    def download_file(self, name: str):
        info = self.client.stat_object(self.bucket, name)
        total_size = info.size
        offset = 0
        while True:
            response = self.client.get_object(self.bucket, name, offset=offset, length=2048)
            yield response.read()
            offset = offset + 2048
            if offset >= total_size:
                break

Моменты, которые нужно отметить особенно:

  • Метод upload_file принимает не байты, а BinaryIO, что дает возможность пробросить из UploadFile в FastAPI не байты, а поток. Это избавляет от дополнительных приседаний с вычиткой данных во временную память.

  • Метод download_file сначала получает информацию о файле (нас интересует его длина), и затем выдает блоками по 2048 байт. Т.к. мы получаем данные через HTTP-запрос, то производительность отдачи будет вызывать вопросы, но с другой стороны вычитать весь файл в память, и отдавать его пользователю - такое себе занятие.

Сервис на FastAPI выглядит так:

import datetime
import os
import jwt
from typing import Annotated

from dateutil.relativedelta import relativedelta

from fastapi import FastAPI, UploadFile, File, Form
from starlette.responses import StreamingResponse, JSONResponse

from minio_fastapi.minio_handler import MinioHandler
app = FastAPI()

minio_handler = MinioHandler(
    os.getenv('MINIO_URL'),
    os.getenv('MINIO_ACCESS_KEY'),
    os.getenv('MINIO_SECRET_KEY'),
    os.getenv('MINIO_BUCKET'),
    False
)


@app.post('/upload')
async def upload(file: Annotated[UploadFile, Form()]):
    minio_handler.upload_file(file.filename, file.file, file.size)
    return {
        "status": "uploaded",
        "name": file.filename
    }


@app.get('/list')
async def list_files():
    return minio_handler.list()


@app.get('/link/{file}')
async def link(file: str):
    obj = minio_handler.stats(file)
    payload = {
        "filename": obj.object_name,
        "valid_til": str(datetime.datetime.utcnow() + relativedelta(minutes=int(os.getenv('LINK_VALID_MINUTES', 10))))
    }
    encoded_jwt = jwt.encode(payload, os.getenv('JWT_SECRET'), algorithm="HS256")

    return {
        "link": f"/download/{encoded_jwt}"
    }


@app.get('/download/{temp_link}')
async def download(temp_link: str):
    try:
        decoded_jwt = jwt.decode(temp_link, os.getenv('JWT_SECRET'), algorithms=["HS256"])
    except:
        return JSONResponse({
            "status": "failed",
            "reason": "Link expired or invalid"
        }, status_code=400)

    valid_til = datetime.datetime.strptime(decoded_jwt['valid_til'], '%Y-%m-%d %H:%M:%S.%f')
    if valid_til > datetime.datetime.utcnow():
        filename = decoded_jwt['filename']
        return StreamingResponse(
            minio_handler.download_file(filename),
            media_type='application/octet-stream'
        )
    return JSONResponse({
        "status": "failed",
        "reason": "Link expired or invalid"
    }, status_code=400)
  • Метод /upload только загружает файл в хранилище

  • Метод /list отдает информацию о файлах и времени модификации

  • Метод /link по имени файла выдаст ссылку с JWT-токеном для скачивания

  • Метод /download по JWT-токену из ссылки достанет время и имя файла, если время истекло - никакого файла пользователь не получит

Резюме

В результате мы получили демо-пример сервиса, который более-менее аккуратно расходует ресурсы сервера при скачивании\загрузке файлов. Конечно, с балансировкой, репликацией, замером скоростей и резервным копированием статья была бы исчерпывающей для понимания всех подводных камней, но... не в этот раз.

Ссылки

Про практические инструменты разработки мы с коллегами рассказываем в рамках онлайн-курсов. Заглядывайте в каталог и выбирайте подходящее направление.

Комментарии (2)


  1. AlexSpaizNet
    01.04.2024 18:01
    +1

    Самой большой проблемой загрузки файлов была всегда связка browser-server.

    Я помню времена когда загрузчики писались на флешь, что бы можно было много файлов загружать, делать resume.

    Конечно сегодня дела обстоят лучше. Но вот у меня стоит задача сделать загрузку до 3k файлов с браузера. Все еще не решил, идти в сторону обычного загрузчика с браузера, или все таки упороться в маутинг хранилища как диска в операционке. Но кажется задача не простая.


  1. vorozhbit
    01.04.2024 18:01
    +1

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