Zurück zum Blog
28. August 2026Sergei Solod12 Min. Lesezeit

Zwei Monate Bunny Storage hinter Nginx: Warum ich Bilder, Video und Audio auf einen eigenen Media-Server verschoben habe

Zwei Monate lang nutzte ich Bunny Storage nicht über Bunny CDN, sondern als privaten Origin hinter meinem eigenen Nginx-Cache. Produktionslogs zeigten Long-Tail-Verbindungsprobleme bei kalten Mediendateien sowie ein separates MP4-Problem mit Nginx Slice und inkonsistenten ETags; am Ende wechselte ich zu einem einfachen privaten Media-Origin.

Bunny StorageNginxVideo-CachingCache-MissMedia-Server

Fast zwei Monate lang sah mein Pfad für die Medienauslieferung so aus:

Browser
  ↓
Arbeitsserver
  ↓
Nginx
  ↓
lokaler proxy_cache
  ↓ cache MISS
Bunny Storage

Bilder, Videos und Audiodateien lagen in Bunny Storage. Die Nutzer griffen nicht direkt auf Bunny zu. Mein Nginx holte Objekte von [https://storage.bunnycdn.com](https://storage.bunnycdn.com), setzte den serverseitigen AccessKey, speicherte die Antwort auf einer lokalen SSD und lieferte die öffentliche URL selbst aus.

Die Abgrenzung ist wichtig: Das ist kein Test von Bunny CDN. Für dieses Experiment habe ich Bunny CDN nicht verwendet. Storage Zone plus Pull Zone/CDN ist eine andere Architektur, und Bunny CDN halte ich weiterhin für ein starkes Produkt. Getestet habe ich konkret Bunny Storage als Runtime-Origin für meinen eigenen Nginx-Cache.

Warum die ursprüngliche Architektur sinnvoll aussah

Der Workload besteht aus sehr vielen unveränderlichen Mediendateien: AVIF, JPEG, PNG, MP4, WebM, Audio und weiteren statischen Dateien, von kleinen Bildern bis zu deutlich größeren Videos. Ich wollte nicht den kompletten Datenbestand auf dem Application-Server halten, aber öffentliche URLs, Cache-Regeln, Redirects, Verhalten bei fehlenden Dateien, Range und Fallbacks über meinen eigenen Nginx kontrollieren.

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

Bei einem HIT funktionierte das hervorragend. Nginx lieferte direkt von der lokalen Platte; Bunny war an diesem Request überhaupt nicht beteiligt. Genau das verdeckte lange Zeit den entscheidenden Teil der Architektur: den kalten MISS.

Populäre Dateien versteckten die echte Origin-Latenz

Ein häufig angefordertes Bild bleibt mit hoher Wahrscheinlichkeit im Cache. Eine alte, selten geöffnete Datei wird eher verdrängt. Deshalb wurden alte Seiten für mich unbeabsichtigt zu einem Origin-Benchmark.

Ein Snapshot im Juli zeigte ungefähr 36 GB Media-Cache, mehr als 355.000 Cache-Dateien, eine konfigurierte Obergrenze von rund 35 GB und ein Root-Dateisystem mit etwa 93% Belegung. In benachbarten Snapshots näherte sich die Zahl der Cache-Einträge 400.000. Auch ein so großer Cache ist endlich.

Die entscheidende Erkenntnis: Ein schneller HIT beweist keinen schnellen Origin. Er beweist nur, dass der lokale Cache schnell ist.

Die Produktionslogs zeigten Fehler noch vor der eigentlichen Dateiübertragung

In den Nginx-Error-Logs tauchten wiederholt Meldungen auf wie:

upstream timed out
while connecting to upstream

und:

upstream timed out ... while SSL handshaking to upstream

Das ist etwas anderes als eine große Datei, die lange übertragen wird. In diesen Fällen versuchte Nginx noch, die Upstream-Verbindung bzw. den TLS-Handshake aufzubauen.

In einem Diagnose-Snapshot vom 30. Juli enthielten die letzten 5.000 Zeilen des Media-Error-Logs 317 Treffer für Upstream-Timeouts. Im Snapshot vom Vortag waren es 578. Das sind Log-Treffer, keine eindeutigen Nutzer, aber klar mehr als ein einzelner Ausreißer.

Die schlechten Fälle waren nicht auf eine Storage-IP beschränkt

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Fehler traten bei verschiedenen Adressen aus diesem Set auf. Aus Sicht meines Servers war damit der externe Origin-Pfad als Ganzes das Problem, nicht eine dauerhaft schlechte einzelne IP.

Aus meinen Logs kann ich nicht beweisen, ob die Ursache im Storage-Backend, im Routing, Peering, beim Pfad meines Providers zu Bunny, in der Lastverteilung oder anderswo lag. Belegen kann ich lediglich, dass manche kalten MISS-Requests die Bunny-Storage-Upstream-Verbindung nicht rechtzeitig aufbauen konnten.

Eine Messung machte den Long Tail deutlich

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

Ein gemessener Pfad sah dagegen so aus:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Das bedeutet nicht, dass Bunny Storage grundsätzlich eine Sekunde langsam war. Die meisten Messungen lagen im Bereich weniger Dutzend Millisekunden. Entscheidend war die Streuung: Derselbe Origin-Hostname konnte zu einem Verbindungspfad führen, der schon vor der eigentlichen Nutzdatenübertragung mehr als eine Sekunde kostete.

Bei Medienseiten zählt die Tail-Latenz stärker als der Mittelwert

Wenn 79 Bilder in 30–50 ms laden und eines eine Sekunde benötigt, kann der Mittelwert immer noch ordentlich aussehen. Der Nutzer sieht keinen Mittelwert. Er sieht ein leeres Bild, auf das er wartet.

Für Galerien und Seiten mit vielen unabhängigen Objekten sind deshalb p95, p99 und der langsamste kalte MISS für mich inzwischen wichtiger als ein schöner Durchschnittswert.

Video brachte ein zweites, völlig anderes Problem ans Licht

Browser können Videos per HTTP-Byte-Range laden:

Range: bytes=0-1048575

und nach einem Sprung etwa:

Range: bytes=50000000-51048575

Mit 206 Partial Content lassen sich Wiedergabe und Seeking ermöglichen, ohne alle davorliegenden Bytes herunterzuladen.

Mein erster Range-Cache fragmentierte ein physisches Video in viele 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 sind beliebig. bytes=0-1048575, bytes=0-999999 und ein Offset nach einem Seek ergeben unterschiedliche Cache-Keys für dieselbe MP4-Datei. Ich habe nicht gemessen, wie viele Gigabyte diese Fragmentierung konkret kostete, daher nenne ich dafür keine erfundene Zahl.

Dann wechselte ich auf feste 1-MB-Slices

Das Nginx Slice-Modul normalisiert große Ressourcen in feste Bereiche:

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;

Für wiederverwendbare Cache-Einträge war das wesentlich attraktiver. Dann erschien in Produktion ein sehr spezifischer Fehler:

etag mismatch in slice response while reading response header from upstream

Die MP4-Slices scheiterten an der ETag-Konsistenz

Am 30. Juli erzeugten wiederholte Slice-Subrequests einer MP4-Datei etag mismatch in slice response, während Requests unter anderem 109.61.89.53, 109.61.89.57, 79.127.226.193 und 109.61.89.55 erreichten. Am 31. Juli trat derselbe Fehlertyp bei einer weiteren MP4-Datei auf.

Ich hatte das zunächst salopp als unterschiedliche „Signaturen“ der Stücke bezeichnet. Technisch ging es um ETag, nicht um den Storage-AccessKey, eine signierte URL oder eine kryptografische Signatur.

Warum Nginx Slices mit unterschiedlichen ETags nicht zusammensetzt

Slice 1 → Bytes 0–1 MB → ETag A
Slice 2 → Bytes 1–2 MB → ETag A

ist konsistent. Bei:

Slice 1 → ETag A
Slice 2 → ETag B

kann Nginx nicht mehr sicher annehmen, dass beide Teile zur gleichen Repräsentation gehören. Ein blindes Zusammenfügen könnte Bytes aus unterschiedlichen Dateiversionen mischen. Der Nginx-Beitrag zum Byte-Range-Caching beschreibt diese Validator-Prüfung.

Meine Logs belegen inkompatible ETags zwischen Slice-Antworten derselben MP4. Sie beweisen nicht die genaue interne Ursache, da ich die konkreten ETag-Werte jedes einzelnen Subrequests damals nicht separat protokolliert habe. Unterschiedliche Upstream-Adressen machen inkonsistente Backend-Antworten plausibel, bleiben aber eine Schlussfolgerung.

Das bedeutet nicht, dass Bunny Storage keine Range-Requests unterstützt

Range funktionierte, und ich cachierte ausdrücklich 206 Partial Content. Das engere Problem war die Kombination aus Nginx Slice, mehreren Storage-Antworten und der ETag-Konsistenz, die Nginx zum sicheren Zusammensetzen benötigte.

Parallel dazu konnten Videos auch in normale Origin-Timeouts laufen. Es gab also zwei getrennte Fehlertypen: Verbindungs-/Origin-Latenz und Slice-/ETag-Konsistenz.

Für MP4 entfernte ich Slice schließlich wieder

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;

Teilantworten wurden bewusst nicht als vollständiges Objekt gespeichert. Damit verschwand die problematische Slice-Zusammensetzung, aber ein kalter Range-Request hing wieder stärker direkt vom externen Origin ab.

Ich merkte, dass ich vor Storage einen Teil eines CDN nachbaute

Mein Nginx enthielt inzwischen Cache-Locking, Stale-Antworten, Background-Updates, Range, 206, Slice, feste Chunks, Keepalive, TLS-Session-Reuse, Retries, Timeout-Tuning und individuelle Cache-Keys.

Jedes Werkzeug war für sich sinnvoll. Zusammen führten sie zu einer einfacheren Frage: Was brauche ich vom Origin tatsächlich? Unveränderliche Dateien speichern und ihre Bytes ausliefern.

Der Ersatz-Origin ist absichtlich langweilig

Ich verschob den Medienbestand auf einen dedizierten Server, dessen Aufgabe im Wesentlichen aus Nginx und SSD besteht. Für einen reinen Static-Origin kann man als Startpunkt durchaus eine kleine Maschine mit ungefähr 1 vCPU, etwa 1 GB RAM und einigen hundert GB SSD betrachten, sofern Netzwerk und Platte passen.

Das ist keine universelle Sizing-Regel. Bandbreite, Objektgrößen, Parallelität, IOPS und MISS-Rate entscheiden. Mein aktuell gemessener Origin besitzt mehr Ressourcen; deshalb behaupte ich nicht, dass meine aktuellen Spitzenwerte ein Benchmark exakt für 1 vCPU und 1 GB sind.

Die neue Architektur

Browser
   ↓
Arbeitsserver
   ↓
Nginx + lokaler Media-Cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     Media-Origin
          ↓
        Nginx
          ↓
         SSD

Der Origin ist privat und über eine feste private IP erreichbar. Nutzer-HTTPS endet weiterhin am Arbeitsserver. Zwischen den Servern kann HTTP innerhalb des bereits verschlüsselten WireGuard-Tunnels verwendet werden.

Was aus dem kalten Pfad verschwunden ist

Arbeitsserver
↓
WireGuard
↓
feste private IP
↓
Nginx
↓
SSD

Öffentliches DNS, ein öffentlicher Routing-Pfad, die Auswahl aus mehreren Storage-Adressen und ein zusätzlicher TLS-Handshake zum Storage-API sind aus dieser konkreten Server-zu-Server-Verbindung verschwunden.

Cold MISS wurde so günstig, dass HIT und MISS optisch fast gleich wirken

Kleine Range-Tests mit echten Dateien vom Arbeitsserver zum Origin lagen typischerweise bei:

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

10/10, 20/20 sequenziell und 20/20 parallel liefen erfolgreich. Lokal auf dem Origin benötigte derselbe kleine Request ungefähr 0,5–0,9 ms.

Das sind kleine Range-/TTFB-Tests und keine Behauptung, ein vollständiges Video lade in 20 ms. Für die Gesamtübertragung bleiben Dateigröße und verfügbare Bandbreite entscheidend.

Der schlechte alte Fall und der neue Cold Path unterscheiden sich deutlich

Normale alte Messungen lagen häufig bei 29–44 ms. Der beobachtete schlechte Pfad lag bei etwa 1,054 s. Der neue kleine Cold Request liegt ungefähr bei 20 ms.

Der konkrete 1,054-s-Ausreißer ist mathematisch ungefähr 52-mal so hoch wie 20 ms. Das ist kein Produkt-Benchmark „mein Server ist 52× schneller als Bunny Storage“, sondern der Vergleich zweier tatsächlich beobachteter Pfade.

Auch MP4 ist jetzt einfacher

Beim kalten Fill kann Nginx die komplette MP4 vom privaten Origin holen, als eine Cache-Datei speichern und anschließend beliebige Byte-Ranges lokal ausliefern. Während des Fills kann Upstream-Range entfernt werden; proxy_force_ranges erlaubt lokale Range-Antworten.

Cold MISS
↓
komplette MP4 vom privaten Origin
↓
eine Cache-Datei
↓
Client-Ranges lokal als 206

Full-Object-Caching kostet ebenfalls etwas

Ist eine kalte MP4 500 MB groß und der erste Nutzer möchte nur zehn Sekunden sehen, kann der Cache-Fill trotzdem die gesamten 500 MB zwischen den Servern übertragen. Für meine Größen und Zugriffsmuster ist dieser Trade-off akzeptabel. Bei überwiegend kalten Multi-GB-Videos würde ich einen stabilen Slice-Cache, HLS/DASH, ein Video-CDN oder Bunny Stream prüfen.

Feste Slices bleiben für andere große Medien sinnvoll

Nginx Slice ist nicht grundsätzlich schlecht. Mit unveränderlichen Dateien und stabilem Origin bleibt es nützlich. Jetzt kommen jedoch alle Stücke aus einem kontrollierten Origin und derselben kontrollierten Dateirepräsentation.

Ein kleinerer Cache kann schneller wirken, wenn MISS günstig ist

Der alte Cache lag bei ungefähr 35–36 GB. Der neue pendelte sich bei etwa 16 GB ein, weil ich mit min_free bewusst ungefähr 8 GB frei halte.

alt:
HIT  = schnell
MISS = manchmal schmerzhaft

neu:
HIT  = schnell
MISS = ebenfalls schnell genug

Ein großer Cache reduziert die Zahl der MISSes. Ein guter Origin reduziert die Kosten jedes einzelnen MISS. Für diesen Workload war die zweite Eigenschaft wichtiger als erwartet.

Echter Produktionsverkehr bestätigte die Rolle des Origins

In einem 60-Sekunden-Snapshot lieferte der Arbeitsserver etwa 96.84 Mbit/s an Nutzer aus und erhielt etwa 7.07 Mbit/s vom Media-Origin. In diesem Messfenster erschienen keine neuen 502, 503, 504 oder Upstream-Timeouts.

In einem späteren 30-Sekunden-Snapshot betrug Public TX 61.36 Mbit/s, während über WireGuard nur 1.40 Mbit/s vom Origin eintrafen. Das Byte-Verhältnis Origin/Public-TX lag in diesem Fenster bei 2,28%.

Den Kehrwert nenne ich bewusst nicht exakte Cache-Hit-Rate: Public Traffic enthält mehr als Media, und Byte-Verhältnisse sind keine Request-Verhältnisse. Die Zahlen zeigen aber klar, dass der Origin nach dem Warm-up hauptsächlich Cold Fills bediente.

Was ich heute überwachen würde

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

HIT und MISS würde ich getrennt auswerten, insbesondere p95 und p99 der MISS-Latenz.

Bei Video würde ich Anfang, Mitte und Ende per Range prüfen, mehrere nicht sequenzielle Ranges für Seeking testen, Cold und Warm vergleichen und 206, Content-Range, Content-Length, Accept-Ranges sowie ETag kontrollieren. Bei Slice würde ich explizit die Validator-Konsistenz über mehrere Slices prüfen.

Eine kalte URL und 20 verschiedene kalte URLs sind nicht derselbe Test

proxy_cache_lock hilft bei vielen Requests auf denselben kalten Cache-Key. Es macht aus zwanzig unterschiedlichen Dateien aber keinen einzelnen Origin-Request. Eine Seite mit fünfzig verschiedenen kalten Bildern kann deshalb ein deutlich realistischeres Origin-Szenario sein als ein Benchmark, der dieselbe URL wiederholt.

Self-Hosting gibt Kontrolle, aber keine kostenlose Zuverlässigkeit

Der private Media-Server macht den kritischen Pfad für mich verständlicher, verschiebt aber Backups, Plattenzustand, freien Speicher, Updates, Firewall, Monitoring, Restore und gegebenenfalls Redundanz zu mir.

Ein einzelner Origin ist für nicht gecachte Objekte außerdem ein Single Point of Failure. High Availability erfordert zusätzliche Architektur.

Wann ich Bunny weiterhin wählen würde

Für globale Auslieferung an Nutzer, Managed Redundancy, schnell wachsenden Storage oder möglichst wenig eigene Administration würde ich Bunny jederzeit wieder prüfen. Dann würde ich ausdrücklich auch die von Bunny dokumentierte Storage-plus-Pull-Zone/CDN-Architektur bewerten.

Mein Experiment zeigt nicht, dass ein einzelner VPS besser ist als Bunny CDN, dass Bunny Storage immer langsam ist oder dass Bunny keine Ranges kann. Es zeigt, dass Bunny Storage als direkter Runtime-Origin meines eigenen Nginx-Caches bei meinem Workload Cold-Path- und Slice-Verhalten erzeugte, das ich nicht länger verwalten wollte.

Die Regel, die ich heute verwende

In zwei Monaten optimierte ich Cache-Größe, Cache-Keys, Range, 206, Slice, 1-MB-Chunks, Keepalive, TLS-Reuse, Retries, Stale und Timeouts. Das stärkste Improvement war wesentlich einfacher: Ich wechselte den Origin.

Was passiert, wenn die vom Nutzer gewünschte Datei gerade nicht im Cache liegt?

Wenn die Antwort ein stabiler Origin, vorhersehbarer TTFB, korrektes Range-Verhalten, konsistente Validatoren und nachvollziehbare Fehlermodi sind, ist Cache eine Optimierung. Wenn gute UX davon abhängt, dass nie ein MISS auftritt, versteckt der Cache ein tieferes Architekturproblem.

Für meinen Workload war ein kleiner, privater und langweiliger Media-Origin die bessere Lösung. Er ist kein CDN, keine Magie und nimmt mir den Betrieb nicht ab. Er macht nur den Cold Path langweilig. Nach zwei Monaten Debugging von Bildern, Verbindungs-Timeouts, Video-Ranges und etag mismatch in slice response war genau das mein Ziel.