Retour au blog
28 août 2026Sergei Solod13 min de lecture

Deux mois avec Bunny Storage derrière Nginx : pourquoi j’ai déplacé images, vidéos et audio vers un serveur média dédié

Pendant deux mois, j’ai utilisé Bunny Storage comme origin privé derrière mon propre cache Nginx, sans passer par Bunny CDN. Les logs de production ont révélé des latences extrêmes et des timeouts sur les médias froids, ainsi qu’un problème MP4 distinct entre Nginx Slice et des ETag incohérents ; j’ai finalement opté pour un origin média privé beaucoup plus simple.

Bunny StorageNginxCache vidéoCache missServeur média

Pendant près de deux mois, mon chemin de diffusion des médias ressemblait à ceci :

Browser
  ↓
serveur de travail
  ↓
Nginx
  ↓
proxy_cache local
  ↓ cache MISS
Bunny Storage

Images, vidéos et audio étaient stockés dans Bunny Storage. Les utilisateurs ne contactaient jamais Bunny directement. Mon Nginx récupérait l’objet depuis [https://storage.bunnycdn.com](https://storage.bunnycdn.com), ajoutait le AccessKey côté serveur, plaçait la réponse en cache sur SSD local puis servait lui-même l’URL publique.

La portée de ce retour d’expérience doit être claire : ce n’est pas un test de Bunny CDN. Je n’ai pas utilisé Bunny CDN ici. Storage Zone + Pull Zone/CDN correspond à une architecture de diffusion différente, et je considère toujours Bunny CDN comme un très bon produit. J’ai testé Bunny Storage comme origin d’exécution pour mon propre cache Nginx.

Pourquoi l’architecture initiale semblait logique

La charge comprend de très nombreux objets média immuables : AVIF, JPEG, PNG, MP4, WebM, audio et autres fichiers statiques, depuis de petites images jusqu’à des vidéos bien plus volumineuses. Je ne voulais pas héberger tout le dataset sur le serveur applicatif, mais je voulais garder la maîtrise des URL publiques, du cache, des redirections, des fichiers absents, de Range et des fallbacks dans mon Nginx.

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

Sur un HIT, le système fonctionnait très bien. Nginx servait directement le fichier local et Bunny n’intervenait pas dans la requête. C’est justement ce bon comportement qui a longtemps masqué le point important : le MISS froid.

Les fichiers populaires masquaient la vraie latence de l’origin

Une image demandée souvent reste facilement en cache. Un ancien fichier rarement consulté a davantage de chances d’avoir été évincé. Les anciennes pages sont donc devenues, involontairement, un benchmark de l’origin.

Un snapshot de juillet montrait environ 36 GB de cache média, plus de 355 000 fichiers de cache, une limite configurée autour de 35 GB et un filesystem racine rempli à environ 93%. D’autres snapshots voisins approchaient 400 000 entrées. Même un cache aussi grand reste fini.

Leçon principale : un HIT rapide ne prouve pas que l’origin est rapide. Il prouve seulement que le cache local l’est.

Les logs montraient des échecs avant même le transfert du corps du fichier

upstream timed out
while connecting to upstream

et :

upstream timed out ... while SSL handshaking to upstream

Ce n’est pas la même chose qu’un gros fichier qui se télécharge lentement. Ici, Nginx essayait encore d’établir la connexion upstream ou de terminer TLS.

Dans un snapshot de diagnostic du 30 juillet, les 5 000 dernières lignes du media error log contenaient 317 correspondances d’upstream timeout. La veille, il y en avait 578. Ce sont des lignes de log, pas 317 ou 578 utilisateurs uniques, mais ce n’était clairement pas un incident isolé.

Les mauvais cas n’étaient pas liés à une seule IP

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

J’ai observé des erreurs sur différentes adresses de cet ensemble. Du point de vue de mon serveur, le problème concernait donc le chemin externe vers l’origin plutôt qu’une seule IP constamment défaillante.

Mes logs ne permettent pas de prouver si la cause exacte se trouvait dans un backend de stockage, le routage, le peering, le chemin de mon hébergeur vers Bunny, le load balancing ou ailleurs. Ils permettent cependant d’affirmer que certains MISS froids n’arrivaient pas à établir la connexion Bunny Storage dans le délai attendu.

Une mesure a rendu le long tail évident

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

Une route mesurée était très différente :

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Cela ne signifie pas que Bunny Storage prenait toujours une seconde. La plupart des chemins étaient dans les dizaines de millisecondes. Ce qui comptait était la variance : le même hostname pouvait mener à un chemin de connexion dépassant une seconde avant même le transfert utile du fichier.

Pour une page média, la tail latency compte plus que la moyenne

Si 79 images arrivent en 30–50 ms et qu’une seule prend une seconde, la moyenne reste correcte. L’utilisateur ne voit pas la moyenne ; il voit une case vide. Pour les galeries, p95, p99 et le MISS froid le plus lent sont donc devenus plus importants à mes yeux qu’un joli temps moyen.

La vidéo a révélé un deuxième problème complètement différent

Un navigateur peut demander des byte ranges :

Range: bytes=0-1048575

puis après un seek :

Range: bytes=50000000-51048575

Une réponse 206 Partial Content permet de commencer la lecture ou de se déplacer sans télécharger tous les octets précédents.

Mon premier cache Range fragmentait une vidéo physique en de nombreuses clés

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;

Les ranges du navigateur sont arbitraires. bytes=0-1048575, bytes=0-999999 et un autre offset après un seek créent des clés différentes pour le même MP4. Je n’ai pas mesuré combien de GB cette fragmentation a réellement consommés, donc je n’invente pas de chiffre.

Je suis ensuite passé à des slices fixes de 1 MB

Le module Slice de Nginx peut normaliser les gros fichiers :

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;

C’était beaucoup plus intéressant pour la réutilisation du cache. Puis production a commencé à afficher :

etag mismatch in slice response while reading response header from upstream

Les slices MP4 échouaient sur la cohérence des ETag

Le 30 juillet, les subrequests de slices d’un MP4 ont produit à répétition etag mismatch in slice response tandis que les requêtes passaient par 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55, entre autres. Le 31 juillet, le même type d’erreur est réapparu sur un autre MP4.

J’avais d’abord parlé, de façon imprécise, de « signatures différentes ». Techniquement, il s’agissait de ETag, pas du AccessKey, d’une URL signée ou d’une signature cryptographique.

Pourquoi Nginx refuse d’assembler des slices dont les ETag diffèrent

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

est cohérent. Mais :

slice 1 → ETag A
slice 2 → ETag B

ne permet plus de garantir que les deux morceaux appartiennent à la même représentation. Les concaténer aveuglément pourrait mélanger deux versions. L’article Nginx sur le byte-range caching décrit ce contrôle.

Mes logs prouvent que Nginx a vu des ETag incompatibles entre des slices du même MP4. Ils ne prouvent pas la cause interne exacte, car je n’enregistrais pas la valeur littérale de chaque ETag pour chaque subrequest. Le changement d’adresse upstream rend une incohérence entre réponses backend plausible, mais cela reste une inférence.

Cela ne signifie pas que Bunny Storage ne supporte pas Range

Range fonctionnait et je cachais explicitement 206 Partial Content. Le problème précis était la combinaison Nginx Slice + plusieurs réponses de storage + la cohérence ETag requise pour assembler une représentation sûre.

Parallèlement, des vidéos pouvaient également subir un simple timeout de connexion à l’origin. J’avais donc deux modes de panne différents : connexion/latence de l’origin et cohérence Slice/ETag.

J’ai finalement supprimé Slice pour les 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;

Les réponses partielles n’étaient plus enregistrées comme fichier complet. Cela supprimait le problème d’assemblage, mais rendait à nouveau un Range froid plus dépendant de l’origin externe.

Je construisais en réalité une partie d’un CDN devant Storage

Mon Nginx avait fini par gérer cache lock, stale, background update, Range, 206, Slice, chunks normalisés, keepalive, réutilisation TLS, retries, timeouts et clés personnalisées. Chaque élément était justifié. Mais le besoin réel de l’origin était simple : stocker un fichier immutable et renvoyer ses octets.

Le nouvel origin est volontairement simple

J’ai déplacé les médias vers un serveur dédié dont la tâche est essentiellement Nginx + SSD. Pour un origin purement statique, une petite machine d’environ 1 vCPU, autour de 1 GB de RAM et quelques centaines de GB de SSD peut être un point de départ raisonnable si le réseau et le disque suivent.

Ce n’est pas une règle universelle : bandwidth, taille des objets, concurrence, IOPS et MISS rate comptent. Le serveur utilisé pour mes mesures actuelles dispose de davantage de ressources ; je ne présente donc pas ces pics comme un benchmark exact d’une machine 1-vCPU/1-GB.

La nouvelle architecture

Browser
   ↓
serveur de travail
   ↓
Nginx + cache média local
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
      media origin
          ↓
        Nginx
          ↓
         SSD

L’origin est privé. Le serveur de travail parle à une IP privée fixe. HTTPS public reste terminé sur le serveur de travail ; le trafic origin peut utiliser HTTP à l’intérieur du tunnel WireGuard déjà chiffré.

Ce qui a disparu du cold path

serveur de travail
↓
WireGuard
↓
IP privée fixe
↓
Nginx
↓
SSD

Pour cette relation directe entre deux serveurs, je n’ai plus besoin du DNS public, d’un choix entre plusieurs adresses storage, d’un chemin Internet public ni d’un handshake TLS supplémentaire avec l’API Storage.

Le MISS froid est devenu assez peu coûteux pour ressembler visuellement à un HIT

De petits tests Range sur de vrais fichiers donnaient généralement :

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

Les tests 10/10, 20/20 séquentiels et 20/20 parallèles ont réussi. Sur l’origin lui-même, la même petite requête prenait environ 0.5–0.9 ms.

Ce sont des tests Range/TTFB courts, pas le temps de téléchargement d’une vidéo complète. Le transfert total dépend toujours de la taille et du throughput.

Le vieux mauvais cas et le nouveau cold path sont très éloignés

Les anciennes mesures normales se situaient souvent autour de 29–44 ms. Le mauvais chemin observé était d’environ 1.054 s. Le nouveau petit MISS froid tourne autour de 20 ms.

Comparer précisément ce outlier de 1.054 s à 20 ms donne environ 52× de différence de latence. Ce n’est pas « mon serveur est 52× plus rapide que Bunny Storage » : ce sont deux chemins réellement mesurés.

MP4 est également plus simple maintenant

Lors d’un cold fill, Nginx peut récupérer le MP4 complet depuis mon origin privé, conserver une seule entrée de cache, puis répondre localement aux byte ranges du navigateur. Range peut être supprimé vers l’upstream pendant le fill et proxy_force_ranges permet ensuite des réponses partielles locales.

cold MISS
↓
MP4 complet depuis l’origin privé
↓
un fichier en cache
↓
Range client servi localement en 206

Le cache du fichier complet a lui aussi un coût

Si un MP4 froid pèse 500 MB et que le premier utilisateur ne veut voir que dix secondes, le fill peut tout de même transférer 500 MB entre les serveurs. Pour mes tailles et mes habitudes d’accès, le compromis est acceptable. Pour une bibliothèque de vidéos multi-GB essentiellement froides, j’envisagerais un slice cache stable, HLS/DASH, un CDN vidéo ou Bunny Stream.

Les slices fixes restent pertinents pour d’autres gros médias

Nginx Slice n’est pas mauvais en soi. Avec un objet immutable et un origin stable, il reste utile. La différence est que tous les morceaux viennent maintenant d’un origin et d’une représentation de fichier que je contrôle.

Un cache plus petit peut sembler plus rapide si le MISS est bon marché

L’ancien cache atteignait environ 35–36 GB. Le nouveau s’est stabilisé autour de 16 GB parce que je garde volontairement près de 8 GB libres avec min_free.

avant :
HIT  = rapide
MISS = parfois pénible

maintenant :
HIT  = rapide
MISS = suffisamment rapide aussi

Un grand cache réduit le nombre de MISS. Un bon origin réduit le coût de chaque MISS. Pour ce workload, le second point s’est révélé plus important que prévu.

Le trafic réel de production a confirmé le rôle de l’origin

Sur un snapshot de 60 secondes, le serveur envoyait environ 96.84 Mbit/s aux utilisateurs et recevait environ 7.07 Mbit/s du media origin. Aucun nouveau 502, 503, 504 ni upstream timeout n’est apparu pendant cette fenêtre.

Sur un snapshot ultérieur de 30 secondes, le public TX était de 61.36 Mbit/s et le WireGuard RX depuis l’origin de 1.40 Mbit/s. Le rapport octets-origin/public-TX était de 2.28%.

Je n’en déduis pas un taux HIT exact : le trafic public ne contient pas que les médias et un ratio d’octets n’est pas un ratio de requêtes. Mais cela montre que l’origin sert principalement les cold fills après le warm-up.

Ce que je surveillerais aujourd’hui

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

Je séparerais HIT et MISS, avec une attention particulière aux p95/p99 des MISS.

Pour la vidéo, je testerais un Range au début, au milieu, à la fin, plusieurs ranges non séquentiels pour le seek, cold contre warm, et je vérifierais 206, Content-Range, Content-Length, Accept-Ranges et ETag. Avec Slice, je vérifierais explicitement la cohérence du validator sur plusieurs slices.

Une URL froide et 20 URL froides différentes ne constituent pas le même test

proxy_cache_lock aide lorsque de nombreuses requêtes visent la même clé froide. Il ne fusionne pas vingt fichiers différents en une requête origin. Une page contenant cinquante images froides distinctes peut donc être un meilleur test qu’un benchmark répétant toujours la même URL.

Le self-hosting apporte du contrôle, pas de la fiabilité gratuite

Un serveur média privé rend le chemin critique plus simple à comprendre, mais me rend responsable des sauvegardes, de l’état du disque, de l’espace libre, des mises à jour, du firewall, du monitoring, de la restauration et éventuellement de la redondance.

Un origin unique est aussi un point de panne unique pour les objets non cachés. La haute disponibilité demande davantage d’ingénierie.

Quand je choisirais encore Bunny

Pour une diffusion mondiale, de la redondance managée, un stockage qui grandit vite ou un minimum d’administration, je considérerais de nouveau Bunny sans hésiter. J’évaluerais alors explicitement Storage + Pull Zone/CDN.

Mon test ne prouve pas qu’un VPS est meilleur que Bunny CDN, que Bunny Storage est toujours lent ou que Bunny ne sait pas servir Range. Il montre que, pour mon workload, Bunny Storage utilisé directement comme runtime origin de mon cache Nginx produisait un cold path et un comportement de vidéo sliced que je ne voulais plus gérer.

La règle que j’utilise maintenant

Pendant ces deux mois, j’ai optimisé taille de cache, cache keys, Range, 206, Slice, chunks de 1 MB, keepalive, réutilisation TLS, retries, stale et timeouts. L’amélioration la plus forte était bien plus simple : changer l’origin.

Que se passe-t-il lorsque le fichier demandé par l’utilisateur n’est pas dans le cache ?

Si la réponse est un origin stable, un TTFB prévisible, un Range correct, des validators cohérents et des modes de panne compréhensibles, le cache est une optimisation. Si une bonne UX dépend du fait que l’utilisateur ne rencontre jamais de MISS, le cache masque un problème d’architecture plus profond.

Pour mon workload, un petit media origin privé et ennuyeux était plus adapté. Ce n’est pas un CDN, ce n’est pas magique et cela ne supprime pas les responsabilités opérationnelles. Cela rend simplement le cold path ennuyeux. Après deux mois à diagnostiquer images, timeouts de connexion, Range vidéo et etag mismatch in slice response, c’était exactement ce que je voulais.