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

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

Источник: https://blog.uspeshnyy.ru/blog/media-v-gcs-za-cloudflare/
Автор: Александр Успешный · blog.uspeshnyy.ru
Опубликовано: 2026-05-29
Теги: система

---

## Проблема: диск 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 (хранилище).

При первом запросе файла 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 после заливки:

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

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

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

```bash
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`.

```bash
#!/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-прокси с матчером по каталогу и расширению** дала ровно то, что хотелось: разгруженный сервер, надёжное хранилище и прежнюю скорость — без единой изменённой ссылки.

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

- [Лендинг, который работает вместо менеджера: itc34.ru](/blog/keys-itc34-lending/) — как один HTML-файл без сборки годами живёт и деплоится одним `scp`.
- [Telegram вместо CRM](/blog/telegram-vmesto-crm/) — почему для маленькой команды рабочий чат оказывается надёжнее тяжёлой системы.
- [Куда утекают заявки: 7 дыр](/blog/kuda-utekayut-zayavki-7-dyr/) — про инженерные мелочи, которые тихо стоят бизнесу денег.

**Следующий шаг:** если у вас растёт сайт и диск сервера превращается в узкое место, а скорость терять не хочется — [напишите мне в Telegram](https://t.me/uspeshnyy), разберём вашу инфраструктуру и подберём хранилище под задачу.