Wróć do bloga
28 sierpnia 2026Sergei Solod10 min czytania

Dwa miesiące z Bunny Storage za Nginx: dlaczego przeniosłem obrazy, wideo i audio na osobny serwer multimediów

Przez dwa miesiące używałem Bunny Storage jako prywatnego originu za własnym cache Nginx, a nie przez Bunny CDN. Logi produkcyjne pokazały długi ogon opóźnień i timeouty przy zimnych mediach oraz osobny problem MP4 związany z Nginx Slice i niespójnymi ETagami; ostatecznie przeszedłem na prosty prywatny media origin.

Bunny StorageNginxCache wideoCache missSerwer multimediów

Przez prawie dwa miesiące ścieżka dostarczania mediów wyglądała tak:

Browser
  ↓
serwer roboczy
  ↓
Nginx
  ↓
lokalny proxy_cache
  ↓ cache MISS
Bunny Storage

Obrazy, wideo i audio znajdowały się w Bunny Storage. Użytkownik nie łączył się z Bunny bezpośrednio. Nginx pobierał obiekt z [https://storage.bunnycdn.com](https://storage.bunnycdn.com), dodawał serwerowy AccessKey, zapisywał odpowiedź na lokalnym SSD i sam obsługiwał publiczny URL.

Ważne rozgraniczenie: to nie jest test Bunny CDN. W tym eksperymencie nie używałem Bunny CDN. Storage Zone + Pull Zone/CDN to inna architektura dostarczania i nadal uważam Bunny CDN za bardzo dobry produkt. Testowałem konkretnie Bunny Storage jako runtime origin dla własnego cache Nginx.

Dlaczego początkowa architektura wyglądała rozsądnie

Workload obejmuje ogromną liczbę immutable media: AVIF, JPEG, PNG, MP4, WebM, audio i inne pliki statyczne, od małych obrazów po dużo większe filmy. Nie chciałem trzymać całego datasetu na serwerze aplikacji, ale chciałem kontrolować publiczne URL-e, politykę cache, redirecty, brakujące pliki, Range i fallbacki we własnym Nginx.

                    ┌── HIT ── lokalny SSD
                    │
Browser → cache Nginx
                    │
                    └── MISS ── Bunny Storage

Przy HIT działało to świetnie. Nginx serwował obiekt z lokalnego dysku, więc Bunny w ogóle nie uczestniczył w takim żądaniu. Właśnie to przez długi czas maskowało zachowanie cold MISS.

Popularne pliki ukrywały rzeczywistą latencję originu

Często żądany obraz prawdopodobnie pozostanie w cache. Stary, rzadko otwierany plik jest częściej usuwany. Dlatego stare strony niechcący stały się benchmarkiem originu.

Jeden lipcowy snapshot pokazywał około 36 GB cache mediów, ponad 355 000 plików cache, limit około 35 GB i root filesystem zajęty w około 93%. Inne snapshoty zbliżały się do 400 000 wpisów. Nawet tak duży cache jest skończony.

Najważniejsza lekcja: szybki HIT nie dowodzi szybkiego originu. Dowodzi tylko, że lokalny cache jest szybki.

Logi produkcyjne pokazywały błędy przed rozpoczęciem przesyłania pliku

upstream timed out
while connecting to upstream

oraz:

upstream timed out ... while SSL handshaking to upstream

To nie to samo co duży plik pobierający się powoli. Nginx wciąż próbował zestawić połączenie upstream albo zakończyć TLS.

W snapshotcie diagnostycznym z 30 lipca ostatnie 5 000 linii media error log zawierało 317 dopasowań upstream timeout. Dzień wcześniej było ich 578. To linie logu, nie unikalni użytkownicy, ale problem wyraźnie nie był pojedynczym przypadkiem.

Złe przypadki nie dotyczyły jednego IP storage

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Błędy pojawiały się na różnych adresach z tego zestawu. Z perspektywy mojego serwera problem dotyczył więc zachowania całej zewnętrznej ścieżki do originu, a nie jednego zawsze wadliwego IP.

Z logów nie da się udowodnić, czy przyczyną był storage backend, routing, peering, trasa mojego providera do Bunny, balancing czy inny element sieci. Mogę natomiast potwierdzić, że część cold MISS nie zestawiała połączenia Bunny Storage w oczekiwanym czasie.

Jeden pomiar dobrze pokazał 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

Jedna zmierzona ścieżka wyglądała zupełnie inaczej:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

To nie znaczy, że Bunny Storage zawsze potrzebował sekundy. Większość pomiarów mieściła się w dziesiątkach ms. Kluczowa była zmienność: ten sam hostname originu mógł prowadzić do ścieżki, która kosztowała ponad sekundę jeszcze przed użytecznym transferem danych.

Na stronach multimedialnych tail latency jest ważniejsza niż średnia

Jeżeli 79 obrazów ładuje się w 30–50 ms, a jeden potrzebuje sekundy, średnia może nadal wyglądać dobrze. Użytkownik nie widzi średniej; widzi jeden pusty prostokąt. Dlatego p95, p99 i najwolniejszy cold MISS stały się dla mnie ważniejszymi metrykami.

Wideo ujawniło drugi, zupełnie inny problem

Przeglądarka może żądać zakresów bajtów:

Range: bytes=0-1048575

a po seek:

Range: bytes=50000000-51048575

Odpowiedź 206 Partial Content pozwala rozpocząć odtwarzanie lub przeskoczyć bez pobierania wszystkich wcześniejszych bajtów.

Pierwszy cache Range dzielił jeden fizyczny film na wiele cache keys

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;

Zakresy generowane przez browser są arbitralne. bytes=0-1048575, bytes=0-999999 i inne offsety po seek tworzyły różne klucze dla tego samego MP4. Nie mierzyłem, ile GB zajmowała ta fragmentacja, więc nie podaję wymyślonej liczby.

Potem przeszedłem na stałe 1 MB slices

Nginx Slice module pozwala normalizować duży zasób:

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;

Było to dużo lepsze dla reuse cache. Wtedy w produkcji pojawił się:

etag mismatch in slice response while reading response header from upstream

MP4 slices wywracały się na spójności ETag

30 lipca powtarzające się subrequesty slice dla jednego MP4 generowały etag mismatch in slice response, a żądania trafiały m.in. do 109.61.89.53, 109.61.89.57, 79.127.226.193 i 109.61.89.55. 31 lipca ten sam typ błędu pojawił się dla innego MP4.

Początkowo opisywałem to potocznie jako różne „podpisy” kawałków. Technicznie chodziło o ETag, a nie AccessKey, signed URL czy podpis kryptograficzny.

Dlaczego Nginx nie scala slices z różnymi ETagami

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

jest spójne. Ale:

slice 1 → ETag A
slice 2 → ETag B

nie daje gwarancji, że oba fragmenty należą do tej samej reprezentacji. Ślepe połączenie mogłoby zmieszać bajty różnych wersji. Artykuł Nginx o byte-range caching opisuje tę kontrolę validatorów.

Moje logi dowodzą, że Nginx widział niezgodne ETagi pomiędzy slices tego samego MP4. Nie dowodzą dokładnej przyczyny, ponieważ nie logowałem literalnej wartości ETag każdego subrequestu. Zmiana adresów upstream czyni niespójność odpowiedzi backendu sensowną hipotezą, ale pozostaje ona wnioskiem.

To nie znaczy, że Bunny Storage nie obsługuje Range

Range działał i cache'owałem 206 Partial Content. Konkretnym problemem było połączenie Nginx Slice, wielu odpowiedzi storage i spójności ETag wymaganej do bezpiecznego złożenia reprezentacji.

Wideo mogło jednocześnie trafiać na zwykłe timeouty originu. Były więc dwa oddzielne failure modes: połączenie/latency originu oraz spójność Slice/ETag.

Ostatecznie usunąłem Slice dla MP4

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 nie był już zapisywany jako pełny obiekt. Problem assembly zniknął, ale cold Range znów mocniej zależał od zewnętrznego originu.

Zrozumiałem, że buduję część CDN przed Storage

Nginx obsługiwał już cache lock, stale, background update, Range, 206, Slice, stałe chunks, keepalive, TLS reuse, retries, timeout tuning i własne cache keys. Każdy element miał sens, ale realna potrzeba wobec originu była prosta: przechować immutable plik i zwrócić jego bajty.

Nowy origin jest celowo prosty

Przeniosłem media na dedykowany serwer, którego zadaniem jest zasadniczo Nginx + SSD. Dla pure static origin mała maszyna około 1 vCPU, mniej więcej 1 GB RAM i kilkaset GB SSD może być rozsądnym punktem startowym, jeśli sieć i dysk są wystarczające.

To nie jest uniwersalna reguła sizingu. Liczą się bandwidth, rozmiar obiektów, concurrency, IOPS i MISS rate. Serwer używany w obecnych pomiarach ma więcej zasobów, więc nie przedstawiam aktualnych peaków jako dokładnego benchmarku maszyny 1-vCPU/1-GB.

Nowa architektura

Browser
   ↓
serwer roboczy
   ↓
Nginx + lokalny media cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

Origin jest prywatny. Serwer roboczy łączy się z jednym stałym private IP. Publiczne HTTPS nadal kończy się na serwerze roboczym; ruch do originu może używać HTTP wewnątrz już zaszyfrowanego tunelu WireGuard.

Co zniknęło z cold path

serwer roboczy
↓
WireGuard
↓
stałe private IP
↓
Nginx
↓
SSD

W tej bezpośredniej relacji nie potrzebuję publicznego DNS, wyboru jednego z kilku adresów storage, publicznej trasy ani dodatkowego TLS do Storage API.

Cold MISS stał się tak tani, że wizualnie prawie nie różni się od HIT

Małe testy Range na rzeczywistych plikach zwykle dawały:

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

Testy 10/10, 20/20 sekwencyjnie i 20/20 równolegle przechodziły poprawnie. Lokalnie na originie ten sam mały request zajmował około 0.5–0.9 ms.

To małe testy Range/TTFB, nie czas pełnego pobrania dużego wideo. Cały transfer nadal zależy od rozmiaru i throughput.

Stary zły przypadek i nowy cold path mocno się różnią

Normalne stare pomiary często mieściły się w 29–44 ms. Zły zmierzony przypadek wynosił około 1.054 s. Nowy tiny cold request to około 20 ms.

Konkretny outlier 1.054 s podzielony przez 20 ms daje około 52× różnicy latency. Nie jest to stwierdzenie „mój serwer jest 52× szybszy od Bunny Storage”, tylko porównanie dwóch konkretnych zaobserwowanych ścieżek.

MP4 jest teraz prostsze

Przy cold fill Nginx może pobrać całe MP4 z private origin, zapisać jeden cache entry, a później lokalnie obsługiwać dowolne byte ranges przeglądarki. Range do upstream można usunąć podczas fill, a proxy_force_ranges pozwala zwracać lokalne odpowiedzi częściowe.

cold MISS
↓
pełne MP4 z private origin
↓
jeden cached file
↓
Range klienta lokalnie jako 206

Full-object caching także ma koszt

Jeśli cold MP4 ma 500 MB, a pierwszy użytkownik chce zobaczyć tylko dziesięć sekund, cache fill może przesłać całe 500 MB. Dla moich rozmiarów i wzorców jest to akceptowalne. Dla głównie zimnych filmów multi-GB rozważyłbym stabilny sliced cache, HLS/DASH, video CDN albo Bunny Stream.

Stałe slices nadal mają sens dla innych dużych mediów

Nginx Slice nie jest złą funkcją. Z immutable object i stabilnym originem nadal jest przydatny. Różnica polega na tym, że wszystkie kawałki pochodzą teraz z jednego kontrolowanego originu i jednej kontrolowanej reprezentacji pliku.

Mniejszy cache może wydawać się szybszy, jeśli MISS jest tani

Stary cache miał około 35–36 GB. Nowy ustabilizował się w okolicach 16 GB, ponieważ przez min_free celowo utrzymuję około 8 GB wolnego miejsca.

stare:
HIT  = szybko
MISS = czasem boleśnie

nowe:
HIT  = szybko
MISS = również wystarczająco szybko

Duży cache zmniejsza liczbę MISS. Dobry origin zmniejsza koszt każdego MISS. Dla tego workloadu drugi parametr okazał się ważniejszy.

Rzeczywisty ruch produkcyjny potwierdził rolę originu

W 60-sekundowym snapshotcie serwer wysyłał do użytkowników około 96.84 Mbit/s i odbierał około 7.07 Mbit/s z media origin. W tym oknie nie pojawiły się nowe 502, 503, 504 ani upstream timeout.

W późniejszym 30-sekundowym snapshotcie public TX wynosił 61.36 Mbit/s, a WireGuard RX z originu 1.40 Mbit/s. Stosunek origin bytes/public TX wyniósł 2.28%.

Nie nazywam odwrotności tej liczby dokładnym cache-hit rate, bo public traffic zawiera nie tylko media, a stosunek bajtów nie jest stosunkiem requestów. Pokazuje jednak, że po warm-upie origin obsługuje głównie cold fill.

Co monitorowałbym teraz

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

Rozdzielałbym HIT i MISS, szczególnie p95/p99 dla MISS.

Dla wideo testowałbym początkowy Range, Range ze środka, suffix Range, kilka nieciągłych zakresów do seek, cold i warm oraz sprawdzał 206, Content-Range, Content-Length, Accept-Ranges i ETag. Przy Slice jawnie testowałbym spójność validatorów w wielu slices.

Jedna cold URL i 20 różnych cold URL to nie ten sam test

proxy_cache_lock pomaga, gdy wiele żądań chce tego samego zimnego cache key. Nie zamienia 20 różnych plików w jeden request origin. Strona z 50 różnymi cold images może być więc bardziej realistycznym testem niż powtarzanie jednego URL.

Self-hosting daje kontrolę, nie darmową niezawodność

Prywatny media server upraszcza critical path, ale backup, stan dysku, wolne miejsce, aktualizacje, firewall, monitoring, restore i redundancy stają się moją odpowiedzialnością.

Jeden origin jest też single point of failure dla obiektów nieobecnych w cache. High availability wymaga dodatkowej inżynierii.

Kiedy nadal wybrałbym Bunny

Przy globalnej dystrybucji, managed redundancy, szybko rosnącym storage albo minimalnej administracji ponownie rozważyłbym Bunny. Wtedy świadomie oceniłbym także Storage + Pull Zone/CDN.

Ten eksperyment nie dowodzi, że pojedynczy VPS jest lepszy od Bunny CDN, że Bunny Storage zawsze jest wolny ani że Bunny nie obsługuje Range. Pokazuje, że dla mojego workloadu Bunny Storage jako bezpośredni runtime origin cache Nginx dawał cold-path i sliced-video behavior, którym nie chciałem już zarządzać.

Zasada, której używam teraz

Przez dwa miesiące optymalizowałem cache size, cache keys, Range, 206, Slice, 1-MB chunks, keepalive, TLS reuse, retries, stale i timeouts. Najsilniejsza poprawa była prostsza: zmieniłem origin.

Co się stanie, gdy pliku potrzebnego użytkownikowi nie ma w cache?

Jeśli odpowiedzią jest stabilny origin, przewidywalny TTFB, poprawny Range, spójne validatory i zrozumiałe failure modes, cache jest optymalizacją. Jeżeli dobra UX zależy od tego, by użytkownik nigdy nie zobaczył MISS, cache ukrywa głębszy problem architektoniczny.

W moim workloadzie mały, prywatny i nudny media origin pasował lepiej. Nie jest CDN, nie jest magią i nie usuwa odpowiedzialności operacyjnej. Po prostu uczynił cold path nudnym. Po dwóch miesiącach debugowania obrazów, connection timeout, video Range i etag mismatch in slice response dokładnie tego chciałem.