Volver al blog
28 de agosto de 2026Sergei Solod13 min de lectura

Dos meses con Bunny Storage detrás de Nginx: por qué moví imágenes, vídeo y audio a un servidor multimedia propio

Durante dos meses usé Bunny Storage como origen privado detrás de mi propia caché Nginx, no a través de Bunny CDN. Los logs de producción mostraron latencias extremas y timeouts en contenido frío, además de un problema independiente en MP4 con Nginx Slice y ETag inconsistentes; terminé migrando a un origen multimedia privado y mucho más simple.

Bunny StorageNginxCaché de vídeoCache missServidor multimedia

Durante casi dos meses mi ruta de entrega de contenido multimedia era esta:

Browser
  ↓
servidor de trabajo
  ↓
Nginx
  ↓
proxy_cache local
  ↓ cache MISS
Bunny Storage

Las imágenes, los vídeos y el audio estaban en Bunny Storage. El usuario no accedía directamente a Bunny. Mi Nginx pedía el objeto a [https://storage.bunnycdn.com](https://storage.bunnycdn.com), añadía el AccessKey del servidor, guardaba la respuesta en un SSD local y servía él mismo la URL pública.

Conviene delimitar el experimento: esto no es una prueba de Bunny CDN. No utilicé Bunny CDN en este caso. Storage Zone más Pull Zone/CDN es otra arquitectura de distribución y sigo considerando Bunny CDN un producto muy sólido. Lo que probé específicamente fue Bunny Storage como origen en tiempo de ejecución para mi propia caché Nginx.

Por qué la arquitectura original parecía razonable

El workload contiene una gran cantidad de objetos multimedia inmutables: AVIF, JPEG, PNG, MP4, WebM, audio y otros archivos estáticos, desde imágenes pequeñas hasta vídeos bastante grandes. No quería alojar todo el dataset en el servidor de aplicación, pero sí controlar con mi Nginx las URLs públicas, la política de caché, los redirects, los archivos inexistentes, Range y los fallbacks.

                    ┌── HIT ── SSD local
                    │
Browser → caché Nginx
                    │
                    └── MISS ── Bunny Storage

Con un HIT funcionaba muy bien: Nginx entregaba el objeto directamente desde el disco local y Bunny no participaba en esa petición. Precisamente ese buen comportamiento ocultó durante mucho tiempo la parte importante: el MISS frío.

Los archivos populares ocultaban la latencia real del origen

Una imagen solicitada con frecuencia tiende a permanecer en caché. Un archivo antiguo y poco visitado tiene más probabilidades de haber sido expulsado. Por eso las páginas antiguas se convirtieron, sin que yo lo buscara, en una especie de benchmark del origen.

Un snapshot de julio mostraba unos 36 GB de caché multimedia, más de 355.000 archivos de caché, un límite configurado cercano a 35 GB y el filesystem raíz alrededor del 93% de uso. Otros snapshots cercanos se aproximaban a 400.000 entradas. Incluso una caché así sigue siendo finita.

La lección fue sencilla: un HIT rápido no demuestra que el origen sea rápido. Solo demuestra que la caché local lo es.

Los logs mostraban fallos antes de empezar a transferir el archivo

En el error log de Nginx aparecían repetidamente mensajes como:

upstream timed out
while connecting to upstream

y:

upstream timed out ... while SSL handshaking to upstream

No es lo mismo que un archivo grande tardando en descargarse. En esos casos Nginx seguía intentando establecer la conexión upstream o completar TLS.

En un snapshot diagnóstico del 30 de julio, las últimas 5.000 líneas del media error log contenían 317 coincidencias de upstream timeout. En el snapshot del día anterior había 578. Son coincidencias de log, no 317 o 578 usuarios únicos, pero dejan claro que no era un caso aislado.

Los casos malos no pertenecían a una sola IP

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Vi fallos en distintas direcciones de ese conjunto. Desde el punto de vista de mi servidor, el comportamiento problemático estaba en la ruta externa al origen, no en una única IP permanentemente defectuosa.

Mis logs no permiten demostrar si la causa estaba en un backend de storage, routing, peering, la ruta de mi proveedor hacia Bunny, balanceo u otro componente de red. Sí demuestran algo más concreto: algunos MISS fríos no conseguían establecer la conexión con Bunny Storage dentro del tiempo esperado.

Una medición hizo visible el 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

Pero una ruta medida fue muy diferente:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Eso no significa que Bunny Storage tardara siempre un segundo. La mayoría de las rutas medidas estaban en decenas de milisegundos. Lo importante era la variación: el mismo hostname podía terminar en una ruta que añadía más de un segundo antes incluso de considerar la transferencia útil del objeto.

En páginas multimedia importa más la cola de latencia que el promedio

Si 79 imágenes cargan en 30–50 ms y una tarda un segundo, el promedio puede seguir pareciendo bueno. El usuario no ve el promedio: ve un hueco que aún no tiene imagen.

Para galerías y páginas con muchos objetos independientes, p95, p99 y el MISS frío más lento me parecen ahora métricas más útiles que un buen promedio.

El vídeo reveló un segundo problema completamente distinto

Los navegadores pueden pedir byte ranges de vídeo:

Range: bytes=0-1048575

y después de un seek:

Range: bytes=50000000-51048575

Con una respuesta 206 Partial Content, el reproductor puede empezar o saltar sin descargar todos los bytes anteriores.

Mi primera caché de Range fragmentaba un vídeo físico en muchas claves

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;

Los Range del navegador son arbitrarios. bytes=0-1048575, bytes=0-999999 o un offset tras hacer seek producían claves diferentes para el mismo MP4. No medí cuántos GB consumió exactamente esta fragmentación, así que no voy a inventar una cifra.

Después pasé a slices normalizados de 1 MB

El módulo Slice de Nginx permite normalizar el recurso:

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 mucho mejor para reutilizar caché. Entonces producción empezó a registrar:

etag mismatch in slice response while reading response header from upstream

Los slices de MP4 fallaban por consistencia de ETag

El 30 de julio, varios subrequests de un mismo MP4 generaron etag mismatch in slice response mientras alcanzaban direcciones como 109.61.89.53, 109.61.89.57, 79.127.226.193 y 109.61.89.55. El 31 de julio volvió a aparecer la misma clase de error con otro MP4 y varias direcciones del pool.

Al principio lo describí informalmente como “firmas diferentes” entre partes. Técnicamente era ETag, no el AccessKey, una URL firmada ni una firma criptográfica.

Por qué Nginx no une slices con ETag diferentes

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

es coherente. Pero:

slice 1 → ETag A
slice 2 → ETag B

impide asumir de forma segura que ambas partes pertenecen a la misma representación. Unirlas a ciegas podría mezclar bytes de versiones distintas. El artículo de Nginx sobre byte-range caching describe este control de validadores.

Mis logs demuestran que Nginx vio ETags incompatibles entre slices del mismo MP4. No demuestran la causa interna exacta porque no guardé el valor literal de cada ETag de cada subrequest. El cambio de direcciones upstream hace plausible una inconsistencia entre respuestas de backend, pero lo trato como inferencia.

Eso no significa que Bunny Storage no soporte Range

Range funcionaba y yo almacenaba explícitamente 206 Partial Content. El problema específico era la combinación Nginx Slice + varias respuestas de storage + la consistencia ETag necesaria para ensamblar una representación segura.

Además, los vídeos también podían sufrir timeouts normales del origen. Por tanto había dos tipos de fallo distintos: conexión/latencia del origin y consistencia Slice/ETag.

Finalmente dejé 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;

Las respuestas parciales ya no se almacenaban como si fueran el objeto completo. Eso eliminó el ensamblado problemático, pero devolvió otro trade-off: un Range frío podía depender directamente del origin externo.

Me di cuenta de que estaba construyendo parte de un CDN delante de Storage

Mi Nginx ya tenía cache lock, stale, background update, Range, 206, Slice, chunks normalizados, keepalive, reutilización TLS, retries, timeouts y claves personalizadas. Cada pieza tenía sentido, pero la pregunta esencial era más simple: ¿qué necesitaba realmente del origen? Guardar archivos inmutables y devolver bytes.

El nuevo origen es deliberadamente simple

Moví los medios a un servidor dedicado cuya tarea es básicamente Nginx + SSD. Para un origin puramente estático, una máquina pequeña de alrededor de 1 vCPU, aproximadamente 1 GB de RAM y cientos de GB de SSD puede ser un punto de partida razonable si red y disco son adecuados.

No es una regla universal. Importan el bandwidth, tamaños de objetos, concurrencia, IOPS y MISS rate. El servidor con el que hice las mediciones actuales tiene más recursos, por lo que no presento mis picos actuales como benchmark exacto de una máquina 1-vCPU/1-GB.

La nueva arquitectura

Browser
   ↓
servidor de trabajo
   ↓
Nginx + caché multimedia local
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
      media origin
          ↓
        Nginx
          ↓
         SSD

El origin es privado. El servidor de trabajo habla con una IP privada fija. El HTTPS público sigue terminando en el servidor de trabajo y el tráfico al origin puede usar HTTP dentro del túnel WireGuard ya cifrado.

Qué desapareció del cold path

servidor de trabajo
↓
WireGuard
↓
IP privada fija
↓
Nginx
↓
SSD

Ya no necesito DNS público, seleccionar entre varias direcciones de storage, recorrer el camino público ni negociar otro TLS para esta relación concreta entre servidores.

El MISS frío se volvió tan barato que visualmente se parece a un HIT

Los pequeños tests Range sobre archivos reales daban normalmente:

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

Pasaron 10/10, 20/20 secuenciales y 20/20 paralelos. En el propio origin, la misma petición pequeña tardaba unos 0.5–0.9 ms.

Son tests pequeños de Range/TTFB, no el tiempo de descarga de un vídeo completo. La transferencia total sigue dependiendo del tamaño y del throughput.

El antiguo caso malo y el nuevo cold path están muy separados

Los antiguos casos normales solían estar en 29–44 ms. El caso malo observado fue de unos 1.054 s. El nuevo request frío pequeño ronda 20 ms.

Ese outlier concreto dividido por 20 ms da aproximadamente 52× de diferencia de latencia. No significa “mi servidor es 52× más rápido que Bunny Storage”; solo compara dos rutas observadas concretas.

MP4 también es más sencillo ahora

En un cold fill, Nginx puede obtener el MP4 completo del origin privado, almacenar un único archivo de caché y luego atender localmente cualquier byte Range del navegador. Se puede eliminar Range hacia upstream durante el fill, mientras proxy_force_ranges permite respuestas parciales locales.

cold MISS
↓
MP4 completo desde el origin privado
↓
un archivo en caché
↓
Range del cliente servido localmente como 206

Cachear el objeto completo también cuesta

Si un MP4 frío pesa 500 MB y el primer usuario solo quiere diez segundos, llenar la caché puede transferir los 500 MB completos entre servidores. Para mis tamaños y patrones es un trade-off aceptable. Para bibliotecas de vídeos de varios GB mayoritariamente fríos consideraría slice estable, HLS/DASH, un CDN de vídeo o Bunny Stream.

Los slices fijos siguen teniendo sentido para otros medios grandes

No considero malo Nginx Slice. Con objetos inmutables y un origen estable sigue siendo útil. La diferencia es que ahora todas las partes provienen de un origen y una representación de filesystem que controlo.

Una caché menor puede sentirse más rápida si el MISS es barato

La caché antigua alcanzaba 35–36 GB. La nueva se estabilizó cerca de 16 GB porque mantengo unos 8 GB libres mediante min_free.

antes:
HIT  = rápido
MISS = a veces doloroso

ahora:
HIT  = rápido
MISS = también suficientemente rápido

Una caché grande reduce el número de MISS. Un buen origen reduce el coste de cada MISS. En este workload, lo segundo resultó más importante de lo esperado.

El tráfico real de producción confirmó el papel del origin

En un snapshot de 60 segundos el servidor enviaba unos 96.84 Mbit/s a usuarios y recibía unos 7.07 Mbit/s del media origin. Durante esa ventana no aparecieron nuevos 502, 503, 504 ni upstream timeout.

En otro snapshot de 30 segundos, public TX fue 61.36 Mbit/s y WireGuard RX desde el origin 1.40 Mbit/s. La relación de bytes origin/public TX fue 2.28%.

No lo convierto en una tasa HIT exacta porque el tráfico público incluye más cosas que media y una proporción de bytes no es una proporción de requests. Sí muestra que, una vez caliente la caché, el origin se dedica sobre todo a cold fills.

Qué monitorizaría ahora

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

Separaría HIT y MISS y prestaría especial atención a p95/p99 del MISS.

Para vídeo probaría el primer Range, uno intermedio, uno desde el final, varios ranges no secuenciales para seek, cold frente a warm y verificaría 206, Content-Range, Content-Length, Accept-Ranges y ETag. Si uso Slice, comprobaría expresamente la consistencia del validator entre varios slices.

Una URL fría y 20 URLs frías distintas no son el mismo test

proxy_cache_lock ayuda cuando muchas solicitudes quieren la misma clave fría. No convierte veinte archivos distintos en una única petición al origin. Una página con cincuenta imágenes frías distintas puede ser una prueba más realista que repetir una sola URL.

Self-hosting da control, no fiabilidad gratuita

El servidor privado simplifica mi critical path, pero backups, salud del disco, espacio libre, actualizaciones, firewall, monitorización, restauración y redundancia pasan a ser responsabilidad mía.

Un único origin también es un single point of failure para los objetos no cacheados. La alta disponibilidad requiere más ingeniería.

Cuándo volvería a elegir Bunny

Si necesitara distribución global, redundancia gestionada, almacenamiento con crecimiento rápido o minimizar la administración, volvería a considerar Bunny sin problema. En ese caso evaluaría de forma explícita Storage + Pull Zone/CDN.

Mi prueba no demuestra que un VPS sea mejor que Bunny CDN, que Bunny Storage sea siempre lento o que Bunny no soporte Range. Demuestra que, para mi workload, Bunny Storage como runtime origin directo de mi caché Nginx produjo un cold path y un comportamiento de vídeo sliced que ya no quería gestionar.

La regla que uso ahora

Durante esos dos meses optimicé tamaño de caché, claves, Range, 206, Slice, chunks de 1 MB, keepalive, reutilización TLS, retries, stale y timeouts. La mejora más fuerte fue más sencilla: cambiar el origin.

¿Qué ocurre cuando el archivo que quiere el usuario no está en caché?

Si la respuesta es un origen estable, TTFB predecible, Range correcto, validators coherentes y fallos comprensibles, la caché es una optimización. Si una buena UX depende de que nunca ocurra un MISS, la caché está ocultando un problema arquitectónico más profundo.

Para mi workload, un media origin privado, pequeño y aburrido fue una mejor opción. No es un CDN, no es magia y no elimina la responsabilidad operativa. Solo volvió aburrido el cold path. Después de dos meses depurando imágenes, timeouts de conexión, Range de vídeo y etag mismatch in slice response, eso era exactamente lo que quería.