Назад в блог
28 августа 2026 г.Sergei Solod11 мин чтения

Два месяца с Bunny Storage за Nginx: почему я перенёс картинки, видео и аудио на отдельный медиасервер

Два месяца я использовал Bunny Storage не через Bunny CDN, а как приватный origin за собственным Nginx-кешем. Production-логи показали long-tail задержки и таймауты на холодных медиа, а с MP4 возникла отдельная проблема Nginx Slice и несовпадающих ETag; в итоге я перешёл на простой приватный media-origin.

Bunny StorageNginxКеширование видеоCache missМедиасервер

Почти два месяца моя схема раздачи медиа выглядела так:

Browser
  ↓
рабочий сервер
  ↓
Nginx
  ↓
локальный proxy_cache
  ↓ cache MISS
Bunny Storage

В Bunny Storage лежали изображения, видео и аудио. Пользователь к Bunny напрямую не обращался. Мой Nginx запрашивал объект через [https://storage.bunnycdn.com](https://storage.bunnycdn.com), добавлял серверный AccessKey, сохранял ответ на локальный SSD и уже сам обслуживал публичный URL.

Сразу важная граница: это не тест Bunny CDN. Bunny CDN в этом эксперименте я не использовал. Storage Zone + Pull Zone/CDN — другая архитектура доставки, и Bunny CDN я по-прежнему считаю сильным продуктом. Я проверял конкретно Bunny Storage как runtime-origin собственного Nginx-кеша.

Почему исходная архитектура казалась разумной

У меня большое количество immutable media: AVIF, JPEG, PNG, MP4, WebM, аудио и другие статические файлы — от маленьких изображений до значительно более крупных видео. Хранить весь dataset на application server я не хотел, но хотел, чтобы мой Nginx контролировал публичные URL, кеш, redirects, поведение отсутствующих файлов, Range и fallback.

                    ┌── HIT ── локальный SSD
                    │
Browser → Nginx cache
                    │
                    └── MISS ── Bunny Storage

На HIT схема работала прекрасно. Nginx отдавал файл с локального диска, Bunny в таком запросе вообще не участвовал. Именно поэтому хороший HIT долго скрывал то, что происходило на холодном MISS.

Популярные файлы маскировали реальную скорость origin

Часто запрашиваемая картинка с высокой вероятностью остаётся в кеше. Старый редко открываемый файл чаще вытесняется. Поэтому старые страницы невольно стали для меня тестом origin.

В одном июльском snapshot было около 36 GB media-cache, более 355 000 кеш-файлов, лимит примерно 35 GB и root filesystem, заполненная примерно на 93%. В соседних snapshot число кеш-записей приближалось к 400 000. Даже такой кеш конечен.

Главный вывод: быстрый HIT не доказывает быстрый origin. Он доказывает только быстрый локальный кеш.

Production-логи показывали ошибки ещё до передачи тела файла

upstream timed out
while connecting to upstream

и:

upstream timed out ... while SSL handshaking to upstream

Это не ситуация, когда большой файл просто долго скачивается. В этих случаях Nginx ещё пытался установить upstream connection или завершить TLS.

В диагностическом snapshot 30 июля в последних 5 000 строках media error log было 317 совпадений upstream timeout. В snapshot предыдущего дня — 578. Это строки логов, а не число уникальных пользователей, но проблема явно не была единичной.

Плохие случаи не были привязаны к одному IP Storage

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Ошибки встречались на разных адресах из этого набора. Поэтому с точки зрения моего сервера нестабильным оказывался внешний origin-path в целом, а не один постоянно плохой IP.

По моим логам нельзя доказать, где именно находилась первопричина: в storage backend, routing, peering, маршруте моего провайдера к Bunny, балансировке или другом сетевом компоненте. Но можно доказать более узкое утверждение: некоторые cold MISS не успевали установить соединение с Bunny Storage за ожидаемое время.

Один замер очень хорошо показал long tail

109.61.89.53   total ≈ 29.8 ms
109.61.89.54   total ≈ 28.5 ms
109.61.89.57   total ≈ 42.7 ms
79.127.226.193 total ≈ 43.6 ms

Но один измеренный путь оказался совсем другим:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Это не значит, что Bunny Storage всегда отвечал за секунду. Большинство измерений укладывалось в десятки миллисекунд. Важна была дисперсия: тот же hostname мог привести к пути, где больше секунды уходило ещё до полезной передачи объекта.

На media-page tail latency важнее красивого среднего

Если 79 картинок загрузились за 30–50 ms, а одна — за секунду, среднее всё равно может выглядеть хорошо. Пользователь среднего не видит. Он видит один пустой прямоугольник. Поэтому для галерей я стал гораздо больше смотреть на p95, p99 и самый медленный cold MISS.

Видео показало вторую, совершенно отдельную проблему

Браузер может запрашивать byte ranges:

Range: bytes=0-1048575

а после перемотки:

Range: bytes=50000000-51048575

Ответ 206 Partial Content позволяет начинать воспроизведение и делать seek, не скачивая все предыдущие байты.

Первая Range-схема дробила один физический MP4 на множество cache key

proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_cache_key "$scheme|$host|$request_uri|range=$http_range";
proxy_cache_valid 200 206 301 302 30d;

Browser Range произвольный. bytes=0-1048575, bytes=0-999999 и другой offset после seek превращались в разные cache keys одного MP4. Я не измерял, сколько именно гигабайт съела эта фрагментация, поэтому число придумывать не буду.

Затем я перешёл на фиксированные slices по 1 MB

Nginx Slice module позволяет нормализовать большой объект:

0–1 MB
1–2 MB
2–3 MB
...
slice 1m;
proxy_set_header Range $slice_range;
proxy_cache_key "$scheme|$host|$uri|$slice_range";
proxy_cache_valid 200 206 30d;

Для повторного использования кеша это было намного лучше. А затем production начал писать:

etag mismatch in slice response while reading response header from upstream

MP4-slices ломались на согласованности ETag

30 июля повторные slice-subrequests одного MP4 выдавали etag mismatch in slice response, при этом запросы шли на 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 и другие адреса. 31 июля тот же класс ошибки повторился для другого MP4.

Изначально я называл это «разными подписями кусков». Технически это неверно. Речь шла об ETag, а не об AccessKey, signed URL или криптографической подписи.

Почему Nginx не склеивает slices с разными ETag

slice 1 → bytes 0–1 MB → ETag A
slice 2 → bytes 1–2 MB → ETag A

— согласованная ситуация. Но:

slice 1 → ETag A
slice 2 → ETag B

не позволяет безопасно считать оба куска одной representation. Если склеить их вслепую, можно смешать байты разных версий файла. В материале Nginx про byte-range caching описана эта проверка validators.

Мои логи доказывают, что Nginx видел несовместимые ETag между slice responses одного MP4. Они не доказывают точную внутреннюю причину, потому что я не логировал буквальное значение ETag каждого subrequest. Переключение upstream-адресов делает несовместимость backend responses правдоподобной гипотезой, но это остаётся выводом, а не доказанным фактом.

Это не значит, что Bunny Storage не поддерживает Range

Range работал, и я явно кешировал 206 Partial Content. Проблема была уже: сочетание Nginx Slice, нескольких storage responses и ETag consistency, необходимой для безопасной сборки одного представления.

Параллельно видео могло попасть и в обычный origin timeout. Поэтому у меня было два независимых failure mode: connection/origin latency и Slice/ETag consistency.

В итоге для MP4 я отказался от Slice

proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_cache_key "$scheme|$host|$uri";
proxy_no_cache $http_range;
proxy_cache_valid 200 30d;

Partial response больше не сохранялся как полный объект. Это убрало проблемную сборку MP4, но cold Range снова сильнее зависел от внешнего origin.

Я понял, что фактически строю кусок CDN перед Storage

В Nginx постепенно появились cache lock, stale, background update, Range, 206, Slice, фиксированные chunks, keepalive, TLS reuse, retries, timeout tuning и специальные cache keys. Каждая вещь была разумной по отдельности, но моя настоящая потребность от origin была очень простой: хранить immutable file и отдавать его байты.

Новый origin специально сделан скучным

Я перенёс media dataset на отдельный сервер, задача которого практически сводится к Nginx + SSD. Для pure static origin можно рассматривать как стартовую точку небольшую машину примерно с 1 vCPU, около 1 GB RAM и несколькими сотнями GB SSD, если сеть и диск подходят.

Это не универсальное правило sizing. Важны bandwidth, размеры объектов, concurrency, IOPS и MISS rate. Сервер, на котором я делал текущие измерения, имеет больше ресурсов, поэтому его реальные пики я не выдаю за benchmark ровно 1-vCPU/1-GB машины.

Новая архитектура

Browser
   ↓
рабочий сервер
   ↓
Nginx + локальный media cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

Origin приватный. Рабочий сервер знает один фиксированный private IP. Пользовательский HTTPS по-прежнему завершается на рабочем сервере, а между серверами можно использовать обычный HTTP внутри уже зашифрованного WireGuard-туннеля.

Что исчезло из cold path

рабочий сервер
↓
WireGuard
↓
фиксированный private IP
↓
Nginx
↓
SSD

Для этой конкретной связи server-to-server мне больше не нужны публичный DNS, выбор одного из нескольких storage-адресов, public Internet path и отдельный TLS handshake к Storage API.

Cold MISS стал настолько дешёвым, что визуально почти не отличается от HIT

Маленькие Range-тесты на реальных файлах обычно показывали:

CONNECT ≈ 9.5–12.5 ms
TTFB    ≈ 19–23 ms
TOTAL   ≈ 19–23 ms

Проходили 10/10, 20/20 sequential и 20/20 parallel. На самом origin тот же маленький запрос занимал примерно 0.5–0.9 ms.

Это маленькие Range/TTFB-тесты, а не время скачивания полного большого видео. Полный transfer по-прежнему зависит от размера файла и throughput.

Старый плохой случай и новый cold path отличаются очень сильно

Нормальные старые измерения часто были около 29–44 ms. Плохой observed case — примерно 1.054 s. Новый tiny cold request — около 20 ms.

Если математически поделить конкретный outlier 1.054 s на 20 ms, получится примерно 52× по latency. Но это не утверждение «мой сервер в 52 раза быстрее Bunny Storage», а сравнение двух конкретных измеренных путей.

MP4 теперь тоже проще

На cold fill Nginx может получить полный MP4 с private origin, сохранить его одной cache entry, а затем обслуживать любые browser byte Range уже локально. Во время fill upstream Range можно убрать, а proxy_force_ranges позволяет выдавать локальные partial responses.

cold MISS
↓
полный MP4 с private origin
↓
один cached file
↓
client Range локально как 206

У full-object caching тоже есть цена

Если cold MP4 весит 500 MB, а первый пользователь хочет посмотреть только десять секунд, заполнение кеша может всё равно передать между серверами все 500 MB. Для моих размеров файлов и access pattern это приемлемый trade-off. Для библиотеки в основном cold multi-GB видео я бы смотрел на стабильный sliced cache, HLS/DASH, video CDN или Bunny Stream.

Фиксированные slices по-прежнему имеют смысл для других больших media

Nginx Slice сам по себе не плох. С immutable object и стабильным origin он полезен. Разница теперь в том, что все части приходят из одного контролируемого origin и одного контролируемого filesystem representation.

Меньший cache может ощущаться быстрее, если MISS дешёвый

Старый cache достигал примерно 35–36 GB. Новый стабилизировался примерно на 16 GB, потому что через min_free я сознательно оставляю около 8 GB свободного места.

раньше:
HIT  = быстро
MISS = иногда больно

сейчас:
HIT  = быстро
MISS = тоже достаточно быстро

Большой cache уменьшает количество MISS. Хороший origin уменьшает стоимость каждого MISS. Для этого workload второй параметр оказался важнее, чем я ожидал.

Реальный production traffic подтвердил роль origin

В одном 60-секундном snapshot рабочий сервер отдавал пользователям примерно 96.84 Mbit/s, а с media origin получал около 7.07 Mbit/s. За это окно не появилось новых 502, 503, 504 или upstream timeout.

В более позднем 30-секундном snapshot public TX составлял 61.36 Mbit/s, а WireGuard RX от origin — 1.40 Mbit/s. Отношение origin bytes к public TX было 2.28%.

Я не называю обратное значение точным cache-hit rate: публичный трафик содержит не только media, а отношение байтов не равно отношению запросов. Но хорошо видно, что после прогрева origin в основном занимается cold fills.

Что я бы мониторил сейчас

$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status

HIT и MISS отдельно, особенно p95/p99 MISS.

Для видео я бы проверял первый Range, Range из середины, suffix Range с конца, несколько несвязанных Range для seek, cold и warm отдельно, плюс 206, Content-Range, Content-Length, Accept-Ranges и ETag. Если используется Slice — отдельно consistency validators на нескольких slices.

Один cold URL и 20 разных cold URL — не один и тот же тест

proxy_cache_lock помогает, когда много запросов хотят один cold cache key. Он не превращает 20 разных файлов в один запрос к origin. Поэтому страница с 50 разными cold images может быть гораздо более реалистичным тестом, чем повторение одного URL.

Self-hosting даёт контроль, а не бесплатную надёжность

Приватный media server делает critical path проще для понимания, но backup, состояние диска, свободное место, обновления, firewall, monitoring, restore и redundancy становятся моей ответственностью.

Один origin — это ещё и single point of failure для uncached объектов. Для HA нужна дополнительная архитектура.

Когда я всё равно выбрал бы Bunny

Если нужна глобальная доставка пользователям, managed redundancy, быстро растущий storage или минимум администрирования, я спокойно снова рассмотрю Bunny. При этом отдельно оценю архитектуру Storage + Pull Zone/CDN.

Этот эксперимент не доказывает, что один VPS лучше Bunny CDN, что Bunny Storage всегда медленный или что Bunny не умеет Range. Он показывает, что для моего workload Bunny Storage как прямой runtime origin собственного Nginx-cache давал cold-path и sliced-video behavior, которым я больше не хотел заниматься.

Правило, которым я пользуюсь теперь

За два месяца я оптимизировал cache size, cache keys, Range, 206, Slice, 1 MB chunks, keepalive, TLS reuse, retries, stale и timeouts. Самое сильное улучшение оказалось проще: я поменял origin.

Что произойдёт, если нужного пользователю файла сейчас нет в cache?

Если ответ — стабильный origin, предсказуемый TTFB, корректный Range, согласованные validators и понятные failure modes, cache действительно является оптимизацией. Если хороший UX зависит от того, чтобы пользователь никогда не столкнулся с MISS, cache скрывает более глубокую архитектурную проблему.

Для моего workload маленький, приватный и скучный media origin оказался лучше. Это не CDN, не магия и не отмена операционной ответственности. Он просто сделал cold path скучным. После двух месяцев отладки изображений, connection timeout, video Range и etag mismatch in slice response именно этого я и хотел.