Пока веб-продукт существует только в виде прототипа, вопрос хранения файлов кажется почти элементарным.
Пользователь загружает PDF.
Backend получает его.
Файл сохраняется:
/uploads/document.pdfГотово.
Потом появляется второй пользователь.
Потом сотый.
В систему начинают загружать фотографии, договоры, архивы, чеки, аудиозаписи и документы.
Через некоторое время возникают уже совсем другие вопросы:
Что произойдёт с файлами при переносе приложения на другой сервер?
Попадут ли они в резервную копию?
Как работать с файлами, если backend запущен сразу на двух серверах?
Можно ли открыть чужой документ, если узнать его URL?
Что делать, если файл успешно загрузился в хранилище, а запись в базе создать не удалось?
Где хранить десятки или сотни гигабайт пользовательских данных?
И нужно ли вообще держать файлы на том же VPS, где работает приложение?
В этот момент становится понятно, что задача:
«куда сохранить файл?»
на самом деле является частью архитектуры продукта.
Обычно рассматривают три основных варианта:
1. База данных
2. Локальный диск VPS
3. Object Storage
с S3-совместимым APIКаждый из них может быть правильным.
И каждый способен стать серьёзной проблемой, если использовать его не в том продукте.
Разберём, чем эти подходы отличаются и какую архитектуру мы бы выбирали для CRM, SaaS, клиентского кабинета или другого сложного веб-сервиса.
Файл — это не только набор байтов
Представим, клиент загрузил:
contract.pdfУ файла есть содержимое.
Но приложению обычно нужно знать намного больше:
Кто загрузил?
К какому проекту относится?
Как называется?
Какой размер?
Какой MIME-type?
Кто может скачать?
Когда создан?
Удалён ли?
Есть ли новая версия?
Где физически хранится?Поэтому практически в любой зрелой системе полезно разделять:
Файл как бизнес-сущностьи:
Физическое содержимое файлаНапример, PostgreSQL может хранить:
files
id
owner_id
project_id
original_name
storage_key
content_type
size
status
created_atА непосредственно PDF находится уже в выбранном файловом хранилище.
Это разделение станет особенно важным, когда мы доберёмся до S3.
Вариант №1. Хранить файл прямо в базе данных
PostgreSQL позволяет хранить бинарные данные, например через bytea.
Условно:
files
id
name
contentгде content содержит сам PDF, JPG или архив.
На первый взгляд у такого подхода есть очень привлекательное свойство:
данные и файл находятся внутри одной базы.
Чем база данных удобна
Представим операцию:
создать документ
+
сохранить его содержимоеЕсли всё находится в PostgreSQL, можно использовать одну транзакцию.
BEGIN
INSERT metadata
INSERT binary data
COMMITЛибо произошло всё.
Либо ничего.
Не возникает ситуации:
файл записанно:
metadata отсутствуетили наоборот.
Это действительно сильное преимущество.
Backup тоже кажется проще
Если все данные находятся внутри PostgreSQL, резервная копия базы содержит:
пользователей;
проекты;
сообщения;
документы;
сами файлы.Не требуется отдельно согласовывать восстановление:
database backup
+
filesystem backupС точки зрения целостности это довольно удобно.
Когда хранение файлов в БД вполне нормально
Не каждый бинарный файл требует отдельного object storage.
Допустим, приложение хранит:
небольшие аватары;
маленькие подписи;
миниатюрные документы;
редкие бинарные объекты.И общий объём составляет:
100–500 МБДля некоторых проектов помещать такие данные в PostgreSQL может быть вполне практично.
Особенно если простота и транзакционность важнее стоимости хранения.
Проблемы начинаются с ростом объёма
Представим SaaS.
Есть:
10 000 пользователейКаждый загрузил в среднем:
50 МБПолучается:
≈ 500 ГБ файловТеперь основной PostgreSQL содержит не только бизнес-данные, но и сотни гигабайт бинарных объектов.
Это меняет практически всё.
Резервные копии становятся тяжелее
Раньше backup содержал:
2 ГБ relational dataТеперь:
500+ ГБДаже если большая часть файлов вообще никогда не меняется.
Каждый механизм:
backup;
restore;
replication;
migration;
disk managementстановится существенно тяжелее.
Восстановление базы начинает зависеть от файлов
Представим аварийную ситуацию.
Нужно восстановить:
пользователей;
проекты;
платежи;
настройки.Без файлов это могла бы быть база размером несколько гигабайт.
Но если внутри лежит архив пользовательских загрузок на сотни гигабайт, время восстановления резко увеличивается.
Получается странная связь:
Чтобы восстановить таблицу пользователей, нужно восстановить ещё и весь архив фотографий.
Репликация тоже начинает переносить файлы
Если PostgreSQL реплицируется, бинарные данные становятся частью той же системы.
Добавили большой файл:
500 МБэта операция влияет на storage и репликацию БД.
То есть база превращается одновременно в:
transactional databaseи:
file storageХотя требования к этим двум типам данных обычно разные.
Стоимость быстрого диска базы выше
PostgreSQL полезен быстрый storage с хорошими характеристиками I/O.
Но архив пользовательских PDF может месяцами не читаться.
Хранить такие холодные данные на том же дорогом диске, что и активно работающие таблицы, часто экономически невыгодно.
База данных лучше хранит структуру, чем огромный архив файлов
Для многих веб-продуктов мы бы рассматривали PostgreSQL как источник истины для:
кто владеет файлом;
где он находится;
какие у него права;
какой статус;
какой размер;
к какой сущности относится.Но не обязательно для:
самих гигабайтов бинарных данных.Вариант №2. Локальный диск VPS
Самый понятный подход выглядит так:
/opt/app/uploads/или:
/var/lib/app/uploads/Backend получает файл и выполняет:
write fileПосле этого PostgreSQL сохраняет:
storage_pathНапример:
uploads/
2026/
09/
f8a7...У локального диска есть серьёзные преимущества
Во-первых, простота.
Не нужен отдельный storage-сервис.
Не нужен S3 API.
Не нужны дополнительные credentials.
Архитектура:
Browser
↓
Backend
↓
VPS diskочень понятна.
Локальный диск быстрый
Если backend и файл находятся на одном сервере, чтение происходит локально.
Нет дополнительного сетевого запроса к object storage.
Для небольшого продукта это может работать очень хорошо.
И зачастую это самый дешёвый вариант на старте
VPS уже имеет:
40–100 ГБ SSD/NVMeЕсли приложение занимает несколько гигабайт, а пользователи загружают:
200 МБ файлов в месяцпокупать отдельную storage-инфраструктуру только ради архитектурной чистоты необязательно.
Простое решение может быть правильным решением.
Проблема №1: приложение начинает зависеть от конкретного сервера
Пока существует:
VPS #1всё прекрасно.
Но затем нужен новый сервер.
Разработчик переносит:
код;
PostgreSQL;
Redis;
Nginx.И внезапно выясняется:
А ещё есть 37 ГБ пользовательских файлов в /uploads.Теперь их тоже нужно переносить.
Deploy становится опаснее
Представим структуру:
/app
/src
/public
/uploadsЕсли deployment устроен неправильно и новый release заменяет весь каталог приложения, можно случайно удалить пользовательские данные.
Поэтому mutable user data вообще лучше не смешивать с immutable application release.
Например:
/opt/app/releases/...и отдельно:
/var/lib/app/uploadsЭто простая, но очень важная граница.
Docker добавляет ещё одну ловушку
Если приложение работает в контейнере и файл сохраняется просто в:
/app/uploadsвнутри writable layer контейнера, после пересоздания контейнера данные могут исчезнуть.
Пользовательские файлы должны находиться:
в volume;
bind mount;
или внешнем storage.Контейнер нельзя считать постоянным хранилищем пользовательских данных.
Проблема №2: backup становится вашей ответственностью
Если PostgreSQL резервируется каждый день, это ещё не означает, что пользовательские документы тоже защищены.
Можно иметь:
database backup: ✓
uploads backup: ✗После аварии база восстановится.
В ней будет:
file #1842
storage_path = ...Но самого файла уже нет.
С точки зрения БД всё выглядит корректно.
Для пользователя документ потерян.
Backup локальных файлов должен проектироваться отдельно
Нужно решить:
куда копировать;
как часто;
сколько версий хранить;
как проверить restore.Если backup лежит:
на том же VPSон защищает от некоторых ошибок.
Но не от потери всего сервера.
Для настоящего disaster recovery нужна независимая копия.
Проблема №3: локальный диск плохо сочетается с несколькими backend
Представим масштабирование.
Было:
Nginx
↓
API #1Стало:
┌→ API #1
Nginx ──┤
└→ API #2Пользователь загрузил файл через:
API #1Файл появился только:
на диске API #1.Следующий запрос попал на:
API #2.Там файла нет.
Можно использовать shared filesystem.
Но в этот момент архитектура уже становится сложнее.
Именно здесь object storage начинает выглядеть намного естественнее.
Проблема №4: диск конечен
Представим VPS:
60 ГБНа нём уже находятся:
операционная система;
Docker;
PostgreSQL;
Redis;
логи;
backup;
releases;
пользовательские файлы.Теперь пользователь загружает:
10 ГБ видео.Если приложение никак не контролирует storage, один upload способен повлиять на всю систему.
Свободное место может понадобиться PostgreSQL.
Backup.
Deployment.
Логам.
Поэтому при локальном хранении обязательно нужны:
quota;
file size limits;
disk monitoring;
safety reserve.Иначе пользовательские файлы конкурируют с базой за один диск
Это принципиально важная проблема.
Представим:
free disk:
3 ГБПользователь начинает upload:
2,8 ГБФормально файл помещается.
Но после него PostgreSQL почти не остаётся пространства для нормальной работы.
Поэтому хорошая система проверяет не:
Файл меньше свободного места?
А:
После сохранения файла останется достаточный operational reserve?
Когда локальный диск VPS остаётся хорошим выбором
Например:
внутренняя CRM;
100 пользователей;
несколько тысяч документов;
общий объём 5 ГБ;
один сервер;
простая инфраструктура.При нормальном backup и monitoring выделенный каталог на VPS может быть совершенно практичным решением.
Не каждый продукт обязан иметь S3.
Вариант №3. S3-совместимое Object Storage
Третий подход — вынести бинарные данные из application server.
Архитектура становится такой:
PostgreSQL
↗
Backend
↘
Object StoragePostgreSQL хранит metadata.
Object Storage — содержимое.
Например:
File metadata:
id:
f_1842
ownerId:
u_482
projectId:
p_91
originalName:
contract.pdf
storageKey:
projects/91/files/a8f7...
size:
418293
contentType:
application/pdfА сами:
418 293 bytesнаходятся отдельно.
Почему S3 удобно масштабируется
Backend больше не зависит от локального диска.
Можно иметь:
API #1
API #2
API #3Все они работают с одним storage.
API #1
\
API #2 → Object Storage
/
API #3Файл не принадлежит конкретному экземпляру приложения.
Это значительно упрощает горизонтальное масштабирование.
Сервер приложения можно заменить без переноса файлов
Появился новый VPS.
Развернули код.
Подключили:
PostgreSQL
Redis
S3И пользовательские файлы уже доступны.
Не нужно переносить каталог:
/uploadsмежду application servers.
Application instances становятся ближе к stateless-модели.
Большие файлы перестают занимать production-диск приложения
На VPS остаются:
приложение;
база;
логи;
служебные данные.А пользовательские:
PDF;
JPEG;
ZIP;
audio;
videoживут отдельно.
Это уменьшает вероятность, что массовая загрузка файлов внезапно заполнит диск PostgreSQL-сервера.
Но S3 не делает архитектуру автоматически правильной
Появляется новый класс проблем.
Главная из них:
PostgreSQL и Object Storage не находятся в одной ACID-транзакции.
Классическая проблема: файл записался, база — нет
Процесс:
1. Upload object
2. INSERT metadataПервый шаг прошёл.
Второй:
database errorТеперь в bucket существует:
object a8f7...но приложение ничего о нём не знает.
Получился:
orphan object.
Обратный вариант тоже возможен
Сначала:
INSERT metadataа затем:
upload objectне удался.
Теперь база говорит:
файл существуета storage:
файла нетНельзя решить проблему простым изменением порядка операций.
Нужна стратегия согласования.
Один из практичных подходов — статус загрузки
Создаём metadata:
File
status:
PENDINGДалее загружаем object.
После успешной проверки:
PENDING
↓
ACTIVEПользователь видит только:
ACTIVEфайлы.
Если upload не завершён:
PENDINGзапись можно очистить позднее.
Другой вариант — upload сначала, metadata потом
Тогда при ошибке базы приложение сразу пытается выполнить:
DELETE objectЕсли storage временно недоступен и удалить не получилось, создаётся durable cleanup job:
DELETE_OBJECTWorker повторяет очистку позднее.
Это пример compensating action.
Когда две системы нельзя объединить одной транзакцией, мы компенсируем незавершённую операцию отдельным действием.
Такой cleanup нельзя просто забыть
Плохая реализация:
try {
await deleteObject();
} catch {
// ничего
}Через несколько месяцев storage может содержать тысячи orphan objects.
Пользователь их не видит.
База их не знает.
Но провайдер продолжает хранить данные и выставлять счёт.
Поэтому failure cleanup тоже должен быть durable.
Удаление файла имеет ту же проблему
Пользователь нажимает:
[Удалить]Можно удалить metadata.
А затем не суметь удалить object.
Или наоборот.
Практичный lifecycle:
ACTIVE
↓
DELETED
↓
physical cleanupДля пользователя файл исчезает сразу.
Physical delete может быть выполнен worker.
Если временно не получилось:
retry.Почему soft delete особенно полезен
Кроме надёжности он даёт время исправить ошибку.
Пользователь случайно удалил важный документ.
Если система мгновенно уничтожила object, восстановление возможно только из backup.
Если существует:
deleted_atи физическое удаление отложено:
24 часаможно реализовать:
ВосстановитьНо срок должен соответствовать продукту и требованиям к удалению данных.
Object Storage особенно хорошо работает с immutable-файлами
PDF загрузили.
Он не меняется.
Если пользователь загрузил новую версию договора, практичнее создать новый object:
file-v2вместо перезаписи байтов старого файла.
Так проще:
версионирование;
audit;
cache;
backup.Оригинальное имя и storage key — не одно и то же
Пользователь загрузил:
Договор Иванов финал (2).pdfНе обязательно использовать это имя как physical key.
Гораздо безопаснее:
projects/
91/
files/
1f8e71de-....pdfА в БД хранить:
original_name =
"Договор Иванов финал (2).pdf"Пользователь продолжает видеть знакомое название.
Storage получает стабильный технический идентификатор.
Почему нельзя строить безопасность на «секретном URL»
Например:
https://storage.example.com/
client-91/
contract.pdfЕсли bucket публичный, человек, получивший URL, может скачать документ в обход приложения.
Backend уже не участвует в проверке:
кто пользователь;
к какому проекту относится;
есть ли право FILE_READ.Для приватных пользовательских файлов обычно нужен private storage.
Доступ к файлу должен начинаться с авторизации
Например:
GET /api/files/f_1842/downloadBackend проверяет:
Пользователь авторизован?
Файл существует?
Файл принадлежит доступному проекту?
Есть FILE_READ?Только после этого даётся возможность скачать object.
Есть два основных способа отдачи файла
Первый:
Browser
↓
Backend
↓
Object Storage
↓
Backend
↓
BrowserBackend проксирует содержимое.
Плюсы:
полный контроль;
простая модель доступа.Минусы:
весь файловый трафик
проходит через приложение.Если пользователи скачивают гигабайты, backend становится ненужным посредником.
Второй способ — временная подписанная ссылка
Схема:
Browser
↓
Backend
↓
Authorization
↓
Temporary signed URL
↓
Browser → Object StorageBackend разрешает доступ, но сами байты передаются напрямую storage.
Это уменьшает нагрузку на API.
Подписанная ссылка должна быть временной
Если пользователь получил:
signed URLдействующий год, он почти превратился в постоянную публичную ссылку.
Для приватных данных срок лучше выбирать исходя из реального сценария.
Например:
5 минутили:
15 минута не:
навсегда.При следующем скачивании backend снова проверит permissions и создаст новую ссылку.
Но signed URL не отменяет права доступа
Плохой endpoint:
GET /api/files/:id/urlнаходит object и сразу подписывает его.
Правильная цепочка:
User
↓
Membership
↓
Project access
↓
File access
↓
Signed URLИначе S3 станет обходным путём вокруг всей permission architecture.
Можно сделать прямой upload в Object Storage
Обычная загрузка:
Browser
↓
Backend
↓
Object Storageозначает, что большой файл дважды проходит по сети приложения.
Например:
Browser → API: 500 МБ
API → S3: 500 МБBackend становится транспортным узлом.
Для больших файлов можно использовать direct upload
Например:
1. Browser → Backend:
хочу загрузить файл
2. Backend:
проверяет пользователя,
quota и параметры
3. Backend:
создаёт временное разрешение
4. Browser → Object Storage:
загружает файл напрямую
5. Backend:
подтверждает завершениеСхема:
Backend
↗ ↘
Authorization Upload permission
↖ ↙
Browser
│
│ bytes
▼
Object StorageBackend контролирует операцию.
Но гигабайты данных через Node.js не проходят.
Direct upload требует отдельного lifecycle
Нельзя просто выдать пользователю URL:
Загружай что хочешь.
Перед выдачей разрешения backend проверяет:
можно ли загружать;
размер;
план пользователя;
quota;
тип объекта;
целевой project.Создаётся:
UploadSessionнапример:
PENDINGПосле завершения файл проверяется и только затем становится:
ACTIVEЧто проверять после прямой загрузки
Нельзя полностью доверять данным, которые frontend сообщил до upload.
Клиент мог сказать:
size = 2 MBа загрузить:
200 MB.Поэтому backend полезно сверить фактический объект:
существует?
какой размер?
ожидаемый key?
допустимый тип?И только потом активировать metadata.
MIME-type от браузера тоже нельзя считать абсолютной истиной
Пользователь может прислать:
Content-Type:
image/jpegдля файла, который изображением не является.
В зависимости от риска продукта могут потребоваться:
проверка сигнатуры;
антивирус;
парсинг;
sandbox;
перекодирование.Не каждому сервису нужен сложный malware pipeline.
Но доверять только расширению .jpg тоже не стоит.
Имя файла — пользовательский ввод
Например:
../../somethingили необычные управляющие символы.
Если используется локальный filesystem и приложение просто соединяет:
UPLOAD_DIR + filenameможно получить path traversal или другие проблемы.
Physical filename лучше генерировать самостоятельно.
Оригинальное пользовательское имя хранить как metadata и безопасно использовать только для отображения или Content-Disposition.
Ограничение размера должно работать на нескольких уровнях
Например:
Frontend:
показывает лимит 50 МБЭто UX.
Но пользователь может обойти frontend.
Поэтому должен быть:
Backend:
max 50 MBЕсли upload идёт напрямую в storage, лимит должен учитываться и при выдаче upload permission.
А reverse proxy тоже может иметь:
client_max_body_sizeпри proxy upload.
Несколько согласованных уровней полезнее одного.
Нужна и пользовательская quota
Представим SaaS:
Free:
1 ГБ
Pro:
50 ГБПри каждом новом upload система должна учитывать:
current usage
+
projected file sizeа не ждать, пока bucket или VPS физически закончится.
Quota и физический лимит storage — разные ограничения
Пользователь может иметь:
ещё 10 ГБ quotaНо инфраструктура при этом почти заполнена.
Поэтому нужны два независимых ограничения:
User quotaи:
Infrastructure capacityПервое обеспечивает fair use.
Второе защищает всю систему.
Когда файлы находятся в S3, основной VPS не знает реальный объём автоматически
Поэтому админ-панель и мониторинг должны получать storage metrics отдельно.
Например:
Objects:
128 420
Storage:
742 GB
Uploads today:
18.4 GBЭто помогает заранее видеть рост.
Lifecycle — одно из важных преимуществ Object Storage
Не все объекты должны храниться вечно.
Например:
временные upload;
незавершённые multipart upload;
промежуточные exports;
старые версии.Для них можно определить автоматический lifecycle.
Например:
temporary export
→ удалить через 24 часаНо нельзя применять одну универсальную политику ко всем пользовательским данным.
Договор и временный ZIP-экспорт имеют совершенно разную ценность.
Версионирование тоже требует внимания
Object Storage может поддерживать versioning.
Это полезно:
защита от случайной перезаписи;
восстановление версии.Но есть обратная сторона.
Пользователь видит:
1 файла storage физически хранит:
версия 1
версия 2
версия 3
версия 4Если lifecycle старых versions не настроен, объём может незаметно расти.
Delete marker не всегда означает физическое освобождение места
В versioned storage логическое удаление текущего объекта может оставлять старые версии.
То есть интерфейс говорит:
файл удалёна storage bill почти не изменился.
При проектировании lifecycle важно понимать особенности конкретного S3-совместимого провайдера.
Multipart upload тоже способен оставлять мусор
Большие файлы часто загружаются частями.
Например:
part 1
part 2
part 3
...Если загрузка оборвалась и multipart session никогда не завершилась, части могут остаться в storage.
Поэтому полезно иметь автоматическую очистку abandoned multipart uploads.
CDN — ещё одно преимущество отдельного storage
Если приложение отдаёт:
фотографии;
видео;
публичные изображения;
статические документы,перед object storage можно использовать CDN.
Тогда пользователь получает файл с ближайшей edge-точки.
Основной backend не участвует.
Но для приватных данных CDN тоже должен уважать access model.
Не все пользовательские файлы нужно отдавать через CDN
Например:
аватари:
подписанный договоримеют разные требования.
Аватар может быть публичным и активно кешироваться.
Договор:
private;
authorization required;
short-lived access.Одно и то же storage может обслуживать разные классы объектов, но policy должны различаться.
Полезно классифицировать файлы
Например:
PUBLIC_ASSET
PRIVATE_ATTACHMENT
TEMP_EXPORT
SYSTEM_BACKUP
USER_MEDIAДля каждого класса определить:
доступ;
retention;
backup;
encryption;
cache;
максимальный размер.Это значительно лучше подхода:
все файлы лежат в одной папке uploads.Backup Object Storage устроен иначе, чем backup PostgreSQL
В PostgreSQL snapshot содержит консистентное состояние relational data.
Object storage — отдельная система.
Если мы восстанавливаем:
DB вчера 03:00а storage:
сегодня 12:00могут появиться расхождения.
Например, база не знает о новом файле, который storage уже содержит.
Нужно понимать модель disaster recovery
Для каждого файла важны два вопроса:
Где metadata?
Где bytes?
Если:
PostgreSQL
→ backup A
Object Storage
→ backup/lifecycle Bнужно понимать, как эти два слоя восстанавливаются вместе.
В небольших системах допустима eventual reconciliation после restore.
В критичных — потребуется более строгая процедура.
Полезен storage reconciliation
Периодически можно искать:
Metadata без object
База:
file A exists
Storage:
object A missingи:
Object без metadata
Storage:
object B exists
База:
file B unknownВторой случай особенно важен для борьбы с orphan data.
Не обязательно сканировать миллионы объектов каждый час.
Но механизм проверки полезно иметь.
Что считать источником истины
В типичной архитектуре мы бы считали PostgreSQL источником истины для бизнес-смысла файла:
существует ли он для пользователя;
кому принадлежит;
можно ли читать;
удалён ли;
какой storage key.Object Storage отвечает за:
физические bytes.То есть наличие случайного объекта в bucket ещё не означает:
Это действующий пользовательский файл.
Без активной metadata приложение его не выдаёт.
И наоборот: metadata не гарантирует существование bytes
Поэтому операция скачивания должна корректно обрабатывать:
object missing.Не стоит возвращать пользователю generic:
500если можно зарегистрировать storage inconsistency и показать контролируемую ошибку.
Шифрование — отдельный вопрос
Есть несколько уровней.
HTTPSзащищает файл при передаче.
Storage provider может поддерживать encryption at rest.
А в приложениях с более строгими требованиями можно использовать дополнительное application-level encryption.
Тогда object storage хранит:
ciphertextа ключи управляются отдельно.
Но дополнительное шифрование имеет цену
Нужно решить:
как создавать ключи;
как хранить;
как ротировать;
как восстанавливать;
как стримить большие файлы;
как сделать backup ключей.Если потерять master key:
storage может быть цел,
но данные фактически потеряны.Поэтому шифрование нужно проектировать как систему управления ключами, а не просто как вызов:
encrypt(file)На локальном VPS тоже нельзя забывать права файловой системы
Если backend работает от:
appuserне нужно делать:
chmod 777 uploadsтолько потому, что:
Иначе не записывает.
У каталога должен быть понятный владелец и минимальные права.
Публичный web-root — плохое место для приватных файлов
Например:
/public/uploads/contracts/...Если Nginx напрямую отдаёт каталог, backend authorization может вообще не выполняться.
Для приватных документов полезнее хранить их вне публичного web-root и выдавать только через контролируемый механизм.
Пример №1. Маленькая внутренняя CRM
Условия:
30 сотрудников
2 000 документов
общий объём:
3 ГБ
один VPSВ такой системе локальный диск может быть наиболее практичным решением.
Например:
/var/lib/crm/uploadsпри условии:
отдельного backup;
monitoring;
quota;
правильных permissions.Вводить отдельный S3 только потому, что он архитектурно красивее, необязательно.
Пример №2. B2B-клиентский кабинет
Есть:
несколько тысяч клиентов;
договоры;
акты;
архивы;
изображения;
постоянный рост.Backend планируется запускать в нескольких экземплярах.
Здесь мы бы уже предпочли:
PostgreSQL
→ metadata
S3-compatible storage
→ bytesЭто значительно уменьшает связанность приложения с конкретным VPS.
Пример №3. Сервис обработки аудио и видео
Пользователь загружает:
500 МБ
2 ГБ
5 ГБПроксировать всё через основной backend может быть дорого и неудобно.
Практичная схема:
Browser
↓
получить upload permission
↓
Object Storage
↓
background processing
↓
result storageBackend управляет lifecycle, но не является трубой для каждого гигабайта.
Пример №4. Приложение с десятком небольших файлов
Если весь продукт хранит:
20 PDF
по 300 КБстроить полноценную S3-архитектуру, multipart upload, reconciliation jobs и lifecycle, возможно, просто избыточно.
Здесь даже PostgreSQL может быть приемлем.
Поэтому нет ответа «всегда используйте S3»
Выбор зависит от:
| Критерий | База данных | Диск VPS | S3/Object Storage |
|---|---|---|---|
| Простота старта | Высокая | Очень высокая | Средняя |
| Транзакционность с metadata | Высокая | Низкая | Низкая |
| Большие объёмы | Неудобно | Ограниченно | Хорошо |
| Несколько backend | Не проблема | Проблема без shared FS | Хорошо |
| Backup DB | Становится тяжёлым | Отдельный backup | Разделён |
| CDN | Неестественно | Возможно, но сложнее | Естественно |
| Direct upload | Практически нет | Обычно через backend | Удобно |
| Масштабирование | Ограничивает БД | Ограничивает сервер | Лучше подходит |
| Operational complexity | Низкая | Низкая/средняя | Средняя |
| Orphan objects | Нет | Возможны | Возможны |
Эта таблица не выбирает технологию автоматически.
Она показывает компромиссы.
Что мы чаще всего выбирали бы для современного SaaS
Базовая модель:
PostgreSQL
↓
File metadata
S3-compatible storage
↓
File bytesBackend связывает два слоя.
Например:
files.id
↓
files.storage_key
↓
Object StorageВ результате relational database остаётся относительно компактной.
Storage масштабируется независимо.
Полезно спрятать конкретное хранилище за abstraction layer
В коде домена не обязательно писать везде:
s3.putObject()Можно иметь:
StorageServiceс интерфейсом:
put()
get()
delete()
exists()
createDownloadUrl()А реализации:
LocalStorageAdapter
S3StorageAdapterЭто особенно удобно на локальной разработке.
Локально не всегда нужен настоящий S3
Development может использовать:
filesystem adapterа production:
S3 adapterИли локальный S3-совместимый сервис.
Главное, чтобы domain layer не был намертво привязан к конкретному способу хранения.
Такая абстракция помогает и при миграции
Сегодня файлы лежат:
на VPS.Продукт вырос.
Нужно перейти в S3.
Если вся система работает через:
StorageServiceможно:
1. скопировать objects;
2. обновить storage keys/backend adapter;
3. постепенно переключить чтение.Если каждый endpoint самостоятельно делает:
fs.readFile(...)миграция будет значительно сложнее.
Но abstraction не должна скрывать реальные различия
Filesystem и S3 — не абсолютно одинаковые системы.
Например, у них отличаются:
способ выдачи URL;
multipart upload;
consistency guarantees конкретного провайдера;
metadata;
lifecycle;
стоимость запросов.Поэтому не нужно создавать чрезмерно абстрактный интерфейс:
doAnythingWithFile()Достаточно изолировать то, что действительно общее.
Какие ошибки встречаются чаще всего
Первая:
хранить пользовательские файлы внутри release-каталога приложения.
Новый deployment способен их удалить.
Вторая:
иметь backup PostgreSQL и считать, что backup продукта готов.
Файлы могли остаться без копии.
Третья:
хранить приватные документы в публичном bucket.
Ссылка начинает обходить authorization.
Четвёртая:
доверять исходному имени файла как physical path.
Это создаёт проблемы с безопасностью и коллизиями.
Пятая:
не ограничивать размер и quota.
Один пользователь способен занять всё storage.
Шестая:
сохранять файл в S3 и забывать о rollback/cleanup при ошибке базы.
Появляются orphan objects.
Седьмая:
считать удаление metadata физическим удалением файла.
Storage продолжает расти.
Восьмая:
включить versioning и никогда не настроить lifecycle.
Старые versions накапливаются незаметно.
Девятая:
смешать временные файлы и постоянные пользовательские данные.
К ним начинают применять одинаковый retention.
И десятая:
не проверять восстановление.
Backup существует, но никто не знает, удастся ли из него вернуть документы.
Самый важный вопрос — не «где дешевле сохранить гигабайт»
Стоимость storage важна.
Но архитектурная стоимость обычно складывается из большего числа факторов:
сколько стоит backup;
сколько стоит восстановление;
как выполняется миграция;
можно ли добавить второй backend;
как работает безопасность;
сколько занимает deployment;
насколько легко расследовать потерю файла.Самый дешёвый гигабайт не обязательно даёт самую дешёвую систему.
Когда хранить в базе
Мы бы рассматривали этот вариант, когда:
файлы маленькие;
их немного;
они тесно связаны с транзакцией;
простота важнее масштабирования.Например, очень небольшие бинарные артефакты или специализированные данные.
Когда хранить на диске VPS
Это практичный выбор, когда:
продукт небольшой;
backend один;
объём контролируемый;
простая инфраструктура важна;
backup файлов уже продуман.Для небольших корпоративных систем это может быть оптимальный баланс.
Когда выбирать S3
Object Storage становится особенно интересным, когда:
файлов много;
они большие;
объём быстро растёт;
backend может масштабироваться;
нужен direct upload;
нужен CDN;
нужно отделить данные от application VPS.Для зрелого SaaS, B2B-портала или сервиса с пользовательскими медиа это часто наиболее практичная архитектура.
Но S3 — это не просто «куда залить файл»
Production-модель требует подумать ещё о:
metadata;
permissions;
upload sessions;
signed URLs;
quota;
lifecycle;
orphan cleanup;
backup;
monitoring;
reconciliation.То есть object storage снимает одни проблемы и создаёт другие.
Это нормально.
Любое разделение системы имеет цену.
Как выглядит зрелый lifecycle пользовательского файла
Например:
Пользователь выбирает файл
↓
Backend проверяет права
↓
Проверяет quota
↓
Создаёт upload session
↓
Файл загружается
↓
Проверяется размер/тип
↓
Metadata становится ACTIVE
↓
Файл доступен по permissions
↓
Пользователь удаляет
↓
Metadata → DELETED
↓
Cleanup job
↓
Object удалён физическиЗдесь файл рассматривается как полноценная сущность с жизненным циклом.
Не просто как:
что-то появилось в папке.Важный архитектурный принцип: bytes и business meaning — разные уровни
Object Storage может знать:
key = a8f72...
size = 418293Но он не знает:
кто владелец;
можно ли скачать;
является ли проект архивным;
есть ли у сотрудника FILE_READ.Это знает приложение.
Поэтому:
Object Storageне заменяет:
PostgreSQL + authorization.Они решают разные задачи.
Не нужно давать пользователю прямую структуру storage
Например, URL:
/client-1842/
passport.pdfвыдаёт внутреннюю структуру и иногда бизнес-информацию.
Лучше использовать технические keys:
objects/
4e/
f2/
4ef2c8...или другой непрозрачный формат.
Пользовательское название остаётся в metadata.
Наблюдаемость тоже нужна
Полезно понимать:
сколько объектов;
общий объём;
скорость роста;
ошибки загрузки;
ошибки удаления;
число failed cleanup jobs;
старые PENDING uploads.Если:
orphan cleanup failedмесяцами остаётся незаметным, storage будет медленно расти.
Особенно полезно следить за PENDING
Например:
UploadSession
PENDING
старше 24 часовскорее всего является незавершённой загрузкой.
Такие записи можно анализировать и очищать по отдельной retention policy.
Файл должен иметь историю там, где это важно
Для договора иногда нельзя просто:
заменить старый PDF новым.Правильнее:
Document #91
Version 1
Version 2
Version 3где каждая версия имеет собственный storage key.
Теперь можно доказать:
Какая версия была доступна 15 сентября?
Это уже не вопрос файлового хранилища.
Это domain model.
И это показывает главный принцип всей темы
Выбор:
PostgreSQL
VPS
S3не должен определять бизнес-модель продукта.
Сначала мы понимаем:
что такое файл для бизнеса;
кому он принадлежит;
сколько живёт;
кто читает;
есть ли версии;
что означает удаление.И только потом выбираем физическое хранилище.
Практичная схема для сложного веб-продукта
Для многих современных CRM, SaaS и клиентских кабинетов мы бы начинали примерно с такой архитектуры:
┌──────────────┐
│ PostgreSQL │
│ │
│ metadata │
│ permissions │
│ ownership │
│ versions │
└──────┬───────┘
│
│ storage_key
│
Browser │
│ │
▼ ▼
Backend ─────────────────→ Object Storage
│ │
│ auth │ bytes
│ quota │
│ policy │
│ │
└──── temporary signed URL ──┘При больших upload поток можно изменить:
Browser
↓
Backend
↓
authorization + temporary upload permission
↓
Browser
↓
Object StorageА после завершения:
Backend
↓
verify
↓
ACTIVEНо начинать можно проще
Очень важно не превратить хорошую архитектуру в культ сложности.
Если сегодня продукт имеет:
20 клиентов;
300 PDF;
1 VPS;можно начать с локального filesystem.
Но сделать это так:
отдельный data directory;
backup;
quota;
storage abstraction;
monitoring.Тогда будущий переход в S3 не потребует переписывать половину системы.
Хорошая архитектура оставляет путь роста
Сегодня:
LocalStorageAdapterЗавтра:
S3StorageAdapterПри этом:
Fileкак бизнес-сущность остаётся прежним.
Это намного важнее, чем выбрать «самую масштабируемую» технологию в первый день проекта.
Вместо вывода
На вопрос:
Где лучше хранить пользовательские файлы?
нет универсального ответа:
Только PostgreSQL.или:
Всегда S3.Правильнее сначала ответить на другие вопросы:
Сколько файлов будет?
Какого они размера?
Должно ли приложение работать на нескольких серверах?
Кто имеет к ним доступ?
Как выполняется backup?
Как быстро нужно восстановиться после аварии?
Нужны ли версии?
Есть ли большие upload?
Как будет расти продукт?
После этого выбор становится намного понятнее.
Для небольшого закрытого проекта локальный диск VPS может оказаться самым практичным решением.
Для небольших бинарных данных база данных тоже не является ошибкой сама по себе.
Но по мере роста SaaS, CRM или B2B-платформы всё чаще появляется естественное разделение:
PostgreSQL
→ смысл файла
Object Storage
→ байты файлаБаза знает, кому принадлежит документ и кто может его получить.
Хранилище отвечает за надёжное размещение самого объекта.
А backend соединяет эти уровни через authorization, quota, lifecycle и контроль целостности.
Именно поэтому хороший выбор хранилища — это не вопрос:
«Куда проще записать Buffer?»
Гораздо полезнее другой:
«Как сделать так, чтобы через три года мы могли восстановить, перенести, удалить, защитить и масштабировать пользовательские файлы, не ломая остальной продукт?»
Если архитектура отвечает на этот вопрос, место хранения выбрано гораздо осознаннее.