Bloga dön
28 Ağustos 2026Sergei Solod10 dk okuma

Nginx Arkasında Bunny Storage ile İki Ay: Görsel, Video ve Sesi Neden Ayrı Bir Medya Sunucusuna Taşıdım

İki ay boyunca Bunny CDN kullanmadan Bunny Storage'ı kendi Nginx önbelleğimin arkasında özel origin olarak kullandım. Production logları cold media isteklerinde long-tail connection timeout'ları ve MP4 tarafında Nginx Slice ile tutarsız ETag'lerden kaynaklanan ayrı bir sorunu gösterdi; sonunda çok daha basit bir private media origin'e geçtim.

Bunny StorageNginxVideo önbelleğiCache missMedya sunucusu

Yaklaşık iki ay boyunca medya dağıtım yolum şöyleydi:

Browser
  ↓
çalışan sunucu
  ↓
Nginx
  ↓
yerel proxy_cache
  ↓ cache MISS
Bunny Storage

Görseller, videolar ve ses dosyaları Bunny Storage'da bulunuyordu. Kullanıcı Bunny'ye doğrudan bağlanmıyordu. Nginx, [https://storage.bunnycdn.com](https://storage.bunnycdn.com) üzerinden object istiyor, sunucu tarafındaki AccessKey'i ekliyor, cevabı yerel SSD'ye cache'liyor ve public URL'yi kendisi sunuyordu.

Kapsamı baştan netleştirmek önemli: bu Bunny CDN testi değil. Bu deneyde Bunny CDN kullanmadım. Storage Zone + Pull Zone/CDN farklı bir dağıtım mimarisidir ve Bunny CDN'i hâlâ güçlü bir ürün olarak görüyorum. Test ettiğim şey Bunny Storage'ın kendi Nginx cache'im için runtime origin olarak kullanılmasıydı.

İlk mimari neden mantıklı görünüyordu

Workload çok sayıda immutable media object içeriyor: AVIF, JPEG, PNG, MP4, WebM, audio ve başka static dosyalar; küçük görsellerden çok daha büyük videolara kadar. Dataset'in tamamını application server'da tutmak istemiyordum ama public URL, cache policy, redirect, missing-file davranışı, Range ve fallback'leri kendi Nginx'imle kontrol etmek istiyordum.

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

HIT olduğunda sistem çok iyi çalışıyordu. Nginx dosyayı yerel diskten veriyor, Bunny o request'e hiç dahil olmuyordu. Bu başarı cold MISS davranışını uzun süre gizledi.

Popüler dosyalar gerçek origin latency'yi gizliyordu

Sık istenen bir görsel cache'de kalmaya yatkındır. Eski ve nadiren açılan bir dosyanın evict edilme ihtimali daha yüksektir. Bu nedenle eski sayfalar istemeden origin benchmark'ına dönüştü.

Temmuz ayındaki bir snapshot yaklaşık 36 GB media cache, 355.000'den fazla cache dosyası, yaklaşık 35 GB limit ve %93 dolu root filesystem gösteriyordu. Yakın snapshot'larda entry sayısı 400.000'e yaklaştı. Bu kadar büyük cache bile sonludur.

Ana ders: hızlı HIT, hızlı origin'i kanıtlamaz. Sadece yerel cache'in hızlı olduğunu kanıtlar.

Production logları dosya gövdesi aktarılmadan önce hatalar gösteriyordu

upstream timed out
while connecting to upstream

ve:

upstream timed out ... while SSL handshaking to upstream

Bu, büyük bir dosyanın yavaş aktarılması değildir. Nginx hâlâ upstream connection kurmaya veya TLS handshake'i tamamlamaya çalışıyordu.

30 Temmuz tarihli diagnostic snapshot'ta media error log'un son 5.000 satırında 317 upstream timeout eşleşmesi vardı. Önceki gün 578 eşleşme vardı. Bunlar unique kullanıcı sayısı değil, log satırlarıdır; ancak sorun tek bir request değildi.

Kötü vakalar tek bir storage IP'ye bağlı değildi

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

Bu kümedeki farklı adreslerde failure gördüm. Dolayısıyla sunucum açısından problem tek bir sürekli kötü IP değil, external origin path davranışıydı.

Loglar temel sebebin storage backend, routing, peering, provider'ımdan Bunny'ye giden yol, balancing veya başka bir ağ faktörü olduğunu kanıtlamıyor. Kanıtlayabildiğim şey bazı cold MISS'lerin beklenen sürede Bunny Storage upstream connection kuramadığıdır.

Bir ölçüm long tail'i çok net gösterdi

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

Bir ölçülen yol ise tamamen farklıydı:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

Bu Bunny Storage'ın her zaman bir saniye yavaş olduğu anlamına gelmiyor. Çoğu ölçüm onlarca ms idi. Önemli olan variance: aynı origin hostname, faydalı object transferi başlamadan bir saniyeden fazla connection latency üreten bir path'e denk gelebiliyordu.

Medya sayfalarında tail latency ortalamadan daha önemlidir

79 görsel 30–50 ms'de, bir görsel bir saniyede yüklenirse ortalama hâlâ iyi görünebilir. Kullanıcı ortalamayı değil, boş kalan görseli görür. Bu nedenle p95, p99 ve en yavaş cold MISS benim için daha önemli hâle geldi.

Video tamamen farklı ikinci bir sorun ortaya çıkardı

Browser byte Range isteyebilir:

Range: bytes=0-1048575

seek sonrasında:

Range: bytes=50000000-51048575

206 Partial Content, önceki tüm byte'ları indirmeden oynatma ve seek sağlar.

İlk Range cache'im tek fiziksel videoyu çok sayıda cache key'e bölüyordu

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 Range değerleri keyfidir. bytes=0-1048575, bytes=0-999999 ve seek sonrası başka bir offset aynı MP4 için farklı cache key oluşturabilir. Bu fragmentasyonun kaç GB tuttuğunu ölçmedim, bu yüzden sayı uydurmuyorum.

Sonra sabit 1 MB slice'lara geçtim

Nginx Slice module büyük bir resource'u sabit chunk'lara normalize edebilir:

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;

Cache reuse açısından çok daha iyiydi. Ardından production'da şu error çıktı:

etag mismatch in slice response while reading response header from upstream

MP4 slice'ları ETag consistency nedeniyle başarısız oluyordu

30 Temmuz'da bir MP4'ün tekrar eden slice subrequest'leri etag mismatch in slice response üretti; request'ler 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 gibi adreslere gidiyordu. 31 Temmuz'da aynı hata sınıfı başka bir MP4 için tekrarlandı.

İlk başta bunu parçaların “farklı imzaları” olarak tanımlamıştım. Teknik olarak doğru değer ETag'di; AccessKey, signed URL veya kriptografik imza değildi.

Nginx neden farklı ETag'li slice'ları birleştirmiyor

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

tutarlıdır. Ancak:

slice 1 → ETag A
slice 2 → ETag B

iki parçanın aynı representation'a ait olduğunu güvenle varsaymayı engeller. Körü körüne birleştirmek farklı dosya sürümlerinin byte'larını karıştırabilir. Nginx'in byte-range caching yazısı bu validator kontrolünü açıklar.

Loglarım Nginx'in aynı MP4'ün slice response'ları arasında uyumsuz ETag gördüğünü kanıtlıyor. Neden farklı olduklarının kesin iç sebebini kanıtlamıyor; çünkü her subrequest'in literal ETag değerini kaydetmedim. Upstream adreslerinin değişmesi backend response inconsistency'yi makul kılıyor ama bu bir inference.

Bu Bunny Storage'ın Range desteklemediği anlamına gelmez

Range çalışıyordu ve 206 Partial Content cache'liyordum. Özel problem Nginx Slice, birden fazla storage response ve güvenli assembly için gereken ETag consistency birleşimiydi.

Video ayrıca normal origin timeout yaşayabiliyordu. Yani iki ayrı failure mode vardı: origin connection/latency ve Slice/ETag consistency.

Sonunda MP4 için Slice kullanmayı bıraktım

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 complete object olarak saklanmıyordu. Problemli assembly ortadan kalktı ama cold Range yeniden external origin'e daha bağımlı hâle geldi.

Storage önünde kendi CDN parçamı kurduğumu fark ettim

Nginx'im cache lock, stale, background update, Range, 206, Slice, normalized chunk, keepalive, TLS reuse, retry, timeout tuning ve özel cache key'lerle dolmuştu. Her biri tek başına mantıklıydı, ama origin'den ihtiyacım basitti: immutable file sakla ve byte'ları gönder.

Yeni origin bilinçli olarak basit

Media dataset'i temelde Nginx + SSD işi yapan dedicated bir sunucuya taşıdım. Pure static origin için network ve disk yeterliyse yaklaşık 1 vCPU, 1 GB RAM ve birkaç yüz GB SSD içeren küçük bir makine makul başlangıç noktası olabilir.

Bu evrensel sizing kuralı değil. Bandwidth, object size, concurrency, IOPS ve MISS rate önemlidir. Güncel ölçümlerde kullandığım origin daha fazla resource'a sahip, dolayısıyla güncel peak değerlerini birebir 1-vCPU/1-GB benchmark olarak sunmuyorum.

Yeni mimari

Browser
   ↓
çalışan sunucu
   ↓
Nginx + yerel media cache
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

Origin private. Çalışan sunucu tek bir fixed private IP ile konuşuyor. Public HTTPS çalışan sunucuda terminate olmaya devam ediyor; origin trafiği zaten şifreli WireGuard tünelinin içinde HTTP kullanabiliyor.

Cold path'ten neler çıktı

çalışan sunucu
↓
WireGuard
↓
fixed private IP
↓
Nginx
↓
SSD

Bu direkt server-to-server bağlantı için public DNS, birden fazla storage adresi arasından seçim, public Internet path ve Storage API'ye ek TLS handshake artık yok.

Cold MISS, HIT'e görsel olarak çok yakın olacak kadar ucuzladı

Gerçek dosyalara küçük Range testleri genellikle:

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

sonucunu verdi. 10/10, 20/20 sequential ve 20/20 parallel testleri başarılıydı. Origin'in kendi içinde aynı küçük request yaklaşık 0.5–0.9 ms sürüyordu.

Bunlar küçük Range/TTFB testleri; tam videonun 20 ms'de indiği anlamına gelmez. Full transfer boyut ve throughput'a bağlıdır.

Eski kötü vaka ile yeni cold path arasında büyük fark var

Eski normal ölçümler sıklıkla 29–44 ms idi. Kötü observed path yaklaşık 1.054 s idi. Yeni tiny cold request yaklaşık 20 ms.

O spesifik 1.054 s outlier ile 20 ms arasında matematiksel olarak yaklaşık 52× latency farkı var. Bu “sunucum Bunny Storage'dan 52× hızlı” anlamına gelmiyor; iki somut ölçülmüş path'i karşılaştırıyor.

MP4 artık daha basit

Cold fill sırasında Nginx private origin'den MP4'ün tamamını alabilir, tek cache entry saklayabilir ve sonra browser'ın byte Range isteklerini yerel cache'den verebilir. Fill sırasında upstream Range kaldırılabilir, proxy_force_ranges yerel partial response sağlar.

cold MISS
↓
private origin'den tam MP4
↓
tek cached file
↓
client Range yerelden 206

Full-object caching'in de maliyeti var

Cold MP4 500 MB ise ve ilk kullanıcı yalnızca on saniye izlemek istiyorsa cache fill yine 500 MB'ın tamamını iki sunucu arasında taşıyabilir. Benim file boyutlarım ve access pattern'im için kabul edilebilir. Çoğunlukla cold multi-GB videolarda stabil slice cache, HLS/DASH, video CDN veya Bunny Stream düşünürdüm.

Diğer büyük medya için fixed slice hâlâ mantıklı

Nginx Slice kötü değil. Immutable object ve stabil origin ile faydalıdır. Fark şu ki artık tüm chunk'lar kontrol ettiğim tek bir origin ve filesystem representation'dan geliyor.

MISS ucuzsa daha küçük cache daha hızlı hissedebilir

Eski cache yaklaşık 35–36 GB idi. Yeni cache min_free ile yaklaşık 8 GB boş alan bıraktığım için yaklaşık 16 GB'da dengelendi.

eski:
HIT  = hızlı
MISS = bazen acılı

yeni:
HIT  = hızlı
MISS = yeterince hızlı

Büyük cache MISS sayısını azaltır. İyi origin her MISS'in maliyetini azaltır. Bu workload için ikincisi beklediğimden önemli çıktı.

Gerçek production traffic origin rolünü doğruladı

60 saniyelik snapshot'ta çalışan sunucu kullanıcılara yaklaşık 96.84 Mbit/s gönderiyor, media origin'den yaklaşık 7.07 Mbit/s alıyordu. Bu pencerede yeni 502, 503, 504 veya upstream timeout yoktu.

Daha sonraki 30 saniyelik snapshot'ta public TX 61.36 Mbit/s, origin'den WireGuard RX 1.40 Mbit/s idi. Origin bytes/public TX oranı %2.28'di.

Bunun tersini kesin cache-hit rate olarak adlandırmıyorum; public traffic yalnızca media değil ve byte oranı request oranı değildir. Ama warm-up sonrası origin'in ağırlıklı olarak cold fill yaptığı görülüyor.

Şimdi neyi izlerdim

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

HIT ve MISS'i ayrı, özellikle MISS p95/p99.

Video için first Range, middle Range, suffix Range, seek için birkaç non-sequential Range, cold/warm ve 206, Content-Range, Content-Length, Accept-Ranges, ETag kontrolü. Slice kullanırsam birkaç slice arasında validator consistency'yi doğrudan test ederim.

Bir cold URL ile 20 farklı cold URL aynı test değildir

proxy_cache_lock aynı cold cache key için çok request olduğunda yardımcı olur. 20 ayrı dosyayı tek origin request yapmaz. 50 farklı cold image içeren bir sayfa, aynı URL'yi tekrar tekrar çağıran benchmark'tan daha gerçekçi origin testi olabilir.

Self-hosting kontrol verir, bedava reliability vermez

Private media server critical path'i anlamayı kolaylaştırır ama backup, disk health, free space, update, firewall, monitoring, restore ve redundancy benim sorumluluğuma geçer. Tek origin uncached object'ler için single point of failure olabilir.

Bunny'yi ne zaman yine seçerdim

Global delivery, managed redundancy, hızla büyüyen storage veya minimum sistem yönetimi gerektiğinde Bunny'yi yeniden rahatlıkla değerlendiririm. O durumda Storage + Pull Zone/CDN mimarisini açıkça test ederim.

Bu deney tek VPS'in Bunny CDN'den iyi olduğunu, Bunny Storage'ın her zaman yavaş olduğunu veya Bunny'nin Range desteklemediğini kanıtlamıyor. Benim workload'umda Bunny Storage'ı Nginx cache'in direkt runtime origin'i yapmak, artık yönetmek istemediğim cold-path ve sliced-video behavior üretti.

Şimdi kullandığım kural

İki ay boyunca cache size, cache key, Range, 206, Slice, 1 MB chunk, keepalive, TLS reuse, retry, stale ve timeout ayarladım. En güçlü iyileştirme daha basitti: origin'i değiştirmek.

Kullanıcının istediği dosya cache'de değilse tam olarak ne oluyor?

Cevap stabil origin, öngörülebilir TTFB, doğru Range, tutarlı validator ve anlaşılır failure mode ise cache bir optimizasyondur. İyi UX kullanıcının asla MISS görmemesine bağlıysa cache daha derin bir mimari problemi gizliyordur.

Benim workload'um için küçük, private ve sıkıcı bir media origin daha iyi uydu. CDN değil, sihir değil ve operasyon sorumluluğunu yok etmiyor. Sadece cold path'i sıkıcı hâle getirdi. İki ay boyunca görsel, connection timeout, video Range ve etag mismatch in slice response debug ettikten sonra tam olarak istediğim buydu.