AWS

Amazon S3 — Simple Storage Service

Повний посібник з Amazon S3 для .NET і React розробників. Buckets, Storage Classes, Versioning, Lifecycle Policies, безпека, статичний хостинг, CORS, Presigned URLs, SDK for .NET та медіа-стрімінг HLS/DASH.

Amazon S3 — Simple Storage Service

Що таке S3 і чому він один із найважливіших сервісів AWS

Amazon S3 (Simple Storage Service) — це об'єктне сховище з необмеженою місткістю, доступністю 99.999999999% (дев'ять дев'яток!), і ціною від $0.023 за гігабайт на місяць. За цими сухими цифрами стоїть один із найвпливовіших хмарних сервісів в історії IT.

S3 запустили у 2006 році — і він докорінно змінив підхід до зберігання даних. До S3 компанії купували NAS (Network Attached Storage) або будували власні файлові сервери: дорого, складно масштабувати, єдина точка відмови. S3 запропонував: завантажуй скільки хочеш файлів — від одного байта до терабайтів — і плати лише за те, що використовуєш.

Сьогодні S3 використовується для:

  • Зберігання зображень, відео, PDF та будь-яких файлів для веб-застосунків
  • Хостингу статичних веб-сайтів та React/Vue/Angular SPA
  • Сховища резервних копій баз даних та серверів
  • Дистрибуції медіаконтенту (відео HLS/DASH потоки)
  • Data Lake для аналітики (Athena, EMR, SageMaker читають з S3)
  • Логів CloudTrail, ALB, CloudFront
  • Артефактів CI/CD (збірки, Docker образи через ECR який теж базується на S3)
  • Static website hosting для фронтенд SPA
S3 — це не файлова система. Тут немає справжніх директорій, немає POSIX операцій, немає блокувань файлів. Це об'єктне сховище: ви зберігаєте об'єкти (файли + метадані) і отримуєте їх за унікальним ключем. Це принципово важливо для розуміння.

Bucket, Object, Key — фундаментальні концепції

Bucket — контейнер для об'єктів

Bucket — це верхньорівневий контейнер для зберігання об'єктів в S3. Думайте про bucket як про «диск» або «кореневу директорію». Кожен bucket:

  • Має унікальну глобальну назву — унікальну серед всіх AWS клієнтів у всьому світі. Якщо назва photos вже зайнята кимось — ви не зможете створити bucket з такою ж назвою.
  • Прив'язаний до конкретного регіону (eu-central-1, us-east-1 тощо). Дані фізично зберігаються у цьому регіоні.
  • Може містити необмежену кількість об'єктів, але один об'єкт не може перевищувати 5 TB.
  • Назва bucket може містити лише малі літери, цифри та дефіси (my-bucket-2024), довжина 3–63 символи.

Правила іменування bucket: lowercase, лише a-z, 0-9, -. Не може починатись або закінчуватись на -. Не може містити _ або великі літери. Не може виглядати як IP-адреса.

Object — файл з метаданими

Object (об'єкт) — це файл плюс метадані про цей файл. Кожен об'єкт складається з:

  • Data (Body): власне вміст файлу — від 0 байтів до 5 TB
  • Key: унікальний ідентифікатор об'єкту у bucket (докладніше нижче)
  • Metadata: набір пар ключ-значення з інформацією про об'єкт
    • System metadata: Content-Type, Content-Length, Last-Modified, ETag
    • User metadata: будь-які дані, наприклад x-amz-meta-author: Ivan
  • Version ID: якщо включене версіонування — унікальний ID версії
  • Storage Class: клас зберігання (Standard, IA, Glacier тощо)

Key — адреса об'єкту

Key — це рядок, що однозначно ідентифікує об'єкт у bucket. Key може виглядати як шлях з «директоріями»:

photos/2024/january/birthday.jpg
uploads/users/ivan/avatar.png
reports/monthly/2024-01-report.pdf

Насправді ніяких реальних директорій немає — є лише рядок з / символами. S3 Console та AWS CLI лише імітують директорії для зручності, фільтруючи об'єкти за префіксом. Але для програми photos/2024/january/birthday.jpg — це просто рядок-ключ.

Повна адреса об'єкту (URL):

https://my-bucket.s3.eu-central-1.amazonaws.com/photos/2024/january/birthday.jpg
або через path-style (застарілий):
https://s3.eu-central-1.amazonaws.com/my-bucket/photos/2024/january/birthday.jpg
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

package "AWS S3" {
    package "Bucket: my-app-storage\n(eu-central-1)" as B #dbeafe {
        rectangle "Object: uploads/avatar.png\nSize: 234 KB | Class: Standard\nMetadata: Content-Type: image/png" as O1 #bbf7d0
        rectangle "Object: reports/jan-2024.pdf\nSize: 1.2 MB | Class: Standard-IA\nMetadata: Content-Type: application/pdf" as O2 #bbf7d0
        rectangle "Object: videos/intro.mp4\nSize: 450 MB | Class: Intelligent-Tiering" as O3 #fef3c7
    }
}

note bottom of B
  Bucket name глобально унікальний
  Регіон: eu-central-1
  Об'єктів: необмежено
end note
@enduml

S3 Storage Classes — класи зберігання

Storage Class — це рівень зберігання об'єкту, який визначає баланс між вартістю зберігання, вартістю отримання та часом доступу. AWS пропонує кілька класів, кожен оптимізований для різних патернів використання.

S3 Standard — для активних даних

Вартість: ~$0.023/GB/місяць (eu-central-1) Доступність: 99.99%, Durability: 99.999999999% (11 дев'яток) Затримка: мілісекунди Мінімум зберігання: немає

Зберігає дані у мінімум трьох Availability Zones. Ідеальний для даних, до яких часто звертаються: аватари, завантажені файли, артефакти CI/CD, логи.

Типові сценарії використання Standard:

  • SaaS-платформа з документами: користувач щойно завантажив файл → фронтенд одразу відображає його. Він може відкривати документ кілька разів на день — потрібна найнижча затримка.
  • E-commerce продуктові зображення: кожна сторінка товару завантажує 5–10 фото. Мільйони запитів на день — будь-яка затримка читання відразу відчутна у conversion rate.
  • Артефакти CI/CD: Jenkins або GitHub Actions зберігають ZIP-архів після кожного pipeline. Повторний деплой через 2 хвилини потягне його знову — не може бути IA.
  • Активні логи застосунку: CloudWatch або Datadog парсять логи в реальному часі — дані читаються одразу після запису.

Коли Standard — неправильний вибір:

Якщо об'єкт після завантаження лежить невитребуваним більше місяця — ви переплачуєте. Наприклад, щомісячний дамп бази на 50 GB займає Standard і ніколи не читається → $1.15/місяць замість $0.63 при Standard-IA.

Текстова демо: вартість SaaS-платформиУявіть: ваш застосунок зберігає аватари та документи. 1 000 активних користувачів, по 5 MB в середньому:
  • Зберігання: 1000 × 5 MB = 5 GB × $0.023 = $0.115/місяць
  • 50 000 GET запитів / місяць = безкоштовно (Standard не тарифікує GET за вибірку з S3 в рамках Free Tier, а зверху — $0.0004/1000 запитів)
  • Загалом: < $1 на місяць за файлове сховище

S3 Standard-IA — для рідкісного доступу

IA = Infrequent AccessВартість: ~$0.0125/GB/місяць (зберігання дешевше), але є додаткова плата за кожне читання ($0.01/GB retrieved) Мінімум зберігання: 30 днів (якщо видалити раніше — все одно заплатите за 30 днів)

Для резервних копій, старих логів, даних що рідко переглядаються, але потрібні при потребі. Вигідний якщо дані зберігаються 30+ днів і читаються не частіше ніж раз на місяць.

Типові сценарії використання Standard-IA:

  • Щотижневі дампи PostgreSQL: база на 20 GB дампується щонеділі, зберігається 3 місяці. Читається лише якщо трапляється інцидент — 1–2 рази на квартал. Ідеальний кандидат.
  • Сезонні дані: рітейл-платформа, дані транзакцій за минулий рік. Потрібні для звіту раз на рік — але мають бути досяжними за мілісекунди, а не годинами.
  • Disaster recovery копії: вторинна копія об'єктів, що вже є в Standard у іншому регіоні. Читається лише при збої основного сховища.
  • Processed ML datasets: 500 GB датасет оброблено, модель натренована. Може знадобитись через 2 місяці для re-training.

Пастка Standard-IA — маленькі файли, що часто читаються:

Мінімальна одиниця тарифікації читання — 128 KB. Якщо ваш файл 2 KB і він читається часто — ви платите як за 128 KB кожного разу. Плюс $0.01/GB retrieved. Для файлів < 128 KB і частого доступу Standard вигідніший.Приклад: тисяча 4-KB thumbnail-зображень у Standard-IA, до яких звертаються щохвилини — це дорожче, ніж Standard.
Текстова демо: вибір між Standard і Standard-IAСценарій: 1 TB backup-файлів, читаються 1 раз на місяць (50 GB retrieved):
КласЗберіганняЧитанняРазом/місяць
Standard1024 GB × $0.023 = $23.5550 GB × $0 = $0$23.55
Standard-IA1024 GB × $0.0125 = $12.8050 GB × $0.01 = $0.50$13.30
Економія: $10.25/місяць$123/рік тільки за рахунок зміни класу.

S3 One Zone-IA — один AZ, ще дешевше

Вартість: ~$0.01/GB/місяць Зберігає лише в одному Availability Zone — якщо AZ вийде з ладу, дані недоступні (але не втрачаються). Для відтворюваних даних (ескізи зображень, тимчасові файли).

Типові сценарії використання One Zone-IA:

  • Image thumbnails: у вас є 100 000 фото від користувачів у Standard, а thumbnail 200×200 генерується динамічно при першому запиті і кешується. Якщо AZ впаде — thumbnail просто перегенерується, нічого не втрачається.
  • Build cache: артефакти .gradle, .npm, vendor/ після CI-пайплайну. Якщо щось зникне — наступний build просто завантажить заново.
  • Staging environment assets: дані для staging, який і так відтворюється з нуля при потребі.

Коли One Zone-IA заборонений:

Якщо даних немає в іншому місці — ніколи не використовуйте One Zone-IA. Збій одного AZ (це рідкість, але трапляється) зробить дані недоступними. Для critical storage завжди вибирайте multi-AZ класи.

Текстова демо: економія на thumbnails500 GB thumbnail зображень, читаються 20 GB/місяць:
КласЗберіганняЧитанняРазом
Standard-IA$6.25$0.20$6.45
One Zone-IA$5.00$0.20$5.20
Економія скромна ($1.25/місяць), але при терабайтах thumbnail — вже $30–50 на місяць.

S3 Intelligent-Tiering — автоматичний вибір класу

Вартість: аналогічна Standard/Standard-IA + невелика плата за моніторинг (~$0.0025/1000 об'єктів)

AWS автоматично переміщує об'єкти між рівнями:

  • Frequent Access tier: для активно використовуваних об'єктів
  • Infrequent Access tier: якщо об'єкт не читався 30 днів
  • Archive Instant Access: якщо не читався 90 днів
  • Archive Access: якщо не читався 90–180 днів

Ідеальний якщо патерн доступу непередбачуваний. AWS сам оптимізує витрати.

Типові сценарії використання Intelligent-Tiering:

  • Медіатека: відео-контент, де нові відео дивляться мільйони, а старі — рідко. Новий сезон серіалу потрапляє у Frequent Access, серіал 2018 року автоматично мігрує в Infrequent і далі в Archive Instant після 90 днів бездіяльності.
  • User-generated content: платформа зберігання документів. Більшість документів читаються одразу після завантаження, потім лежать роками. IT-команда не хоче вручну налаштовувати Lifecycle для кожного bucket.
  • Наукові датасети: великі CSV/Parquet файли. Одні активно використовуються для навчання моделей, інші — забуті. Непередбачувано.

Коли Intelligent-Tiering може бути зайвим:

Якщо у вас 10 000 маленьких файлів (< 128 KB кожен) — плата за моніторинг ($0.0025/1000 об'єктів) може перевищити економію на зберіганні. Для дрібних файлів з відомим патерном доступу — вручну призначте Standard-IA.

Текстова демо: Intelligent-Tiering vs ручний вибірМедіатека: 10 TB відео. 2 TB — активні (переглядаються), 8 TB — старий контент (майже не читається).
ПідхідВартість
Все у Standard10240 GB × $0.023 = $235.52/міс
Вручну: 2 TB Standard + 8 TB Standard-IA$47.10 + $102.40 = $149.50/міс
Intelligent-Tiering (авто)~$47.10 + $81.92 + $0.026 моніторинг ≈ $129.05/міс
IT-tiering сам «знайде» ті 8 TB неактивного контенту без жодної конфігурації.

S3 Glacier — для архівів

S3 Glacier Instant Retrieval: ~$0.004/GB/місяць. Миттєвий доступ, але дорожче за читання. Для архівів, до яких звертаються раз на квартал.

S3 Glacier Flexible Retrieval: ~$0.0036/GB/місяць. Відновлення займає від 1 хвилини до 12 годин (залежно від режиму). Для довгострокових архівів, compliance.

S3 Glacier Deep Archive: ~$0.00099/GB/місяць — найдешевший клас. Відновлення займає 12–48 годин. Для даних що зберігаються роками і майже ніколи не читаються.

Режими відновлення Glacier Flexible (впливають на вартість та час):

РежимЧас відновленняВартість відновлення (+ вибірка)Використання
Expedited1–5 хвилин$0.03/GB + $0.01/1000 reqТермінові, невеликі об'єкти
Standard3–5 годин$0.01/GB + $0.05/1000 reqЗвичайне відновлення
Bulk5–12 годин$0.0025/GB + $0.025/1000 reqМасове відновлення, найдешевше

Типові сценарії використання Glacier:

  • Glacier Instant Retrieval: медичні знімки (DICOM), до яких можуть звернутись будь-якої миті, але в середньому — раз на рік. Лікар вводить номер пацієнта — знімок завантажується за мілісекунди.
  • Glacier Flexible: фінансова звітність за 7 років (вимога законодавства). Відновлення потрібне лише при аудиті — раз на 2–3 роки, можна почекати 3–5 годин.
  • Glacier Deep Archive: медичні карти, юридичні договори, відеозаписи з камер відеоспостереження за попередні роки. GDPR і HIPAA вимагають зберігати дані — витяги з них майже ніколи не потрібні.

Підводні камені Glacier:

Мінімальний термін зберігання: Glacier Flexible — 90 днів, Deep Archive — 180 днів. Якщо видалите раніше — AWS нараховує плату ніби дані пролежали весь мінімальний термін. Не кладіть в Glacier те, що може знадобитись видалити через місяць.Вартість читання може перевищити зберігання: 1 TB у Deep Archive = $1.01/місяць зберігання. Але якщо хтось помилково вирішить прочитати весь 1 TB через Expedited режим Flexible — виставка рахунку на $30+ за одноразове відновлення.
Текстова демо: compliance архів на 10 роківНотаріальна контора зобов'язана зберігати скановані документи 10 років. Накопичується 5 GB на місяць.Після 10 років: 600 GB у Glacier Deep Archive:
  • Зберігання: 600 GB × $0.00099 = $0.59/місяць
  • Vs Standard: 600 GB × $0.023 = $13.80/місяць
  • Економія: $13.21/місяць$1585 за 10 років тільки за переведення в архів

Порівняльна таблиця Storage Classes

КласВартість збер.ЗатримкаМінімум збер.Плата за читанняDurability
S3 Standard$0.023/GBмснемаєнемає11×9
S3 Standard-IA$0.0125/GBмс30 днів$0.01/GB11×9
S3 One Zone-IA$0.010/GBмс30 днів$0.01/GB11×9*
Intelligent-Tiering$0.023→$0.004/GBмс–хвилининемаєнемає11×9
Glacier Instant$0.004/GBмс90 днів$0.03/GB11×9
Glacier Flexible$0.0036/GB1хв–12год90 днів$0.01/GB11×9
Glacier Deep Archive$0.00099/GB12–48 год180 днів$0.02/GB11×9

* One Zone-IA зберігає дані лише в одному AZ — при збої AZ дані стають тимчасово недоступними.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
title "S3 Storage Classes: ієрархія вартості та затримки доступу"

rectangle "АКТИВНІ ДАНІ\n(часто читаються)" as ACTIVE #d1fae5 {
    rectangle "S3 Standard\n$0.023/GB\nLatency: мілісекунди\nMin storage: немає" as STD #bbf7d0
}

rectangle "НЕЧАСТІ ДАНІ\n(рідко читаються)" as INFREQ #fef3c7 {
    rectangle "Standard-IA\n$0.0125/GB\n+$0.01/GB retrieved\nMin: 30 днів" as SIA #fef3c7
    rectangle "One Zone-IA\n$0.010/GB\n+$0.01/GB retrieved\nОдин AZ" as OZIA #fef3c7
}

rectangle "АВТОМАТИЧНА ОПТИМІЗАЦІЯ" as AUTO #dbeafe {
    rectangle "Intelligent-Tiering\n$0.023→$0.004/GB\nАвто між рівнями\n+$0.0025/1000 obj" as IT #dbeafe
}

rectangle "АРХІВ\n(майже не читаються)" as ARCH #fce7f3 {
    rectangle "Glacier Instant\n$0.004/GB\n+$0.03/GB\nMin: 90 днів" as GI #fce7f3
    rectangle "Glacier Flexible\n$0.0036/GB\n1хв–12год відновл.\nMin: 90 днів" as GF #fce7f3
    rectangle "Glacier Deep Archive\n$0.00099/GB\n12–48год відновл.\nMin: 180 днів" as GDA #fca5a5
}

STD -down-> SIA : Lifecycle:\n30+ днів без доступу
SIA -down-> GF : Lifecycle:\n90+ днів
GF -down-> GDA : Lifecycle:\n365+ днів

note bottom of GDA
  Найдешевший клас.
  Для compliance, юридичних
  архівів, медичних записів.
end note

@enduml

Частий доступ

Standard — $0.023/GB. Зображення, відео, активні файли, веб-ресурси.

Рідкий доступ

Standard-IA — $0.0125/GB. Резервні копії, старі логи, рідко використовувані файли.

Архів

Glacier Deep Archive — $0.001/GB. Відповідність регуляторним вимогам, юридичні архіви.

Невідомий патерн

Intelligent-Tiering — автоматична оптимізація. Якщо не знаєте частоту доступу.

Як обрати Storage Class: практичні сценарії

Нижче — 4 реальних юзкейси з підрахунком вартості та відповіддю «що вибрати і чому».

Сценарій 1: 500 GB старих логів, читаємо 1 раз на рік
# Маємо
Розмір: 500 GB
Читання: ~50 GB/рік (аудит раз на рік)
Зберігаємо: 3 роки
# Розрахунок Standard (не оптимально)
Зберігання: 500 GB × $0.023 = $11.50/міс
Читання: $0 (включено)
За 3 роки: $414.00
# Розрахунок Glacier Flexible (оптимально)
Зберігання: 500 GB × $0.0036 = $1.80/міс
Читання: 50 GB × $0.01 (Standard retrieval) = $0.50/рік
За 3 роки: $65.30
Рішення: Glacier Flexible. Економія: $348.70 за 3 роки
Але: читання займе 3–5 годин — прийнятно для аудиту
Сценарій 2: Фотосток — 50% нових, 50% старих фото
# Маємо
Всього: 10 TB фото (зростає на 100 GB/міс)
Нові фото: 5 TB — читаються щодня (Standard)
Старі фото: 5 TB — рідко (раз на кілька місяців)
# Варіант A: все у Standard
10240 GB × $0.023 = $235.52/міс
# Варіант B: Lifecycle — Standard → Standard-IA (після 30 днів)
5120 GB Standard: 5120 × $0.023 = $117.76
5120 GB Standard-IA: 5120 × $0.0125 = $64.00
Читання зі Standard-IA: ~100 GB × $0.01 = $1.00
Разом: $182.76/міс
# Варіант C: Intelligent-Tiering (без налаштувань)
~5120 Frequent + ~5120 Infrequent + моніторинг
≈ $117.76 + $51.20 + $0.256 = $169.22/міс
Рішення: Intelligent-Tiering. $66/міс → $792/рік економії
Плюс: не треба вручну налаштовувати Lifecycle для кожного bucket
Сценарій 3: PostgreSQL бекапи — щоденні, зберігаємо 3 місяці
# Маємо
Розмір бекапу: 15 GB (gzip dump)
Частота: щодня (90 бекапів за 3 місяці)
Читання: лише при інцидент (1–2 рази на квартал)
Загальний розмір: ~1350 GB
# Standard-IA vs Standard
Standard: 1350 × $0.023 = $31.05/міс
Standard-IA: 1350 × $0.0125 + 15 GB × $0.01 (retrieval) = $17.03
Але! 128 KB мінімум × 1350 файлів = тарифікується коректно
Standard-IA: $17.18/міс
Рішення: Standard-IA з Lifecycle для видалення після 90 днів
Економія: $13.87/міс → $166/рік
Сценарій 4: GDPR compliance — документи 7 років, майже ніколи не читаються
# Маємо
Розмір: 200 GB/рік (сканування договорів)
Термін: 7 років (регуляторна вимога)
Читання: при аудиті — 1–2 рази за весь термін
# Порівняння після 7 років (накопичено 1400 GB)
Standard: 1400 × $0.023 = $32.20/міс
Glacier Flexible: 1400 × $0.0036 = $5.04/міс
Deep Archive: 1400 × $0.00099 = $1.39/міс
Рішення: Deep Archive з S3 Object Lock (WORM)
При аудиті: Bulk retrieval (5–12 год) — $0.0025/GB retrieval
200 GB для перевірки: $0.50 одноразово
Загальна економія vs Standard за 7 років: ~$2500

Як встановити Storage Class

# Завантажити з конкретним класом
aws s3 cp backup.sql.gz s3://my-bucket/backups/ \
  --storage-class STANDARD_IA

# Доступні значення:
# STANDARD | REDUCED_REDUNDANCY | STANDARD_IA | ONEZONE_IA
# INTELLIGENT_TIERING | GLACIER | DEEP_ARCHIVE | GLACIER_IR

# Синхронізувати папку з класом
aws s3 sync ./logs/ s3://my-bucket/logs/ \
  --storage-class INTELLIGENT_TIERING
При міграції існуючих об'єктів між класами через CLI або SDK фактично виконується copy-in-place — об'єкт копіюється сам у себе з новим storage class. Це генерує PUT-запит (оплачується). Для масової міграції тисяч об'єктів використовуйте S3 Batch Operations — дешевше та ефективніше.

S3 Versioning — збереження всіх версій об'єктів

S3 Versioning — це механізм захисту даних від випадкового видалення та перезапису. Коли версіонування увімкнено, Amazon S3 зберігає кожну версію кожного об'єкту у bucket, призначаючи кожній унікальний ідентифікатор — Version ID. При повторному завантаженні об'єкту з тим самим ключем попередня версія не перезаписується, а стає «некурентною» (noncurrent), і нова версія набуває статусу поточної.

Концепція Delete Marker. Видалення об'єкту при увімкненому версіонуванні не знищує дані фізично. Натомість S3 додає спеціальний Delete Marker — порожній маркерний об'єкт без тіла, що стає поточною «версією» з цим ключем. Будь-який GET-запит на цей ключ повертатиме HTTP 404, однак усі попередні версії залишаються фізично збереженими у bucket. Для відновлення достатньо видалити Delete Marker.

Стани Versioning:

  • Suspended (за замовчуванням): версіонування вимкнене. Нові об'єкти зберігаються без Version ID (значення null). Якщо bucket раніше був версіонованим — старі версії залишаються.
  • Enabled: кожен PUT-запит створює нову версію з унікальним Version ID. Після увімкнення — не можна повністю вимкнути. Можна лише перевести у Suspended (нові версії не створюються, старі зберігаються).

Навіщо потрібне версіонування:

  • Захист від випадкового видалення: видалений об'єкт отримує Delete Marker — відновлення тривіальне
  • Захист від перезапису: можна повернутись до будь-якої попередньої версії файлу
  • Регуляторна відповідність (GDPR, SOC 2, ISO 27001, HIPAA): зберігати незмінювані версії документів
  • Відкат деплою: якщо новий build фронтенду зламав сайт — повернути попередню версію за секунди
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
title "S3 Versioning: структура версій при PUT та DELETE операціях"

package "Bucket: my-app-bucket (Versioning: Enabled)" as BKT {

    package "Key: uploads/avatar.png" as K1 {
        rectangle "VersionId: Abc1xZy3\n◀ CURRENT (IsLatest: true)\nSize: 89 KB | Uploaded: May 20" as V1 #bbf7d0
        rectangle "VersionId: mK8pQrT2\n(noncurrent)\nSize: 76 KB | Uploaded: May 15" as V2 #fef3c7
        rectangle "VersionId: nJ5wLvR9\n(noncurrent)\nSize: 81 KB | Uploaded: May 5" as V3 #e5e7eb
    }

    package "Key: config/app.json" as K2 {
        rectangle "DELETE MARKER\n◀ CURRENT (IsLatest: true)\n(нема тіла — GET → 404)" as DM #fca5a5
        rectangle "VersionId: zT6hMwK1\n(noncurrent — дані збережені!)\nSize: 12 KB" as V4 #fef3c7
    }
}

V1 -[hidden]down- V2
V2 -[hidden]down- V3
DM -[hidden]down- V4

note bottom of K2
  Відновлення config/app.json:
  1. aws s3api delete-object --version-id <DM_ID>
  2. Об'єкт знову доступний — дані не втрачались!
end note

note right of V3
  Lifecycle Policy:
  NoncurrentVersionExpiration.NoncurrentDays: 30
  → автоматично видалить noncurrent
  версії старші 30 днів
end note

@enduml

Увімкнення версіонування:

  1. Відкрийте S3 → оберіть bucket → вкладка Properties
  2. Прокрутіть до секції Bucket VersioningEdit
  3. Оберіть EnableSave changes

Перегляд версій об'єктів:

  1. S3 → bucket → у верхньому правому куті ввімкніть перемикач Show versions
  2. У списку з'являться всі версії кожного файлу та Delete Markers (з міткою Delete marker)
  3. Стовпець Version ID — унікальний ідентифікатор версії

Відновлення «видаленого» об'єкту (видалення Delete Marker):

  1. Bucket → Show versions: ON
  2. Знайдіть рядок з міткою Delete marker для потрібного ключа
  3. Оберіть його прапорцем → Delete → підтвердіть
  4. Об'єкт знову доступний — попередня версія автоматично стала поточною

Відновлення конкретної версії:

  1. Show versions: ON → знайдіть потрібну версію
  2. Клікніть на Version ID → Download або Copy S3 URI
  3. Щоб зробити її поточною: завантажте та повторно завантажте (або скопіюйте через CLI copy-object)
aws s3api list-object-versions
$ aws s3api list-object-versions --bucket my-app-bucket --prefix "uploads/avatar.png" ...
{
"Versions": [
{
"Key": "uploads/avatar.png",
"VersionId": "Abc1xZy3mno456pqr",
"IsLatest": true,
"Size": 91136
},
{
"Key": "uploads/avatar.png",
"VersionId": "mK8pQrT2xyz789abc",
"IsLatest": false,
"Size": 77824
}
],
"DeleteMarkers": []
}
Версіонування суттєво збільшує витрати на зберігання: кожна версія кожного файлу займає місце і тарифікується окремо. Обов'язково налаштуйте Lifecycle Policy з NoncurrentVersionExpiration для автоматичного видалення старих версій — наприклад, зберігати не більше 5 попередніх версій і видаляти noncurrent старші 30 днів.

S3 Lifecycle Policies — автоматичне управління об'єктами

Lifecycle Policy — це набір правил, що автоматично переміщують або видаляють об'єкти через певний час. Механізм є ключовим інструментом оптимізації витрат: AWS оцінює правила щоночі і виконує переходи для об'єктів, що відповідають умовам.

Кожне правило складається з трьох компонентів: фільтру (до яких об'єктів застосовується), переходів (коли і в який клас перемістити) та закінчення терміну (коли видалити).

Типові сценарії:

  • Логи: Standard 30 днів → Standard-IA 90 днів → Glacier → видалення через 365 днів
  • Резервні копії БД: Standard 7 днів → Glacier Flexible 30 днів → Deep Archive 365 днів → видалення через 7 років
  • Старі версії при Versioning: зберігати поточну + 5 попередніх → видаляти noncurrent старші 30 днів
  • Незавершені Multipart Uploads: AbortIncompleteMultipartUpload після 7 днів — прибирає «сміття»
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
title "S3 Lifecycle: автоматична зміна Storage Class (правило для logs/)"

[*] --> Standard : PUT object
note on link
  День 0: файл завантажено
  до s3://bucket/logs/
end note

Standard --> StandardIA : Day 30
note on link
  Transition: STANDARD_IA
  Вартість знижується:
  $0.023 → $0.0125/GB
end note

StandardIA --> Glacier : Day 90
note on link
  Transition: GLACIER
  Вартість: $0.0036/GB
end note

Glacier --> Deleted : Day 365
note on link
  Expiration: Days 365
  Об'єкт видалено
end note

Deleted --> [*]

note "Noncurrent версії (при Versioning enabled):\nNoncurrentVersionExpiration:\n  NoncurrentDays: 30\n  NewerNoncurrentVersions: 5\n→ зберігати 5 версій, решту видалити" as N1

@enduml

Структура конфігурації Lifecycle Policy:

{
    "Rules": [
        {
            "ID": "LogsLifecycle",
            "Status": "Enabled",
            "Filter": { "Prefix": "logs/" },
            "Transitions": [
                { "Days": 30, "StorageClass": "STANDARD_IA" },
                { "Days": 90, "StorageClass": "GLACIER" }
            ],
            "Expiration": { "Days": 365 },
            "NoncurrentVersionExpiration": {
                "NoncurrentDays": 30,
                "NewerNoncurrentVersions": 5
            },
            "AbortIncompleteMultipartUpload": {
                "DaysAfterInitiation": 7
            }
        }
    ]
}

Документація полів правила:

ID
string required
Унікальний ідентифікатор правила в межах bucket. Максимум 255 символів. Використовується для ідентифікації у звітах та AWS CLI.
Status
string required
Стан правила: Enabled — активне, Disabled — призупинене. Дозволяє тимчасово вимкнути правило без видалення.
Filter
object
Фільтр об'єктів: {"Prefix": "logs/"} — за префіксом ключа; {"Tag": {"Key": "env", "Value": "prod"}} — за тегом; {} — всі об'єкти в bucket.
Transitions
array
Масив переходів між Storage Class. Поле Days — кількість днів від дати створення об'єкту (не від останнього доступу). StorageClass — цільовий клас: STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, GLACIER, DEEP_ARCHIVE.
Expiration
object
Автоматичне видалення поточних версій. {"Days": 365} — через 365 днів. {"ExpiredObjectDeleteMarker": true} — видаляти Delete Markers без noncurrent версій.
NoncurrentVersionExpiration
object
Управління старими версіями при увімкненому Versioning. NoncurrentDays — зберігати не більше N днів. NewerNoncurrentVersions — зберігати не більше N попередніх версій.
AbortIncompleteMultipartUpload
object
{"DaysAfterInitiation": 7} — автоматично скасовувати та видаляти незавершені multipart uploads старші 7 днів. Критично важливо для уникнення непомітних витрат на неповні завантаження.
  1. S3 → оберіть bucket → вкладка Management
  2. У секції Lifecycle rulesCreate lifecycle rule
  3. Rule name: LogsLifecycle
  4. Choose a rule scope: Limit the scope to specific prefixes or tags → вкажіть prefix logs/
  5. Lifecycle rule actions: оберіть:
    • Transition current versions of objects between storage classes
    • Expire current versions of objects
    • Delete expired object delete markers or incomplete multipart uploads
  6. У Transition current versions: додайте два переходи:
    • Standard-IA after 30 days
    • Glacier Flexible Retrieval after 90 days
  7. Expiration: ✅ → 30 days after object creation → enter 365
  8. Delete incomplete multipart uploads7 days
  9. Create rule
aws s3api get-bucket-lifecycle-configuration
$ aws s3api get-bucket-lifecycle-configuration --bucket my-app-bucket --region eu-central-1
{
"Rules": [
{
"ID": "LogsLifecycle",
"Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER" }
],
"Expiration": { "Days": 365 }
}
]
}

S3 Security — безпека та контроль доступу

Безпека Amazon S3 реалізована у вигляді багаторівневої моделі захисту. Кожен рівень є незалежним і може застосовуватись окремо або у комбінації з іншими. Помилки в налаштуванні S3 bucket стали причиною гучних витоків даних (Capital One, GoDaddy, NASA та інших організацій), тому розуміння всіх рівнів є критично необхідним.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
title "S3: багаторівнева модель захисту доступу"

actor "Зовнішній\nкористувач" as EXT #fca5a5
actor "IAM User/Role\n(внутрішній)" as IAM #bbf7d0

rectangle "AWS Account" as ACC {
    rectangle "Рівень 1: Block Public Access" as L1 #fce7f3 {
        note as N1
          BlockPublicPolicy=true
          RestrictPublicBuckets=true
          IgnorePublicAcls=true
          BlockPublicAcls=true
          ───────────────────────────
          Перевизначає ВСІ інші
          дозволи на публічний доступ
        end note
    }

    rectangle "Рівень 2: Bucket Policy (Resource-based)" as L2 #dbeafe {
        note as N2
          Principal: *, Role ARN, Account
          Action: s3:GetObject, s3:PutObject...
          Condition: IP, VPC, MFA, Tags
          ───────────────────────────
          Прикріплена до bucket.
          Може дозволяти крос-акаунтний доступ
        end note
    }

    rectangle "Рівень 3: IAM Policy (Identity-based)" as L3 #d1fae5 {
        note as N3
          Прикріплена до User/Role/Group.
          Оцінюється одночасно з Bucket Policy.
          Діє принцип LEAST PRIVILEGE:
          Allow потрібен в ОБОХ або в одному
          (якщо Principal — той самий акаунт)
        end note
    }

    rectangle "Рівень 4: Encryption at Rest" as L4 #fef3c7 {
        note as N4
          SSE-S3 (AES-256, ключ AWS)
          SSE-KMS (ваш ключ в KMS)
          SSE-C (ваш ключ per-request)
          ───────────────────────────
          Шифрує дані фізично на диску
        end note
    }

    rectangle "S3 Bucket" as BKT #e5e7eb
}

EXT -down-> L1 : HTTP/HTTPS запит
L1 -down-> L2 : (якщо не заблоковано)
L2 -down-> L3 : (якщо Policy дозволяє)
L3 -down-> L4 : (якщо IAM дозволяє)
L4 -down-> BKT : Розшифровані дані
IAM -down-> L2

@enduml

Block Public Access — перший захист

Block Public Access — це налаштування на рівні bucket та на рівні AWS-акаунту, яке перевизначає всі інші дозволи та гарантує, що bucket залишається приватним, навіть якщо існує Bucket Policy, що явно дозволяє публічний доступ. Цей механізм є першою і найважливішою лінією захисту.

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

BlockPublicAcls
boolean
Забороняє додавання нових ACL (Access Control Lists), що надають публічний доступ. PUT-запити з публічними ACL відхиляються з помилкою. Не впливає на вже існуючі ACL.
IgnorePublicAcls
boolean
Ігнорує всі існуючі публічні ACL на bucket та об'єктах. Навіть якщо ACL вже є і надають публічний доступ — вони ігноруються. Діє разом з BlockPublicAcls.
BlockPublicPolicy
boolean
Забороняє додавання нових Bucket Policy, що надають публічний доступ. PUT-запити на Bucket Policy, що містять публічні Principal, відхиляються.
RestrictPublicBuckets
boolean
Найбільш агресивний прапорець: обмежує доступ до bucket лише для AWS-сервісів та авторизованих Principal в тому ж акаунті, навіть якщо Bucket Policy вже існує і надає публічний доступ.

Налаштування Block Public Access для bucket:

  1. Відкрийте S3 → оберіть bucket → вкладка Permissions
  2. У секції Block public access (bucket settings)Edit
  3. Встановіть усі 4 прапорці ✅ (рекомендовано для всіх bucket з приватними даними)
  4. Save changes → введіть confirm у діалозі підтвердження

Налаштування Block Public Access на рівні акаунту (рекомендовано):

  1. Відкрийте S3 → у лівому меню Block Public Access settings for this account
  2. Edit → встановіть усі 4 прапорці
  3. Save changesconfirm

Налаштування на рівні акаунту застосовується до ВСІХ поточних та майбутніх bucket в акаунті. Це найбезпечніший підхід: навіть якщо хтось помилково створить bucket без захисту — акаунтовий Block Public Access захистить його.

aws s3api get-public-access-block
$ aws s3api get-public-access-block --bucket my-app-bucket --region eu-central-1
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
AWS рекомендує: усі bucket за замовчуванням приватні. Увімкнення публічного доступу — навмисна дія з потенційними наслідками для безпеки. Єдиний правомірний сценарій вимкнення Block Public Access — статичний вебсайт, де весь контент свідомо публічний. Навіть тоді краще використовувати CloudFront OAC замість прямого публічного доступу до S3.

Bucket Policy — JSON-правила доступу

Bucket Policy — це ресурсно-орієнтована IAM-політика у форматі JSON, що прикріплюється безпосередньо до bucket і визначає, які Principal (користувачі, ролі, сервіси, акаунти) можуть виконувати які дії з bucket та його об'єктами. На відміну від IAM Policy (прикріпленої до ідентифікатора), Bucket Policy є атрибутом самого ресурсу і дозволяє надавати крос-акаунтний доступ без зміни IAM в інших акаунтах.

Оцінка доступу: S3 оцінює одночасно Bucket Policy і IAM Policy. Якщо хоча б одна з них містить явну заборону (Deny) — доступ заблокований. Для надання доступу principal всередині того самого акаунту достатньо дозволу в одній з двох (IAM або Bucket Policy). Для крос-акаунтного доступу потрібен Allow в обох.

Приклад 1: публічний доступ на читання для статичного вебсайту:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::my-website-bucket/*"
        }
    ]
}

Приклад 2: доступ лише для конкретної Lambda функції (IAM Role):

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "LambdaReadWrite",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::123456789012:role/MyLambdaRole"
            },
            "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
            "Resource": "arn:aws:s3:::my-app-bucket/uploads/*"
        }
    ]
}

Приклад 3: доступ лише з певного VPC (ізоляція private API):

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyOutsideVPC",
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:*",
            "Resource": ["arn:aws:s3:::private-bucket", "arn:aws:s3:::private-bucket/*"],
            "Condition": {
                "StringNotEquals": {
                    "aws:SourceVpc": "vpc-0a1b2c3d4e5f"
                }
            }
        }
    ]
}

Приклад 4: обов'язкове шифрування при завантаженні (deny незашифрованих PUT):

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyUnencryptedPut",
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::my-secure-bucket/*",
            "Condition": {
                "StringNotEquals": {
                    "s3:x-amz-server-side-encryption": "aws:kms"
                }
            }
        }
    ]
}

Документація полів Statement:

Sid
string
Ідентифікатор statement (Statement ID). Довільний рядок без пробілів. Використовується для читабельності та debugging. Необов'язковий, але рекомендований — AWS Console і CloudTrail відображають його у подіях доступу.
Effect
string required
Allow або Deny. При конфлікті між Allow і Deny — Deny завжди перемагає (explicit deny overrides everything). Використовуйте Deny для безумовних заборон, наприклад, заборона без MFA або за межами VPC.
Principal
string | object required
Кому надається дозвіл. "*" — всі (публічно). {"AWS": "arn:...role/..."} — конкретна IAM роль. {"Service": "cloudfront.amazonaws.com"} — AWS сервіс. {"AWS": "123456789012"} — весь інший AWS акаунт (крос-акаунтний доступ).
Action
string | array required
Масив дій S3 API. Наприклад: s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket, s3:* (всі). s3:ListBucket застосовується до bucket ARN, s3:GetObject — до об'єктного ARN (bucket/*).
Resource
string | array required
ARN ресурсу. arn:aws:s3:::bucket-name — сам bucket (для ListBucket). arn:aws:s3:::bucket-name/* — всі об'єкти. arn:aws:s3:::bucket-name/prefix/* — об'єкти з префіксом.
Condition
object
Умовне застосування правила. Ключі: aws:SourceIp (IP-адреса клієнта), aws:SourceVpc (VPC ID), aws:MultiFactorAuthPresent (наявність MFA), s3:prefix (prefix запиту), s3:x-amz-server-side-encryption (тип шифрування). Умови комбінуються логічним AND.

Додавання Bucket Policy через Policy Editor:

  1. S3 → оберіть bucket → вкладка Permissions
  2. Прокрутіть до Bucket policyEdit
  3. Введіть JSON вручну або скористайтесь Policy generator (кнопка праворуч)
  4. AWS Console підсвічує синтаксичні помилки в реальному часі
  5. Save changes

Порада: скористайтесь AWS Policy Generator (кнопка у консолі) або IAM Policy Simulator для тестування політики перед застосуванням.

aws s3api get-bucket-policy
$ aws s3api get-bucket-policy --bucket my-app-bucket --query Policy --output text | python3 -m json.tool
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "LambdaReadWrite",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/MyLambdaRole" },
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-app-bucket/uploads/*"
}
]
}

S3 Encryption — шифрування даних

Аmazon S3 підтримує три незалежні режими серверного шифрування (SSE — Server-Side Encryption), що відрізняються за тим, хто керує ключами та яка гнучкість управління ними надається.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff
title "SSE-KMS: схема шифрування та розшифрування об'єктів"

participant ".NET App\n(EC2/Lambda)" as APP #dbeafe
participant "Amazon S3" as S3 #bbf7d0
participant "AWS KMS" as KMS #fef3c7
participant "S3 Storage\n(диск)" as DISK #e5e7eb

== PUT: завантаження об'єкту ==

APP -> S3 : PUT /bucket/file.pdf\nx-amz-server-side-encryption: aws:kms\nx-amz-server-side-encryption-aws-kms-key-id: arn:kms:...\n[дані в plaintext]

S3 -> KMS : GenerateDataKey(KeyId=arn:kms:...)
note right: KMS генерує пару:\n- Plaintext DEK (Data Encryption Key)\n- Encrypted DEK

KMS --> S3 : {plaintext_DEK, encrypted_DEK}

S3 -> S3 : Шифрує файл за допомогою plaintext_DEK\n(AES-256-GCM)
note right: plaintext_DEK видаляється\nз пам'яті відразу після\nшифрування!

S3 -> DISK : Зберігає: encrypted_file + encrypted_DEK
S3 --> APP : 200 OK\nx-amz-server-side-encryption: aws:kms

== GET: читання об'єкту ==

APP -> S3 : GET /bucket/file.pdf
S3 -> DISK : Читає encrypted_file + encrypted_DEK
S3 -> KMS : Decrypt(encrypted_DEK)\n[перевірка IAM прав на kms:Decrypt]
KMS --> S3 : plaintext_DEK
S3 -> S3 : Розшифровує файл
S3 --> APP : 200 OK + plaintext файл

@enduml
РежимУправління ключемАудит KMSВартістьРекомендація
SSE-S3AWS (автоматично)НемаєБезкоштовноБазовий захист, всі bucket
SSE-KMSВи (через AWS KMS)CloudTrail$0.03/10K запитівЧутливі дані
SSE-CВи (per-request)НемаєБезкоштовноРідко, особливі вимоги
DSSE-KMSВи (подвійне)CloudTrail$0.06/10K запитівCompliance (FIPS 140-3)

SSE-S3 (AES-256): AWS автоматично шифрує всі об'єкти при завантаженні та розшифровує при читанні. Ключами керує AWS. Безкоштовно. Рекомендовано вмикати на всіх bucket за замовчуванням. Починаючи з 2023 року — увімкнено за замовчуванням для всіх нових bucket.

SSE-KMS: шифрування через AWS Key Management Service. Ви контролюєте ключ, маєте повний аудитний журнал кожного використання ключа в CloudTrail, можете відкликати доступ до ключа (що унеможливлює читання всіх зашифрованих даних). Підходить для чутливих даних (медична, фінансова інформація, PII). BucketKeyEnabled — важливий параметр: замість виклику KMS для кожного об'єкту, S3 кешує DEK на рівні bucket, зменшуючи кількість KMS API викликів і, відповідно, вартість.

SSE-C: ви самі надаєте AES-256 ключ при кожному PUT/GET запиті. AWS використовує ключ для шифрування/розшифрування, але не зберігає його. Якщо ключ втрачено — дані назавжди недоступні. Рідко використовується через складність управління.

DSSE-KMS (Double-layer SSE-KMS): подвійне шифрування двома незалежними ключами KMS. Відповідає вимогам FIPS 140-3 Level 3 для defense-grade сумісності.

AWS KMS — що це і як працює

AWS Key Management Service (KMS) — це керований сервіс для створення та управління криптографічними ключами. KMS не зберігає ваші дані — він зберігає тільки Customer Master Keys (CMK) і використовує їх для шифрування/розшифрування коротких шматків даних (Data Encryption Keys, DEK).

Коли S3 шифрує об'єкт через SSE-KMS, відбувається таке:

  1. S3 просить KMS згенерувати DEK (унікальний ключ для цього об'єкта)
  2. KMS повертає два варіанти DEK: відкритий (plaintext) і зашифрований (encrypted)
  3. S3 шифрує об'єкт відкритим DEK, потім знищує відкритий DEK з пам'яті
  4. На диску зберігаються тільки зашифровані дані + зашифрований DEK
  5. При читанні: S3 надсилає encrypted DEK у KMS → KMS розшифровує → S3 читає файл

Таким чином, навіть якщо хтось отримає доступ до диску S3 — без KMS ключа дані марні.

Типи KMS ключів:

ТипУправлінняРотаціяЦінаКоли використовувати
AWS Managed Key (aws/s3)Автоматично AWSАвтоматична (щороку)БезкоштовноПростий аудит без тонкого контролю
Customer Managed Key (CMK)Ви (Key Policy)Ручна або авто$1/ключ/місяць + $0.03/10K APIПовний контроль, відкликання, cross-account
Custom Key Store (CloudHSM)Ви (апаратний HSM)Ви контролюєтеДорого (~$1.45/год за HSM)FIPS 140-2 Level 3, regulatory compliance
Що таке Key Policy?Кожен CMK має Key Policy — JSON-документ, що визначає хто може використовувати ключ (kms:GenerateDataKey, kms:Decrypt) і хто може управляти ним (kms:CreateKey, kms:ScheduleKeyDeletion). Без явного дозволу в Key Policy — навіть адміністратор аккаунту не може використати ключ.
Створення CMK та налаштування для S3 (SSE-KMS)
# 1. Створити Customer Managed Key
$ aws kms create-key \
--description "Key for my-app S3 bucket encryption" \
--key-usage ENCRYPT_DECRYPT \
--key-spec SYMMETRIC_DEFAULT \
--region eu-central-1
# Відповідь (скорочено):
{
"KeyMetadata": {
"KeyId": "mrk-0a1b2c3d4e5f6a7b8",
"Arn": "arn:aws:kms:eu-central-1:123456789012:key/mrk-0a1b2c3d4e5f6a7b8",
"KeyState": "Enabled",
"KeySpec": "SYMMETRIC_DEFAULT"
}
}
# 2. Створити зручний alias
$ aws kms create-alias \
--alias-name "alias/my-app-s3" \
--target-key-id "mrk-0a1b2c3d4e5f6a7b8" \
--region eu-central-1
# 3. Увімкнути авторотацію ключа (щороку автоматично)
$ aws kms enable-key-rotation \
--key-id "alias/my-app-s3" \
--region eu-central-1
# 4. Призначити ключ як default encryption для bucket
$ aws s3api put-bucket-encryption \
--bucket my-app-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "alias/my-app-s3"
},
"BucketKeyEnabled": true
}]
}'
# Готово. Всі нові об'єкти в bucket шифруються через alias/my-app-s3
Вартість KMS: без BucketKey vs з BucketKey
# Сценарій: завантажуємо 100 000 файлів на місяць
# і 500 000 GET-запитів на місяць
Без BucketKey (кожен PUT та GET = 1 KMS API виклик):
PUT: 100 000 викликів × $0.03 / 10 000 = $0.30
GET: 500 000 викликів × $0.03 / 10 000 = $1.50
CMK ключ: = $1.00
Разом: = $2.80/міс
З BucketKey (S3 кешує DEK на рівні bucket,
99% GET-запитів не йдуть у KMS):
PUT: 100 000 викликів × $0.03 / 10 000 = $0.30
GET: ~5 000 викликів × $0.03 / 10 000 = $0.015
CMK ключ: = $1.00
Разом: = $1.315/міс
Економія: ~$1.49/міс → $17.88/рік
На мільйон GET-запитів BucketKey економить ~$3/міс
Завжди вмикайте BucketKeyEnabled: true!

SSE-KMS у .NET SDK — завантаження та читання зашифрованих об'єктів:

using Amazon.S3;
using Amazon.S3.Model;

public class EncryptedS3Service
{
    private readonly IAmazonS3 _s3;
    // ARN або alias KMS ключа: "alias/my-app-s3"
    private const string KmsKeyId = "alias/my-app-s3";

    public EncryptedS3Service(IAmazonS3 s3) => _s3 = s3;

    // Завантажити файл з явним SSE-KMS шифруванням
    public async Task UploadEncryptedAsync(string bucket, string key, Stream data)
    {
        var request = new PutObjectRequest
        {
            BucketName = bucket,
            Key = key,
            InputStream = data,
            // Явно вказуємо SSE-KMS (перевизначає default encryption bucket'а)
            ServerSideEncryptionMethod = ServerSideEncryptionMethod.AWSKMS,
            ServerSideEncryptionKeyManagementServiceKeyId = KmsKeyId,
        };

        var response = await _s3.PutObjectAsync(request);

        // response.ServerSideEncryptionMethod == "aws:kms"
        // response.ServerSideEncryptionKeyManagementServiceKeyId — ARN ключа що використовувався
    }

    // Читання — прозоре, SDK сам запитує KMS decrypt
    // IAM роль вашого застосунку ПОВИННА мати kms:Decrypt на цей ключ
    public async Task<Stream> DownloadEncryptedAsync(string bucket, string key)
    {
        var response = await _s3.GetObjectAsync(bucket, key);
        return response.ResponseStream; // вже розшифровано S3
    }

    // Перевірити яким ключем зашифровано об'єкт
    public async Task<string?> GetObjectEncryptionKeyAsync(string bucket, string key)
    {
        var meta = await _s3.GetObjectMetadataAsync(bucket, key);
        return meta.ServerSideEncryptionKeyManagementServiceKeyId;
        // Поверне ARN KMS ключа або null якщо SSE-S3
    }
}
IAM дозволи для SSE-KMS: щоб ваш застосунок (EC2 роль, Lambda, ECS task role) міг завантажувати та читати зашифровані SSE-KMS об'єкти, IAM роль повинна мати обидва дозволи:
  • kms:GenerateDataKey — для PUT (генерація DEK)
  • kms:Decrypt — для GET (розшифрування DEK)
Без kms:Decrypt читання поверне 403 Access Denied навіть якщо є s3:GetObject. Це типова помилка при першому налаштуванні.

Налаштування шифрування за замовчуванням для bucket:

  1. S3 → оберіть bucket → вкладка Properties
  2. Прокрутіть до Default encryptionEdit
  3. Encryption type:
    • Server-side encryption with Amazon S3 managed keys (SSE-S3) — для базового захисту
    • Server-side encryption with AWS Key Management Service keys (SSE-KMS) — для повного контролю
  4. Якщо SSE-KMS:
    • AWS managed key (aws/s3) — безкоштовний ключ AWS
    • Customer managed key — ваш власний KMS ключ (більше контролю)
    • Увімкніть Bucket Key (рекомендовано: зменшує витрати на KMS)
  5. Save changes
aws s3api get-bucket-encryption
$ aws s3api get-bucket-encryption --bucket my-app-bucket --region eu-central-1
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:eu-central-1:123456789012:key/mrk-abc123"
},
"BucketKeyEnabled": true
}
]
}
}

S3 Presigned URLs — тимчасовий доступ до приватних об'єктів

Presigned URL — це спеціально підписана URL-адреса, яка надає тимчасовий доступ до приватного S3 об'єкту без необхідності давати постійні права. Типова тривалість: від 1 хвилини до 7 днів.

Навіщо це потрібно?

S3 bucket приватний — ніхто ззовні не може читати чи записувати файли. Але іноді треба дати тимчасовий доступ конкретній людині, не відкриваючи bucket публічно і не передаючи AWS-ключі клієнту. Presigned URL вирішує саме це.

Три типових сценарії:

1. Скачування приватного файлу (Presigned GET)

Користувач натискає «Завантажити звіт» у вашому застосунку. Файл зберігається в закритому S3 bucket — напряму його не відкрити. Бекенд перевіряє права користувача, генерує Presigned GET URL (діє 15 хвилин) і повертає її фронтенду. Браузер відкриває цю URL і скачує файл напряму з S3 — трафік не йде через ваш сервер.

2. Завантаження файлу від користувача напряму в S3 (Presigned PUT)

Користувач хоче завантажити фото. Якщо передавати файл через бекенд — він займає пам'ять і пропускну здатність сервера. Натомість: бекенд генерує Presigned PUT URL (дозвіл записати один конкретний файл) і повертає її фронтенду. Фронтенд робить PUT запит напряму в S3 — бекенд файл взагалі не бачить.

3. Посилання в email або месенджері

Після генерації звіту (PDF, Excel) система відправляє email з кнопкою «Переглянути». За кнопкою — Presigned GET URL з терміном 24–72 години. Отримувач клікає і отримує файл. Якщо термін вийшов — посилання більше не працює, файл залишається закритим.

Демо: Сценарій 1 — скачування приватного файлу
# 1. Користувач натискає «Завантажити звіт» у браузері
Browser → Backend: GET /api/reports/42/download
Authorization: Bearer eyJhbGci...
# 2. Бекенд перевіряє права і генерує Presigned URL
Backend → AWS S3: GeneratePresignedUrl(
bucket="reports-private",
key="reports/2026/q1-report.pdf",
expires=15min,
verb=GET
)
# 3. Бекенд повертає URL фронтенду (файл ще НЕ відкрито)
Backend → Browser: 200 OK
{
"url": "https://reports-private.s3.amazonaws.com/reports/2026/q1-report.pdf
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=AKIA...%2F20260528%2Feu-central-1%2Fs3%2Faws4_request
&X-Amz-Date=20260528T140000Z
&X-Amz-Expires=900
&X-Amz-Signature=3ae45f..."
}
# 4. Браузер відкриває URL — запит іде НАПРЯМУ в S3, минаючи бекенд
Browser → S3: GET /reports/2026/q1-report.pdf?X-Amz-Expires=900&...
S3 → Browser: 200 OK + q1-report.pdf (2.4 MB)
# 5. Той самий запит через 16 хвилин
Browser → S3: GET /reports/2026/q1-report.pdf?X-Amz-Expires=900&...
S3 → Browser: 403 Forbidden — Request has expired
Демо: Сценарій 2 — завантаження фото напряму в S3 (PUT)
# 1. Користувач вибирає фото у формі
Browser → Backend: POST /api/users/me/avatar/upload-url
{ "filename": "photo.jpg", "contentType": "image/jpeg" }
# 2. Бекенд генерує унікальний ключ та Presigned PUT URL
Backend logic: key = "avatars/users/usr_789/2026-05-28_a3f1c.jpg"
GeneratePresignedUrl(key, verb=PUT, expires=5min)
Backend → Browser: 200 OK
{
"uploadUrl": "https://my-bucket.s3.amazonaws.com/avatars/users/usr_789/...",
"finalKey": "avatars/users/usr_789/2026-05-28_a3f1c.jpg"
}
# 3. Браузер завантажує файл НАПРЯМУ в S3 — бекенд файл не бачить!
Browser → S3: PUT /avatars/users/usr_789/2026-05-28_a3f1c.jpg?...
Content-Type: image/jpeg
[бінарні дані фото, 3.1 MB]
S3 → Browser: 200 OK
# 4. Браузер повідомляє бекенд що upload завершено
Browser → Backend: PATCH /api/users/me/avatar
{ "key": "avatars/users/usr_789/2026-05-28_a3f1c.jpg" }
Backend: Зберігає ключ у БД, не торкаючись файлу
# Результат: 3.1 MB НЕ пройшли через бекенд-сервер
# При 1000 завантажень/день — це ~3 GB трафіку зекономлено на сервері
Демо: Сценарій 3 — посилання у email
# Тригер: генерація місячного звіту о 00:00
CronJob: GenerateMonthlyReport() → finance/2026-04/report.xlsx
S3.PutObject("reports-private", "finance/2026-04/report.xlsx")
# Генерація Presigned URL та відправка email
Backend: url = GeneratePresignedUrl(expires=72h)
SendEmail(to="cfo@company.com", template="report-ready", url=url)
# Email отримано (через 2 години після генерації)
Email text: "Звіт за квітень готовий."
[Переглянути звіт →] → https://reports-private.s3.amazonaws.com/...&X-Amz-Expires=259200
# CFO клікає — 70 годин від генерації, ще в межах 72h
Browser → S3: GET /finance/2026-04/report.xlsx?...
S3 → Browser: 200 OK + report.xlsx (580 KB)
# Помічник намагається відкрити той самий лист через 3 дні
Browser → S3: GET /finance/2026-04/report.xlsx?...
S3 → Browser: 403 Forbidden — Request has expired
Файл у bucket цілий, але URL вже не діє. Bucket залишається закритим.
// AWS SDK for .NET — генерація Presigned URL
using Amazon.S3;
using Amazon.S3.Model;

public class S3Service
{
    private readonly IAmazonS3 _s3;

    public S3Service(IAmazonS3 s3) => _s3 = s3;

    // Presigned GET URL — для завантаження файлу
    public string GenerateDownloadUrl(string bucket, string key, int expiresInMinutes = 15)
    {
        var request = new GetPreSignedUrlRequest
        {
            BucketName = bucket,
            Key = key,
            Expires = DateTime.UtcNow.AddMinutes(expiresInMinutes),
            Verb = HttpVerb.GET
        };
        return _s3.GetPreSignedURL(request);
    }

    // Presigned PUT URL — для завантаження файлу напряму з браузера
    public string GenerateUploadUrl(string bucket, string key,
        string contentType, int expiresInMinutes = 5)
    {
        var request = new GetPreSignedUrlRequest
        {
            BucketName = bucket,
            Key = key,
            Expires = DateTime.UtcNow.AddMinutes(expiresInMinutes),
            Verb = HttpVerb.PUT,
            ContentType = contentType
        };
        return _s3.GetPreSignedURL(request);
    }
}

Використання Presigned PUT URL у JavaScript:

// Отримуємо URL з бекенду
const { uploadUrl } = await fetch('/api/files/upload-url?filename=photo.jpg')

// Завантажуємо файл напряму в S3 — бекенд не торкається файлу!
await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: { 'Content-Type': 'image/jpeg' },
})

S3 Static Website Hosting

S3 Static Website Hosting — можливість використовувати S3 як хостинг для статичних веб-сайтів: HTML, CSS, JavaScript, зображення. Це ідеально для React/Vue/Angular SPA (Single Page Application).

Переваги перед традиційним хостингом:

  • Вартість: S3 Static Hosting коштує копійки (зберігання + трафік), немає витрат на сервер
  • Масштабованість: S3 витримає будь-яке навантаження без конфігурації
  • Надійність: 99.99% доступність без жодних зусиль

Особливість SPA — проблема з прямими посиланнями

У звичайному сайті кожна сторінка — це окремий файл: /aboutabout.html, /contactcontact.html. Сервер шукає файл за URL і повертає його.

У React/Vue/Angular SPA на диску є тільки один файлindex.html. Всі «сторінки» (/about, /users/123, /dashboard/settings) — це JavaScript-маршрути, які React Router обробляє вже у браузері після завантаження index.html.

Проблема виникає в двох випадках:

  • Користувач відкрив пряме посилання mysite.com/users/123 у новій вкладці
  • Користувач натиснув F5 (оновити сторінку) перебуваючи на будь-якому маршруті крім /

В обох випадках браузер іде в S3 і питає: «дай мені файл /users/123». S3 шукає в bucket файл з таким ключем — і не знаходить, бо такого файлу немає. Повертає 404.

Що відбувається без правильного налаштування
# Користувач відкриває головну — все добре
Browser → S3: GET /
S3 → Browser: 200 OK → index.html → React завантажується
React Router бачить "/" → рендерить HomePage ✓
# Користувач клікає посилання /users/123 у меню — все добре
Browser: React Router перехоплює клік, змінює URL без запиту до S3
Рендерить UserPage — S3 не питається взагалі ✓
# Користувач натискає F5 на сторінці /users/123 — ПОМИЛКА
Browser → S3: GET /users/123
S3 → Browser: 404 Not Found (файлу "users/123" в bucket не існує)
Біла сторінка з помилкою. Користувач розгублений ✗

Рішення: Error Document = index.html

В налаштуваннях Static Hosting є поле Error document — файл, який S3 повертає замість помилки 404. Якщо вказати index.html, то замість «Not Found» S3 завжди повертатиме головний файл застосунку. React завантажиться, React Router прочитає поточний URL (/users/123) і відрендерить потрібну сторінку — так, ніби нічого не сталося.

Те саме після налаштування Error Document = index.html
# Користувач натискає F5 на сторінці /users/123
Browser → S3: GET /users/123
S3: Файл не знайдено → але Error Document = index.html
S3 → Browser: 200 OK* → index.html
React завантажується, бачить URL "/users/123"
React Router рендерить UserPage ✓
# Користувач надсилає другу посилання /dashboard/reports/april
Browser → S3: GET /dashboard/reports/april
S3 → Browser: index.html → React Router → рендерить ReportsPage ✓
* Технічна деталь: S3 повертає HTTP 404 з тілом index.html.
CloudFront може перетворити це на 200 через custom error pages —
важливо для SEO та моніторингу помилок.

# Налаштувати Static Website Hosting
aws s3api put-bucket-website \
    --bucket my-react-app \
    --website-configuration '{
        "IndexDocument": {"Suffix": "index.html"},
        "ErrorDocument": {"Key": "index.html"}
    }' \
    --region eu-central-1

# Після цього сайт доступний за:
# http://my-react-app.s3-website.eu-central-1.amazonaws.com

Важливо: S3 Static Hosting URL — це http:// (без S). Для HTTPS використовуйте CloudFront як CDN перед S3 — це стандартна production-архітектура.


S3 CORS — Cross-Origin Resource Sharing

CORS (Cross-Origin Resource Sharing) — це браузерна політика безпеки, яка забороняє JavaScript на одному домені робити запити до іншого домену без явного дозволу. Це не S3-специфічна проблема — це стандарт усіх браузерів.

Що таке «інший домен» з точки зору CORS:

Будь-яка відмінність у протоколі, хості або порті — це вже інший origin:

Ваш сайтЗапит доCORS?
https://app.example.comhttps://bucket.s3.amazonaws.com✗ Заблоковано
http://localhost:3000https://bucket.s3.amazonaws.com✗ Заблоковано
https://app.example.comhttps://app.example.com/api✓ Той самий origin

Коли виникає при роботі з S3:

  • Фронтенд робить fetch() або axios.put() на Presigned PUT URL (завантаження файлу напряму в S3)
  • Фронтенд робить fetch() на S3 URL щоб прочитати JSON або завантажити файл
  • Static website, захостований на S3, звертається до API на іншому домені
Демо: CORS-помилка при завантаженні файлу в S3 (без налаштування)
# Фронтенд (app.example.com) отримав Presigned PUT URL і робить запит
JavaScript: await fetch(presignedUrl, { method: 'PUT', body: file })
# Браузер спочатку робить preflight-запит (OPTIONS)
Browser → S3: OPTIONS /uploads/photo.jpg?X-Amz-...
Origin: https://app.example.com
Access-Control-Request-Method: PUT
# S3 відповідає без CORS заголовків (бо не налаштовано)
S3 → Browser: 200 OK
(немає Access-Control-Allow-Origin)
# Браузер бачить відсутність дозволу і БЛОКУЄ основний запит
Console Error: Access to fetch at 'https://my-bucket.s3.amazonaws.com/...'
from origin 'https://app.example.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
# PUT запит до S3 взагалі не відбувся — браузер заблокував ще на preflight
Демо: те саме після налаштування CORS на bucket
# Браузер знову робить preflight (OPTIONS)
Browser → S3: OPTIONS /uploads/photo.jpg?X-Amz-...
Origin: https://app.example.com
Access-Control-Request-Method: PUT
# Тепер S3 відповідає з CORS заголовками
S3 → Browser: 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, POST, DELETE, HEAD
Access-Control-Max-Age: 3000
# Браузер бачить дозвіл і виконує основний PUT запит
Browser → S3: PUT /uploads/photo.jpg?X-Amz-...
Content-Type: image/jpeg
[бінарні дані, 3.1 MB]
S3 → Browser: 200 OK — файл завантажено ✓
# MaxAge: 3000 — браузер кешує preflight на 50 хвилин
# Наступні 50 хвилин PUT-запити йдуть без preflight → швидше

Коли потрібен CORS на S3:

  • Фронтенд завантажує файли напряму в S3 через Presigned PUT URL
  • Фронтенд читає файли з S3 напряму (зображення, JSON) через fetch()
  • Static website на S3 робить API запити до іншого домену
[
    {
        "AllowedHeaders": ["*"],
        "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
        "AllowedOrigins": ["https://app.example.com", "http://localhost:3000"],
        "ExposeHeaders": ["ETag", "x-amz-version-id"],
        "MaxAgeSeconds": 3000
    }
]
# Застосувати CORS конфігурацію
aws s3api put-bucket-cors \
    --bucket my-app-bucket \
    --cors-configuration file://cors.json \
    --region eu-central-1

Пояснення полів:

  • AllowedOrigins: які домени можуть звертатись до bucket. Для розробки — http://localhost:3000. Для production — ваш реальний домен. Ніколи не використовуйте * для bucket з приватними даними.
  • AllowedMethods: які HTTP методи дозволені. PUT — для завантаження, GET — для читання.
  • AllowedHeaders: які заголовки браузер може надіслати. * — всі.
  • ExposeHeaders: які заголовки відповіді браузер може читати з JavaScript. ETag — для верифікації завантаженого файлу.
  • MaxAgeSeconds: як довго браузер кешує CORS preflight відповідь (3000 сек = 50 хв).

S3 Transfer Acceleration

S3 Transfer Acceleration — це функція, яка прискорює завантаження та скачування великих файлів завдяки використанню глобальної мережі edge locations AWS (CloudFront). Замість прямого підключення до S3 у регіоні, дані проходять через найближчий CloudFront edge-вузол, а далі через оптимізовану внутрішню мережу AWS.

Чому звичайне з'єднання повільне на великих відстанях

Коли ваш S3 bucket у Франкфурті, а користувач в Токіо — пакети даних мандрують через публічний інтернет: десятки роутерів, підводні кабелі, різні провайдери. Кожен стрибок додає затримку. На відстані 9 000+ км RTT (round-trip time) може досягати 250–350 мс, а TCP slow start і втрати пакетів ще більше знижують реальну швидкість.

Як Transfer Acceleration вирішує це

Замість того щоб дані йшли весь шлях через публічний інтернет, вони:

  1. Потрапляють у найближчий edge location AWS (їх 400+ по всьому світу)
  2. Далі йдуть до S3 по приватній оптимізованій магістралі AWS — не через публічний інтернет
Демо: порівняння маршрутів — Токіо → S3 у Франкфурті
# Без Transfer Acceleration — публічний інтернет
Токіо → [ISP] → [підводний кабель] → [Сінгапур] → [Індія]
→ [Близький Схід] → [Єгипет] → [Середземне море]
→ [Франкфурт AWS S3]
RTT: ~270 мс | Швидкість завантаження 500 MB: ~4–6 хв
# З Transfer Acceleration — через найближчий edge
Токіо → [AWS Edge: Tokyo ap-northeast-1]
↓ (приватна мережа AWS, оптимізована)
→ [Франкфурт AWS S3]
RTT до edge: ~5 мс | Швидкість завантаження 500 MB: ~1–2 хв
Прискорення: 2–5x на великих відстанях
Доплата: $0.04/GB (Americas/Europe) або $0.08/GB (всі інші регіони)

Коли вигідно / невигідно:

СценарійРекомендація
Користувачі по всьому світу завантажують файли 50+ MB✓ Вмикайте
Відеоплатформа приймає завантаження від creators у різних країнах✓ Вмикайте
SaaS: синхронізація великих файлів між офісами на різних континентах✓ Вмикайте
Bucket у eu-central-1, всі користувачі в Україні/Польщі/Німеччині✗ Не потрібне
Файли < 1 MB (іконки, JSON, невеликі документи)✗ Не допоможе
Бекенд-сервер в тому ж регіоні що і S3✗ Не потрібне
AWS надає безкоштовний інструмент порівняння швидкості — можна протестувати реальний приріст для вашого регіону і bucket перед увімкненням.
Увімкнення та використання Transfer Acceleration
# 1. Увімкнути Transfer Acceleration на bucket
$ aws s3api put-bucket-accelerate-configuration \
--bucket my-app-bucket \
--accelerate-configuration Status=Enabled \
--region eu-central-1
# 2. Завантажити через accelerated endpoint (URL відрізняється!)
Стандартний endpoint: my-app-bucket.s3.eu-central-1.amazonaws.com
Accelerated endpoint: my-app-bucket.s3-accelerate.amazonaws.com
$ aws s3 cp large-video.mp4 s3://my-app-bucket/ \
--endpoint-url https://s3-accelerate.amazonaws.com
# 3. Перевірити статус
$ aws s3api get-bucket-accelerate-configuration \
--bucket my-app-bucket --region eu-central-1
# {"Status": "Enabled"}
# 4. Вимкнути якщо не потрібне
$ aws s3api put-bucket-accelerate-configuration \
--bucket my-app-bucket \
--accelerate-configuration Status=Suspended

У .NET SDK:

var config = new AmazonS3Config
{
    UseAccelerateEndpoint = true, // Увімкнути Transfer Acceleration
    RegionEndpoint = RegionEndpoint.EUCentral1
};
var s3 = new AmazonS3Client(config);

Юзкейс: Медіа файли та HLS/DASH відео-стрімінг

Це один із найважливіших та найпоширеніших use cases для S3 у сучасних застосунках. Розберемо детально.

Що таке HLS та DASH

HLS (HTTP Live Streaming) — протокол відео-стрімінгу розроблений Apple. Відео нарізається на короткі сегменти (зазвичай 6–10 секунд) у форматі .ts або .fmp4. Плеєр завантажує manifest-файл (.m3u8), який описує всі сегменти та доступні якості, потім послідовно завантажує сегменти. Підтримується нативно у Safari та iOS, в інших браузерах — через бібліотеки (hls.js).

DASH (Dynamic Adaptive Streaming over HTTP) — відкритий стандарт (MPEG-DASH), аналогічний HLS. Manifest-файл (.mpd), сегменти (.m4s). Підтримується у більшості сучасних браузерів через dash.js.

Adaptive Bitrate (ABR): і HLS, і DASH підтримують кілька якостей одного відео (240p, 480p, 720p, 1080p). Плеєр автоматично перемикається між якостями залежно від швидкості з'єднання користувача — якість не заморожується, а плавно деградує.

Архітектура відео-стрімінгу на S3 + CloudFront

Відеофайл → AWS Elemental MediaConvert → S3 (HLS/DASH сегменти) → CloudFront → Користувач

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

actor "Контент-менеджер" as CM
actor "Глядач (UA)" as UA
actor "Глядач (US)" as US

queue "S3: raw-videos" as S3raw #fef3c7

rectangle "AWS Elemental MediaConvert" as MC #dbeafe {
    note right
      Транскодує відео у:
      HLS (240p/480p/720p/1080p)
      DASH (240p/480p/720p/1080p)
    end note
}

package "S3: video-assets" as S3out #d1fae5 {
    file "master.m3u8 (HLS manifest)" as HLS
    file "manifest.mpd (DASH manifest)" as DASH
    file "segment_001.ts ... (сегменти)" as SEG
}

rectangle "Amazon CloudFront (CDN)" as CF #bbf7d0 {
    rectangle "Edge: Frankfurt" as EF
    rectangle "Edge: New York" as EN
}

CM -right-> S3raw : upload .mp4
S3raw -right-> MC : EventBridge trigger
MC -right-> S3out : конвертовані файли
S3out -down-> CF : origin
CF -left-> UA : edge Frankfurt (~10ms)
CF -right-> US : edge New York (~10ms)

@enduml

Структура файлів HLS у S3

Після конвертації через MediaConvert у S3 bucket утворюється наступна структура:

s3://video-assets/
└── videos/
    └── movie-id-abc123/
        ├── hls/
        │   ├── master.m3u8              ← головний manifest (посилання на якості)
        │   ├── 1080p/
        │   │   ├── stream.m3u8          ← playlist для 1080p
        │   │   ├── segment_000.ts       ← сегмент 0 (0-6 сек)
        │   │   ├── segment_001.ts       ← сегмент 1 (6-12 сек)
        │   │   └── ...
        │   ├── 720p/
        │   │   ├── stream.m3u8
        │   │   └── ...
        │   └── 360p/
        │       ├── stream.m3u8
        │       └── ...
        └── dash/
            ├── manifest.mpd
            └── ...

Вміст master.m3u8:

#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p/stream.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
720p/stream.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
360p/stream.m3u8

Плеєр завантажує master.m3u8, бачить три якості, обирає відповідну до швидкості з'єднання і завантажує відповідний stream.m3u8, а потім послідовно всі .ts сегменти.

CloudFront для відео-стрімінгу

Відео через S3 напряму — не рекомендовано для production з кількох причин:

  • Висока latency для глядачів далеко від регіону S3
  • Дорогий трафік ($0.09/GB з S3, порівняно з $0.015/GB з CloudFront)
  • Немає кешування сегментів — кожен запит іде до S3

CloudFront (CDN від AWS) кешує сегменти у 400+ edge locations по всьому світу. Перший глядач у Токіо завантажить сегмент з S3 у Франкфурті — але CloudFront закешує його в Токіо. Всі наступні глядачі в Токіо отримають цей сегмент за мілісекунди.

Захист медіа через Signed URLs / Cookies

Якщо відео-контент платний або приватний — потрібно захистити доступ. CloudFront підтримує два механізми:

CloudFront Signed URLs: кожен URL підписується і містить термін дії, дозволений IP, дозволені шляхи. Аналог S3 Presigned URL.

CloudFront Signed Cookies: встановлюється cookie з правами доступу до цілої «директорії» в S3 (наприклад, всі сегменти конкретного відео). Зручніше для відео — не потрібно підписувати кожен сегмент окремо.

// .NET: генерація CloudFront Signed Cookie для відео
using Amazon.CloudFront;

public string GenerateSignedCookiePolicy(
    string resourceUrl,      // "https://cdn.example.com/videos/movie-id/*"
    string cloudFrontKeyId,  // ID ключа CloudFront
    RSA privateKey,          // RSA приватний ключ
    DateTime expiresAt)
{
    var policy = new CloudFrontCannedPolicy
    {
        Resource = resourceUrl,
        DateLessThan = expiresAt
    };
    return AmazonCloudFrontUrlSigner.BuildPolicyForSignedUrl(
        cloudFrontKeyId, privateKey, policy);
}

Реалізація відео-плеєра у React з hls.js

import { useEffect, useRef } from 'react'
import Hls from 'hls.js'

function VideoPlayer({ manifestUrl }) {
    const videoRef = useRef(null)

    useEffect(() => {
        const video = videoRef.current
        if (!video) return

        if (Hls.isSupported()) {
            // Для більшості браузерів — використовуємо hls.js
            const hls = new Hls({
                // Починати з нижчої якості для швидкого старту
                startLevel: -1, // -1 = auto
                // Кешувати в пам'яті не більше 30 сек контенту
                maxBufferLength: 30,
            })
            hls.loadSource(manifestUrl) // URL до master.m3u8
            hls.attachMedia(video)
            hls.on(Hls.Events.MANIFEST_PARSED, () => video.play())

            return () => hls.destroy()
        } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
            // Safari — нативна підтримка HLS
            video.src = manifestUrl
            video.play()
        }
    }, [manifestUrl])

    return <video ref={videoRef} controls style={{ width: '100%', maxHeight: '600px' }} />
}

// Використання:
// <VideoPlayer manifestUrl="https://cdn.example.com/videos/movie-id/hls/master.m3u8" />

Storage Class для відео-файлів

Різні типи відеофайлів вимагають різних Storage Class:

Тип файлуStorage ClassПричина
Raw відео (оригінал)S3 Standard → через 90 днів GlacierРідко потрібен після конвертації
HLS/DASH manifest (.m3u8, .mpd)S3 StandardЧасто запитується плеєром
HLS/DASH сегменти (.ts, .m4s)S3 StandardАктивно читаються під час перегляду
Старі сезони серіалуS3 Intelligent-TieringПатерн доступу непередбачуваний
Архівні відеоS3 Glacier Instant RetrievalПотрібні рідко, але потрібен швидкий доступ

AWS SDK for .NET — повна інтеграція з S3

Встановлення та налаштування

Для роботи з S3 з .NET потрібні два пакети:

  • AWSSDK.S3 — основний клієнт: upload, download, presigned URLs, bucket operations
  • AWSSDK.Extensions.NETCore.Setup — інтеграція з DI ASP.NET Core: AddAWSService<T>() реєструє IAmazonS3 як singleton і автоматично читає регіон з appsettings.json
# Встановіть NuGet пакет
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.Extensions.NETCore.Setup

Як SDK знаходить AWS credentials — SDK перебирає джерела по черзі, зупиняється на першому знайденому:

// Program.cs — реєстрація S3 через DI
builder.Services.AddAWSService<IAmazonS3>();
// SDK автоматично читає credentials з:
// 1. Змінних середовища (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
// 2. ~/.aws/credentials (локально)
// 3. IAM Role (на EC2/ECS/Lambda) ← рекомендовано для production

Локально зручно використовувати ~/.aws/credentials після aws configure. На сервері (EC2, ECS, Lambda) — ніколи не кладіть ключі у змінні середовища або код: натомість призначте IAM Role з s3:GetObject, s3:PutObject на потрібний bucket — SDK підхопить їх автоматично.

// appsettings.json
{
    "AWS": {
        "Region": "eu-central-1"
    },
    "S3": {
        "BucketName": "my-app-bucket"
    }
}

Повний S3Service для .NET

Замість того щоб викликати IAmazonS3 напряму в контролерах, виносимо всю логіку роботи з S3 в окремий сервіс S3StorageService. Це дає три переваги:

  • Єдине місце для зміни bucket, регіону, storage class — не шукати по всьому проєкту
  • Тестованість — можна замокати IAmazonS3 або весь S3StorageService в unit-тестах
  • Інкапсуляція деталей — контролер не знає про S3, він працює з абстракцією «зберегти файл»

У сервісі використовується TransferUtility — обгортка над сирим S3 API, яка автоматично вмикає Multipart Upload для файлів > 16 MB (розбиває на частини, завантажує паралельно) і повторює спроби при тимчасових помилках мережі. Для більшості задач краще використовувати саме її.

using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Microsoft.Extensions.Configuration;

public class S3StorageService
{
    private readonly IAmazonS3 _s3;
    private readonly string _bucket;

    public S3StorageService(IAmazonS3 s3, IConfiguration config)
    {
        _s3 = s3;
        // ЗАМІНІТЬ значення "S3:BucketName" реальним іменем вашого bucket
        _bucket = config["S3:BucketName"] ?? throw new InvalidOperationException("S3:BucketName not configured");
    }

    // Завантаження файлу в S3
    // key — шлях у bucket, наприклад "uploads/users/123/avatar.jpg"
    public async Task<string> UploadAsync(Stream fileStream, string key, string contentType)
    {
        using var transferUtility = new TransferUtility(_s3);

        await transferUtility.UploadAsync(new TransferUtilityUploadRequest
        {
            BucketName = _bucket,
            Key = key,
            InputStream = fileStream,
            ContentType = contentType,
            // Встановити Storage Class (за замовчуванням Standard)
            StorageClass = S3StorageClass.Standard,
            // Метадані об'єкту
            Metadata =
            {
                ["x-amz-meta-uploaded-at"] = DateTime.UtcNow.ToString("O"),
                ["x-amz-meta-content-type"] = contentType
            }
        });

        return $"https://{_bucket}.s3.eu-central-1.amazonaws.com/{key}";
    }

    // Завантаження файлу з S3
    public async Task<Stream> DownloadAsync(string key)
    {
        var response = await _s3.GetObjectAsync(new GetObjectRequest
        {
            BucketName = _bucket,
            Key = key
        });
        return response.ResponseStream;
    }

    // Перелік файлів за префіксом (аналог "директорії")
    public async Task<List<string>> ListFilesAsync(string prefix)
    {
        var response = await _s3.ListObjectsV2Async(new ListObjectsV2Request
        {
            BucketName = _bucket,
            Prefix = prefix,
            MaxKeys = 1000
        });
        return response.S3Objects.Select(o => o.Key).ToList();
    }

    // Видалення об'єкту
    public async Task DeleteAsync(string key)
    {
        await _s3.DeleteObjectAsync(_bucket, key);
    }

    // Presigned URL для завантаження (GET)
    public string GetPresignedDownloadUrl(string key, int expiresInMinutes = 60)
    {
        return _s3.GetPreSignedURL(new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key = key,
            Expires = DateTime.UtcNow.AddMinutes(expiresInMinutes),
            Verb = HttpVerb.GET
        });
    }

    // Presigned URL для прямого завантаження з браузера (PUT)
    public string GetPresignedUploadUrl(string key, string contentType,
        int expiresInMinutes = 10)
    {
        return _s3.GetPreSignedURL(new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key = key,
            Expires = DateTime.UtcNow.AddMinutes(expiresInMinutes),
            Verb = HttpVerb.PUT,
            ContentType = contentType
        });
    }

    // Multipart upload для великих файлів (> 100 MB)
    // TransferUtility автоматично розбиває файл на PartSize-частини
    // і завантажує їх паралельно (ThreadsCount потоків).
    // Якщо завантаження перервалось — можна відновити з місця зупинки
    // через InitiateMultipartUploadRequest / UploadPartRequest вручну.
    // 500 MB при ThreadsCount=5 завантажуються приблизно в 5x швидше ніж послідовно.
    public async Task UploadLargeFileAsync(string filePath, string key)
    {
        using var transferUtility = new TransferUtility(_s3);
        await transferUtility.UploadAsync(new TransferUtilityUploadRequest
        {
            BucketName = _bucket,
            Key = key,
            FilePath = filePath,
            // Multipart: частини по 50 MB, до 5 паралельних потоків
            PartSize = 50 * 1024 * 1024,
            ThreadsCount = 5
        });
    }

    // Копіювання об'єкту всередині S3 (без завантаження на клієнт)
    // CopyObjectRequest — S3 копіює файл на своєму боці, трафік не йде через ваш сервер.
    // Використовується для: перейменування (copy + delete), зміни storage class,
    // переміщення між bucket'ами, або дублювання файлу при збереженні версії.
    public async Task CopyAsync(string sourceKey, string destinationKey)
    {
        await _s3.CopyObjectAsync(new CopyObjectRequest
        {
            SourceBucket = _bucket,
            SourceKey = sourceKey,
            DestinationBucket = _bucket,
            DestinationKey = destinationKey
        });
    }
}

API Controller з прикладом використання:

Контролер відповідає тільки за HTTP-шар: валідація вхідних даних, авторизація, формування відповіді. Всю роботу з S3 делегує S3StorageService. Зверніть увагу на підхід з Presigned PUT URL — файл від користувача не проходить через ASP.NET: бекенд лише генерує дозвіл, а браузер завантажує файл напряму в S3. Це знімає навантаження з сервера і прибирає ліміт розміру файлу на рівні ASP.NET.

[ApiController]
[Route("api/[controller]")]
public class FilesController : ControllerBase
{
    private readonly S3StorageService _storage;

    public FilesController(S3StorageService storage) => _storage = storage;

    // POST /api/files/upload
    [HttpPost("upload")]
    public async Task<IActionResult> Upload(IFormFile file)
    {
        if (file.Length == 0) return BadRequest("File is empty");

        // Генеруємо унікальний ключ щоб уникнути колізій
        var key = $"uploads/{Guid.NewGuid()}/{file.FileName}";

        using var stream = file.OpenReadStream();
        var url = await _storage.UploadAsync(stream, key, file.ContentType);

        return Ok(new { url, key });
    }

    // GET /api/files/download-url?key=uploads/abc123/photo.jpg
    [HttpGet("download-url")]
    public IActionResult GetDownloadUrl([FromQuery] string key)
    {
        var url = _storage.GetPresignedDownloadUrl(key, expiresInMinutes: 30);
        return Ok(new { url, expiresIn = "30 minutes" });
    }

    // GET /api/files/upload-url?filename=photo.jpg&contentType=image/jpeg
    [HttpGet("upload-url")]
    public IActionResult GetUploadUrl([FromQuery] string filename,
        [FromQuery] string contentType)
    {
        // Ключ з userId щоб ізолювати файли користувачів
        var userId = User.FindFirst("sub")?.Value ?? "anonymous";
        var key = $"users/{userId}/{Guid.NewGuid()}_{filename}";

        var url = _storage.GetPresignedUploadUrl(key, contentType);
        return Ok(new { url, key });
    }
}

Практичний приклад: React SPA на S3 від А до Я

Побудуємо повноцінний React SPA з клієнтським роутингом та задеплоємо на S3 Static Website Hosting. Кроки йдуть у логічному порядку: спочатку створюємо застосунок, перевіряємо локально — потім налаштовуємо AWS-інфраструктуру та деплоємо.


Крок 1: Створення React застосунку

# Vite — сучасний збирач, що замінив webpack/CRA: холодний старт ~200мс, HMR миттєвий
# --template react — JSX шаблон (є також react-ts для TypeScript)
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install

# react-router v7 — пакет тепер називається просто "react-router" (не react-router-dom)
npm install react-router
npm create vite@latest my-react-app -- --template react
$ npm create vite@latest my-react-app -- --template react
Scaffolding project in /home/user/my-react-app...
Done. Now run:
cd my-react-app
npm install
npm run dev

Структура файлів після генерації та наших змін:

my-react-app/
├── index.html          ← точка входу Vite (у корені, не в public/!)
├── public/             ← статичні ресурси (favicon тощо)
├── src/
│   ├── main.jsx        ← монтує React у DOM
│   ├── App.jsx         ← layout-компонент: навбар + <Outlet />
│   ├── routes.jsx      ← конфіг маршрутів (JS-об'єкти, не JSX)
│   ├── index.css       ← глобальні стилі
│   └── pages/
│       ├── Home.jsx    ← головна сторінка /
│       └── About.jsx   ← сторінка /about
├── vite.config.js
└── package.json

Створюємо src/routes.jsx — маршрути як JS-об'єкти (React Router v7 data API):

// src/routes.jsx
import { createBrowserRouter } from 'react-router'
import App from './App'
import Home from './pages/Home'
import About from './pages/About'

// createBrowserRouter — React Router v7 data API.
// Маршрути описуються масивом об'єктів, а не JSX-деревом <Routes><Route>.
// Component: (з великої) — статичний імпорт компонента.
function NotFound() {
    return (
        <div className="not-found">
            <h1>404</h1>
            <p>Сторінку не знайдено</p>
        </div>
    )
}

export const router = createBrowserRouter([
    {
        path: '/',
        Component: App, // layout: рендерить навбар + <Outlet />
        children: [
            { index: true, Component: Home }, // рендериться на /
            { path: 'about', Component: About }, // рендериться на /about
        ],
    },
    { path: '*', Component: NotFound },
])

Замінюємо src/main.jsxRouterProvider замість BrowserRouter:

// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import { router } from './routes'
import './index.css'

// RouterProvider — єдина точка монтування.
// При прямому переході на /about S3 повертає 404 —
// якщо не налаштований Error Document: index.html.
createRoot(document.getElementById('root')).render(
    <StrictMode>
        <RouterProvider router={router} />
    </StrictMode>,
)

Замінюємо src/App.jsx — layout-компонент з <Outlet />:

// src/App.jsx
import { Outlet, NavLink } from 'react-router'

// App — layout: рендерить навбар один раз,
// а <Outlet /> підставляє потрібну сторінку залежно від URL
export default function App() {
    return (
        <div className="app">
            <nav className="navbar">
                <span className="brand">☁️ S3 Demo App</span>
                <div className="nav-links">
                    {/* NavLink автоматично додає клас active до поточного посилання */}
                    <NavLink to="/" end>
                        Home
                    </NavLink>
                    <NavLink to="/about">About</NavLink>
                </div>
            </nav>
            <main className="content">
                <Outlet /> {/* тут рендериться Home або About залежно від URL */}
            </main>
        </div>
    )
}

Замінюємо src/index.css (Vite генерує цей файл як глобальний):

/* src/index.css */
*,
*::before,
*::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: #f0f2f5;
    color: #1a1a2e;
}

.app {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

.navbar {
    background: #232f3e; /* AWS dark navy */
    color: white;
    padding: 1rem 2rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

.brand {
    font-size: 1.2rem;
    font-weight: 700;
}

.nav-links a {
    color: #adb5bd;
    text-decoration: none;
    margin-left: 1.5rem;
    font-weight: 500;
    transition: color 0.2s;
}

.nav-links a.active,
.nav-links a:hover {
    color: #ff9900;
} /* AWS orange */

.content {
    flex: 1;
    max-width: 900px;
    margin: 2rem auto;
    padding: 0 1.5rem;
    width: 100%;
}

/* Сторінки */
.page h1 {
    font-size: 2rem;
    margin-bottom: 1rem;
    color: #232f3e;
}
.page h2 {
    font-size: 1.4rem;
    margin: 1.5rem 0 0.75rem;
    color: #232f3e;
}
.page p {
    color: #555;
    line-height: 1.6;
    margin-bottom: 1rem;
}
.page ol {
    padding-left: 1.5rem;
    color: #555;
    line-height: 2;
}

/* Картки */
.cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: 1rem;
    margin-top: 1.5rem;
}

.card {
    background: white;
    border-radius: 8px;
    padding: 1.5rem;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}

.card h3 {
    margin-bottom: 0.5rem;
    color: #232f3e;
}
.card p {
    font-size: 0.9rem;
    color: #666;
    margin: 0;
}

/* 404 */
.not-found {
    text-align: center;
    padding: 4rem 0;
}
.not-found h1 {
    font-size: 5rem;
    color: #dee2e6;
}
.not-found p {
    color: #aaa;
    margin-top: 0.5rem;
}

/* Inline elements */
kbd {
    background: #eee;
    border: 1px solid #ccc;
    border-radius: 3px;
    padding: 1px 6px;
    font-size: 0.85em;
}
code {
    background: #f4f4f4;
    padding: 1px 5px;
    border-radius: 3px;
    font-size: 0.88em;
    font-family: monospace;
}

Створюємо src/pages/Home.jsx:

// src/pages/Home.jsx
export default function Home() {
    return (
        <div className="page">
            <h1>🚀 Ласкаво просимо!</h1>
            <p>
                Цей React SPA задеплоєно на <strong>Amazon S3 Static Website Hosting</strong>.
            </p>
            <div className="cards">
                <div className="card">
                    <h3>⚡ Швидкість</h3>
                    <p>Статичні файли роздаються напряму з S3 або через CloudFront CDN.</p>
                </div>
                <div className="card">
                    <h3>💰 Вартість</h3>
                    <p>Зберігання від $0.023/GB. Для типового SPA — менше $1 на місяць.</p>
                </div>
                <div className="card">
                    <h3>🔒 Надійність</h3>
                    <p>S3 гарантує 99.99% доступність та 11 дев'яток довговічності даних.</p>
                </div>
            </div>
        </div>
    )
}

Створюємо src/pages/About.jsx:

// src/pages/About.jsx
export default function About() {
    return (
        <div className="page">
            <h1>ℹ️ Про застосунок</h1>
            <p>
                Навчальний React SPA розгорнутий на <strong>AWS S3 Static Website Hosting</strong>.
            </p>
            <p>
                Спробуйте натиснути <kbd>F5</kbd> — завдяки <code>Error Document: index.html</code> роутинг працює
                навіть при прямому переході на цей URL.
            </p>
            <h2>Як це працює?</h2>
            <ol>
                <li>
                    Браузер запитує <code>/about</code> безпосередньо у S3
                </li>
                <li>
                    S3 не знаходить файл <code>about</code> і мав би повернути 403/404
                </li>
                <li>
                    Але замість помилки S3 повертає <code>index.html</code> (Error Document)
                </li>
                <li>
                    React Router зчитує URL і рендерить компонент <code>&lt;About /&gt;</code>
                </li>
            </ol>
        </div>
    )
}

Крок 2: Локальний запуск та перевірка

# Vite dev server — значно швидший за webpack (HMR ~50мс замість ~2с у CRA)
npm run dev
# Відкрийте http://localhost:5173 у браузері
npm run dev
$ npm run dev
VITE v5.4.0 ready in 213 ms
➜ Local: http://localhost:5173/
➜ Network: http://192.168.1.10:5173/
➜ press h + enter to show help

Що перевірити перед деплоєм:

  1. http://localhost:5173 — відображається Home з картками
  2. Клік About у навбарі — URL змінюється на /about без перезавантаження
  3. Оновлення на /about (F5) — dev-сервер коректно обробляє маршрут
  4. Перехід на /xyz — відображається компонент 404
Локально React Router працює завдяки Vite dev server, який перенаправляє всі 404 на index.html. На S3 цю роль виконуватиме Error Document: index.html.

Крок 3: Створення S3 Bucket

  1. Відкрийте S3 у AWS Console → Create bucket
  2. Bucket name: my-react-app-2024 (назва глобально унікальна, тому додайте рік або ваше ім'я)
  3. AWS Region: eu-central-1 (Europe Frankfurt)
  4. Object Ownership: ACLs disabled (recommended)
  5. Block Public Access: для публічного сайту знімаємо Block all public access → підтвердіть попередження
  6. Versioning: Enable (рекомендовано — щоб відкатити деплой)
  7. Create bucket

Крок 4: Bucket Policy для публічного читання

# Зберегти у файл policy.json
cat > /tmp/react-bucket-policy.json << EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::my-react-app-2024/*"
        }
    ]
}
EOF

# Застосувати Policy
# ЗАМІНІТЬ my-react-app-2024 на ваше реальне ім'я bucket
aws s3api put-bucket-policy \
    --bucket my-react-app-2024 \
    --policy file:///tmp/react-bucket-policy.json \
    --region eu-central-1

Крок 5: Налаштування Static Website Hosting

  1. S3 → ваш bucket → вкладка Properties
  2. Прокрутіть до Static website hostingEdit
  3. Enable
  4. Index document: index.html
  5. Error document: index.html (критично для React Router!)
  6. Save changes
  7. Скопіюйте Bucket website endpoint — знадобиться для тестування

Крок 6: CORS конфігурація

cat > /tmp/cors.json << 'EOF'
[
    {
        "AllowedHeaders": ["*"],
        "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
        "AllowedOrigins": [
            "http://my-react-app-2024.s3-website.eu-central-1.amazonaws.com",
            "http://localhost:3000",
            "https://yourdomain.com"
        ],
        "ExposeHeaders": ["ETag"],
        "MaxAgeSeconds": 3000
    }
]
EOF

# ЗАМІНІТЬ my-react-app-2024 на ваш bucket
aws s3api put-bucket-cors \
    --bucket my-react-app-2024 \
    --cors-configuration file:///tmp/cors.json \
    --region eu-central-1

Крок 7: Збірка та деплой React застосунку

Перейдіть у директорію проєкту (створили у Кроці 1):

# Перейдіть у директорію проєкту
cd my-react-app

# Зберіть production build
# Vite (на відміну від CRA) кладе результат у dist/, а не build/
npm run build
npm run build
$ npm run build
vite v5.4.0 building for production...
✓ 34 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-C2PyeNYV.css 1.50 kB │ gzip: 0.75 kB
dist/assets/index-BNMJvBVp.js 142.62 kB │ gzip: 45.73 kB
✓ built in 891ms
# Завантажте dist/ папку у S3 (Vite кладе build у dist/, а не build/)
# aws s3 sync — синхронізація директорії з S3 (завантажує лише змінені файли)
# --delete — видаляє файли з S3, яких немає локально (прибирає старий deploy)
# ЗАМІНІТЬ my-react-app-2024 на ваш bucket
aws s3 sync ./dist s3://my-react-app-2024/ \
    --delete \
    --region eu-central-1

# Встановити правильний Content-Type для HTML файлів (важливо!)
aws s3 cp s3://my-react-app-2024/index.html s3://my-react-app-2024/index.html \
    --metadata-directive REPLACE \
    --content-type "text/html" \
    --cache-control "no-cache, no-store, must-revalidate" \
    --region eu-central-1
aws s3 sync результат
$ aws s3 sync ./dist s3://my-react-app-2024/ --delete --region eu-central-1
upload: build/index.html to s3://my-react-app-2024/index.html
upload: build/static/js/main.abc123.js to s3://my-react-app-2024/static/js/main.abc123.js
upload: build/static/css/main.def456.css to s3://my-react-app-2024/static/css/main.def456.css
upload: build/favicon.ico to s3://my-react-app-2024/favicon.ico

Відкрийте ваш сайт у браузері: http://my-react-app-2024.s3-website.eu-central-1.amazonaws.com


Крок 8: Перевірка у браузері

URL вашого сайту:

http://my-react-app-2024.s3-website.eu-central-1.amazonaws.com

Сценарії для тестування:

  1. Home сторінка — відкрийте URL → відображаються картки з інформацією про S3
  2. Навігація через UI — клікніть About у навбарі → URL змінюється на /about без перезавантаження сторінки
  3. Прямий перехід (критичний тест) — вставте в адресний рядок .../about та натисніть Enter → має відобразитись About сторінка, а не XML-помилка S3
  4. Оновлення сторінки — натисніть F5 на /about → завдяки Error Document: index.html роутинг зберігається
Якщо при переході на /about ви бачите XML на кшталт <Error><Code>NoSuchKey</Code>... — значить Error Document не налаштований або вказаний неправильно. Поверніться до Кроку 5 і переконайтесь, що і Index document, і Error document вказують на index.html.

Для швидкої перевірки через термінал:

# Перевіряємо що обидва маршрути повертають 200 OK з index.html
curl -sI http://my-react-app-2024.s3-website.eu-central-1.amazonaws.com | head -1
curl -sI http://my-react-app-2024.s3-website.eu-central-1.amazonaws.com/about | head -1
curl перевірка роутингу
$ curl -sI http://my-react-app-2024.s3-website.eu-central-1.amazonaws.com/about | head -1
HTTP/1.1 200 OK
# 200 (не 404/403) — Error Document спрацював, index.html повернуто
# React Router на клієнті далі рендерить /about компонент

Крок 9: Lifecycle Policy для старих версій

Оскільки увімкнено Versioning, при кожному деплої накопичуються старі версії. Налаштуємо автоматичне очищення:

cat > /tmp/lifecycle.json << 'EOF'
{
    "Rules": [
        {
            "ID": "CleanOldVersions",
            "Status": "Enabled",
            "Filter": {},
            "NoncurrentVersionExpiration": {
                "NoncurrentDays": 30,
                "NewerNoncurrentVersions": 5
            },
            "AbortIncompleteMultipartUpload": {
                "DaysAfterInitiation": 7
            }
        }
    ]
}
EOF

aws s3api put-bucket-lifecycle-configuration \
    --bucket my-react-app-2024 \
    --lifecycle-configuration file:///tmp/lifecycle.json \
    --region eu-central-1

Це правило: зберігати не більше 5 попередніх версій кожного файлу, і видаляти версії старші 30 днів. Також прибираємо незавершені multipart uploads старші 7 днів.


Крок 10: ОБОВ'ЯЗКОВО — Очищення

S3 сам по собі дуже дешевий, але Elastic IP та інші ресурси можуть тарифікуватись. Для навчального bucket витрати мінімальні, але після завершення роботи видаліть.
BUCKET="my-react-app-2024"
REGION="eu-central-1"

# Видалити всі об'єкти (включаючи всі версії)
aws s3api delete-objects \
    --bucket $BUCKET \
    --delete "$(aws s3api list-object-versions \
        --bucket $BUCKET \
        --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' \
        --output json)" \
    --region $REGION

# Видалити Delete Markers (якщо є)
# (аналогічна команда з DeleteMarkers замість Versions)

# Видалити сам bucket (лише якщо порожній)
aws s3api delete-bucket --bucket $BUCKET --region $REGION

Практичний приклад: Product Image Manager (ASP.NET + S3)

Реалістичний сценарій: REST API для управління продуктами, де зображення зберігаються в приватному S3 bucket. Ключовий патерн — Presigned PUT Upload: фронтенд завантажує файл напряму в S3, не гоняючи бінарні дані через ASP.NET сервер.

┌─────────┐  1. GET /products/1/upload-url   ┌─────────────────┐
│         ├─────────────────────────────────►│   ASP.NET API   │
│         │◄─────────────────────────────────┤ (генерує signed │
│         │  { uploadUrl, key }              │  PUT URL + key) │
│         │                                  └─────────────────┘
│ Browser │
│         │  2. PUT image.jpg (напряму!)     ┌─────────────────┐
│         ├─────────────────────────────────►│      S3         │
│         │◄─────────────────────────────────┤  (приватний     │
│         │  200 OK                          │   bucket)       │
│         │                                  └─────────────────┘
│         │  3. PUT /products/1/image        ┌─────────────────┐
│         │     { key: "products/1/..." }    │   ASP.NET API   │
│         ├─────────────────────────────────►│ (зберігає key   │
│         │◄─────────────────────────────────┤  у БД, повертає │
└─────────┘  { imageUrl: presigned GET URL } │  GET URL)       │
                                             └─────────────────┘

Сервер ніколи не отримує файл — він лише підписує URL та зберігає ключ у БД.


Крок 1: Підготовка S3 Bucket

Bucket для зображень — приватний (Block Public Access ON). Доступ лише через Presigned URLs.

  1. S3Create bucket
  2. Bucket name: product-images-2024 (додайте власний суфікс)
  3. AWS Region: eu-central-1
  4. Block Public Access: залишаємо увімкненим — bucket приватний
  5. Create bucket
  6. Відкрийте bucket → PermissionsCross-origin resource sharing (CORS)Edit
  7. Вставте CORS конфіг з секції нижче → Save changes

CORS — потрібен щоб браузер міг робити PUT напряму в S3:

cat > /tmp/products-cors.json << 'EOF'
[
    {
        "AllowedHeaders": ["Content-Type"],
        "AllowedMethods": ["PUT", "GET"],
        "AllowedOrigins": [
            "http://localhost:5173",
            "https://yourdomain.com"
        ],
        "ExposeHeaders": ["ETag"],
        "MaxAgeSeconds": 3000
    }
]
EOF

aws s3api put-bucket-cors \
    --bucket product-images-2024 \
    --cors-configuration file:///tmp/products-cors.json \
    --region eu-central-1
AllowedHeaders: ["Content-Type"] — мінімальний необхідний набір. Presigned PUT підписується разом із Content-Type, тому браузер зобов'язаний надіслати цей заголовок — він має бути у дозволених.

Крок 2: Ініціалізація ASP.NET проєкту

# Minimal API проєкт без HTTPS (для локальної розробки)
dotnet new webapi -n ProductImageManager --no-https
cd ProductImageManager

# EF Core + SQLite — продукти зберігаємо локально (не потрібен окремий сервер БД)
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

# AWS SDK
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.Extensions.NETCore.Setup

Структура проєкту після наших змін:

ProductImageManager/
├── Data/
│   └── AppDbContext.cs     ← EF Core контекст
├── Models/
│   └── Product.cs          ← сутність продукту
├── Services/
│   └── ProductImageService.cs  ← S3 операції
├── Program.cs              ← Minimal API endpoints
└── appsettings.json

Крок 3: Модель та DbContext

Models/Product.cs:

// Models/Product.cs
namespace ProductImageManager.Models;

public class Product
{
    public int     Id          { get; set; }
    public string  Name        { get; set; } = string.Empty;
    public string  Description { get; set; } = string.Empty;
    public decimal Price       { get; set; }

    // S3 ключ зображення: "products/{id}/{guid}.jpg"
    // null — зображення ще не завантажено
    public string? ImageKey    { get; set; }

    public DateTime CreatedAt  { get; set; } = DateTime.UtcNow;
}

Data/AppDbContext.cs:

// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using ProductImageManager.Models;

namespace ProductImageManager.Data;

public class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
}

Крок 4: ProductImageService

Services/ProductImageService.cs — інкапсулює всі S3 операції з зображеннями:

// Services/ProductImageService.cs
using Amazon.S3;
using Amazon.S3.Model;

namespace ProductImageManager.Services;

public class ProductImageService(IAmazonS3 s3, IConfiguration config)
{
    private readonly string _bucket = config["S3:BucketName"]
        ?? throw new InvalidOperationException("S3:BucketName is not configured");

    private const int UploadUrlExpiry  = 15; // хвилин — короткий TTL для безпеки
    private const int DisplayUrlExpiry = 60; // хвилин — для відображення в UI

    // Унікальний S3 ключ для зображення продукту
    // Формат: products/{productId}/{guid}{extension}
    // Guid гарантує відсутність колізій при повторних завантаженнях
    public static string BuildImageKey(int productId, string extension)
        => $"products/{productId}/{Guid.NewGuid()}{extension}";

    // Presigned PUT URL — фронтенд завантажує файл напряму в S3
    // Content-Type підписується у URL → клієнт зобов'язаний надіслати той самий заголовок
    public string GetUploadUrl(string key, string contentType) =>
        s3.GetPreSignedURL(new GetPreSignedUrlRequest
        {
            BucketName  = _bucket,
            Key         = key,
            Verb        = HttpVerb.PUT,
            ContentType = contentType,
            Expires     = DateTime.UtcNow.AddMinutes(UploadUrlExpiry),
        });

    // Presigned GET URL — тимчасове посилання для <img src> або завантаження
    public string GetDisplayUrl(string key) =>
        s3.GetPreSignedURL(new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key        = key,
            Verb       = HttpVerb.GET,
            Expires    = DateTime.UtcNow.AddMinutes(DisplayUrlExpiry),
        });

    // Видалення при зміні або видаленні продукту — не залишаємо orphan файли
    public Task DeleteAsync(string key) =>
        s3.DeleteObjectAsync(_bucket, key);
}

Крок 5: Endpoints (Program.cs)

Замінюємо вміст Program.cs:

// Program.cs
using Amazon.S3;
using Microsoft.EntityFrameworkCore;
using ProductImageManager.Data;
using ProductImageManager.Models;
using ProductImageManager.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// EF Core + SQLite
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlite(builder.Configuration.GetConnectionString("Default")
                ?? "Data Source=products.db"));

// AWS SDK — читає регіон з appsettings, credentials з ~/.aws або env vars
builder.Services.AddAWSService<IAmazonS3>();
builder.Services.AddScoped<ProductImageService>();

// CORS для Vite dev server (якщо є React фронтенд)
builder.Services.AddCors(o =>
    o.AddDefaultPolicy(p => p
        .WithOrigins("http://localhost:5173")
        .AllowAnyHeader()
        .AllowAnyMethod()));

var app = builder.Build();
app.UseCors();

// Створюємо таблиці при першому запуску (зручно для демо)
using (var scope = app.Services.CreateScope())
    await scope.ServiceProvider.GetRequiredService<AppDbContext>()
               .Database.EnsureCreatedAsync();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// ─── GET /api/products ────────────────────────────────────────────────────────
// Список усіх продуктів — presigned GET URL генерується на льоту для кожного
app.MapGet("/api/products", async (AppDbContext db, ProductImageService images) =>
{
    var products = await db.Products.AsNoTracking().ToListAsync();
    return products.Select(p => new
    {
        p.Id, p.Name, p.Description, p.Price,
        ImageUrl = p.ImageKey is not null ? images.GetDisplayUrl(p.ImageKey) : null,
        p.CreatedAt,
    });
});

// ─── GET /api/products/{id} ───────────────────────────────────────────────────
app.MapGet("/api/products/{id:int}", async (int id, AppDbContext db, ProductImageService images) =>
{
    if (await db.Products.FindAsync(id) is not { } p)
        return Results.NotFound();

    return Results.Ok(new
    {
        p.Id, p.Name, p.Description, p.Price,
        ImageUrl = p.ImageKey is not null ? images.GetDisplayUrl(p.ImageKey) : null,
        p.CreatedAt,
    });
});

// ─── POST /api/products ───────────────────────────────────────────────────────
// Створення продукту без зображення — зображення завантажується окремим кроком
app.MapPost("/api/products", async (CreateProductDto dto, AppDbContext db) =>
{
    var product = new Product
    {
        Name        = dto.Name,
        Description = dto.Description,
        Price       = dto.Price,
    };
    db.Products.Add(product);
    await db.SaveChangesAsync();

    return Results.Created($"/api/products/{product.Id}", new { product.Id, product.Name });
});

// ─── GET /api/products/{id}/upload-url?contentType=image/jpeg ─────────────────
// Генерує presigned PUT URL — фронтенд завантажує файл напряму в S3
app.MapGet("/api/products/{id:int}/upload-url",
    async (int id, string contentType, AppDbContext db, ProductImageService images) =>
    {
        if (await db.Products.FindAsync(id) is null)
            return Results.NotFound();

        // Приймаємо лише зображення — валідація до генерації URL
        var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp" };
        if (!allowedTypes.Contains(contentType))
            return Results.BadRequest($"Allowed content types: {string.Join(", ", allowedTypes)}");

        var extension = contentType switch
        {
            "image/jpeg" => ".jpg",
            "image/png"  => ".png",
            _            => ".webp",
        };

        var key       = ProductImageService.BuildImageKey(id, extension);
        var uploadUrl = images.GetUploadUrl(key, contentType);

        return Results.Ok(new { uploadUrl, key, expiresInMinutes = 15 });
    });

// ─── PUT /api/products/{id}/image ─────────────────────────────────────────────
// Фронтенд викликає після успішного PUT у S3 — зберігаємо S3 key у БД
app.MapPut("/api/products/{id:int}/image",
    async (int id, ConfirmImageDto dto, AppDbContext db, ProductImageService images) =>
    {
        if (await db.Products.FindAsync(id) is not { } product)
            return Results.NotFound();

        // Старе зображення видаляємо з S3 — уникаємо orphan файлів
        if (product.ImageKey is not null)
            await images.DeleteAsync(product.ImageKey);

        product.ImageKey = dto.Key;
        await db.SaveChangesAsync();

        return Results.Ok(new { imageUrl = images.GetDisplayUrl(dto.Key) });
    });

// ─── DELETE /api/products/{id} ────────────────────────────────────────────────
// Видалення продукту разом із зображенням у S3
app.MapDelete("/api/products/{id:int}",
    async (int id, AppDbContext db, ProductImageService images) =>
    {
        if (await db.Products.FindAsync(id) is not { } product)
            return Results.NotFound();

        // Спочатку видаляємо з S3, потім з БД
        if (product.ImageKey is not null)
            await images.DeleteAsync(product.ImageKey);

        db.Products.Remove(product);
        await db.SaveChangesAsync();

        return Results.NoContent();
    });

app.Run();

// ─── DTOs ─────────────────────────────────────────────────────────────────────
record CreateProductDto(string Name, string Description, decimal Price);
record ConfirmImageDto(string Key);

Крок 6: Конфігурація

appsettings.json:

{
    "ConnectionStrings": {
        "Default": "Data Source=products.db"
    },
    "AWS": {
        "Region": "eu-central-1"
    },
    "S3": {
        "BucketName": "product-images-2024"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information"
        }
    }
}

AWS credentials — ніколи не вказуйте в appsettings.json. SDK шукає їх у такому порядку:

  1. ~/.aws/credentials (для локальної розробки після aws configure)
  2. Змінні середовища AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
  3. IAM Role — через Instance Profile (EC2) або Task Role (ECS) у продакшні — рекомендований спосіб

Продакшн (рекомендовано): IAM Role без жодних ключів у коді

IAM Role vs Instance Profile — в чому різниця?

IAM Role — це набір дозволів (policy). Сама по собі вона нічого не робить, це просто «документ із правилами»: «дозволено робити PutObject у bucket X».

Instance Profile — це «контейнер», через який IAM Role прикріплюється до EC2 інстансу. EC2 не може взяти Role напряму — йому потрібен Instance Profile як проміжна ланка.

IAM Role (дозволи) → Instance Profile (контейнер) → EC2 Instance
Коли створюєте роль через AWS Console з типом EC2 — консоль автоматично створює Instance Profile з тим самим іменем. Тому ця деталь часто непомітна. Через CLI — треба створювати окремо, що й показано нижче.

EC2 — прикріпіть Instance Profile до інстансу:

# 1. Створити IAM Role з потрібними S3 дозволами
aws iam create-role \
    --role-name product-image-manager-role \
    --assume-role-policy-document '{
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": { "Service": "ec2.amazonaws.com" },
            "Action": "sts:AssumeRole"
        }]
    }'

# 2. Прикріпити S3 policy (лише потрібний bucket — принцип найменших привілеїв)
aws iam put-role-policy \
    --role-name product-image-manager-role \
    --policy-name s3-product-images \
    --policy-document '{
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
            "Resource": "arn:aws:s3:::product-images-2024/*"
        }]
    }'

# 3. Створити Instance Profile та прикріпити Role
# Instance Profile — це окрутня обгортка для ролі, потрібна саме для EC2.
# create-instance-profile: реєструємо профіль у IAM
# add-role-to-instance-profile: вкладаємо роль у профіль (один профіль = одна роль)
aws iam create-instance-profile --instance-profile-name product-image-manager-profile
aws iam add-role-to-instance-profile \
    --instance-profile-name product-image-manager-profile \
    --role-name product-image-manager-role

# 4. Прикріпити до EC2 інстансу
aws ec2 associate-iam-instance-profile \
    --instance-id i-0abc123def456 \
    --iam-instance-profile Name=product-image-manager-profile

Як EC2 отримує credentials після прикріплення профілю?

Після прикріплення Instance Profile, EC2 автоматично отримує доступ до IMDS (Instance Metadata Service) — спеціальний HTTP-ендпоінт, доступний тільки зсередини інстансу за адресою http://169.254.169.254. AWS SDK робить запит до нього і отримує тимчасові credentials, які автоматично оновлюються кожні ~1 годину.

Як AWS SDK отримує credentials через IMDS (відбувається автоматично)
# SDK (і boto3, і AWSSDK.NET) робить це приховано від вас
SDK → IMDS: GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
IMDS → SDK: product-image-manager-role
SDK → IMDS: GET http://169.254.169.254/latest/meta-data/iam/security-credentials/product-image-manager-role
IMDS → SDK: {
"AccessKeyId": "ASIA...тимчасовий ключ",
"SecretAccessKey": "****",
"Token": "IQoJ...session token",
"Expiration": "2026-05-28T15:30:00Z" ← оновлюється кожну годину
}
# SDK кешує ці credentials і перезапитує за 5 хвилин до закінчення
# Жоден ключ не зберігається на диску чи в коді — тільки в пам'яті процесу

У коді — нічого змінювати не потрібно. AddAWSService<IAmazonS3>() автоматично отримає тимчасові credentials через IMDS (http://169.254.169.254).

ECS — вкажіть taskRoleArn у Task Definition:

{
    "family": "product-image-manager",
    "taskRoleArn": "arn:aws:iam::123456789012:role/product-image-manager-role",
    "containerDefinitions": [
        {
            "name": "api",
            "image": "your-ecr-repo/product-image-manager:latest",
            "portMappings": [{ "containerPort": 5000 }]
        }
    ]
}

SDK отримає credentials через ECS Task Metadata endpoint — так само автоматично.

Локальна розробка (альтернатива): статичні ключі через aws configure

aws configure
# AWS Access Key ID:     AKIA...
# AWS Secret Access Key: ****...
# Default region:        eu-central-1
# Default output format: json

Крок 7: Демонстрація (повний flow)

dotnet run
# http://localhost:5000/swagger — Swagger UI

Кроки через curl:

BASE="http://localhost:5000"

# ── 1. Створити продукт ──────────────────────────────────────────────────────
curl -s -X POST "$BASE/api/products" \
    -H "Content-Type: application/json" \
    -d '{"name":"Gaming Laptop","description":"RTX 4090, 32GB RAM","price":2499.99}'
POST /api/products
$ curl -X POST .../api/products -d '{"name":"Gaming Laptop",...}'
{
"id": 1,
"name": "Gaming Laptop"
}
# ── 2. Отримати Presigned PUT URL для завантаження зображення ────────────────
curl -s "$BASE/api/products/1/upload-url?contentType=image/jpeg"
GET /api/products/1/upload-url
{
"uploadUrl": "https://product-images-2024.s3.eu-central-1.amazonaws.com/products/1/f3a2...jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
"key": "products/1/f3a2c8d1-4b5e-4f6a-8c9d-1e2f3a4b5c6d.jpg",
"expiresInMinutes": 15
}
# ── 3. Завантажити зображення НАПРЯМУ в S3 (без ASP.NET!) ────────────────────
UPLOAD_URL="https://product-images-2024.s3.eu-central-1.amazonaws.com/products/1/f3a2...jpg?..."
S3_KEY="products/1/f3a2c8d1-4b5e-4f6a-8c9d-1e2f3a4b5c6d.jpg"

curl -s -X PUT "$UPLOAD_URL" \
    -H "Content-Type: image/jpeg" \
    --upload-file ./laptop.jpg
# Порожня відповідь — 200 OK означає успіх
PUT → S3 presigned URL (прямо з браузера)
$ curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --upload-file ./laptop.jpg
# (empty response body)
HTTP 200 OK — файл збережено в S3 без участі ASP.NET
# ── 4. Підтвердити завантаження — зберегти S3 key у БД ───────────────────────
curl -s -X PUT "$BASE/api/products/1/image" \
    -H "Content-Type: application/json" \
    -d "{\"key\":\"$S3_KEY\"}"
PUT /api/products/1/image
{
"imageUrl": "https://product-images-2024.s3.eu-central-1.amazonaws.com/products/1/f3a2...jpg?X-Amz-Expires=3600&..."
}
# ── 5. Отримати продукт з presigned GET URL (вставити в <img src>) ───────────
curl -s "$BASE/api/products/1"
GET /api/products/1
{
"id": 1,
"name": "Gaming Laptop",
"description": "RTX 4090, 32GB RAM",
"price": 2499.99,
"imageUrl": "https://product-images-2024.s3.eu-central-1.amazonaws.com/products/1/f3a2...jpg?X-Amz-Expires=3600&...",
"createdAt": "2024-01-15T10:30:00Z"
}
# ── 6. Видалення продукту (зображення з S3 видаляється автоматично) ──────────
curl -s -X DELETE "$BASE/api/products/1"
# HTTP 204 No Content

Крок 8: ОБОВ'ЯЗКОВО — Очищення

BUCKET="product-images-2024"
REGION="eu-central-1"

# Видалити всі зображення
aws s3 rm s3://$BUCKET --recursive --region $REGION

# Видалити сам bucket
aws s3api delete-bucket --bucket $BUCKET --region $REGION

MinIO — S3-сумісне сховище для локальної розробки

AWS S3 — чудовий сервіс, але він платний і вимагає підключення до інтернету. Під час локальної розробки запускати весь код проти реального S3 незручно: платите за кожен запит, потрібні AWS-ключі у кожного розробника, CI/CD залежить від зовнішньої мережі. MinIO вирішує всі ці проблеми.

MinIO — відкрите, S3-сумісне об'єктне сховище. Це означає, що ваш .NET код, React фронтенд і будь-який інший клієнт, написаний для AWS S3, без жодних змін працює з MinIO — достатньо замінити URL ендпоінту. MinIO реалізує той самий HTTP API, що й S3: PutObject, GetObject, DeleteObject, Presigned URLs, Multipart Upload, Bucket Policies, CORS — все.

Локальна розробка

  • Запуск одною командою через Docker
  • Нема витрат — безплатно
  • Нема залежності від інтернету
  • Ізольоване середовище для кожного розробника

CI/CD Pipeline

  • MinIO в GitHub Actions / GitLab CI як сервіс
  • Тести не потребують mock — реальний S3 API
  • Швидко (локальна мережа між контейнерами)
  • Повторювані результати без flaky S3 запитів

Self-hosted / On-premise

  • Власні сервери — повний контроль
  • GDPR/compliance: дані не виходять з вашої інфраструктури
  • Кластерний режим (erasure coding) для надійності
  • Kubernetes Operator для автоматичного масштабування

Міграція та гібрид

  • Той самий SDK — перемикання між MinIO і S3 за конфігом
  • MinIO Gateway: проксі поверх Azure Blob, GCS
  • Поступова міграція з AWS до власної інфраструктури

MinIO vs AWS S3 — порівняння

ХарактеристикаAWS S3MinIO
ТипManaged Cloud SaaSSelf-hosted / Open Source
API сумісністьS3 (оригінал)100% S3-compatible
Вартість$0.023/GB/міс + запитиБезкоштовно (лише сервер)
Локальний запуск✅ Docker / binary
SLA99.999999999% (11 дев'яток)Залежить від вашого сервера
МасштабуванняНеобмежене (керує AWS)До сотень петабайт (DIY)
ШифруванняSSE-S3, SSE-KMS, SSE-CAES-256, TLS, Vault
IAM/RBACAWS IAMMinIO вбудований RBAC
Versioning
Lifecycle Policies
Presigned URLs
Static Website Hosting
Object Lock (WORM)
КонсольAWS ConsoleMinIO Console (веб-UI)
Ідеально дляПродакшнЛокальна розробка + on-premise
Для продакшну зі стандартними вимогами → AWS S3. Для локальної розробки, CI/CD та on-premise → MinIO. Один і той самий код — різна конфігурація.

Архітектура MinIO

Loading diagram...
graph LR
    subgraph DEV["💻 Локальна розробка"]
        dotnet["ASP.NET API<br/>localhost:5000"]
        react["React SPA<br/>localhost:3000"]
    end

    subgraph MINIO["🪣 MinIO<br/>localhost:9000"]
        api["S3 API<br/>:9000"]
        console["Web Console<br/>:9001"]
        b1["bucket: uploads"]
        b2["bucket: avatars"]
        b3["bucket: documents"]
        api --> b1
        api --> b2
        api --> b3
    end

    subgraph PROD["☁️ Продакшн"]
        s3["AWS S3"]
    end

    dotnet -->|"AWSSDK.S3<br/>endpoint=localhost:9000"| api
    react -->|"Presigned URL<br/>PUT/GET"| api
    console -->|"Браузер"| api

    dotnet -.->|"Та сама конфігурація<br/>endpoint=s3.amazonaws.com"| s3

    style DEV fill:#1e293b,stroke:#3b82f6,color:#e2e8f0
    style MINIO fill:#1e293b,stroke:#f59e0b,color:#e2e8f0
    style PROD fill:#1e293b,stroke:#10b981,color:#e2e8f0

Ключова ідея: .NET SDK не знає, з ким розмовляє. Він надсилає HTTP запити за S3 протоколом. MinIO відповідає за тим самим протоколом. У продакшні ви змінюєте endpoint у конфігурації — і більше нічого.


Встановлення та запуск

docker run -d \
  --name minio \
  -p 9000:9000 \
  -p 9001:9001 \
  -e MINIO_ROOT_USER=minioadmin \
  -e MINIO_ROOT_PASSWORD=minioadmin \
  -v minio-data:/data \
  quay.io/minio/minio server /data --console-address ":9001"
docker run minio
$ docker run -d --name minio -p 9000:9000 -p 9001:9001 ...
Unable to find image 'quay.io/minio/minio:latest' locally
latest: Pulling from minio/minio
a803e7c4b030: Pull complete
f23a5c4e84f2: Pull complete
Status: Downloaded newer image for quay.io/minio/minio:latest
7f3a2c1d8e9b4f5a6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
$ docker ps
CONTAINER ID IMAGE COMMAND PORTS
7f3a2c1d8e9b quay.io/minio/minio "/usr/bin/docker-en…" 0.0.0.0:9000->9000/tcp, 0.0.0.0:9001->9001/tcp

::

Додайте MinIO як сервіс у docker-compose.yml поряд з вашим API і БД:

::code-tree

```yaml [docker-compose.yml]
services:
    api:
        build: .
        ports:
            - '5000:8080'
        environment:
            - AWS__ServiceURL=http://minio:9000
            - AWS__AccessKey=minioadmin
            - AWS__SecretKey=minioadmin
            - AWS__BucketName=uploads
        depends_on:
            minio:
                condition: service_healthy

    minio:
        image: quay.io/minio/minio:latest
        command: server /data --console-address ":9001"
        ports:
            - '9000:9000'
            - '9001:9001'
        environment:
            MINIO_ROOT_USER: minioadmin
            MINIO_ROOT_PASSWORD: minioadmin
        volumes:
            - minio-data:/data
        healthcheck:
            test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
            interval: 5s
            timeout: 5s
            retries: 5

    # Автоматичне створення bucket при старті
    minio-init:
        image: quay.io/minio/mc:latest
        depends_on:
            minio:
                condition: service_healthy
        entrypoint: >
            /bin/sh -c "
            mc alias set local http://minio:9000 minioadmin minioadmin &&
            mc mb --ignore-existing local/uploads &&
            mc mb --ignore-existing local/avatars &&
            mc anonymous set download local/avatars &&
            echo 'MinIO initialized'
            "

volumes:
    minio-data:
```

::
docker compose up
$ docker compose up -d
[+] Running 3/3
Container project-minio-1 Healthy
Container project-minio-init-1 Exited (0)
Container project-api-1 Started
$ docker compose logs minio-init
minio-init-1 | Added `local` successfully.
minio-init-1 | Bucket created successfully `local/uploads`.
minio-init-1 | Bucket created successfully `local/avatars`.
minio-init-1 | MinIO initialized

mc — CLI для управління MinIO (і S3). Встановлюється окремо.

# macOS
brew install minio/stable/mc

# Linux
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc && sudo mv mc /usr/local/bin/

# Налаштування alias
mc alias set local http://localhost:9000 minioadmin minioadmin
mc alias set prod s3 YOUR_ACCESS_KEY YOUR_SECRET_KEY --api S3v4
mc — типові команди
$ mc alias set local http://localhost:9000 minioadmin minioadmin
Added `local` successfully.
$ mc mb local/uploads local/avatars local/documents
Bucket created successfully `local/uploads`.
Bucket created successfully `local/avatars`.
Bucket created successfully `local/documents`.
$ mc ls local/
[2026-05-28 14:22:01 UTC] 0B avatars/
[2026-05-28 14:22:01 UTC] 0B documents/
[2026-05-28 14:22:01 UTC] 0B uploads/
$ mc cp ./photo.jpg local/avatars/user-42/photo.jpg
...photo.jpg: 284 KiB / 284 KiB ━━━━━━━━━━━━━━━━ 100% 1.2 MiB/s
$ mc stat local/avatars/user-42/photo.jpg
Name : photo.jpg
Date : 2026-05-28 14:23:17 UTC
Size : 291 KiB
ETag : d41d8cd98f00b204e9800998ecf8427e
Type : file
Metadata :
Content-Type: image/jpeg

::


MinIO Console — веб-інтерфейс

MinIO постачається з вбудованим веб-інтерфейсом на порту 9001. Він дозволяє керувати bucket-ами, об'єктами, доступами та налаштуваннями без CLI.

Відкрийте браузер: http://localhost:9001 → логін: minioadmin / minioadmin

Для продакшну обов'язково змініть credentials: MINIO_ROOT_USER і MINIO_ROOT_PASSWORD — мінімум 8 символів. Зберігайте у .env файлі, який додано до .gitignore.

Через консоль можна:

  • Створювати та видаляти bucket-и
  • Завантажувати, переглядати та видаляти об'єкти
  • Налаштовувати Access Policy (Public / Private)
  • Керувати lifecycle rules (аналог S3 Lifecycle)
  • Генерувати Access Key / Secret Key для застосунків
  • Переглядати логи подій та метрики

Підключення .NET SDK до MinIO

MinIO повністю S3-сумісний — той самий AWSSDK.S3 пакет, лише інша конфігурація ендпоінту.

Конфігурація в appsettings.json:

{
    "AWS": {
        "ServiceURL": "http://localhost:9000",
        "AccessKey": "minioadmin",
        "SecretKey": "minioadmin",
        "BucketName": "uploads",
        "ForcePathStyle": true
    }
}
ForcePathStyle: true — обов'язкова опція для MinIO. AWS S3 використовує bucket.s3.amazonaws.com (virtual-hosted style), а MinIO — localhost:9000/bucket (path style). Без цієї опції SDK буде намагатися звернутись до uploads.localhost:9000, що не працюватиме.

Реєстрація сервісів у Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Спільна конфігурація для MinIO та AWS S3
builder.Services.AddSingleton<IAmazonS3>(sp =>
{
    var config = builder.Configuration.GetSection("AWS");
    var serviceUrl = config["ServiceURL"]; // null у продакшні

    var s3Config = new AmazonS3Config
    {
        ForcePathStyle = bool.TryParse(config["ForcePathStyle"], out var fps) && fps
    };

    // MinIO: явний URL ендпоінту
    // AWS S3: регіон (ServiceURL = null → SDK бере регіон)
    if (!string.IsNullOrEmpty(serviceUrl))
        s3Config.ServiceURL = serviceUrl;
    else
        s3Config.RegionEndpoint = RegionEndpoint.GetBySystemName(config["Region"] ?? "eu-central-1");

    var credentials = new BasicAWSCredentials(
        config["AccessKey"] ?? throw new InvalidOperationException("AWS AccessKey required for MinIO"),
        config["SecretKey"] ?? throw new InvalidOperationException("AWS SecretKey required for MinIO")
    );

    // У продакшні з IAM Role — credentials беруться автоматично через IMDS
    if (string.IsNullOrEmpty(serviceUrl))
        return new AmazonS3Client(s3Config); // IAM Role / env vars

    return new AmazonS3Client(credentials, s3Config);
});

Загальний сервіс — працює однаково з MinIO та S3:

public class FileStorageService(IAmazonS3 s3, IConfiguration config)
{
    private readonly string _bucket = config["AWS:BucketName"]!;

    // Завантажити файл → той самий код для MinIO і S3
    public async Task<string> UploadAsync(Stream content, string key, string contentType)
    {
        await s3.PutObjectAsync(new PutObjectRequest
        {
            BucketName = _bucket,
            Key = key,
            InputStream = content,
            ContentType = contentType
        });
        return key;
    }

    // Presigned URL → той самий код
    public string GeneratePresignedUrl(string key, TimeSpan expiry)
    {
        return s3.GetPreSignedURL(new GetPreSignedUrlRequest
        {
            BucketName = _bucket,
            Key = key,
            Expires = DateTime.UtcNow.Add(expiry),
            Verb = HttpVerb.GET
        });
    }

    // Видалити → той самий код
    public async Task DeleteAsync(string key)
    {
        await s3.DeleteObjectAsync(_bucket, key);
    }
}
Тест завантаження через MinIO (локально)
$ dotnet run
info: Application started. Press Ctrl+C to shut down.
info: Now listening on: http://localhost:5000
$ curl -X POST http://localhost:5000/api/files \
-F "file=@photo.jpg" -F "folder=avatars"
{
"key": "avatars/user-1/2026-05-28-photo.jpg",
"url": "http://localhost:9000/uploads/avatars/user-1/2026-05-28-photo.jpg?X-Amz-..."
}
# Відкрийте URL у браузері — файл доступний через MinIO

MinIO у тестах (.NET)

MinIO ідеально підходить для інтеграційних тестів — замість mock-ів ви тестуєте реальну S3-поведінку локально.

// TestContainers — запуск MinIO у тестах автоматично
// dotnet add package Testcontainers.Minio

public class FileStorageTests : IAsyncLifetime
{
    private MinioContainer _minio = null!;
    private IAmazonS3 _s3 = null!;

    public async Task InitializeAsync()
    {
        _minio = new MinioBuilder()
            .WithUsername("testuser")
            .WithPassword("testpass12")
            .Build();

        await _minio.StartAsync();

        _s3 = new AmazonS3Client(
            new BasicAWSCredentials(_minio.GetAccessKey(), _minio.GetSecretKey()),
            new AmazonS3Config
            {
                ServiceURL = _minio.GetConnectionString(), // http://localhost:PORT
                ForcePathStyle = true
            }
        );

        // Створити тестовий bucket
        await _s3.PutBucketAsync("test-bucket");
    }

    [Fact]
    public async Task UploadAndDownload_ShouldWork()
    {
        var content = "Hello, MinIO!"u8.ToArray();
        using var stream = new MemoryStream(content);

        await _s3.PutObjectAsync(new PutObjectRequest
        {
            BucketName = "test-bucket",
            Key = "test/hello.txt",
            InputStream = stream,
            ContentType = "text/plain"
        });

        var response = await _s3.GetObjectAsync("test-bucket", "test/hello.txt");
        using var reader = new StreamReader(response.ResponseStream);
        var result = await reader.ReadToEndAsync();

        Assert.Equal("Hello, MinIO!", result);
    }

    public async Task DisposeAsync() => await _minio.DisposeAsync();
}
Testcontainers.Minio автоматично завантажує MinIO Docker образ, запускає його на випадковому порту та зупиняє після тестів. Не потрібно вручну керувати жодним контейнером.

MinIO у GitHub Actions

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
    test:
        runs-on: ubuntu-latest

        services:
            minio:
                image: quay.io/minio/minio:latest
                ports:
                    - 9000:9000
                env:
                    MINIO_ROOT_USER: minioadmin
                    MINIO_ROOT_PASSWORD: minioadmin
                options: >-
                    --health-cmd "curl -f http://localhost:9000/minio/health/live"
                    --health-interval 5s
                    --health-retries 5

        steps:
            - uses: actions/checkout@v4

            - name: Setup .NET
              uses: actions/setup-dotnet@v4
              with:
                  dotnet-version: '9.0.x'

            - name: Create test bucket
              run: |
                  curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
                  chmod +x mc
                  ./mc alias set local http://localhost:9000 minioadmin minioadmin
                  ./mc mb local/test-bucket

            - name: Run tests
              run: dotnet test
              env:
                  AWS__ServiceURL: http://localhost:9000
                  AWS__AccessKey: minioadmin
                  AWS__SecretKey: minioadmin
                  AWS__BucketName: test-bucket
                  AWS__ForcePathStyle: 'true'
GitHub Actions — MinIO сервіс
Set up job
Initialize containers
Starting service container minio (quay.io/minio/minio:latest)
Service container minio healthy
Create test bucket
Added `local` successfully.
Bucket created successfully `local/test-bucket`.
Run tests
Build started...
Passed! - Failed: 0, Passed: 24, Skipped: 0, Total: 24
Test Run Successful.

Переключення між MinIO та AWS S3 у проекті

Основний патерн: одна конфігурація керує тим, куди іде трафік — MinIO локально або AWS S3 у продакшні.

Визначте конфігурацію у appsettings.json

{
    "Storage": {
        "Provider": "minio",
        "BucketName": "uploads"
    }
}

Provider = "minio" у Development, "s3" у Production.

Зареєструйте IAmazonS3 залежно від провайдера

var provider = builder.Configuration["Storage:Provider"];

if (provider == "minio")
{
    builder.Services.AddSingleton<IAmazonS3>(_ =>
        new AmazonS3Client(
            new BasicAWSCredentials("minioadmin", "minioadmin"),
            new AmazonS3Config { ServiceURL = "http://localhost:9000", ForcePathStyle = true }
        ));
}
else
{
    // Продакшн — IAM Role, credentials з IMDS (без ключів у коді)
    builder.Services.AddAWSService<IAmazonS3>();
}

Весь інший код залишається незмінним

// FileStorageService, UploadController, PresignedUrlController —
// всі інжектують IAmazonS3 і не знають, куди вони говорять
public class UploadController(IAmazonS3 s3, IConfiguration cfg)
{
    // Той самий код що і з AWS S3
}

Перевірте локально

dotnet run --environment Development
# → Storage: MinIO @ http://localhost:9000

dotnet run --environment Production
# → Storage: AWS S3 @ eu-central-1

MinIO для self-hosted продакшн

Якщо ваш проект вимагає зберігати дані на власних серверах (GDPR, compliance, низькі витрати на великих обсягах), MinIO можна запустити у продакшн-режимі.

Distributed Mode (erasure coding):

# 4 сервера × 4 диски = 16 дисків
# MinIO зберігає дані з N/2 парітетом — витримує відмову половини дисків
minio server \
  http://minio{1...4}/data{1...4} \
  --console-address ":9001"

Kubernetes (MinIO Operator):

# Встановити оператор
kubectl apply -k github.com/minio/operator

# Створити тенант (кластер MinIO)
kubectl apply -f - <<EOF
apiVersion: minio.min.io/v2
kind: Tenant
metadata:
  name: minio-prod
spec:
  pools:
    - servers: 4
      volumesPerServer: 4
      volumeClaimTemplate:
        spec:
          storageClassName: fast-ssd
          resources:
            requests:
              storage: 1Ti
EOF
Self-hosted MinIO у продакшні вимагає: налаштованого TLS (не HTTP!), регулярних бекапів, моніторингу через MinIO Console або Prometheus, продуманої стратегії оновлень. MinIO активно розвивається, але відповідальність за uptime — на вас.

Порівняння варіантів для різних сценаріїв

СценарійРекомендація
Локальна розробка одного розробникаMinIO у Docker (docker run)
Локальна розробка командиMinIO у docker-compose.yml проекту
CI/CD пайплайнMinIO як сервіс у GitHub Actions / GitLab CI
Інтеграційні тести .NETTestcontainers.Minio
Продакшн, стандартні вимогиAWS S3
Продакшн, GDPR / on-premiseMinIO Distributed або MinIO Kubernetes Operator
Міграція з AWS до self-hostedMinIO + той самий AWSSDK.S3 (тільки змінити endpoint)

Резюме

  • Bucket — глобально унікальний контейнер, прив'язаний до регіону. Назва — лише lowercase + цифри + дефіс.
  • Object = дані + метадані + ключ. Ключ — рядок, а не реальна директорія.
  • Storage Classes: Standard (активні дані), Standard-IA (рідкісний доступ), Glacier (архів, 12–48 год відновлення), Intelligent-Tiering (автоматичний вибір).
  • Versioning: захист від видалення та перезапису. Завжди налаштовуйте Lifecycle щоб контролювати витрати.
  • Block Public Access: завжди вмикайте, якщо bucket не є публічним статичним сайтом.
  • Bucket Policy: JSON-правила на рівні bucket. Для публічного сайту — "Principal": "*", "Action": "s3:GetObject".
  • Encryption: SSE-S3 (безкоштовно) для базового захисту, SSE-KMS для аудиту та compliance.
  • Presigned URLs: тимчасовий доступ до приватних файлів. GET (скачування) та PUT (завантаження з браузера).
  • Static Website Hosting: для React SPA — index.html як Error Document для коректного роутингу.
  • CORS: необхідний для direct upload з браузера та cross-origin запитів.
  • HLS/DASH: S3 зберігає сегменти + manifest, CloudFront роздає їх з низькою latency по всьому світу.
  • Transfer Acceleration: для великих файлів від географічно розподілених користувачів.
  • .NET SDK: TransferUtility для upload/download, GetPreSignedURL для Presigned URLs, ListObjectsV2 для переліку файлів.

Практичні завдання

Рівень 1 (Базовий)

Завдання 1. Порівняйте S3 Standard та S3 Glacier Deep Archive: ціна зберігання, час доступу, мінімальний термін зберігання. Для яких даних підходить кожен клас?

Завдання 2. Чому для React SPA потрібно вказати index.html як Error Document, а не стандартну сторінку 404?

Рівень 2 (Практичний)

Завдання 3. Задеплойте React SPA (або просто index.html з текстом «Hello S3») на S3 Static Hosting. Налаштуйте Lifecycle Policy: зберігати максимум 3 попередніх версії. Перевірте що при прямому переході на /about (неіснуюча сторінка) отримуєте index.html, а не помилку.

Завдання 4. Реалізуйте .NET endpoint POST /api/upload, який приймає файл, генерує унікальний ключ uploads/{userId}/{timestamp}/{filename} та завантажує в S3. Додайте endpoint GET /api/presigned-url?key=... для отримання Presigned URL на 1 годину. Перевірте через Swagger.

Рівень 3 (Архітектура)

Завдання 5. Спроектуйте S3-архітектуру для відео-платформи: bucket для raw відео (завантажені користувачами), bucket для конвертованих HLS-сегментів, Lifecycle Policy для кожного bucket, CORS для фронтенду на окремому домені, Bucket Policy що дозволяє MediaConvert записувати у output bucket та лише читання через CloudFront. Опишіть покрокову Flow від завантаження відео до перегляду через плеєр.


Реальні юзкейси та вартість

Теорія — це добре, але студенти часто запитують: «скільки це реально коштує?». Розберемо кілька реальних сценаріїв з детальними розрахунками на основі актуальних AWS цін для регіону eu-central-1.

Всі розрахунки нижче — приблизні і слугують орієнтиром. Реальна вартість залежить від патерну доступу, вибору Storage Class, регіону та обсягу даних. Точні ціни перевіряйте на AWS Pricing Calculator.

Юзкейс 1: Аніме-стрімінг платформа

Побудуємо розрахунок для платформи з повною колекцією аніме контенту та помірною аудиторією. Це показовий приклад, бо поєднує величезне сховище з інтенсивним відео-трафіком.

Обсяг контенту (станом на 2025 рік):

За даними MyAnimeList / AniList у світі існує близько 17 000 аніме-серіалів із середньою кількістю 22 епізоди. Плюс ~2 800 аніме-фільмів.

КатегоріяКількість
Серіали17 000
Серій × 22 епізоди374 000 епізодів
Фільми2 800

Розміри після HLS конвертації (на одиницю контенту):

Для HLS стрімінгу кожен відеофайл конвертується у кілька якостей і нарізається на 6-секундні .ts сегменти. Загальний розмір HLS файлів приблизно рівний розміру оригінального відео у відповідній якості.

ЯкістьЕпізод (24 хв)Фільм (90 хв)
360p (~500 kbps)150 MB560 MB
480p (~1.2 Mbps)350 MB1.3 GB
720p (~2.4 Mbps)700 MB2.6 GB
1080p (~4.8 Mbps)1.4 GB5.25 GB
Разом (всі якості)2.6 GB9.75 GB

Загальний обсяг сховища:

374 000 епізодів × 2.6 GB  = 972 400 GB = 950 TB
  2 800 фільмів × 9.75 GB  =  27 300 GB = 27 TB
                              ─────────────────────
                              999 700 GB ≈ 976 TB  ≈ 1 Петабайт

Майже 1 петабайт. Це реальна цифра — Netflix, Crunchyroll та аналоги зберігають десятки петабайт.

Оптимізація Storage Class:

Не весь контент дивляться однаково активно. Розподілимо:

ТипКлас% від загальногоОбсягВартість/міс
Топ-аніме (Naruto, AoT, One Piece...)Standard40%390 TB$9 197
Середній попит, сезон минулих 2 рокиStandard-IA40%390 TB$4 998
Ретро, рідко переглядаютьGlacier Instant20%195 TB$800
РАЗОМ сховище100%~976 TB$14 995/міс

Трафік (CloudFront):

Припустимо 100 000 MAU (Monthly Active Users) — це помірна, але не маленька аудиторія. Порівняно: у Crunchyroll 10+ мільйонів передплатників.

100 000 користувачів × 20 епізодів/міс × 700 MB (720p)
= 1 400 000 GB = 1 367 TB трафіку на місяць

CloudFront тарифікується по знижуючих рівнях (чим більше — тим дешевше):

ОбсягЦіна/GBВартість
Перші 10 TB$0.085$850
10–50 TB$0.080$3 200
50–150 TB$0.060$6 000
150–500 TB$0.040$14 000
500–1367 TB$0.030$26 010
РАЗОМ CloudFront$50 060/міс

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

Стаття витратВартість/міс
S3 Storage (~976 TB, змішані класи)$14 995
CloudFront трафік (~1 367 TB)$50 060
S3 API requests (GET/PUT)$280
РАЗОМ~$65 000/міс
На рік~$780 000/рік
$65 000 на місяць при 100 000 MAU — це $0.65 на користувача на місяць. Передплата $7–10/міс покриває витрати з запасом на маржу. При масштабуванні до 1 млн MAU — трафік зростає лінійно ($500K/міс), але CloudFront дає додаткові знижки при об'ємах 1 PB+. Реальні стрімінгові сервіси також укладають приватні угоди з AWS.

Оптимізації для зниження витрат:

  • Популярний контент кешувати агресивно (CloudFront TTL 24+ годин для .ts сегментів — вони незмінні)
  • Glacier Instant для аніме 2000-х — більшість переглядів з кешу CloudFront після першого запиту
  • Spot Instances для MediaConvert задач конвертації нових епізодів (знижка 60–90%)
  • Reserved Capacity CloudFront для гарантованого великого обсягу

Юзкейс 2: React SPA / статичний сайт

Найпростіший юзкейс — хостинг фронтенду.

Параметри: 50 000 відвідувань/місяць, build 50 MB, середнє завантаження 2 MB на сесію.

СтаттяРозрахунокВартість/міс
S3 storage50 MB × $0.023$0.001
CloudFront трафік50 000 × 2 MB = 100 GB$8.50
CloudFront requests50 000 × 50 req = 2.5M$0.008
РАЗОМ~$9/міс

Для сайту з 500 000 відвідувань/міс (1 TB трафіку): ~$85/міс. Хостинг на S3+CloudFront на порядки дешевший за VPS для статичного контенту.


Юзкейс 3: Інтернет-магазин (фото товарів)

Параметри: 50 000 SKU, 6 фото на товар, 3 розміри мініатюр (thumb, medium, large), 200 000 відвідувань/міс.

50 000 товарів × 6 фото × 3 розміри × 500 KB = 440 GB
СтаттяВартість/міс
S3 Standard (440 GB фото)$10
CloudFront (200k × 15 фото × 500KB = 1.5 TB)$125
S3 PUT requests (нові завантаження)$1
РАЗОМ~$136/міс

При 2 000 000 відвідувань/міс (15 TB трафіку): $900/міс. Порівняйте з CDN від Cloudflare ($200/міс) або Fastly (~$300/міс) — AWS не завжди найдешевший для pure CDN, але виграє за інтеграцію з іншими AWS сервісами.


Юзкейс 4: SaaS застосунок з файловим сховищем

Параметри: 5 000 активних користувачів, середньо 2 GB файлів на акаунт (документи, зображення, pdf).

5 000 × 2 GB = 10 000 GB = 10 TB загального сховища

Розподіл: 30% активні файли (відкривають щотижня), 70% архів (рідко):

КласОбсягВартість/міс
Standard (30%)3 TB$69
Standard-IA (70%)7 TB$87
PUT/GET/LIST requests$15
РАЗОМ~$171/міс

При зростанні до 50 000 користувачів: ~$1 700/міс. Lifecycle Policy автоматично переміщує файли що не відкривались 90 днів у Standard-IA.


Юзкейс 5: Резервне копіювання баз даних

Параметри: production PostgreSQL 50 GB, щоденний повний backup, зберігання 30 днів.

50 GB × 30 = 1 500 GB у Glacier Flexible Retrieval
СтаттяВартість/міс
Glacier Flexible (1 500 GB × $0.0036)$5.40
PUT requests (30 uploads × $0.05/1000)$0.00015
РАЗОМ~$5.40/міс

$5.40 на місяць для надійного backup 50 GB БД — це практично безкоштовно. Навіть якщо база зростає до 500 GB: ~$54/міс. Порівняйте з вартістю втрати даних.

Lifecycle Policy: Standard (7 днів) → Glacier Flexible (30–90 днів) → видалення. Щоб відновити — aws s3 restore-object ініціює відновлення за 3–12 годин.


Юзкейс 6: Фотосток / медіа-галерея

Параметри: 2 000 000 фотографій (raw 8 MB + 3 thumbnail розміри), 500 000 відвідувань/міс.

2 000 000 × (8 MB raw + 1.5 MB thumbs) = 2 000 000 × 9.5 MB = 19 000 GB ≈ 18.5 TB
СтаттяКласВартість/міс
Raw фото (рідко потрібні після завантаження)Intelligent-Tiering$290
Thumbnails (активно дивляться)Standard$110
CloudFront (500k × 20 img × 500KB = 5TB)$425
РАЗОМ~$825/міс

Порівняльна таблиця юзкейсів

ЮзкейсСховищеТрафік/місВартість S3Вартість CFРАЗОМ
React SPA (50k MAU)50 MB100 GB~$0$9~$9
Інтернет-магазин440 GB1.5 TB$10$125~$135
SaaS файли (5k users)10 TB2 TB$156$170~$326
Backup БД (50 GB)1.5 TB (Glacier)мінімум$5~$5
Фотосток (2M фото)18.5 TB5 TB$400$425~$825
Аніме стрімінг (100k MAU)~1 PB~1.4 PB$15 000$50 000~$65 000

Головний висновок: S3 — надзвичайно вигідний для статичного контенту та рідкісного доступу (React SPA, backup). Основна вартість у медіа-сервісах — CDN трафік, а не само сховище. При проєктуванні системи зі стрімінгом відео — кешування на CloudFront є не опцією, а обов'язковою вимогою для контролю витрат.

Copyright © 2026