Прямой ответ
Файлы пользователей (аватары, отчёты, документы) не должны жить на диске приложения: стандарт — объектное хранилище с S3-API — бакет + ключ (путь-строка) → объект (байты + метаданные), доступ по HTTP; приложение работает через SDK (boto3), а не через файловую систему — и тогда хранилище масштабируется отдельно, живёт дольше серверов, а деплой не трогает данные. Локальная разработка — MinIO в docker-compose (S3-совместимый сервер одной командой — dev-стек): код один и для локалки, и для облачного S3 (меняются только endpoint/ключи — env-переменные). Три практики, которые отличают зрелую интеграцию: presigned URL для загрузок/скачиваний (клиент грузит напрямую в хранилище по временной подписанной ссылке — файл не идёт через ваш сервер и его память); приватные бакеты по умолчанию (публичный доступ — явное исключение; «файл доступен по угадываемой ссылке» — регулярная утечка); ключи-пути с схемой (users/{user_id}/avatars/{uuid}.jpg — не имена файлов пользователей: коллизии, ../, спецсимволы). И отдельная дисциплина — жизненный цикл: lifecycle-правила (временные файлы удаляются сами), версионирование бакета (защита от перезаписи/удаления — бэкап-логика дополняется, не заменяется), и CDN перед публичной выдачей статики.
Модель S3: бакеты, ключи, операции
Словарь: бакет — контейнер с глобально уникальным именем и настройками (доступ, версионирование, lifecycle); объект — данные + ключ (произвольная строка «пути» — иерархия папок эмулируется слешами, но физически её нет: «переименовать папку» = перечитать-перезаписать объекты); операции — put/get/delete/list (list по префиксу — «содержимое папки»), multipart upload для больших файлов (части грузятся параллельно, провал части не рвёт загрузку — boto3 upload_file делает это сам). Важные отличия от ФС, которые ловят новичков: нет атомарного append (объект перезаписывается целиком), нет rename (copy+delete), list на миллионе объектов — медленный и постраничный (хранилище не БД — инвентарь файлов держите в своей таблице с ключами, list — не для горячих путей), eventual consistency по list в старых системах (современный S3 — строгая согласованность чтения после записи). Метаданные и content-type задаются при загрузке (браузер откроет PDF, а не скачает — если ContentType выставлен правильно; «скачивается вместо открытия» — почти всегда про это).
MinIO в dev-стеке и boto3 в коде
Compose-сервис: minio/minio server /data --console-address ":9001", порты 9000 (API)/9001 (веб-консоль), креды через env; создание бакета — консолью или mc-клиентом. Код — boto3-клиент одним объектом на приложение (не на запрос): s3 = boto3.client('s3', endpoint_url=..., aws_access_key_id=..., aws_secret_access_key=...) — для MinIO/облака код идентичен (локалка задаётся env'ом — никакого «if dev» в логике); операции: put_object/upload_fileobj (загрузка), get_object (чтение потоком — не читать в память целиком большие объекты), delete_object, head_object (существование/метаданные без скачивания). Presigned URL — главная разгрузка сервера: generate_presigned_url('put_object', Params={'Bucket':..., 'Key':...}, ExpiresIn=600) — клиент PUT'ит файл напрямую в хранилище (ваш API только выдал ссылку с правами и сроком — ни байта файла через приложение; для браузера — CORS на бакете под ваш домен); для отдачи — presigned GET (приватный объект по временной ссылке — «скачать отчёт» без проксирования). Ошибки — по кодам (NoSuchKey, AccessDenied) — маппинг в HTTP-ответы приложения; таймауты/ретраи у boto3 настраиваются (Config(retries=..., connect_timeout=...)) — дефолты щедрые.
Прод: доступы, жизненный цикл, CDN
Права: IAM-политики минимальные — сервису только его бакет и нужные операции (s3:PutObject/GetObject на префикс, не s3:* на всё); ключи — из секрет-хранилища (Vault/SOPS), не в репозитории; публичный доступ выключен на уровне бакета (Block Public Access — даже ошибочная политика «public-read» не откроет бакет); раздача публичной статики — через CDN (CloudFront-подобный: кеш по миру + свой домен + защита origin). Lifecycle: uploads/tmp/ — удаление через 24 часа (незавершённые загрузки — отдельное правило abort multipart), логи — переход в холодный класс хранения через 30 дней, архивы — удаление по политике; без lifecycle бакет — бесконечная свалка (счета и list-тормоза). Версионирование на бакетах с важным (перезапись/удаление обратимы; цена — хранение версий — lifecycle на noncurrent-версии). Бэкап объектного хранилища: репликация на другой аккаунт/регион (кросс-репликация) или rclone sync на второй контур — «S3 сам надёжный» верно для железа, не для rm -rf вашим же скриптом (3-2-1 применим и здесь).
Практический сценарий: загрузка аватаров без граблей
Требование: аватар до 5 МБ, приватный по умолчанию, отдача по ссылке. Реализация: (1) API POST /avatars/presign — проверяет права и лимит пользователя, генерирует ключ users/{uid}/avatar/{uuid}.jpg (uuid — не имя файла клиента) и presigned PUT на 10 минут с ContentType в условии подписи (клиент не подменит тип); (2) клиент грузит напрямую в MinIO/S3 (CORS бакета настроен на домен фронта — CORS-механика); (3) клиент вызывает POST /avatars/commit — сервер проверяет объект (head_object: существует, размер ≤5 МБ, тип image/*) и записывает ключ в БД — файл считается загруженным только после commit (пресайн без commit — мусор, который чистит lifecycle tmp-правилом); (4) отдача — API возвращает presigned GET на 15 минут (приватность: прямой постоянной ссылки нет; кеш браузера живёт срок ссылки); (5) смена аватара — новый ключ + обновление записи (старый удаляется фоновой задачей — мгновенное удаление ломает открытые страницы с текущей ссылкой). Проверки: тесты с moto/локальным MinIO (testcontainers-подход); нагрузка — загрузка не нагружает сервер приложений вовсе (только два лёгких вызова API); инцидент-учение: клиент залил 40 МБ, обойдя фронт-валидацию — commit-проверка размера отклонила, объект убрал lifecycle.
FAQ
MinIO в проде — можно? Да (self-hosted S3 для своего контура/гибрида — его прямое назначение: кластер с erasure coding, версионирование); но для публичного продукта облачный S3 обычно дешевле по эксплуатации (репликация регионов, lifecycle, CDN-интеграция — из коробки); MinIO — когда данные должны остаться у себя.
Хранить ли сами файлы в Postgres (bytea)? Мелкие (<1 МБ) и редко читаемые — допустимо (транзакционность «запись+файл» бесплатна); типовой сценарий (картинки, документы, экспорт) — объектное хранилище: БД раздувается, бэкапы тяжелеют, отдача файлов грузит дорогую БД вместо дешёвого хранилища/CDN.
Как отдавать приватные файлы через CDN? Подписанные куки/ссылки CDN (CloudFront signed URLs — кеш работает, доступ контролируется) или origin shield с проверкой в приложении (CDN кеширует 403/200 по cookie); «просто presigned S3 через CDN» без настройки — кеш не работает (URL каждый раз новый) — частая ошибка.
Что делать с незавершёнными multipart-загрузками?
Они невидимы в list, но хранятся и тарифицируются: lifecycle-правило AbortIncompleteMultipartUpload (7 дней — стандарт) обязательно на каждом бакете; иначе «пропавшие гигабайты» находятся только специальными инструментами.
Источники: документация MinIO, boto3, AWS S3 User Guide — ссылки в шапке.