Por quase dois meses, meu caminho de entrega de mídia era este:
Browser
↓
servidor de trabalho
↓
Nginx
↓
proxy_cache local
↓ cache MISS
Bunny Storage
Imagens, vídeo e áudio ficavam no Bunny Storage. O usuário não acessava Bunny diretamente. Meu Nginx buscava o objeto em [https://storage.bunnycdn.com](https://storage.bunnycdn.com), adicionava o AccessKey do servidor, colocava a resposta no SSD local e servia a URL pública por conta própria.
É importante delimitar o teste: isto não é uma avaliação do Bunny CDN. Eu não usei Bunny CDN nesse experimento. Storage Zone + Pull Zone/CDN é outra arquitetura de entrega, e continuo considerando Bunny CDN um produto muito forte. O que testei especificamente foi Bunny Storage como runtime origin do meu próprio cache Nginx.
Por que a arquitetura inicial parecia razoável
O workload tem muitos objetos de mídia imutáveis: AVIF, JPEG, PNG, MP4, WebM, áudio e outros arquivos estáticos, desde imagens pequenas até vídeos bem maiores. Eu não queria manter todo o dataset no application server, mas queria controlar URLs públicas, cache policy, redirects, arquivos ausentes, Range e fallbacks no meu Nginx.
┌── HIT ── SSD local
│
Browser → cache Nginx
│
└── MISS ── Bunny Storage
Em HIT, o desenho funcionava muito bem. Nginx servia diretamente do disco local e Bunny nem participava da requisição. Justamente esse sucesso escondeu por bastante tempo o comportamento realmente importante: o cold MISS.
Arquivos populares escondiam a latência real do origin
Uma imagem muito acessada tende a permanecer em cache. Um arquivo antigo e raramente aberto tem mais chance de ser removido. Por isso páginas antigas acabaram virando, sem intenção, um benchmark do origin.
Um snapshot de julho mostrava cerca de 36 GB de media cache, mais de 355.000 arquivos de cache, limite configurado perto de 35 GB e filesystem raiz com aproximadamente 93% de uso. Outros snapshots se aproximavam de 400.000 entries. Mesmo um cache desse tamanho é finito.
A lição: HIT rápido não prova origin rápido. Prova apenas que o cache local é rápido.
Os logs mostravam falhas antes do corpo do arquivo começar a chegar
upstream timed out
while connecting to upstream
e:
upstream timed out ... while SSL handshaking to upstream
Isso é diferente de um arquivo grande demorando para transferir. Nesses casos Nginx ainda estava tentando estabelecer a conexão upstream ou concluir TLS.
Em um snapshot diagnóstico de 30 de julho, as últimas 5.000 linhas do media error log tinham 317 correspondências de upstream timeout. No dia anterior eram 578. São linhas de log, não usuários únicos, mas claramente não era um caso isolado.
Os casos ruins não estavam presos a um único IP
109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193
Observei failures em diferentes endereços desse conjunto. Do ponto de vista do meu servidor, o problema estava no comportamento do caminho externo para o origin, não em um único IP permanentemente ruim.
Meus logs não provam se a causa era storage backend, routing, peering, caminho do meu provider até Bunny, balancing ou outro fator de rede. Eles provam algo mais específico: alguns cold MISS não conseguiam estabelecer a conexão Bunny Storage dentro do tempo esperado.
Uma medição tornou o long tail evidente
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
Mas um caminho medido foi muito diferente:
109.61.89.55
TCP 1.017782 s
TLS 1.048306 s
TOTAL 1.054474 s
Isso não significa que Bunny Storage sempre levava um segundo. A maior parte dos caminhos ficava em dezenas de ms. O ponto era a variância: o mesmo hostname podia levar a um path com mais de um segundo de connection time antes da transferência útil.
Em páginas de mídia, tail latency pesa mais que a média
Se 79 imagens chegam em 30–50 ms e uma leva um segundo, a média ainda pode parecer boa. O usuário não vê a média; vê um espaço vazio. Por isso p95, p99 e o cold MISS mais lento passaram a importar mais para mim.
Vídeo revelou um segundo problema completamente diferente
O navegador pode solicitar byte ranges:
Range: bytes=0-1048575
e depois de um seek:
Range: bytes=50000000-51048575
Com 206 Partial Content, playback e seek funcionam sem baixar todos os bytes anteriores.
Meu primeiro cache de Range fragmentava um único vídeo em várias 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;
Ranges do navegador são arbitrários. bytes=0-1048575, bytes=0-999999 e offsets após seek geram keys diferentes para o mesmo MP4. Eu não medi quantos GB essa fragmentação consumiu, então não invento um número.
Depois passei para slices fixos de 1 MB
O Nginx Slice module normaliza o arquivo em partes fixas:
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 muito melhor para reutilização do cache. Então produção começou a registrar:
etag mismatch in slice response while reading response header from upstream
Os slices de MP4 falhavam na consistência de ETag
Em 30 de julho, subrequests repetidos de um MP4 geravam etag mismatch in slice response enquanto iam para endereços como 109.61.89.53, 109.61.89.57, 79.127.226.193 e 109.61.89.55. Em 31 de julho, o mesmo tipo de erro apareceu em outro MP4.
No começo eu descrevi isso informalmente como “assinaturas diferentes” dos pedaços. Tecnicamente era ETag, não AccessKey, signed URL ou assinatura criptográfica.
Por que Nginx não une slices com ETag diferentes
slice 1 → bytes 0–1 MB → ETag A
slice 2 → bytes 1–2 MB → ETag A
é consistente. Mas:
slice 1 → ETag A
slice 2 → ETag B
não garante que as duas partes pertençam à mesma representação. Juntá-las cegamente pode misturar bytes de versões diferentes. O artigo do Nginx sobre byte-range caching descreve essa verificação.
Meus logs provam que Nginx viu ETags incompatíveis entre slices do mesmo MP4. Não provam a causa interna exata porque eu não registrava o valor literal de cada ETag de cada subrequest. A mudança de upstream address torna uma inconsistência entre respostas backend plausível, mas continuo tratando isso como inferência.
Isso não significa que Bunny Storage não suporte Range
Range funcionava e eu cacheava explicitamente 206 Partial Content. O problema específico era a combinação de Nginx Slice, múltiplas respostas do storage e a consistência ETag necessária para assembly seguro.
Vídeos também podiam sofrer um origin timeout normal. Portanto havia dois failure modes separados: conexão/latência do origin e consistência Slice/ETag.
Por fim deixei de usar Slice para 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 não era mais gravada como objeto completo. Isso eliminou o assembly problemático, mas tornou um cold Range novamente mais dependente do origin externo.
Percebi que estava construindo parte de um CDN na frente do Storage
Meu Nginx já tinha cache lock, stale, background update, Range, 206, Slice, chunks normalizados, keepalive, TLS reuse, retries, timeout tuning e custom cache keys. Cada recurso fazia sentido, mas o que eu realmente precisava do origin era simples: armazenar arquivo immutable e devolver bytes.
O origin substituto é propositalmente simples
Migrei a mídia para um servidor dedicado cuja função é basicamente Nginx + SSD. Para pure static origin, uma máquina pequena com cerca de 1 vCPU, aproximadamente 1 GB de RAM e algumas centenas de GB SSD é um ponto de partida razoável se rede e disco forem suficientes.
Não é uma regra universal. Bandwidth, tamanho dos objetos, concorrência, IOPS e MISS rate importam. O origin usado nas minhas medições atuais tem mais recursos, então não apresento os picos atuais como benchmark exato de uma máquina 1-vCPU/1-GB.
A nova arquitetura
Browser
↓
servidor de trabalho
↓
Nginx + cache de mídia local
│
├── HIT
└── MISS
↓
WireGuard
↓
media origin
↓
Nginx
↓
SSD
O origin é privado. O servidor de trabalho fala com um IP privado fixo. HTTPS público continua terminando no servidor de trabalho; o tráfego ao origin pode usar HTTP dentro do túnel WireGuard já criptografado.
O que saiu do cold path
servidor de trabalho
↓
WireGuard
↓
IP privado fixo
↓
Nginx
↓
SSD
Para essa relação direta, não preciso mais de DNS público, seleção entre vários storage addresses, rota pública nem handshake TLS adicional com Storage API.
Cold MISS ficou barato o bastante para parecer quase um HIT
Pequenos testes Range em arquivos reais normalmente davam:
CONNECT ≈ 9.5–12.5 ms
TTFB ≈ 19–23 ms
TOTAL ≈ 19–23 ms
Os checks 10/10, 20/20 sequenciais e 20/20 paralelos passaram. No próprio origin, o mesmo pequeno request levava cerca de 0.5–0.9 ms.
São testes pequenos de Range/TTFB, não o tempo de download de um vídeo completo. Transferência total continua dependendo do tamanho e throughput.
O antigo caso ruim e o novo cold path são muito diferentes
Medições antigas normais costumavam ficar em 29–44 ms. O bad case observado foi cerca de 1.054 s. O novo tiny cold request fica por volta de 20 ms.
Comparar aquele outlier específico a 20 ms resulta em cerca de 52× de diferença de latência. Isso não significa “meu servidor é 52× mais rápido que Bunny Storage”; é uma comparação entre dois caminhos observados.
MP4 também ficou mais simples
Num cold fill, Nginx pode buscar o MP4 completo no private origin, manter uma única cache entry e depois servir localmente qualquer byte Range do navegador. Range pode ser removido do upstream durante o fill e proxy_force_ranges permite respostas parciais locais.
cold MISS
↓
MP4 completo do private origin
↓
um arquivo cached
↓
Range do cliente servido localmente como 206
Full-object caching também tem custo
Se um MP4 cold tem 500 MB e o primeiro usuário quer apenas dez segundos, o cache fill pode transferir os 500 MB completos entre servidores. Para meus tamanhos e padrões é aceitável. Para biblioteca de vídeos multi-GB principalmente cold eu consideraria slice cache estável, HLS/DASH, video CDN ou Bunny Stream.
Slices fixos continuam úteis para outros media grandes
Nginx Slice não é ruim. Com objeto immutable e origin estável continua útil. A diferença é que todos os chunks agora vêm de um origin e de uma representação de filesystem sob meu controle.
Um cache menor pode parecer mais rápido quando MISS é barato
O cache antigo chegava a cerca de 35–36 GB. O novo estabilizou perto de 16 GB porque mantenho aproximadamente 8 GB livres com min_free.
antes:
HIT = rápido
MISS = às vezes doloroso
agora:
HIT = rápido
MISS = rápido o suficiente também
Cache grande reduz a quantidade de MISS. Origin bom reduz o custo de cada MISS. Nesse workload, o segundo ponto se mostrou mais importante.
O tráfego real de produção confirmou o papel do origin
Em um snapshot de 60 segundos, o servidor enviava cerca de 96.84 Mbit/s aos usuários e recebia cerca de 7.07 Mbit/s do media origin. Não apareceram novos 502, 503, 504 ou upstream timeout durante essa janela.
Em um snapshot posterior de 30 segundos, public TX foi 61.36 Mbit/s e WireGuard RX do origin 1.40 Mbit/s. A relação origin bytes/public TX foi 2.28%.
Não chamo o inverso disso de cache-hit rate exata porque public traffic não é apenas media e proporção de bytes não é proporção de requests. Mas mostra que, depois do warm-up, o origin trabalha principalmente com cold fills.
O que eu monitoraria hoje
$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status
Separaria HIT e MISS, com atenção especial ao p95/p99 do MISS.
Para vídeo, testaria Range inicial, no meio, suffix Range, vários ranges não sequenciais para seek, cold e warm, além de 206, Content-Range, Content-Length, Accept-Ranges e ETag. Com Slice, validaria explicitamente a consistência do validator entre vários slices.
Uma cold URL e 20 cold URLs diferentes não são o mesmo teste
proxy_cache_lock ajuda quando muitas requests querem a mesma cold cache key. Não transforma 20 arquivos diferentes em um único origin request. Uma página com 50 imagens cold diferentes pode ser um teste de origin muito mais realista do que repetir uma URL.
Self-hosting dá controle, não confiabilidade gratuita
O media server privado deixa o critical path mais fácil de entender, mas backup, saúde do disco, espaço livre, updates, firewall, monitoring, restore e redundancy ficam sob minha responsabilidade.
Um origin único também é single point of failure para objetos não cacheados. Alta disponibilidade exige mais engenharia.
Quando eu ainda escolheria Bunny
Para distribuição global, managed redundancy, storage crescendo rapidamente ou mínimo trabalho de administração, eu consideraria Bunny novamente sem problema. Nesse caso avaliaria explicitamente Storage + Pull Zone/CDN.
Meu teste não prova que um VPS seja melhor que Bunny CDN, que Bunny Storage seja sempre lento ou que Bunny não suporte Range. Ele mostra que, no meu workload, Bunny Storage como runtime origin direto do meu cache Nginx produziu um cold path e um comportamento de sliced video que eu não queria mais administrar.
A regra que uso hoje
Durante dois meses ajustei tamanho de cache, cache keys, Range, 206, Slice, chunks de 1 MB, keepalive, TLS reuse, retries, stale e timeouts. A maior melhoria foi mais simples: trocar o origin.
O que acontece quando o arquivo que o usuário quer não está no cache?
Se a resposta for origin estável, TTFB previsível, Range correto, validators consistentes e failure modes compreensíveis, cache é otimização. Se boa UX depender de nunca existir MISS, o cache está escondendo um problema arquitetural mais profundo.
Para o meu workload, um media origin pequeno, privado e entediante foi a melhor escolha. Não é CDN, não é mágica e não remove responsabilidade operacional. Apenas tornou o cold path entediante. Depois de dois meses depurando imagens, connection timeout, video Range e etag mismatch in slice response, era exatamente isso que eu queria.