Terug naar de blog
28 augustus 2026Sergei Solod11 min leestijd

Twee maanden Bunny Storage achter Nginx: waarom ik afbeeldingen, video en audio naar een aparte mediaserver verhuisde

Twee maanden lang gebruikte ik Bunny Storage als private origin achter mijn eigen Nginx-cache, niet via Bunny CDN. Productionlogs lieten long-tail connection timeouts bij koude media zien en daarnaast een afzonderlijk MP4-probleem met Nginx Slice en inconsistente ETags; uiteindelijk stapte ik over op een eenvoudige private media-origin.

Bunny StorageNginxVideocacheCache missMediaserver

Bijna twee maanden zag mijn media delivery-pad er zo uit:

Browser
  ↓
werkserver
  ↓
Nginx
  ↓
lokale proxy_cache
  ↓ cache MISS
Bunny Storage

Afbeeldingen, video en audio stonden in Bunny Storage. Gebruikers maakten geen rechtstreekse verbinding met Bunny. Mijn Nginx haalde objecten op via [https://storage.bunnycdn.com](https://storage.bunnycdn.com), voegde de server-side AccessKey toe, cachete de response op lokale SSD en leverde zelf de publieke URL.

De afbakening is belangrijk: dit is geen test van Bunny CDN. Ik heb Bunny CDN voor dit experiment niet gebruikt. Storage Zone + Pull Zone/CDN is een andere distributiearchitectuur en ik beschouw Bunny CDN nog steeds als een sterk product. Ik testte Bunny Storage specifiek als runtime-origin voor mijn eigen Nginx-cache.

Waarom de oorspronkelijke architectuur logisch leek

De workload bestaat uit veel immutable media: AVIF, JPEG, PNG, MP4, WebM, audio en andere statische bestanden, van kleine afbeeldingen tot veel grotere video's. Ik wilde de volledige dataset niet op de application server hebben, maar wel publieke URLs, cachebeleid, redirects, ontbrekende bestanden, Range en fallbacks via mijn eigen Nginx beheren.

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

Bij een HIT werkte dit uitstekend. Nginx leverde rechtstreeks vanaf lokale disk; Bunny speelde in die request geen rol. Juist dat goede gedrag verborg lang het belangrijkere deel: de cold MISS.

Populaire bestanden verborgen de echte origin-latency

Een vaak opgevraagde afbeelding blijft waarschijnlijk in cache. Een oud bestand dat zelden wordt geopend, wordt eerder geëvict. Oude pagina's werden daardoor onbedoeld een origin-benchmark.

Een snapshot in juli liet ongeveer 36 GB mediacache zien, meer dan 355.000 cachebestanden, een ingestelde limiet rond 35 GB en een root filesystem dat ongeveer 93% vol was. Andere snapshots kwamen dicht bij 400.000 entries. Ook zo'n grote cache blijft eindig.

De belangrijkste les: een snelle HIT bewijst geen snelle origin. Hij bewijst alleen dat de lokale cache snel is.

Productionlogs toonden failures vóór de file body werd overgedragen

upstream timed out
while connecting to upstream

en:

upstream timed out ... while SSL handshaking to upstream

Dat is iets anders dan een groot bestand dat lang over de overdracht doet. Nginx probeerde hier nog de upstream-verbinding of TLS-handshake te voltooien.

In een diagnostic snapshot van 30 juli bevatten de laatste 5.000 regels van het media error log 317 upstream-timeout matches. De dag ervoor waren het er 578. Dat zijn logregels, geen unieke gebruikers, maar het was duidelijk geen enkel incident.

De slechte gevallen zaten niet op één storage-IP

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Ik zag failures op verschillende adressen uit die set. Vanuit mijn server bezien zat het probleem dus in het gedrag van het externe origin-pad, niet in één permanent slecht IP.

Mijn logs bewijzen niet of de onderliggende oorzaak een storage-backend, routing, peering, het pad van mijn provider naar Bunny, balancing of iets anders was. Ze bewijzen wel dat sommige cold MISS-requests de Bunny Storage-upstreamverbinding niet binnen de verwachte tijd konden opbouwen.

Eén meting maakte de long tail zichtbaar

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

Maar één gemeten pad was totaal anders:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Dit betekent niet dat Bunny Storage altijd één seconde traag was. De meeste metingen zaten in tientallen milliseconden. Belangrijk was de spreiding: dezelfde origin-hostname kon een connection path opleveren dat meer dan een seconde kostte voordat nuttige objectdata begon te stromen.

Voor mediapagina's telt tail latency zwaarder dan het gemiddelde

Als 79 afbeeldingen in 30–50 ms laden en één afbeelding een seconde nodig heeft, kan het gemiddelde nog prima zijn. De gebruiker ziet geen gemiddelde, maar één leeg vlak. Daarom zijn p95, p99 en de langzaamste cold MISS voor mij belangrijker geworden.

Video liet een tweede, volledig ander probleem zien

Browsers kunnen byte ranges aanvragen:

Range: bytes=0-1048575

en na een seek:

Range: bytes=50000000-51048575

Met 206 Partial Content kan playback of seeking werken zonder alle voorgaande bytes te downloaden.

Mijn eerste Range-cache fragmenteerde één fysiek videobestand in veel 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;

Browser-ranges zijn arbitrair. bytes=0-1048575, bytes=0-999999 en een offset na seek leveren verschillende keys op voor dezelfde MP4. Ik heb niet gemeten hoeveel GB deze fragmentatie exact kostte, dus daar verzin ik geen getal voor.

Daarna stapte ik over op vaste slices van 1 MB

De Nginx Slice-module kan grote resources normaliseren:

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;

Dat was veel aantrekkelijker voor cache reuse. Daarna verscheen in productie:

etag mismatch in slice response while reading response header from upstream

De MP4-slices faalden op ETag-consistentie

Op 30 juli produceerden herhaalde slice-subrequests voor één MP4 etag mismatch in slice response, terwijl requests naar onder andere 109.61.89.53, 109.61.89.57, 79.127.226.193 en 109.61.89.55 gingen. Op 31 juli gebeurde hetzelfde met een andere MP4.

Ik noemde dit eerst informeel verschillende “signatures” van delen. Technisch ging het om ETag, niet om de Storage-AccessKey, een signed URL of cryptografische handtekening.

Waarom Nginx slices met verschillende ETags niet samenvoegt

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

is consistent. Maar:

slice 1 → ETag A
slice 2 → ETag B

betekent dat Nginx niet meer veilig kan aannemen dat beide stukken tot dezelfde representation behoren. Blind samenvoegen kan bytes uit verschillende versies mengen. Het Nginx-artikel over byte-range caching beschrijft deze validatorcontrole.

Mijn logs bewijzen dat Nginx incompatibele ETags zag tussen slices van dezelfde MP4. Ze bewijzen niet precies waarom, omdat ik destijds niet de letterlijke ETag van elk subrequest vastlegde. Wisselende upstream-adressen maken inconsistente backend-responses plausibel, maar dat blijft een gevolgtrekking.

Dit betekent niet dat Bunny Storage geen Range ondersteunt

Range werkte en ik cachete expliciet 206 Partial Content. Het specifieke probleem was de combinatie van Nginx Slice, meerdere storage-responses en de ETag-consistentie die voor veilige assembly nodig was.

Video kon daarnaast gewone origin-timeouts krijgen. Er waren dus twee aparte failure modes: origin connection/latency en Slice/ETag-consistentie.

Uiteindelijk stopte ik met Slice voor 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;

Partiële responses werden niet meer als compleet object opgeslagen. Daarmee verdween de problematische assembly, maar een cold Range werd weer sterker afhankelijk van de externe origin.

Ik besefte dat ik zelf een deel van een CDN vóór Storage bouwde

Nginx had inmiddels cache lock, stale, background update, Range, 206, Slice, genormaliseerde chunks, keepalive, TLS reuse, retries, timeout tuning en custom cache keys. Alles afzonderlijk was logisch, maar mijn daadwerkelijke origin-behoefte was simpel: immutable bestanden bewaren en bytes terugsturen.

De nieuwe origin is bewust simpel

Ik verhuisde de media naar een dedicated server die in wezen alleen Nginx + SSD doet. Voor een pure static origin kan een kleine machine met ongeveer 1 vCPU, rond 1 GB RAM en enkele honderden GB SSD een redelijke start zijn als netwerk en disk voldoende zijn.

Dat is geen universele sizingregel. Bandwidth, objectgroottes, concurrency, IOPS en MISS rate bepalen de echte eisen. Mijn huidige meetserver heeft meer resources, dus mijn huidige pieken zijn geen exacte benchmark van een 1-vCPU/1-GB-machine.

De nieuwe architectuur

Browser
   ↓
werkserver
   ↓
Nginx + lokale mediacache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

De origin is private. De werkserver praat met één vaste private IP. Publieke HTTPS eindigt nog steeds op de werkserver; originverkeer kan HTTP gebruiken binnen de al versleutelde WireGuard-tunnel.

Wat uit de cold path verdween

werkserver
↓
WireGuard
↓
vaste private IP
↓
Nginx
↓
SSD

Voor deze directe serverrelatie zijn public DNS, selectie uit meerdere storage-adressen, een publiek Internetpad en een extra TLS-handshake naar de Storage API verdwenen.

Cold MISS werd goedkoop genoeg om visueel bijna als HIT te voelen

Kleine Range-tests op echte files lagen doorgaans rond:

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

10/10, 20/20 sequentieel en 20/20 parallel slaagden. Op de origin zelf kostte dezelfde kleine request ongeveer 0.5–0.9 ms.

Dit zijn kleine Range/TTFB-tests, geen volledige videodownload van 20 ms. Totale overdracht blijft afhankelijk van bestandsgrootte en throughput.

Het oude slechte geval en de nieuwe cold path liggen ver uit elkaar

Normale oude metingen lagen vaak rond 29–44 ms. De geobserveerde slechte route was ongeveer 1.054 s. De nieuwe tiny cold request is rond 20 ms.

Dat specifieke outlierpad vergeleken met 20 ms geeft ongeveer 52× verschil in latency. Dat betekent niet dat mijn server “52× sneller is dan Bunny Storage”; het vergelijkt twee concreet gemeten paden.

MP4 is nu ook eenvoudiger

Tijdens een cold fill kan Nginx de volledige MP4 van de private origin halen, één cache entry bewaren en daarna willekeurige byte ranges lokaal aan de browser leveren. Upstream Range kan tijdens de fill worden verwijderd; proxy_force_ranges maakt lokale partial responses mogelijk.

cold MISS
↓
complete MP4 vanaf private origin
↓
één cached file
↓
client Range lokaal als 206

Full-object caching heeft ook een prijs

Is een cold MP4 500 MB en wil de eerste gebruiker maar tien seconden bekijken, dan kan de fill toch alle 500 MB tussen de servers overzetten. Voor mijn bestandsgroottes en patronen is dat acceptabel. Voor vooral cold multi-GB-video zou ik stabiele sliced cache, HLS/DASH, video-CDN of Bunny Stream overwegen.

Vaste slices blijven nuttig voor andere grote media

Nginx Slice is niet slecht. Met immutable objecten en een stabiele origin blijft het nuttig. Het verschil is dat alle chunks nu van één origin en één filesystem-representation komen die ik zelf beheer.

Een kleinere cache kan sneller aanvoelen als MISS goedkoop is

De oude cache was ongeveer 35–36 GB. De nieuwe stabiliseerde rond 16 GB omdat ik met min_free bewust ongeveer 8 GB vrij houd.

oud:
HIT  = snel
MISS = soms pijnlijk

nieuw:
HIT  = snel
MISS = ook snel genoeg

Een grote cache vermindert het aantal MISSes. Een goede origin verlaagt de kosten van iedere MISS. Voor deze workload bleek het tweede belangrijker.

Echt productionverkeer bevestigde de rol van de origin

In een 60-seconden-snapshot stuurde de werkserver ongeveer 96.84 Mbit/s naar gebruikers en ontving ongeveer 7.07 Mbit/s van de media origin. In dat meetvenster verschenen geen nieuwe 502, 503, 504 of upstream timeouts.

In een latere 30-seconden-snapshot was public TX 61.36 Mbit/s en WireGuard RX vanaf de origin 1.40 Mbit/s. De verhouding origin-bytes/public-TX was 2.28%.

Ik noem de inverse daarvan geen exacte cache-hit rate: public traffic bevat meer dan media en een byteverhouding is geen requestverhouding. Wel laat het zien dat de origin na warm-up vooral cold fills afhandelt.

Wat ik nu zou monitoren

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

HIT en MISS apart, vooral p95/p99 van MISS.

Voor video: eerste Range, midden, suffix Range, meerdere niet-sequentiële Ranges voor seek, cold versus warm, plus 206, Content-Range, Content-Length, Accept-Ranges en ETag. Bij Slice zou ik validatorconsistentie over meerdere slices expliciet controleren.

Eén cold URL en 20 verschillende cold URLs zijn niet dezelfde test

proxy_cache_lock helpt wanneer veel requests dezelfde cold cache key willen. Het maakt van twintig verschillende files geen enkele origin request. Een pagina met vijftig verschillende cold afbeeldingen kan dus een realistischer origin-test zijn dan één URL blijven herhalen.

Self-hosting geeft controle, geen gratis betrouwbaarheid

De private mediaserver maakt het critical path eenvoudiger te begrijpen, maar backups, disk health, vrije ruimte, updates, firewall, monitoring, restore en redundancy zijn mijn verantwoordelijkheid.

Eén origin is voor uncached objecten ook een single point of failure. High availability vereist extra engineering.

Wanneer ik Bunny nog steeds zou kiezen

Voor wereldwijde delivery, managed redundancy, snel groeiende storage of minimale infrastructuuradministratie zou ik Bunny zonder probleem opnieuw overwegen. Dan zou ik Storage + Pull Zone/CDN expliciet meenemen.

Mijn experiment bewijst niet dat één VPS beter is dan Bunny CDN, dat Bunny Storage altijd traag is of dat Bunny geen Range ondersteunt. Het laat zien dat Bunny Storage als directe runtime-origin van mijn Nginx-cache voor mijn workload cold-path- en sliced-video-gedrag opleverde dat ik niet langer wilde beheren.

De regel die ik nu gebruik

In twee maanden optimaliseerde ik cache size, cache keys, Range, 206, Slice, 1-MB-chunks, keepalive, TLS reuse, retries, stale en timeouts. De sterkste verbetering was eenvoudiger: de origin veranderen.

Wat gebeurt er wanneer het bestand dat de gebruiker wil niet in cache staat?

Als het antwoord een stabiele origin, voorspelbare TTFB, correcte Range, consistente validators en begrijpelijke failure modes is, dan is cache een optimalisatie. Als goede UX afhankelijk is van het nooit tegenkomen van een MISS, verbergt de cache een dieper architectuurprobleem.

Voor mijn workload paste een kleine, private en saaie media-origin beter. Het is geen CDN, geen magie en het neemt operationele verantwoordelijkheid niet weg. Het maakte de cold path simpelweg saai. Na twee maanden debuggen van afbeeldingen, connection timeouts, video Range en etag mismatch in slice response was dat precies wat ik wilde.