Torna al blog
28 agosto 2026Sergei Solod12 min di lettura

Due mesi con Bunny Storage dietro Nginx: perché ho spostato immagini, video e audio su un media server dedicato

Per due mesi ho usato Bunny Storage come origin privato dietro la mia cache Nginx, senza Bunny CDN. I log di produzione hanno mostrato long-tail connection timeout sui media cold e un problema MP4 separato tra Nginx Slice ed ETag incoerenti; alla fine sono passato a un media origin privato molto più semplice.

Bunny StorageNginxCache videoCache missMedia server

Per quasi due mesi il mio percorso di distribuzione dei media era questo:

Browser
  ↓
server di lavoro
  ↓
Nginx
  ↓
proxy_cache locale
  ↓ cache MISS
Bunny Storage

Immagini, video e audio vivevano in Bunny Storage. Gli utenti non accedevano direttamente a Bunny. Nginx richiedeva l’oggetto a [https://storage.bunnycdn.com](https://storage.bunnycdn.com), aggiungeva l’AccessKey lato server, salvava la risposta su SSD locale e serviva direttamente l’URL pubblico.

Una distinzione è essenziale: questo non è un test di Bunny CDN. In questo esperimento non ho usato Bunny CDN. Storage Zone + Pull Zone/CDN è un’architettura diversa e continuo a considerare Bunny CDN un prodotto molto valido. Qui ho testato Bunny Storage come runtime origin della mia cache Nginx.

Perché l’architettura iniziale sembrava sensata

Il workload contiene moltissimi oggetti media immutable: AVIF, JPEG, PNG, MP4, WebM, audio e altri file statici, da piccole immagini a video molto più grandi. Non volevo tenere l’intero dataset sull’application server, ma volevo che il mio Nginx controllasse URL pubblici, cache policy, redirect, file mancanti, Range e fallback.

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

Con un HIT funzionava benissimo: Nginx serviva il file dal disco locale e Bunny non partecipava affatto alla richiesta. Proprio questo successo ha nascosto a lungo il comportamento davvero importante: il cold MISS.

I file popolari nascondevano la vera latenza dell’origin

Un’immagine richiesta spesso tende a restare in cache. Un file vecchio e poco visitato ha più probabilità di essere espulso. Per questo le vecchie pagine sono diventate involontariamente un benchmark dell’origin.

Uno snapshot di luglio mostrava circa 36 GB di media cache, più di 355.000 file di cache, un limite configurato intorno a 35 GB e il filesystem root occupato per circa il 93%. Snapshot vicini arrivavano quasi a 400.000 entry. Anche una cache così grande resta finita.

La lezione: un HIT veloce non dimostra che l’origin sia veloce; dimostra soltanto che la cache locale lo è.

I log mostravano errori prima del trasferimento del corpo del file

upstream timed out
while connecting to upstream

e:

upstream timed out ... while SSL handshaking to upstream

Non è la stessa cosa di un file grande che impiega molto a scaricarsi. Qui Nginx stava ancora cercando di stabilire la connessione upstream o completare TLS.

In uno snapshot diagnostico del 30 luglio, le ultime 5.000 righe del media error log contenevano 317 match di upstream timeout. Il giorno precedente erano 578. Sono righe di log, non utenti unici, ma chiaramente non si trattava di un singolo caso isolato.

I casi peggiori non erano legati a un solo IP

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Ho visto failure su indirizzi diversi del set. Dal punto di vista del mio server, quindi, il problema riguardava il percorso esterno verso l’origin, non un singolo IP sempre guasto.

I log non permettono di stabilire se la causa precisa fosse un backend storage, routing, peering, il percorso del mio provider verso Bunny, il bilanciamento o altro. Posso però affermare che alcuni cold MISS non riuscivano a stabilire la connessione Bunny Storage entro il tempo previsto.

Una misura ha reso evidente il 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

Un percorso misurato era invece molto diverso:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Non significa che Bunny Storage fosse sempre lento un secondo. La maggior parte delle misure era nell’ordine di decine di millisecondi. Il punto era la variabilità: lo stesso hostname poteva portare a un percorso che aggiungeva oltre un secondo prima del trasferimento utile.

Per le pagine media conta più la tail latency della media

Se 79 immagini caricano in 30–50 ms e una impiega un secondo, la media può comunque sembrare ottima. L’utente non vede la media: vede un riquadro vuoto. Per questo, in gallerie e pagine con molti oggetti, p95, p99 e il cold MISS più lento sono diventati più importanti per me.

Il video ha mostrato un secondo problema completamente diverso

Il browser può richiedere byte range:

Range: bytes=0-1048575

e dopo un seek:

Range: bytes=50000000-51048575

Una risposta 206 Partial Content consente playback e seek senza scaricare tutti i byte precedenti.

La mia prima cache Range frammentava un singolo video in molte 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;

I Range del browser sono arbitrari. bytes=0-1048575, bytes=0-999999 e offset diversi dopo un seek generano cache key differenti per lo stesso MP4. Non ho misurato quanti GB occupasse precisamente questa frammentazione, quindi non invento un numero.

Sono passato a slice fisse da 1 MB

Il modulo Slice di Nginx può normalizzare una risorsa grande:

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;

Era molto più interessante per il riuso della cache. Poi in produzione è comparso:

etag mismatch in slice response while reading response header from upstream

Le slice MP4 fallivano sulla coerenza ETag

Il 30 luglio, ripetuti subrequest di slice dello stesso MP4 generavano etag mismatch in slice response mentre le richieste passavano da indirizzi tra cui 109.61.89.53, 109.61.89.57, 79.127.226.193 e 109.61.89.55. Il 31 luglio lo stesso tipo di errore è ricomparso su un altro MP4.

All’inizio avevo descritto informalmente il fenomeno come “firme diverse”. Il termine corretto era ETag, non AccessKey, signed URL o firma crittografica.

Perché Nginx non unisce slice con ETag diversi

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

è coerente. Ma:

slice 1 → ETag A
slice 2 → ETag B

non garantisce più che entrambi i pezzi appartengano alla stessa rappresentazione. Unirli alla cieca potrebbe mescolare byte di versioni diverse. L’articolo Nginx sul byte-range caching descrive questo controllo.

I miei log dimostrano che Nginx ha visto ETag incompatibili tra slice dello stesso MP4. Non dimostrano la causa interna esatta, perché non registravo il valore letterale dell’ETag di ogni subrequest. Il cambio di indirizzo upstream rende plausibile un’incoerenza tra risposte backend, ma resta un’inferenza.

Questo non significa che Bunny Storage non supporti Range

Range funzionava e io cachavo esplicitamente 206 Partial Content. Il problema specifico era la combinazione Nginx Slice, più risposte di storage e la coerenza ETag richiesta per assemblare in sicurezza una rappresentazione.

I video potevano inoltre incontrare normali timeout di connessione all’origin. Avevo quindi due failure mode separati: connessione/latency origin e coerenza Slice/ETag.

Alla fine ho eliminato Slice dagli 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;

Le risposte parziali non venivano memorizzate come oggetto completo. Questo eliminava l’assembly problematico, ma rendeva nuovamente un Range cold più dipendente dall’origin esterno.

Mi sono accorto che stavo costruendo una parte di CDN davanti allo Storage

Nginx aveva accumulato cache lock, stale, background update, Range, 206, Slice, chunk normalizzati, keepalive, TLS reuse, retry, timeout tuning e cache key personalizzate. Ogni componente aveva senso, ma il bisogno reale dell’origin era banale: conservare file immutable e inviarne i byte.

Il nuovo origin è volutamente semplice

Ho spostato il dataset su un server dedicato che fa essenzialmente Nginx + SSD. Per un origin puramente statico, una piccola macchina intorno a 1 vCPU, circa 1 GB di RAM e qualche centinaio di GB SSD è un punto di partenza ragionevole se rete e disco sono adeguati.

Non è una regola universale. Contano bandwidth, dimensione degli oggetti, concorrenza, IOPS e MISS rate. L’origin usato nelle mie misure attuali ha più risorse, quindi non presento i picchi misurati come benchmark esatto di una macchina 1-vCPU/1-GB.

La nuova architettura

Browser
   ↓
server di lavoro
   ↓
Nginx + media cache locale
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

L’origin è privato. Il server di lavoro parla con un IP privato fisso. L’HTTPS pubblico termina sempre sul server di lavoro; verso l’origin posso usare HTTP dentro il tunnel WireGuard già cifrato.

Cosa è sparito dal cold path

server di lavoro
↓
WireGuard
↓
IP privato fisso
↓
Nginx
↓
SSD

Per questo collegamento diretto non servono più DNS pubblico, selezione fra più storage address, percorso Internet pubblico e handshake TLS aggiuntivo verso la Storage API.

Il cold MISS è diventato così economico da sembrare quasi un HIT

Piccoli test Range su file reali davano tipicamente:

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

I controlli 10/10, 20/20 sequenziali e 20/20 paralleli sono passati. Sull’origin stesso, la stessa piccola richiesta impiegava circa 0.5–0.9 ms.

Sono test piccoli di Range/TTFB, non il tempo di download di un intero video. Il trasferimento completo dipende sempre da dimensione e throughput.

Il vecchio caso peggiore e il nuovo cold path sono molto diversi

I vecchi casi normali erano spesso 29–44 ms. Il bad case osservato era circa 1.054 s. Il nuovo tiny cold request è intorno a 20 ms.

Il rapporto tra quello specifico outlier e 20 ms è circa 52×. Non significa “il mio server è 52× più veloce di Bunny Storage”: è soltanto il confronto tra due percorsi osservati.

Anche MP4 ora è più semplice

Durante un cold fill, Nginx può scaricare l’MP4 completo dal private origin, memorizzare una sola cache entry e poi servire localmente qualsiasi byte Range al browser. Si può rimuovere Range verso upstream durante il fill e usare proxy_force_ranges per le risposte parziali locali.

cold MISS
↓
MP4 completo dal private origin
↓
un file cached
↓
Range client servito localmente come 206

Il caching dell’oggetto intero ha un costo

Se un MP4 cold è da 500 MB e il primo utente vuole solo dieci secondi, il fill può trasferire tutti i 500 MB. Per i miei file e pattern è accettabile. Per una libreria di video multi-GB prevalentemente cold valuterei slice stabile, HLS/DASH, video CDN o Bunny Stream.

Le slice fisse hanno ancora senso per altri media grandi

Nginx Slice non è un cattivo strumento. Con oggetti immutable e un origin stabile resta utile. La differenza è che adesso tutte le parti arrivano da un origin e una rappresentazione filesystem che controllo.

Una cache più piccola può sembrare più veloce se il MISS costa poco

La vecchia cache raggiungeva circa 35–36 GB. La nuova si è stabilizzata intorno a 16 GB perché con min_free mantengo circa 8 GB liberi.

prima:
HIT  = veloce
MISS = a volte doloroso

ora:
HIT  = veloce
MISS = abbastanza veloce

Una cache grande riduce il numero di MISS. Un buon origin riduce il costo di ogni MISS. Per questo workload, la seconda proprietà si è rivelata più importante.

Il traffico reale di produzione ha confermato il ruolo dell’origin

In uno snapshot di 60 secondi, il server inviava circa 96.84 Mbit/s agli utenti e riceveva circa 7.07 Mbit/s dal media origin. Nessun nuovo 502, 503, 504 o upstream timeout è comparso in quella finestra.

In un successivo snapshot di 30 secondi, public TX era 61.36 Mbit/s e WireGuard RX dall’origin 1.40 Mbit/s. Il rapporto byte origin/public TX era 2.28%.

Non lo chiamo cache-hit rate esatto: il traffico pubblico non è solo media e un rapporto di byte non equivale a un rapporto di richieste. Mostra però che, dopo il warm-up, l’origin serve soprattutto cold fill.

Cosa monitorerei oggi

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

Separerei HIT e MISS, soprattutto p95/p99 del MISS.

Per i video testerei Range iniziale, intermedio, finale, più range non sequenziali per il seek, cold e warm, oltre a 206, Content-Range, Content-Length, Accept-Ranges ed ETag. Con Slice verificherei esplicitamente la coerenza dei validator fra più slice.

Una cold URL e 20 cold URL differenti non sono lo stesso test

proxy_cache_lock aiuta quando molte richieste vogliono la stessa cache key cold. Non trasforma venti file diversi in una sola richiesta origin. Una pagina con cinquanta immagini cold diverse può essere un test più realistico di un benchmark che ripete un solo URL.

Il self-hosting dà controllo, non affidabilità gratuita

Il media server privato rende più semplice capire il critical path, ma backup, salute disco, spazio libero, aggiornamenti, firewall, monitoring, restore e ridondanza diventano responsabilità mia.

Un singolo origin è anche un single point of failure per gli oggetti non in cache. L’alta disponibilità richiede ulteriore ingegneria.

Quando sceglierei ancora Bunny

Per distribuzione globale, ridondanza gestita, storage in rapida crescita o minima amministrazione, valuterei di nuovo Bunny senza problemi, includendo esplicitamente Storage + Pull Zone/CDN.

Il mio test non dimostra che un VPS sia migliore di Bunny CDN, che Bunny Storage sia sempre lento o che Bunny non supporti Range. Dimostra che, per il mio workload, Bunny Storage come runtime origin diretto della mia cache Nginx produceva un cold path e un comportamento sliced-video che non volevo più gestire.

La regola che uso oggi

In due mesi ho ottimizzato dimensione cache, cache key, Range, 206, Slice, chunk da 1 MB, keepalive, TLS reuse, retry, stale e timeout. Il miglior miglioramento è stato più semplice: cambiare origin.

Cosa succede quando il file richiesto dall’utente non è in cache?

Se la risposta è un origin stabile, TTFB prevedibile, Range corretto, validator coerenti e failure mode comprensibili, il cache è un’ottimizzazione. Se una buona UX dipende dal non incontrare mai un MISS, la cache sta nascondendo un problema architetturale più profondo.

Per il mio workload, un piccolo media origin privato e noioso è stato più adatto. Non è un CDN, non è magia e non elimina il lavoro operativo. Ha semplicemente reso noioso il cold path. Dopo due mesi a fare debug di immagini, timeout di connessione, Range video e etag mismatch in slice response, era esattamente ciò che volevo.