Медиа сайта в Google Cloud Storage за Cloudflare: как разгрузить VDS и не потерять скорость

Как я перенёс ~148 МБ медиа блога (обложки webp, видео mp4, og-картинки) в bucket Google Cloud Storage, оставив URL прежними, а Cloudflare — кэшем на edge. Разбор архитектуры, настройки Caddy, граблей с Content-Type webp и матчером рубрик.

Проблема: диск VDS не резиновый

У меня блог на Astro, который раздаётся статикой через Caddy на небольшом VDS в Германии, а перед ним стоит Cloudflare в режиме proxied (оранжевое облако, SSL full strict). Схема годами работала отлично: HTML отдаётся динамически, а всё медиа кэшируется на edge Cloudflare — в ответах честный CF-Cache-Status: HIT, читатель из любой точки получает картинку с ближайшего узла.

Проблема была не в скорости, а в диске. Блог растёт: каждый кейс-пост тащит галерею иллюстраций, обложку 16:9, og-картинку, иногда hero-видео. К моменту, когда я взялся за уборку, только медиа занимало около 148 МБ — обложки в webp, видео mp4, og-превью в png, иллюстрации к статьям. Это немного для облака, но на VDS с ограниченным диском такие вещи копятся, а вместе с ними толстеют бэкапы и дольше идёт каждый деплой (я гоняю весь dist/ на сервер).

Хотелось вынести тяжёлое медиа в отдельное дешёвое надёжное хранилище — и при этом не сломать ни одну существующую ссылку и не потерять скорость. Разбираю, как это устроено и на какие грабли я наступил.

Почему не «просто отдавать с Google Cloud Storage»

Первая наивная мысль — залить всё в bucket Google Cloud Storage и поменять в статьях пути на https://storage.googleapis.com/…. Так делают многие, и это ошибка.

Я замерил. Холодный запрос файла напрямую с storage.googleapis.com у меня занимал около 2 секунд. Причина проста: GCS — это объектное хранилище в конкретном регионе, а не CDN. У него нет узла рядом с каждым читателем. Пользователь из Волгограда, дёргая файл из европейского bucket, платит полную задержку до дата-центра Google на каждый запрос.

Для сравнения, тот же файл через Cloudflare с edge-кэша отдавался примерно за 0.5 секунды — вчетверо быстрее, потому что физически лежит на узле в паре сотен километров от читателя.

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

Главная идея: URL не меняются

Самое важное архитектурное решение — для пользователя (и для браузера, и для поисковика) ничего не меняется. Ссылки остаются прежними:

https://blog.uspeshnyy.ru/covers/keys-itc34-lending.webp
https://blog.uspeshnyy.ru/videos/hero.mp4
https://blog.uspeshnyy.ru/og/keys-itc34-lending.png

Никаких storage.googleapis.com в разметке, никакого переписывания сотен путей в статьях, никаких потерянных SEO-ссылок на картинки. Файл физически лежит в GCS, но снаружи это по-прежнему медиа моего домена.

Достигается это тем, что Caddy перестаёт искать медиа-файлы на локальном диске и вместо этого проксирует медиа-пути на storage.googleapis.com, подставляя нужный путь в bucket. А Cloudflare, как и раньше, кэширует ответ на edge. Получается трёхслойный конвейер: Cloudflare (кэш) → Caddy (обратный прокси) → GCS (хранилище).

flowchart TB
    U["Читатель<br/>запрашивает /covers/x.webp"] --> CF{"Cloudflare edge<br/>есть в кэше?"}

    CF -->|"HIT (~0.5c)"| U2["Отдаёт сразу<br/>с ближайшего узла"]

    CF -->|"MISS"| CAD["Caddy на VDS<br/>reverse_proxy"]
    CAD --> GCS["Google Cloud Storage<br/>gs://bucket/blog/covers/x.webp"]
    GCS --> CAD
    CAD --> CFS["Cloudflare<br/>кэширует ответ"]
    CFS --> U2

    style CF fill:#f59e0b,color:#fff
    style CAD fill:#0ea5e9,color:#fff
    style GCS fill:#22c55e,color:#fff
    style U2 fill:#7c3aed,color:#fff

При первом запросе файла Cloudflare получает MISS, идёт через Caddy в GCS, забирает файл, кэширует его и отдаёт читателю. Все последующие запросы этого файла — HIT с edge, за те же полсекунды, что и раньше. Один медленный запрос на файл, дальше — как было.

Настройка Caddy: матчер по каталогу И расширению

Казалось бы, конфиг простой: перехватываем медиа-пути и проксируем на bucket. Но здесь спрятана тонкая грабля, на которую я наступил, и хочу предупредить.

Наивный матчер /rubriki/* перехватит всё под этим префиксом. А у меня /rubriki/instrumenty/ — это HTML-страница рубрики, которую генерирует Astro, и в bucket её нет. Если такой матчер отправит её в GCS, читатель получит 404 на живую страницу рубрики.

Решение — матчить по регулярному выражению, которое требует одновременно и медиа-каталог, и медиа-расширение:

@media path_regexp ^/(media|videos|covers|images|og|rubriki)/.+\.(webp|mp4|png|jpe?g|pdf|svg|gif)$

handle @media {
    rewrite * /blog{path}
    reverse_proxy https://storage.googleapis.com {
        header_up Host storage.googleapis.com
    }
}

Теперь /rubriki/instrumenty/ (без расширения файла) остаётся на локальной статике Astro, а /covers/x.webp уходит в bucket. Requirement на расширение — не косметика, а то, что разделяет HTML-страницы и файлы, которые случайно живут под одинаковыми именами каталогов.

Bucket: публичное чтение и правильный Content-Type

Два условия, без которых ничего не поедет.

Первое — bucket должен быть публично читаемым. Cloudflare (через Caddy) забирает файлы анонимно, без авторизации в Google. Поэтому объектам нужен доступ allUsers: objectViewer. Если этого не сделать, прокси будет получать 403, и медиа просто не появится.

Второе, и это самая коварная грабля — Content-Type у webp. При заливке через gsutil он определяет тип по расширению, но .webp он не знает и ставит application/octet-stream. В результате браузер не показывает картинку, а скачивает её как файл. Обложки постов внезапно превращаются в загрузки — крайне неприятный сюрприз, который на первый взгляд выглядит как «всё сломалось».

Лечится принудительной простановкой типа всем webp после заливки:

gsutil -m setmeta -h "Content-Type:image/webp" "gs://bucket/blog/**/*.webp"

С png, jpg, mp4, pdf такой проблемы нет — их gsutil определяет корректно сам. Проблема именно у webp, и именно webp у меня — основной формат обложек, так что промахнуться было легко.

Заодно при заливке я сразу проставляю долгий кэш, потому что медиа у меня иммутабельно (файл под конкретным именем не меняется):

gsutil -m setmeta -h "Cache-Control:public, max-age=31536000, immutable" "gs://bucket/blog/**"

Год жизни и immutable говорят и Cloudflare, и браузеру: этот файл можно держать в кэше сколько угодно и не перепроверять.

Деплой: заливать из public/, а не из dist/

Ещё один нюанс, который стоил мне мусора в bucket. У Astro на выходе сборки в dist/ те же каталоги (rubriki, images), что и в исходном public/, но в dist/ они содержат ещё и сгенерированные HTML-страницы. Если залить медиа из dist/, в bucket попадут HTML-файлы рубрик — тот самый мусор, который не должен там лежать (и который вдобавок конфликтует с логикой матчера из прошлого раздела).

Правильно — заливать медиа из public/, где лежат только исходные файлы, без генерированной разметки. Это разделение источников я зашил в helper-скрипт deploy-media-gcs.sh, который:

  • гоняет gsutil rsync -c (сравнение по checksum, а не по времени модификации — заливаются только реально изменившиеся файлы);
  • после синка принудительно правит Content-Type всем webp;
  • проставляет Cache-Control.
#!/usr/bin/env bash
set -euo pipefail
BUCKET="gs://bucket/blog"

gsutil -m rsync -c -r public/ "$BUCKET/"
gsutil -m setmeta -h "Content-Type:image/webp" "$BUCKET/**/*.webp"
gsutil -m setmeta -h "Cache-Control:public, max-age=31536000, immutable" "$BUCKET/**"

rsync -c здесь принципиален: без него скрипт при каждом запуске перезаливал бы всё подряд. С проверкой по checksum обычный деплой после правки одной статьи трогает один-два файла и завершается за секунды.

Что это дало

Считаю по-честному, без приукрашивания.

  • Диск VDS разгрузился на те самые ~148 МБ, и, что важнее, перестал расти вместе с блогом — новое медиа теперь оседает в GCS, а не на сервере.
  • Бэкапы и деплой похудели — я больше не таскаю тяжёлые файлы на сервер при каждой публикации.
  • Хранилище стало надёжнее и дешевле — GCS отвечает за сохранность объектов, это не моя забота и не риск одного диска.
  • Скорость сохранилась. Для читателя ничего не изменилось: те же ~0.5 секунды с edge-кэша Cloudflare. Единственная плата — первый холодный запрос каждого нового файла идёт через GCS чуть дольше, но это происходит один раз на файл и в потоке трафика невидимо.

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

Кому это подходит и где границы

Схема хороша, если у вас статический или полустатический сайт с растущим объёмом медиа и уже стоит Cloudflare перед origin. Она особенно оправдана, когда медиа иммутабельно — обложки, иллюстрации, видео, которые не переписываются под тем же именем.

Границы честные: если у вас нет Cloudflare (или другого CDN) перед хранилищем — не выносите медиа в голый GCS, будет медленнее, чем локальная статика. Если файлы часто меняются под одним именем — придётся продумать инвалидацию кэша (или, проще, версионировать имена). А если объёмы совсем небольшие и диск не жмёт — возможно, овчинка не стоит выделки, локальная раздача с VDS за Cloudflare и так прекрасно кэшируется.

Мне же связка GCS-хранилище + Cloudflare-кэш + Caddy-прокси с матчером по каталогу и расширению дала ровно то, что хотелось: разгруженный сервер, надёжное хранилище и прежнюю скорость — без единой изменённой ссылки.

Читайте также

Следующий шаг: если у вас растёт сайт и диск сервера превращается в узкое место, а скорость терять не хочется — напишите мне в Telegram, разберём вашу инфраструктуру и подберём хранилище под задачу.

Комментарии

Войдите через Telegram, чтобы оставить комментарий:

Пока нет комментариев. Будьте первым.