01Обзор проекта
keepix.cloud — медиа-хостинг для продуктов, которым приходится отдавать чужие файлы: каталожные фотографии витрины, картинки в отзывах маркетплейса, видео-обзоры в приложении. Оригинал уходит одним подписанным запросом, и в ответе уже лежат готовые адреса: именованный набор WebP-вариантов для картинки, лесенка HLS для видео. Файлы отдаются с собственного поддомена клиента — читатель видит его бренд и не видит нас.
Замысел в том, что медиа — не функция, которую пишут один раз на проект. Каждый продукт, где что-то загружают, в итоге пишет одно и то же заново: масштабирование, смену формата, схему имён, политику кэша, снятие метаданных, путь «удалить всё об этом человеке». Написанное в пятый раз — в пятый раз написано плохо, а то, что ломается (утёкшая GPS-метка, SVG, оказавшийся скриптом), ломается молча: без ошибки и без строки в журнале.
Поэтому сервис построен вокруг одной мысли: адрес объявляет сервис, а клиент его хранит, а не вычисляет. Одно это решение убирает самый частый способ сломать медиа — страницу, которая отрисовывается идеально, а на месте товара у неё дыра.
Мы спроектировали и построили продукт целиком: конвейер приёма, модель вариантов, видео-лесенку, мультиарендность со своими доменами и ключами подписи, потарифную тарификацию, кабинет, публичный договор API и документацию. Ниже — какую задачу это решает, как устроено и какие инженерные решения мы приняли бы так же.
02Контекст и задача
Сохранить загруженную картинку легко. Держать медиа-путь правильным годами, под нагрузкой, для нескольких продуктов и на чужих файлах — нет. Когда мы разобрали задачу, грабли вылезли быстро.
- Произвольная ширина — ловушка. Адрес вида
?w=473выглядит гибким и на деле даёт две беды сразу: кэш, который никогда не прогревается, потому что каждая страница просит чуть другое число, и бесплатный способ занять процессор, попросив тысячу ширин подряд. - Вычисленный адрес ломается молча. Правило «id → шард → путь», зашитое в код клиента, живёт ровно до первой смены раскладки на той стороне. После неё страница по-прежнему отрисовывается, а у товара просто дыра вместо фотографии — ни ошибки, ни записи в журнале.
- Метаданные — утечка, которую никто не замечает. Координаты чужого дома в EXIF фотографии из отзыва не поднимают ошибку, не ломают страницу и не доходят до мониторинга. Это утечка ровно тогда, когда кто-то посмотрит.
- SVG — не картинка. Это исполняемая разметка. Принятый как изображение и отданный с домена клиента, скрипт внутри него исполнится от имени этого домена.
- Удаление должно быть доказуемым. Запрос на стирание, отвеченный отметкой «удалено» в строке, пока байты лежат на диске, — это удаление, которого не было.
- Видео — это набор, а не файл. Одна загрузка обязана стать играбельной лесенкой, кадром-постером и запасным вариантом, и ничего из этого не должно происходить внутри запроса, который принял файл.
- Счёт должен быть честным на малых числах. Гигабайт может стоить меньше цента; округление до целых центов съедает весь потарифный счёт и превращает его в выдумку.
Было и ещё одно требование, определившее весь замысел: сервис не должен ходить в интернет вовсе. Медиа-хостинг, который тянет картинку с чужого адреса за клиента, знает, кто и что попросил, и передаёт это знание тому CDN, которым воспользовался. keepix не знает ни одного стороннего адреса — и это свойство кода, а не обещание на странице тарифов.
03Цели проекта
Задача превратилась в список целей, которых мы держались на каждом шаге:
- Один запрос на вход — готовые адреса на выход. Клиент отправляет оригинал и сразу получает адреса, которые нужно сохранить: без второго вызова и без ожидания задания для картинок.
- Именованный и конечный набор вариантов. Три ширины для картинок и четыре формы для видео, объявленные для каждого вида файла, — чтобы кэш был тёплым, а процессор не сдавался в аренду.
- Свой домен клиента. Файлы отдаются с
img.его-домен, сервиса в адресной строке не видно. - Кэш, который можно держать вечно. В адресе стоит хеш содержимого, поэтому замена файла меняет адрес и доходит до читателя сразу.
- Безопасность, встроенная в путь. Тип определяется разбором, антивирус отказывает, а не пропускает, каждая картинка перекодируется, метаданные снимаются, SVG не принимается и не отдаётся.
- Доказуемое стирание. Один запрос по собственному
owner_refклиента и ответ, который называет число ушедших объектов и файлов. - Мультиарендность с настоящей изоляцией. Свои домены, свои виды и варианты, свои потолки, два ключа подписи, чтобы ротация не была простоем.
- Потарифный счёт, который можно проверить. Хранение средним, а не пиком, трафик — реально отданными байтами, операции — поштучно, с разбивкой по дням в кабинете.
- API, который признаёт, что сделал. Каждый ответ говорит, что построено и что пропущено, а для проверки переноса есть сухая сверка пачками.
04Что мы сделали
keepix разбирается на несколько частей, у каждой одна работа:
- Приём. Один подписанный эндпоинт принимает оригинал с его видом, ключом и списком нужных вариантов. Картинки возвращаются готовыми в том же ответе, видео уходит в очередь — и ответ об этом говорит.
- Конвейер обработки. Определение типа, антивирус, перекодирование, снятие метаданных, сборка вариантов — в фиксированном порядке, который нельзя пропустить параметром запроса.
- Отдача. Отдельный хост без сессий, без JavaScript и без единого
Set-Cookie: файлы отдаёт веб-сервер прямо с диска, с годовым неизменяемым кэшем. - API арендатора. Подпись HMAC-SHA256, эндпоинты на получение адресов, сверку пачками, выгрузку описи, удаление объекта и стирание всего, что принадлежит человеку.
- Кабинет. Папки, перетаскивание мышью, поиск по названию, описанию и меткам — и расход с разбивкой по дням. Работает полностью без JavaScript: скрипт добавляет удобство, а не возможность работать.
- Тарификация. Пакет плюс потарифный расход сверх него, в сотых цента, причём на бесплатном тарифе приём останавливается, а не начинает молча тратить деньги.
- Сток. Слой-маркетплейс, где арендаторы покупают и продают свои работы, на том же хранилище и той же отдаче.
Дальше — про решения внутри этих частей: те, которые дорого менять потом.
05Архитектура решения
Сервис — это три пространства с физически разными входами, и это разделение держит весь замысел:
- Публичная отдача — API нет вовсе. Файл ищет на диске веб-сервер. Не нашёл — 404, а не «соберу на лету». Исполняемого кода на этом пути нет совсем, и именно поэтому хост отдачи может нести
default-src 'none'; sandboxвсерьёз. - Приватный API арендатора. Каждый запрос подписан; подпись накрывает метод, путь вместе со строкой запроса, время и хеш тела. Подписать путь без запроса значило бы оставить
kindиkeyвне подписи — подменяемыми, и никто бы этого не заметил. - Админский API для панели управления — на своём входе и со своими учётными данными.
Ответы всегда в JSON и всегда по-английски, с одной формулировкой на код отказа: одна и та же поломка читается одинаково в журнале любого клиента, на каком бы языке ни говорил его продукт.
Внутри хранилище делится на холодное дерево оригиналов, которое наружу не отдаётся никогда, и отдаваемое дерево вариантов. Хранение оригинала — это то, что позволяет собрать новый вариант через год, не прося клиента загрузить файл заново; и это же делает правило «мы не отдаём присланный файл» абсолютным, а не «как правило».
06Жизнь файла
Файл проходит фиксированную последовательность, и порядок здесь — это и есть смысл:
- Тип определяется разбором — по содержимому, а не по расширению и не по тому, что сказал клиент. Расширение выходного файла назначаем мы.
- Антивирус работает и может отказать. Если сканер недоступен, приём отвечает
503, а не пропускает файл. Пропустить молча, пока проверка лежит, — это враньё о сделанной работе, и такое враньё всплывает сильно позже. - Картинка перекодируется. Ни один байт присланного файла наружу не уходит.
- Метаданные не переживают перекодирование. EXIF и GPS исчезают из-за устройства пути, а не потому, что загружающий вспомнил их снять.
- Собираются варианты, и их адреса возвращаются в том же ответе.
SVG не принимается и не отдаётся — никогда, ни на входе, ни на выходе. Проверка идёт дважды, по объявленному типу содержимого и по началу файла, потому что для SVG определитель типа часто просто говорит «текст». Наружу идут WebP, JPEG, PNG, MP4, плейлист HLS и его сегменты; всё остальное — отказ 415.
Приём идемпотентен: тот же sha256 под тем же ключом не делает работу заново и возвращает те же адреса. Повтор после оборванного соединения поэтому бесплатен — а это важно, когда файл на два гигабайта, а сеть — телефон.
07Именованные варианты вместо произвольной ширины
У картинок три именованные ширины — thumb 400, card 1000, full 1400, все WebP качества 82, — и вариант просят по имени. Набор конечен и объявлен для каждого вида файла. Это решение, на которое опирается весь сервис: оно убирает целый класс проблем, а не одну ошибку.
Два правила описывают, когда варианта законно может не быть, и оба существуют, чтобы не врать читателю:
- Мы не растягиваем. Если оригинал уже ширины варианта, вариант остаётся в размере оригинала, и ответ честно об этом говорит: в полях
wиhстоят настоящие числа. Растянутая копия выглядит нашей виной — и была бы ею. - Одинаковые копии не хранятся дважды. Если две ширины дадут ровно тот же файл, второй адрес не создаётся. Два побайтово одинаковых файла по двум адресам — это две загрузки одного и того же на чей-то телефон.
Отсюда следует, что ответ надо читать, а не предполагать: попросили два варианта — можно получить один, с блоком skipped и объяснением. Мы сделали это нормальным ожидаемым ответом, а не краем: интеграция, написанная под оптимистичную форму, ломается на первой же маленькой картинке, которую загрузит пользователь.
Добавить новый вариант позже можно без повторной загрузки: он собирается из оригинала в холодном дереве. Ради этого оригинал и хранится.
08Видео: лесенка HLS, постер и превью
Одна загрузка видео превращается в набор, потому что именно набор нужен плееру: лесенка HLS до трёх ступеней качества (640, 1280 и 1920 px), постер — кадр с 10% длительности, беззвучное превью первых секунд для наведения и автовоспроизведения в ленте и прогрессивный MP4 как запасной вариант для старых клиентов.
Ступень шире оригинала не делается — по той же причине, по которой не растягивается картинка: увеличенная копия выглядит нашей виной. Постер берётся с десятой доли длительности, а не с первого кадра, потому что первый кадр настоящего видео очень часто чёрный.
Обработка видео не происходит внутри запроса, принявшего файл: она уходит в очередь, и ответ об этом говорит. Ответ приёма честен насчёт того, что готово сейчас, а что в работе, — клиент может сохранить готовое и спросить позже, а не держать экран загрузки пользователя на времени перекодирования.
09Свой домен и вечный кэш
Файлы отдаются с собственного поддомена арендатора — img.его-домен, — который он направляет на сервис A-записью. Читатель видит его бренд и его адрес и не видит keepix вовсе. На бесплатном тарифе домен общий; оба пути работают одинаково.
Доменов у арендатора может быть сколько угодно, но канонический ровно один, и это ограничение намеренное: при двух канонических у одного файла появляются два адреса, то есть два кэша и одни и те же байты, скачанные дважды на телефон одного читателя.
Заголовки отдачи одинаковы для всех арендаторов и не меняются: годовой immutable-кэш, nosniff, default-src 'none'; sandbox, разрешительный CORS на чтение и ни одного Set-Cookie. Вечный кэш безопасен ровно потому, что в адресе стоит хеш содержимого: изменились байты — изменился адрес, и заменённая картинка доходит до читателя сразу, а не по истечении TTL.
Именно поэтому адрес нельзя вычислить на стороне клиента. Его возвращает сервис, клиент кладёт его рядом с записью, к которой файл относится, и спрашивает заново через resolve, если запись потерялась.
10Приём: тип, антивирус, метаданные
На сервисе лежат файлы, которые прислали люди: фотография в отзыве, видео-обзор. Правила приёма написаны ради них, а не ради формы, и каждое существует потому, что альтернатива отказывает тихо.
Определение типа разбором, а не по расширению, — не педантизм: расширение это единственное, что загружающий контролирует полностью. Отказ при недоступном сканере стоит доступности и покупает гарантию, что «проверено» значит проверено. Перекодирование каждой картинки означает, что подготовленный файл никогда не доедет до читателя в исходном виде. Снятие метаданных внутри перекодирования означает, что о нём нельзя забыть.
На стороне отдачи гарантии структурные, а не процедурные. У хоста отдачи нет ни сессий, ни JavaScript, ни исполняемого кода на пути; содержимое уходит с политикой безопасности, при которой браузер не запустил бы ничего, даже если бы опасное всё-таки прошло. Два независимых слоя — потому что интересные поломки это те, где первый слой был настроен неправильно и никто не заметил.
Журнал отдачи с адресами читателей живёт 24 часа. Дольше он сервису ни для чего не нужен, а значит, держать его дольше — простое накопление чужих следов.
11Стирание всего, что относится к человеку
У каждого объекта есть owner_ref — то, как сам арендатор называет владельца файла. У каталожных картинок он пуст: они не про человека. У фотографий в отзывах и пользовательского видео он обязателен.
Один запрос стирает всё под ним, и ответ называет число: сколько объектов и сколько файлов ушло. Ради этого числа эндпоинт и существует. Гейт персональных данных должен проверять исполнение, а не намерение: строка с отметкой «удалено» при живых байтах на диске — это удаление, которого не было, и оно так и не произойдёт, пока кто-нибудь не посмотрит.
Стирание сносит записи и байты сразу, а не по расписанию, и забирает с собой отложенные копии прежних версий. Это же делает keepix пригодным для продукта с собственными обязательствами по персональным данным: код удаления на его стороне может вызвать стирание прямо в потоке и прочитать число, а не надеяться, что до этого доберётся какая-то ночная задача.
С ключами подписи то же самое. У арендатора всегда два в работе, потому что при одном ключе ротация означает простой, а простой — причина, по которой ротацию откладывают навсегда. Секрет показывается один раз при выпуске и в панель не возвращается: возможность подписать запрос — это право писать в чужое хранилище.
12Подписанный API и сверка
Каждый приватный запрос несёт четыре заголовка — арендатор, идентификатор ключа, время и подпись, — а подпись это HMAC-SHA256 по методу, полному пути со строкой запроса, времени и хешу тела. Окно — 300 секунд. Одинаковый подписанный запрос, повторённый в ту же секунду, получает отказ как повтор: это защита, работающая как задумано, и договор говорит об этом прямо, а не оставляет интегратору выяснять самому.
Набор эндпоинтов небольшой, и каждый отвечает на настоящий эксплуатационный вопрос:
put— принять оригинал;put-variant— принять уже готовую копию без обработки, для переезда с тем, что у клиента есть. Перекодировать готовое второй раз — терять качество просто так.resolveиresolve-batch(до 1000 ключей) — какие адреса есть у этого ключа. Это инструмент починки: потерялась запись — спрашивают, а не угадывают.reconcile— сухая проверка, до 1000 записей за раз, ничего не меняет и отвечает по каждой записиok/missing/mismatch/pendingплюс отпечаток всей пачки.inventory— выгрузка, которая ведёт сверку в обратную сторону, список сервиса против списка клиента, с курсором по самому ключу, чтобы страница не съезжала, если что-то принимается во время обхода.object,erase,quota,pingи неподписанныйhealthz, который говорит, что фронт жив, и ничего больше — ни версии, ни состояния базы.
Сверка заслуживает отдельного слова, потому что именно она оправдывает весь договор. После большого переноса значение имеет ровно один вопрос: всё ли доехало. Отвечать на него по одному файлу — это столько запросов, сколько файлов, то есть никто этого не делает; и тогда перенос объявляют законченным на основании того, что скрипт загрузки не упал. Сверка пачками делает честный ответ достаточно дешёвым, чтобы его действительно получили.
13Кабинет, расход и тарификация
Кабинет — это библиотека без единой строчки кода со стороны пользователя: папки, перетаскивание мышью, поиск по названию, описанию и меткам. Он работает полностью без JavaScript — скрипт добавляет удобство, а не возможность работать. Это не ностальгия, а самый дешёвый способ гарантировать, что панель останется работоспособной на плохой связи и проверяемой без браузерного движка.
Расход считается с начала месяца и разбивается по дням: сколько хранения, сколько трафика отдачи и сколько операций пришлось на каждую дату, сколько пакета съедено и что пришло сверх него. Разбивка важнее итога — счёт, который нельзя отнести к дню, это счёт, с которым нельзя спорить.
Три решения в тарификации стоит назвать:
- Хранение — среднее за период, а не пик. Один день с большой загрузкой не должен оценивать весь месяц.
- Трафик отдачи — те самые байты, что ушли читателям, из журнала сервера, а не «размер файла × число запросов»: второе завышает каждый диапазонный запрос и каждую брошенную загрузку.
- Ставки держатся в сотых цента. Гигабайт может стоить меньше цента; округление до целых центов съело бы весь потарифный счёт и тихо превратило бы счёт в выдумку.
На бесплатном тарифе потарифного счёта нет вовсе: приём останавливается, а не начинает молча тратить деньги клиента. Сервис, самостоятельно решивший выставить счёт тому, кто не вводил карту, — сервис, который не рекомендуют дважды.
14Технологический стек
Стек выбран под сервис, у которого горячий путь — это веб-сервер, отдающий файл:
- PHP 8.4 для API и панели — зрелый, достаточно быстрый на этом пути и дешёвый в эксплуатации, что важно для продукта с бесплатным тарифом.
- Отдельный хост отдачи, отдающий прямо с диска, без прикладного кода на пути запроса.
- WebP для картинок с фиксированным качеством, H.264 + AAC в HLS для видео и прогрессивный MP4 как запасной.
- Очередь для видео, чтобы перекодирование никогда не сидело внутри запроса на загрузку.
- Подпись HMAC-SHA256 с двумя живыми ключами на арендатора и окном в 300 секунд.
- Холодное дерево оригиналов вне пути отдачи — то, что и делает возможными новые варианты и пересборку позже.
Сервис намеренно консервативен в зависимостях. Он не делает исходящих запросов, не пользуется чужим CDN и не хранит о пользователях клиента ничего, кроме арендатора, ключа, вида, числа байт и вариантов. Каждая зависимость, которой нет, — это зависимость, которая не сломает вам медиа во вторник.
15Дизайн и UX
У публичного сайта была одна задача: сделать инфраструктурный продукт читаемым для инженера, который будет его подключать. Поэтому на первом экране стоит настоящий запрос и настоящий ответ — адреса, возвращающиеся в JSON, — а не абстракция вместо них. Интегратор решает секунд за тридцать, подходит ли ему медиа-сервис, и решает он по форме ответа.
Остальной сайт написан так же. Каждая возможность названа вместе со своим ограничением: именованные варианты — и почему отказано в произвольной ширине; вечный кэш — и хеш в адресе, который делает его безопасным; стирание — и число, которым оно отвечает. Документация, перечисляющая только возможности, оставляет читателю выяснять края в бою.
В кабинете тот же принцип превращается в работу без JavaScript и в разбивку расхода по дням: на экране нет ничего, чего читатель не может проверить, и ничего, что перестаёт работать, если скрипт не загрузился.
16Как мы работали
Проект шёл этапами, каждый заканчивался чем-то показуемым:
- Сначала договор. Мы написали договор API — схему подписи, эндпоинты, коды отказа, формы ответов — до реализации: договор это то, подо что пишут клиенты, и то, что дорого менять.
- Приём и варианты. Конвейер в фиксированном порядке, с двумя правилами о нерастягивании и недублировании, встроенными с самого начала.
- Отдача. Отдельный хост, набор заголовков, адресация по хешу содержимого, делающая вечный кэш безопасным.
- Видео. Лесенка, постер и превью, и очередь, которая держит перекодирование вне запроса загрузки.
- Мультиарендность. Домены, виды, варианты, потолки и модель двух ключей.
- Тарификация и кабинет. Потарифный счёт с разбивкой по дням, а поверх него — библиотека.
- Настоящий перенос. Мы перевезли на сервис живой каталог и сверили его пачками — так эндпоинт сверки и получил свою форму.
Последний этап стоил больше любого объёма внутреннего тестирования. Перенос сотен тысяч настоящих файлов нашёл то, чего не находит набор тестов: объекты, которые сервис принял и потерял, записи с разошедшимся сохранённым адресом и разницу между проверкой, которая сверяет хеши, и проверкой, которая лишь подтверждает наличие строки. Проверка, которой нечего проверять, отвечает «всё сошлось» — поэтому слабый режим у нас называет себя вслух.
17Результат
Получился медиа-хостинг, на который продукт может опереться: один подписанный запрос на входе, готовые адреса на выходе, отдача с домена клиента и кэш, который можно держать вечно.
- Именованные варианты и лесенка HLS, собранные один раз и кэшируемые вечно за адресом с хешем содержимого.
- Собственный поддомен клиента: читатель видит его бренд, а не наш.
- Безопасность, встроенная в путь приёма — разобранный тип, отказывающий антивирус, перекодирование, снятые метаданные, SVG отвергнут с обеих сторон.
- Стирание, отвечающее числом, — гейт персональных данных проверяет исполнение, а не намерение.
- Мультиарендность со своими доменами, потолками и ротацией двух ключей без простоя.
- Потарифный счёт в сотых цента с разбивкой по дням и бесплатный тариф, который останавливается, а не тратит.
18Выводы
keepix — случай, когда бо́льшая часть инженерии ушла в решение, чего сервис делать откажется. Никаких произвольных ширин, никаких вычисляемых адресов, никакого SVG, никаких исходящих запросов, никаких исходных байтов на пути отдачи, никакого счёта на бесплатном тарифе. Каждое из этих «нет» убирает целый класс поломок, а не обрабатывает одну.
Главный урок, который мы унесём в следующий инфраструктурный проект: делайте честный ответ дешёвым. Сверка пачками, расход по дням, стирание, возвращающее число, ответ приёма, называющий пропущенное, — во всех случаях альтернативой был не неверный ответ, а отсутствие ответа, и именно на отсутствие команды тихо соглашаются.
Если вам нужен медиа-сервис под собственным доменом, конвейер файлов, который обязан быть безопасным с чужими загрузками, или API-first продукт, где договор важнее экранов, — такую работу мы делаем от начала до конца.
