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 продукт, де договір важливіший за екрани, — таку роботу ми робимо від початку до кінця.
