Размещение статики в объектном хранилище и раздача через CDN: настройка, кеширование и типичные ошибки | AdminWiki

Размещение статики в объектном хранилище и раздача через CDN: настройка, кеширование и типичные ошибки

22 сентября 2026 16 мин. чтения

Статику (JS, CSS, шрифты, изображения, видео) переносят в S3-совместимое объектное хранилище, а раздают через CDN. Связка решает три задачи сразу: снимает нагрузку с веб-сервера, ускоряет доставку для удалённых пользователей и снижает расходы на трафик за счёт кеширования на edge-серверах.

Переходить стоит, если статика занимает десятки и сотни гигабайт, а аудитория распределена по нескольким регионам. Если у вас один сервер, локальные пользователи и 20 ГБ картинок, объектное хранилище с CDN добавит точки отказа и статей в счёте, а выигрыш окажется в пределах погрешности.

Ниже: создание бакета и политик доступа, настройка CORS и Cache-Control, подключение CDN с HTTPS и инвалидацией кеша, сравнение прямой отдачи и CDN по задержкам и цене трафика, а также ошибки, которые чаще всего приводят к утечкам и лишним списаниям.

Зачем выносить статику в объектное хранилище и CDN

Объектное хранилище хранит данные как объекты в плоском пространстве имён: у каждого объекта есть ключ (путь), содержимое и метаданные. Файловой иерархии и блоков, как у ext4 или LVM, здесь нет. S3 API стал стандартом де-факто: AWS S3, Yandex Object Storage, Selectel, MinIO и другие совместимые сервисы понимают одни и те же команды, поэтому скрипты и код переносятся между ними почти без правок. Модель данных, ключевые операции и настройку клиентов разбирает руководство протокол S3: архитектура, API и настройка клиентов.

CDN - это сеть edge-серверов в разных городах. Первый запрос пользователя уходит к origin (вашему бакету), ответ кешируется на ближайшей точке присутствия, а следующие запросы к тому же файлу отдаются уже с edge. Что даёт связка:

  • Веб-серверу не нужно отдавать гигабайты статики, его CPU и канал уходят под динамику и API.
  • Файлы кешируются на edge, поэтому повторные обращения не доходят до origin.
  • Пользователь получает контент с ближайшей площадки, а не через полмира.
  • Объектное хранилище дешевле блочного на больших объёмах: платите за гигабайты хранения и операции, а не за выделенные IOPS и диски.
  • CDN принимает трафик на себя, что частично закрывает вопрос всплесков и простейших атак.

Пример. Сайт с 10 000 посетителей в день отдаёт статику с одного сервера в Европе, часть аудитории приходит из Азии и Латинской Америки. Каждый запрос идёт через дальний маршрут, и только задержка на сетевом плече даёт ориентировочно 300-500 мс до первого байта. После подключения CDN ответ приходит с ближайшего edge-узла, и та же метрика опускается до 50-100 мс. Точные цифры зависят от провайдера, города пользователя и качества его канала, но порядок разницы именно такой: в разы, а не на проценты.

Обратная сторона. Публично доступный бакет раскрывает все файлы и структуру проекта, а лишний egress в счёте за трафик незаметно превращается в десятки и сотни долларов. Настройке замков и лимитов стоит уделить столько же времени, сколько загрузке файлов.

Создание бакета и базовая настройка S3-совместимого хранилища

Провайдера выбирают по трём параметрам: совместимость с S3 API, регион размещения, цена хранения и трафика. AWS S3 остаётся эталоном реализации, Yandex Object Storage и Selectel закрывают требования по хранению данных внутри страны, MinIO разворачивается на своих серверах. Облачные платформы вроде Timeweb Cloud дают хранилище в одном биллинге с серверами и Kubernetes, что удобно для небольших команд без отдельного облачного контракта.

Имя бакета в AWS глобально уникально: 3-63 символа, только строчные латинские буквы, цифры, точка и дефис. Точки в имени ломают обращение по HTTPS с wildcard-сертификатом, поэтому для раздачи через CDN используйте дефисы: static-example-com.

Регион выбирайте ближайший к основной аудитории или к приложению. Для статики за CDN это менее критично, поскольку кеш закрывает большую часть запросов, но первый запрос после сброса и трафик между CDN и origin всё равно идут оттуда.

Сразу после создания бакета включите блокировку публичного доступа четырьмя флагами:

aws s3api create-bucket --bucket static-example-com --region eu-central-1 --create-bucket-configuration LocationConstraint=eu-central-1

aws s3api put-public-access-block --bucket static-example-com --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Дальше заведите отдельного IAM-пользователя или сервисную учётную запись для деплоя с правами только на этот бакет: s3:PutObject, s3:DeleteObject, s3:ListBucket. Ключи от аккаунта с полным доступом в CI/CD не кладите: утечка такого ключа означает доступ ко всей инфраструктуре.

Загрузка статики с заголовками кеширования прямо из CLI:

aws s3 cp ./build/ s3://static-example-com/ --recursive --exclude "*.html" --cache-control "public, max-age=31536000, immutable"

aws s3 cp ./build/index.html s3://static-example-com/index.html --cache-control "no-cache" --content-type "text/html; charset=utf-8"

Включите версионирование бакета и правило жизненного цикла: старые версии объектов после 30-90 дней удаляются автоматически. Иначе каждая перезагрузка файла с тем же ключом продолжает занимать место, а 10 версий бандла по 2 МБ на релиз превращаются в гигабайты за год.

Self-hosted вариант (MinIO, Ceph RGW) даёт контроль над данными и отсутствие платежей за egress, но требует дисков, репликации и обслуживания. Раздачу изображений под нагрузкой через MinIO с кеширующим прокси разбирает материал архитектура хранилища изображений для высоконагруженного веб-проекта: S3/MinIO, CDN и кеширующий прокси.

Политики доступа: запрет публичного доступа и выдача прав CDN

ACL - это список доступа на уровне объекта или бакета, старая механика S3. Bucket policy - JSON-документ на весь бакет, где описаны принципалы, действия, ресурсы и условия. Рабочая схема: Block Public Access включён, ACL выключены, все права выдаются политиками и IAM. Тогда случайная галочка в консоли не откроет бакет всему интернету.

Для приватного бакета CDN нужен отдельный механизм аутентификации. Исторически в AWS применяли Origin Access Identity (OAI), сейчас это Origin Access Control (OAC): он подписывает запросы к origin через SigV4, поддерживает SSE-KMS и работает для всех типов origin. У Cloudflare, Bunny и Gcore аналог называется по-разному, но принцип один: CDN обращается к бакету как авторизованный пользователь, а анонимный доступ закрыт.

Пример bucket policy, которая разрешает чтение объектов только конкретной дистрибуции CloudFront:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOAC",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::static-example-com/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
        }
      }
    }
  ]
}

Условие AWS:SourceArn привязывает доступ к одной дистрибуции: политика не сработает для чужого аккаунта. Ограничение по Referer или IP используют как дополнительный слой, но подделать Referer тривиально, поэтому основной защитой он быть не может. После изменения политики проверьте результат:

aws s3api get-public-access-block --bucket static-example-com

aws s3api get-bucket-policy --bucket static-example-com

curl -I https://static-example-com.s3.eu-central-1.amazonaws.com/index.html

Последний запрос к домену бакета должен вернуть 403 AccessDenied. Если приходит 200, бакет открыт: ищите разрешающую политику, публичный ACL или отключённый Block Public Access.

Настройка CORS и заголовков кеширования для статики

CORS (Cross-Origin Resource Sharing) - механизм браузера, который разрешает или запрещает странице обращаться к ресурсам с другого домена. Он нужен, когда HTML отдаётся с example.com, а статика с static.example.com. Без правил CORS браузер заблокирует загрузку шрифтов WOFF2 через @font-face, JSON и JS, запрошенных через fetch, изображений для canvas. Теги img, script и link стилей подгрузятся и без CORS-заголовков, а шрифты и модули нет, поскольку к ним применяется правило crossorigin.

Пример конфигурации CORS для шрифтов и JS

Правила задаются JSON-документом на весь бакет:

[
  {
    "AllowedOrigins": ["https://example.com", "https://www.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "Content-Length", "Content-Range"],
    "MaxAgeSeconds": 3600
  }
]

Применить можно командой aws s3api put-bucket-cors --bucket static-example-com --cors-configuration file://cors.json. Разбор полей:

  • AllowedOrigins: точные домены фронтенда. Звёздочку оставьте для локальной отладки, в продакшене она открывает ресурсы любому сайту.
  • AllowedMethods: для статики хватает GET и HEAD. PUT и DELETE нужны только при прямой загрузке из браузера.
  • AllowedHeaders: заголовки, которые может отправить клиент. Звёздочка здесь допустима.
  • ExposeHeaders: то, что прочитает JavaScript. Для диапазонных запросов к медиа полезен Content-Range.
  • MaxAgeSeconds: сколько браузер кеширует результат preflight-запроса. 3600 экономит лишние OPTIONS.

Две ошибки ломают загрузку шрифтов чаще всего: пустой AllowedOrigins при включённом crossorigin на теге link и отсутствие домена www, если часть посетителей приходит по нему. Заголовок Access-Control-Allow-Origin не поддерживает список из нескольких доменов: сервер возвращает конкретное значение, совпадающее с Origin запроса, поэтому в конфигурации перечисляются все варианты.

Стратегия кеширования: хешированные и нехешированные файлы

Разделите статику на два класса. Файлы с хешем в имени (app.4f9a1c.js, styles.8b2d.css, logo.7c1e.png) можно кешировать на год: при изменении содержимого меняется имя, и браузер скачивает новый файл. HTML, service worker, manifest и favicon кешировать надолго нельзя, иначе пользователь месяцами видит старую версию сайта.

Тип файлаCache-ControlЗачем
app.4f9a1c.js, styles.8b2d.csspublic, max-age=31536000, immutableИмя меняется вместе с содержимым, повторная валидация не нужна
index.htmlno-cacheБраузер обязан перепроверить версию через ETag перед использованием
service-worker.jsno-cacheИначе обновление PWA затянется на сутки и дольше
favicon.ico, robots.txtmax-age=3600Компромисс между свежестью и числом запросов

no-cache не означает «не кешировать». Он разрешает хранить копию, но требует валидации: браузер отправляет If-None-Match с ETag, сервер отвечает 304 без тела, и трафик почти не тратится. no-store запрещает хранение полностью, для статики он не нужен: он лишь увеличит число полных загрузок.

Флаг immutable сообщает браузеру, что файл не изменится, и убирает лишние запросы даже при жёсткой перезагрузке страницы. Он уместен только для файлов с хешем в имени.

Content-Type указывайте явно: S3 в большинстве случаев определяет MIME по расширению, но при загрузке через CLI бывают сюрпризы с woff2, webp, avif и mjs. Ошибка в типе приводит к тому, что браузер отказывается применять шрифт или модуль.

Сжатие текстовых файлов готовьте заранее: соберите app.js.gz и app.js.br и загрузите с заголовком Content-Encoding: br. Так CDN отдаёт уже сжатый объект без нагрузки на CPU на каждом запросе. Если между клиентом и бакетом стоит свой Nginx, кеширование и сжатие настраиваются на нём, готовые блоки есть в руководствах по кешированию статических файлов в Nginx и по настройке proxy_cache в Nginx.

Подключение CDN к объектному хранилищу

Порядок работ почти одинаков у всех провайдеров (CloudFront, Cloudflare, Bunny CDN, Gcore, Yandex CDN): создать дистрибуцию, указать origin, включить HTTPS до origin, привязать свой домен и задать правила кеширования. Различия в названиях функций и в способе авторизации origin. Логику маршрутизации, гео-правила и фейловер между origin разбирает статья про интеллектуальную маршрутизацию трафика через CDN.

Настройка origin и HTTPS

Шаги для AWS CloudFront как типовой пример:

  1. Создать дистрибуцию и указать origin: домен бакета вида static-example-com.s3.eu-central-1.amazonaws.com. Для приватного бакета используйте endpoint S3, доступ к которому закроет OAC.
  2. Выбрать Origin access control, создать OAC и скопировать его ARN в bucket policy, как в примере выше.
  3. В настройках поведения (Behavior) включить Redirect HTTP to HTTPS и HTTPS Only для связи с origin: трафик CDN-бакет пойдёт по TLS.
  4. Добавить Alternate domain name (CNAME): static.example.com, и приложить сертификат. Для CloudFront сертификат выпускает ACM в регионе us-east-1, для других CDN подойдёт Let's Encrypt или сертификат провайдера.
  5. В DNS создать CNAME static.example.com на домен дистрибуции. Запись на apex-домен через CNAME не работает, там нужен ALIAS или ANAME.
  6. Разрешить HTTP/2 и HTTP/3 (QUIC): на потерях пакетов в мобильных сетях третий протокол заметно ускоряет повторные загрузки.

Cache policy: default TTL ставьте порядка 86400 секунд для файлов с хешем, minimum TTL в 0 и maximum TTL в 31536000. Для index.html отдельное поведение с TTL 0 и доверием заголовкам origin. Если origin отдаёт один заголовок, а нужен другой, CDN переопределяет его через response headers policy или Cache Rules.

Проверка после подключения: запрос к https://static.example.com/app.4f9a1c.js должен вернуть 200, актуальный Content-Type и заголовок x-cache со значением Hit или Miss в зависимости от прогрева кеша. Ошибка 403 говорит о неверной политике бакета или не привязанном OAC, 404 - о неправильном пути ключа или origin path.

Инвалидация кеша: когда и как

Инвалидация нужна только для файлов без хеша в имени: index.html, service-worker.js, favicon, manifest.json. Хешированные бандлы обновляются сами: новый релиз публикует app.7d3e21.js, и старые записи в кеше никому не мешают.

aws cloudfront create-invalidation --distribution-id EDFDVBD6EXAMPLE --paths "/index.html" "/service-worker.js" "/manifest.json"

Полная очистка через --paths "/*" выполняется дольше и нагружает origin: все edge-узлы пойдут за контентом заново. У CloudFront определённый объём инвалидаций в месяц бесплатен, дальше пути тарифицируются, причём запрос с шаблоном (например, /images/*) считается как один путь. Для больших каталогов удобнее cache tags или surrogate keys, если CDN их поддерживает: при деплое сбрасывается группа файлов по метке, а не список путей.

После инвалидации подождите несколько секунд и проверьте файл через curl -I: заголовки покажут возраст копии (age) и признак попадания в кеш.

Прямая отдача из хранилища или через CDN: сравнение по задержкам и стоимости

Все цены и задержки ниже - ориентиры для планирования. Тарифы провайдеров меняются, зависят от объёма, региона и договора, поэтому итоговые цифры сверяйте в прайс-листе на дату работ.

СхемаЗадержка до первого байтаСтоимость трафикаНагрузка и риски
Прямая отдача из S3 (публичный бакет или presigned URL)20-50 мс в своём регионе, 150-400 мс на дальних направленияхОколо 0,09 $/ГБ egress у AWS на первых 10 ТБ; у других провайдеров бывает дешевле или входит в тарифКаждый запрос бьёт в хранилище, нет защиты от всплесков и DDoS, плата за операции растёт вместе с числом запросов
CDN над бакетом20-80 мс в большинстве городов за счёт edge-кешаОт 0,01 до 0,085 $/ГБ в зависимости от провайдера и объёма; у части CDN есть бесплатный тарифOrigin получает только промахи кеша, всплески гасятся на edge, TLS и HTTP/3 берёт на себя провайдер
CDN плюс свой кеширующий прокси перед бакетомКак у CDN, плюс возможные 5-20 мс на внутреннем плечеТрафик из бакета в свой прокси, затем трафик CDNБольше контроля над заголовками и логикой, но появляется ещё один узел для обслуживания

Арифметика на 1 ТБ в месяц. Прямая отдача из AWS S3 даёт около 90 $ только за egress. CDN с ценой 0,02-0,05 $/ГБ укладывается в 20-50 $, а при высоком cache hit ratio трафик из бакета в CDN либо бесплатен, либо стоит отдельно и по более низкому тарифу. Если 90-95 % запросов закрывает кеш, origin отдаёт лишь 50-100 ГБ вместо терабайта.

Обратный случай тоже реален. Аудитория живёт в одном городе, объём статики 5 ГБ, трафик 80 ГБ в месяц, сервер и пользователи в одном регионе. CDN добавит настройку, домен и лишнее звено, а экономия окажется в пределах 10-20 $ в месяц. Для таких проектов достаточно Nginx с настроенным кешированием.

Порог, после которого CDN окупается: аудитория из двух и более крупных регионов, трафик от 300-500 ГБ в месяц или требования к устойчивости к всплескам.

Типичные ошибки и способы их предотвращения

Большинство инцидентов со статикой в объектном хранилище сводится к шести сценариям. Каждый стоит закрыть до публикации сайта.

  1. Публичный бакет. Любой человек скачивает файлы, узнаёт структуру проекта, а платит за это владелец аккаунта. Защита: Block Public Access включён, ACL выключены, доступ выдаётся только политикой и IAM.
  2. Открытый листинг. Право s3:ListBucket позволяет перечислить все ключи, даже если чтение объектов закрыто. Защита: явный Deny на s3:ListBucket для всех, кроме администраторов и деплой-скриптов.
  3. Неверный Cache-Control. Заголовок no-store на всей статике или длинный max-age на index.html. Защита: два класса файлов с разными политиками кеша.
  4. Отсутствие HTTPS. Смешанный контент блокируется браузером, а канал без TLS позволяет подменять файлы. Защита: TLS на CDN и редирект с HTTP.
  5. Нет правил CORS. Шрифты, модули и fetch-запросы падают с ошибкой. Защита: JSON с точными AllowedOrigins и методами GET, HEAD.
  6. Нет инвалидации и мониторинга расходов. После деплоя часть пользователей видит старые файлы, а счёт за egress приходит неожиданно большим. Защита: инвалидация ключевых путей на каждом релизе плюс бюджетные алерты в облаке.

Публичный доступ и утечки данных

Проверка занимает минуту и стоит привычки перед каждым релизом:

aws s3api get-public-access-block --bucket static-example-com

aws s3api get-bucket-acl --bucket static-example-com

aws s3api get-bucket-policy-status --bucket static-example-com

Первый вывод должен показывать true по всем четырём флагам, второй - только владельца бакета, третий - что политика не публичная.

Отдельная история - ключи. Долгоживущий IAM-ключ с правами на запись статики не место в браузерном коде: любой пользователь прочитает его в DevTools. Для загрузки из браузера выдавайте presigned URL с временем жизни в минутах и ограничением по ключу и размеру.

Дополнительный слой - аудит доступа. Включите логирование обращений к бакету или CDN и раз в месяц просматривайте нетипичные адреса и всплески 403: так обнаруживается и сканирование, и случайно открытые объекты.

Неверные заголовки кеширования

Проверить, что реально получает клиент, проще всего через curl:

curl -I https://static.example.com/static/app.4f9a1c.js

HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
etag: "3f8b1c9a7d"
access-control-allow-origin: https://example.com
x-cache: Hit from cloudfront

Если cache-control приходит как no-store или max-age=0 на хешированном бандле, поправьте заголовки при загрузке и инвалидируйте кеш. Если на index.html стоит max-age=31536000, у пользователей закрепится старая версия, и никакой деплой её не вытеснит: сначала меняют заголовки, потом сбрасывают кеш.

Помните о расхождении: CDN может кешировать дольше браузера. Если у origin стоит max-age=60, а в настройках CDN minimum TTL выставлен в 3600, клиент получит копию возрастом до часа. Проверяйте обе стороны и задавайте TTL согласованно.

Снижение задержек и расходов на трафик

Что даёт эффект на практике:

  • Сжатие Brotli для текста. Бандл JavaScript около 300 КБ после сжатия занимает порядка 70-90 КБ, CSS и HTML уменьшаются на 60-80 % от исходного размера. Точные цифры зависят от содержимого, проверяйте на своих сборках.
  • Предварительное сжатие в файлы .br и .gz со своим Content-Encoding: CDN не тратит CPU на упаковку, отдаёт готовый объект.
  • HTTP/2 и HTTP/3 на CDN. HTTP/2 убирает блокировку на уровне соединения при множестве мелких файлов, HTTP/3 помогает на нестабильных мобильных сетях.
  • Класс хранения. Актуальная статика в Standard, старые сборки и архивы в Infrequent Access или Glacier: цена хранения ниже в разы при редком обращении.
  • Правильный TTL и высокий cache hit ratio. Рост попаданий с 80 до 95 % сокращает обращения к origin в четыре раза.
  • Preload для критичных ресурсов: шрифт и основной CSS, загруженные раньше, улучшают метрики отрисовки.
  • Современные форматы изображений (WebP, AVIF) и отдельный CDN с поддержкой range-запросов для видео.
  • Бюджетные алерты у облачного провайдера: уведомление при превышении суточного или месячного порога трафика.

Сжатие уже сжатых форматов (JPEG, PNG, MP4, WOFF2) результата не даёт: файлы не уменьшаются, а процессорное время тратится. Проверяйте тип перед добавлением в правила.

Проверка работоспособности и отладка

Диагностика идёт по одной схеме: сначала доступность, затем заголовки, после этого кеш и кросс-доменные правила.

  1. curl -I по CDN-домену. Ожидаем 200, правильный content-type, cache-control и наличие x-cache.
  2. curl -I по домену бакета. Ожидаем 403: прямой доступ закрыт, и это правильно.
  3. Ошибки CORS в консоли браузера. Проверьте AllowedOrigins, метод и заголовки preflight в DevTools, вкладка Network.
  4. 403 на CDN. Причины: OAC не привязан к дистрибуции, в bucket policy чужой AWS:SourceArn, объект удалён.
  5. 404 при существующем файле. Причины: лишний origin path, регистр в ключе (S3 различает регистр), неверный префикс.
  6. Старый контент. Проверьте возраст копии по заголовку age и выполните инвалидацию нужных путей.
  7. Медленные повторные загрузки. Смотрите cache hit ratio в отчётах CDN: низкое значение говорит о коротком TTL или о динамических параметрах в URL, которые дробят кеш.

Полезные команды при разборе проблем с TLS и DNS:

openssl s_client -connect static.example.com:443 -tls1_3

dig +short static.example.com CNAME

curl -I -H "Origin: https://example.com" https://static.example.com/app.4f9a1c.js

Последний запрос показывает, вернёт ли сервер Access-Control-Allow-Origin для конкретного источника. Если заголовка нет, ищите причину в конфигурации CORS, а не в браузере.

Чек-лист: от бакета до мониторинга

  1. Создать бакет с именем без точек, в регионе рядом с аудиторией или приложением.
  2. Включить Block Public Access всеми четырьмя флагами и отключить ACL.
  3. Завести отдельного IAM-пользователя с правами только на этот бакет, включить версионирование и правило жизненного цикла.
  4. Загрузить статику с корректными Cache-Control и Content-Type, сжатые версии - с Content-Encoding.
  5. Настроить CORS с точными доменами в AllowedOrigins.
  6. Создать CDN-дистрибуцию, указать origin, привязать OAC или аналог, обновить bucket policy.
  7. Включить HTTPS до origin и до клиента, привязать CNAME и сертификат, разрешить HTTP/2 и HTTP/3.
  8. Задать правила кеширования: длинный TTL для хешированных файлов, нулевой для HTML.
  9. Настроить инвалидацию ключевых путей в пайплайне деплоя.
  10. Проверить результат через curl и DevTools, включить логи CDN и алерты по трафику.

Дальше переносите остальную статику небольшими партиями и сверяйте заголовки после каждого шага. Так ошибка в политике или кеше останется локальной и не затронет весь сайт.

Поделиться:
Сохранить гайд? В закладки браузера