← головнаКомп'ютери та технології

Як перевезти сховище файлів із S3 на власний сервер - і не зламати сайт/застосунок

Рано чи пізно майже кожен проєкт, який зберігає користувацькі файли - аватарки та інші медіа, - стикається з однаковим питанням: чи справді потрібно й надалі платити хмарному провайдеру за кожен байт трафіку та кожен ...

ЗмістНатисність на посилання, щоб перейти до потрібного місця
Чому взагалі варто переїжджати з S3Що таке S3-сумісне сховищеАрхітектура: чому запис і читання повинні бути різнимиЗаписЧитанняRead-only проксі: маленька деталь, яка захищає все сховищеПомилка №1. Відкритий листинг директорійПомилка №2. Cache-Control і параметр alwaysПублічний домен без відкритих портівПеренесення даних: де починаються справжні пригодиСучасні SDK іноді «розумніші», ніж потрібноContent-Type - дрібниця, яка ламає картинкиПопередньо стиснуті файли - окрема історіяНайпростіше рішенняНе всі помилки видно під час копіюванняПеревірка кількості файлів - це не формальністьІноді бреше навіть інструмент перевіркиНайнебезпечніший момент - перемиканняЧому поступове перемикання небезпечнеПравильна послідовність переходу1. Повністю підготувати нову інфраструктуру2. Перенести всі дані3. Один деплой — одна змінаЧек-лист перед вимкненням старого S3Не поспішайте видаляти старий бакетЧи варто взагалі переїжджати?
Рано чи пізно майже кожен проєкт, який зберігає користувацькі файли - аватарки та інші медіа, - стикається з однаковим питанням: чи справді потрібно й надалі платити хмарному провайдеру за кожен байт трафіку та кожен HTTP-запит?
Особливо це актуально для невеликих сервісів. Застосунок уже працює на власному VPS, який має значний запас дискового простору, процесорного часу та пропускної здатності. При цьому окремий сервіс для файлів продовжує тарифікувати кожен GET-запит, кожен гігабайт вихідного трафіку та ще й додатково - CDN перед ним.
У цій статті я розкажу на рівні концепції, як перенести файлове сховище з Amazon S3 на власний сервер, не переписуючи застосунок і не змінюючи бізнес-логіку. Ми розглянемо архітектуру, модель безпеки, реальні проблеми під час міграції та чек-лист, який варто пройти перед тим, як остаточно вимкнути старий бакет.
Чому на рівні концепції? Інструментів багато, а підходи універсальні. Я нещодавно мігрував файли з Amazon S3 на SeaweedFS (контейнер на тому же сервері, де й застосунок). Тож поки досвід свіжий - пишу.

Чому взагалі варто переїжджати з S3

Amazon S3 - відмінний сервіс. Його головні переваги добре відомі: висока доступність, практично необмежене масштабування та мінімум адміністрування.
Але за ці переваги доводиться платити, і не лише за обсяг збережених файлів.
Модель оплати побудована навколо використання сервісу:
  • кожен GET- та PUT-запит;
  • кожен гігабайт вихідного трафіку;
  • додаткові сервіси на кшталт CDN.
Для більшості невеликих сайтів це непомітно. Проблема виникає тоді, коли навантаження перестає бути контрольованим.
Наприклад:
  • скрапер або хотлінкер можуть за кілька годин створити сотні тисяч запитів;
  • CDN лише зменшує навантаження на S3, але сам також тарифікується за запити й трафік;
  • бюджетні алерти AWS повідомляють про перевищення витрат, але не можуть автоматично їх зупинити.
  • Помилка в коді. Наприклад retry-шторм в бекграунд-процесі обробки зображень може привести до постійних запитів до S3. І це з реального досвіду.
У результаті навіть невеликий інцидент може перетворитися на неприємний рахунок наприкінці місяця.
Якщо ж ваш застосунок уже працює на VPS із фіксованою абонплатою, виникає логічне питання: навіщо платити окремо за файлове сховище, якщо сервер має достатньо дискового простору й мережевих ресурсів?
Саме тут і з'являється ідея власного S3-сумісного сховища.

Що таке S3-сумісне сховище

Саме існування S3-сумісних сховищ робить таку міграцію відносно простою.
Сьогодні існує багато відкритих реалізацій об'єктного сховища, які підтримують той самий API, що й Amazon S3:
  • PUT Object
  • GET Object
  • ListObjects
  • AWS Signature Version 4
  • стандартні SDK Amazon
Для застосунку це означає, що він продовжує працювати через той самий клієнт (aws-sdk, fog-aws чи будь-яку іншу бібліотеку), а змінюються лише:
  • endpoint;
  • ключ доступу;
  • секретний ключ;
  • параметр force_path_style (path_style).
Сам код роботи з файлами при цьому залишається практично незмінним.
На ринку є декілька якісних self-hosted реалізацій. Вони відрізняються продуктивністю, вимогами до ресурсів та зрілістю реалізації S3 API, але принцип роботи однаковий.
Саме тому переїзд не перетворюється на переписування половини застосунку - достатньо підмінити адресу сховища.

Архітектура: чому запис і читання повинні бути різними

Найпоширеніша помилка під час переходу - просто відкрити S3 API назовні й використовувати його одночасно для запису та читання.
Так працювати буде.
Але це погана ідея.
Запис файлів і їхнє читання мають абсолютно різні вимоги до безпеки.

Запис

Файли завантажує сам застосунок.
Він працює всередині приватної мережі, має ключі доступу та повинен бачити повний S3 API.
Жоден із цих портів не повинен бути доступний з Інтернету.

Читання

Користувачам потрібно лише завантажувати вже існуючі файли.
Для цього достатньо дозволити тільки два HTTP-методи:
  • GET
  • HEAD
Усе інше має блокуватися ще до того, як запит потрапить до сховища.
У результаті архітектура виглядає приблизно так:
                    ┌────────────────────────────────┐
                    │             Ваш сервер         │
                    │                                │
  Застосунок ──────▶│  S3-сумісне сховище            │
 (внутрішня мережа) │                                │
                    │             │                  │
                    │             ▼                  │
                    │   nginx (read-only proxy)      │
                    └─────────────┬──────────────────┘
                                  │
                           assets.example.com
                               GET / HEAD
Такий підхід має одразу декілька переваг:
  • API запису взагалі недоступне з Інтернету.
  • Відвідувачі можуть лише переглядати файли.
  • У будь-який момент можна додати CDN без зміни застосунку.
  • Nginx може самостійно додавати кешувальні заголовки та обмежувати небажані запити.
Інакше кажучи, застосунок і користувачі більше не працюють із тим самим інтерфейсом - кожен отримує лише ті можливості, які йому дійсно потрібні.

Read-only проксі: маленька деталь, яка захищає все сховище

Зазвичай роль такого проксі виконує звичайний nginx. Він не зберігає файли й не знає нічого про S3 API - його єдине завдання полягає в тому, щоб безпечно віддавати вже існуючі об'єкти.
Типова конфігурація виглядає приблизно так:
server {
    listen 80;
    server_name _;

    # Заборонити перегляд списку файлів.
    location ~ /$ {
        return 403;
    }

    location / {
        limit_except GET HEAD {
            deny all;
        }

        proxy_pass http://storage:8080/bucket-name/;
        proxy_set_header Host $host;

        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}
Конфігурація невелика, але саме в ній найчастіше ховаються помилки.

Помилка №1. Відкритий листинг директорій

Якщо її пропустити, звичайний запит на:
GET /
може повернути список усіх об'єктів у бакеті.
Це не означає, що зловмисник зможе щось видалити чи завантажити. Але він отримає:
  • імена файлів;
  • розміри;
  • дати створення;
  • структуру каталогів.
Фактично ви самі дасте можливість повністю проіндексувати своє файлове сховище.
Саме тому найпростіше рішення - взагалі заборонити запити до директорій. Користувач повинен знати повний шлях до конкретного файлу. Якщо шляху немає - нехай отримує 403 Forbidden.

Помилка №2. Cache-Control і параметр always

Ще підступнішою є інша проблема.
Багато прикладів конфігурацій nginx рекомендують використовувати:
add_header Cache-Control "public, max-age=31536000, immutable" always;
На перший погляд це навіть здається логічним: нехай усі відповіді кешуються однаково.
Але якщо перед сервером працює CDN (наприклад Cloudflare), ситуація може стати дуже неприємною.
Уявімо типовий сценарій деплою.
Новий HTML уже опублікований, але файл із JavaScript ще не встиг потрапити до нового сховища.
Перший користувач відкриває сторінку.
Origin повертає:
404 Not Found
Разом із ним nginx додає:
Cache-Control: public, max-age=31536000
CDN чесно виконує інструкцію й кешує відповідь на рік.
Через декілька секунд файл з'являється в сховищі.
Але користувачі все одно продовжують отримувати 404 із кешу.
З боку застосунку все працює правильно.
Файл існує.
Origin віддає його.
А CDN навіть не перевіряє це повторно.
Саме тому Cache-Control варто додавати лише до успішних відповідей, а не до помилок.

Публічний домен без відкритих портів

Ще одна цікава можливість сучасної інфраструктури - взагалі не відкривати сервер назовні.
Якщо перед сайтом працює CDN або reverse proxy, майже напевно він підтримує вихідні тунелі.
Принцип роботи дуже простий.
Замість того щоб чекати вхідних TCP-з'єднань, сервер сам встановлює постійне вихідне з'єднання з мережею провайдера.
Подальший трафік передається саме через нього.
З точки зору Інтернету це виглядає так, ніби ваш сервер взагалі не існує.
Сканування портів нічого не покаже.
Жодного відкритого HTTP.
Жодного HTTPS.
Жодного S3 API.
Назовні видно лише edge-вузли CDN.
Це дає відразу декілька переваг:
  • IP-адреса origin-сервера прихована;
  • можна повністю закрити firewall для вхідних підключень;
  • значно складніше проводити DDoS-атаки безпосередньо на сервер;
  • сховище недоступне напряму навіть за знання IP.
Для домашнього сервера або невеликого VPS це, мабуть, найпростіший спосіб отримати додатковий рівень захисту практично безкоштовно.

Перенесення даних: де починаються справжні пригоди

На папері все виглядає дуже просто.
Беремо rclone.
Створюємо два remote:
  • Amazon S3;
  • нове S3-сумісне сховище.
Після цього запускаємо:
rclone sync s3:bucket storage:bucket
І чекаємо.
На практиці саме тут починається більшість несподіванок.
Причому вони майже ніколи не пов'язані із самим rclone.
Зазвичай проблеми виникають через дрібні відмінності між реалізаціями S3 API.

Сучасні SDK іноді «розумніші», ніж потрібно

За останні роки клієнтські бібліотеки Amazon навчилися використовувати нові механізми підпису запитів.
Наприклад:
  • streaming signatures;
  • trailer checksums;
  • додаткові перевірки цілісності.
Amazon S3 чудово все це підтримує.
Але далеко не кожне self-hosted сховище.
У результаті можна отримати дуже дивну ситуацію.
Ключі доступу правильні.
Час синхронізований.
Права теж правильні.
А сервер відповідає:
SignatureDoesNotMatch
Через це легко почати шукати проблему в IAM, ключах або часових поясах, хоча справжня причина зовсім інша.
Якщо ви зіткнулися з подібною поведінкою, спробуйте вимкнути нові режими підпису або скористатися нативним інструментом імпорту самого сховища.
Іноді це економить години пошуку неіснуючої помилки.

Content-Type - дрібниця, яка ламає картинки

Ще одна несподівана проблема - MIME-типи.
Багато інструментів під час копіювання намагаються визначити тип файлу самостійно.
Найчастіше вони аналізують перші байти файлу.
Для PNG або JPEG це працює чудово.
Але сучасні формати на кшталт AVIF або HEIC визначаються далеко не завжди.
У результаті файл потрапляє до нового сховища з таким заголовком:
Content-Type: application/octet-stream
Браузер уже не знає, що це зображення.
В одному випадку він запропонує завантажити файл.
В іншому - просто відмовиться його показувати.
Найкращий варіант - взагалі не визначати MIME-тип повторно, а переносити його разом із метаданими оригінального об'єкта.
Саме вони є джерелом істини.

Попередньо стиснуті файли - окрема історія

Якщо у сховищі є JavaScript, CSS або інші текстові файли, які під час завантаження вже були стиснуті (gzip або brotli), варто окремо перевірити, чи нове сховище коректно працює з відповідними HTTP-заголовками.
Насамперед мова про:
  • Content-Encoding
  • Content-Type
Amazon S3 зберігає ці метадані без сюрпризів. Але деякі self-hosted реалізації поводяться менш передбачувано - особливо якщо файли були завантажені сторонніми інструментами.
У результаті браузер може отримати не розпакований JavaScript, а буквально потік стиснутих байтів.
Помилка виглядає досить дивно.
Замість очікуваного JavaScript консоль браузера показує щось на кшталт:
Failed to load module script:
Expected a JavaScript module but the server responded with
application/octet-stream
або
Unexpected token
На перший погляд здається, що проблема у збірці фронтенду.
Насправді ж файл існує, але сервер віддає його з неправильними метаданими.

Найпростіше рішення

Якщо перед вашим сервером уже працює CDN (Cloudflare чи аналог), попереднє стиснення файлів часто взагалі не потрібне.
Сучасні CDN самі стискають текстовий контент під час видачі:
  • gzip;
  • brotli;
  • zstd (залежно від клієнта).
Тому відмова від попередньо стиснутих файлів нерідко навіть спрощує інфраструктуру й прибирає цілий клас потенційних проблем.

Не всі помилки видно під час копіювання

Коли бакет містить кілька тисяч файлів, міграція зазвичай проходить без пригод.
Але якщо їх десятки або сотні тисяч, виникає спокуса максимально збільшити паралельність.
Це правильно.
Інакше копіювання може тривати багато годин.
Проте є інша проблема.
За великої кількості одночасних запитів частина з них майже неминуче завершиться помилкою.
Причини можуть бути різні:
  • короткочасний мережевий збій;
  • перевантаження дискової підсистеми;
  • тимчасова недоступність сервісу;
  • внутрішні таймаути.
У більшості випадків це будуть десяті або навіть соті частки відсотка.
Звучить нестрашно.
Але якщо ви переносите мільйон файлів, навіть 0,05% - це вже сотні відсутніх об'єктів.
Найнебезпечніше те, що деякі інструменти можуть не вважати такі ситуації критичними й завершувати роботу без явної помилки.
Через це після завершення міграції обов'язково потрібно перевірити результат.
Не просто довіритися повідомленню:
Transfer completed successfully
а переконатися, що всі файли справді доїхали.

Перевірка кількості файлів - це не формальність

Після завершення копіювання варто відповісти на дуже просте питання:
Чи однакова кількість об'єктів у старому та новому сховищі?
Якщо відповідь "так" - чудово.
Якщо ні - не поспішайте запускати міграцію заново.
Набагато ефективніше знайти саме відсутні об'єкти й виконати ще один прохід.
Повторне копіювання всього бакета заради кількох десятків або сотень файлів зазвичай лише даремно витрачає час.
Саме тому фінальна "дозаливка" - абсолютно нормальна практика навіть для великих компаній.

Іноді бреше навіть інструмент перевірки

Є ще одна несподіванка, про яку легко забути.
На дуже великих бакетах самі інструменти підрахунку можуть працювати некоректно.
Наприклад, вони можуть:
  • мати внутрішній ліміт на кількість записів;
  • обрізати результат після певної позначки;
  • кешувати список об'єктів.
У такій ситуації здається, що після чергової синхронізації кількість файлів не збільшилася.
Хоча насправді нові об'єкти вже давно присутні в сховищі.
Якщо бачите дивну статистику, не варто одразу панікувати.
Краще перевірити кілька конкретних файлів напряму.
Наприклад:
  • чи відкривається URL;
  • чи повертається правильний Content-Type;
  • чи збігається контрольна сума.
Дуже часто виявляється, що проблема не в міграції, а в самому інструменті, який показує статистику.

Найнебезпечніший момент - перемикання

Після успішного копіювання здається, що найважча частина вже позаду.
Насправді ні.
Найбільший ризик виникає саме в момент перемикання застосунку на нове сховище.
Дуже часто виникає спокуса зробити це поступово.
Наприклад:
  1. спочатку переключити читання;
  2. переконатися, що все працює;
  3. лише потім переключити запис.
Звучить логічно.
Але це одна з найпоширеніших помилок.

Чому поступове перемикання небезпечне

У більшості застосунків URL файлу генерується з двох частин:
  • ключа об'єкта;
  • базового домену сховища.
Якщо читання й запис починають використовувати різні сховища, виникає дуже неприємне вікно неузгодженості.
Уявімо ситуацію.
Користувач завантажує нову аватарку.
Застосунок уже записує її в нове сховище.
Але сторінка профілю все ще формує URL старого S3.
Файл фізично існує.
Проте користувач бачить лише:
404 Not Found
Через кілька хвилин інший користувач відкриває ту саму сторінку.
У нього вже все працює.
У результаті проблема виглядає випадковою, її важко відтворити, а ще важче пояснити.

Правильна послідовність переходу

Набагато безпечніше виконати міграцію в три етапи.

1. Повністю підготувати нову інфраструктуру

Розгортаємо:
  • сховище;
  • nginx;
  • CDN;
  • публічний домен;
  • моніторинг.
При цьому застосунок усе ще працює зі старим S3.

2. Перенести всі дані

Виконуємо повну синхронізацію.
Безпосередньо перед деплоєм запускаємо ще одну коротку синхронізацію, щоб перенести файли, які користувачі завантажили під час першого копіювання.
Саме ця "дельта" робить перемикання практично безболісним.

3. Один деплой — одна зміна

Лише після цього застосунок одночасно змінює:
  • endpoint для запису;
  • endpoint для читання.
Не дві окремі зміни.
Не два різні релізи.
Один атомарний деплой.
Саме так практично не залишається моменту, коли нові файли можуть записуватися в одне місце, а читатися з іншого.

Чек-лист перед вимкненням старого S3

Перш ніж остаточно відмовитися від Amazon S3, варто пройтися невеликим чек-листом. Більшість пунктів здаються очевидними, але саме вони найчастіше стають причиною проблем уже після перемикання.
  • Публічний домен не віддає список файлів. Запит до / або до директорії повертає 403 Forbidden, а не перелік об'єктів.
  • Через публічний домен неможливо нічого записати. Запити PUT, POST, DELETE та інші методи, окрім GET і HEAD, блокуються ще на рівні nginx.
  • S3 API не є публічним. Якщо до нього все ж є доступ, кожен запит обов'язково вимагає коректної автентифікації за AWS Signature V4.
  • CDN дійсно кешує файли. Перевірте заголовки відповіді (CF-Cache-Status, Age або аналогічні) та переконайтеся, що повторні запити не доходять до origin.
  • Content-Type усіх файлів збігається з оригіналом. Особливо це стосується AVIF, WebP, SVG, шрифтів та інших форматів, для яких неправильний MIME-тип може спричинити проблеми в браузері.
  • Заголовки стиснення не загубилися. Якщо використовуються попередньо стиснуті файли, перевірте Content-Encoding.
  • Усі об'єкти перенесено. Кількість файлів у старому й новому сховищі збігається, а випадкова вибіркова перевірка підтверджує, що файли відкриваються й віддають правильні метадані.
  • Виконано фінальну синхронізацію. Перед самим деплоєм перенесено всі файли, які користувачі встигли завантажити під час основної міграції.
Якщо всі пункти виконані 0 саме час переключати застосунок.

Не поспішайте видаляти старий бакет

Після успішного деплою виникає природне бажання одразу видалити старий бакет і перестати за нього платити.
Я б цього не робив. Принаймні місяць-два.
Amazon S3 - це не просто місце, де лежать файли. Це сервіс із високою відмовостійкістю, реплікацією та інфраструктурою, яку складно відтворити на одному VPS.
Натомість власне сховище, особливо якщо воно працює на одному сервері, має єдину точку відмови.
Навіть якщо ви регулярно створюєте резервні копії, перші кілька тижнів після міграції варто залишити старий бакет як додаткову страховку.
Це дає одразу кілька переваг:
  • можна швидко знайти будь-який файл, якщо виникне підозра на помилку під час міграції;
  • легко порівняти метадані або контрольні суми;
  • у крайньому випадку завжди можна швидко повернутися до попередньої конфігурації.
Після того як нове сховище пропрацює певний час без проблем, а резервне копіювання буде перевірене на практиці, старий бакет можна спокійно видаляти.

Чи варто взагалі переїжджати?

Як і майже будь-яке архітектурне рішення, це не універсальна рекомендація.
Якщо ваш сервіс обслуговує мільйони користувачів, використовує десятки регіонів AWS або потребує гарантованої доступності, власне сховище навряд чи буде кращою альтернативою.
Але для невеликих і середніх проєктів ситуація зовсім інша.
Якщо застосунок уже працює на власному сервері, а файлове сховище не потребує глобальної реплікації, перехід на self-hosted рішення може дати цілком відчутні переваги:
  • фіксовані витрати замість тарифікації за кожен запит;
  • відсутність рахунків за вихідний трафік;
  • повний контроль над інфраструктурою;
  • незалежність від конкретного хмарного провайдера.
Найприємніше те, що завдяки S3-сумісному API така міграція не вимагає переписувати застосунок. У більшості випадків достатньо змінити endpoint і облікові дані.
На перший погляд здається, що перенесення файлового сховища - це просто копіювання даних із одного бакета до іншого.
Насправді основна складність полягає не в копіюванні файлів.
Найважливіше - правильно побудувати архітектуру.
Відокремити запис від читання.
Не відкривати S3 API назовні.
Не дозволяти листинг директорій.
Коректно налаштувати кешування.
Перевірити метадані файлів після міграції.
І лише після цього виконати одне атомарне перемикання застосунку.
Якщо все зробити саме в такій послідовності, перехід займає значно менше часу, ніж здається на початку. Натомість ви отримуєте повний контроль над файловим сховищем, передбачувані витрати та інфраструктуру, яку можете розвивати незалежно від тарифів конкретного хмарного провайдера.
Найголовніше - не сприймати міграцію як просте копіювання файлів. Це насамперед зміна архітектури. І саме від того, наскільки уважно буде продумана ця архітектура, залежить, чи помітять користувачі переїзд узагалі.

🔥 Більше дописів

Всі публікації
Elasticsearch, OpenSearch та альтернативи
Програмування5 лип. '26, 10:36

Elasticsearch, OpenSearch та альтернативи

Уявіть, що у вас є мільйон документів. Ви хочете знайти всі, де є слово «кава», але тільки ті, де...