लगभग दो महीने तक मेरा media delivery path ऐसा था:
Browser
↓
working server
↓
Nginx
↓
local proxy_cache
↓ cache MISS
Bunny Storage
Images, video और audio Bunny Storage में रहते थे। User सीधे Bunny से connect नहीं करता था। मेरा Nginx [https://storage.bunnycdn.com](https://storage.bunnycdn.com) से object मांगता, server-side AccessKey जोड़ता, response को local SSD पर cache करता और public URL खुद serve करता था।
Scope साफ है: यह Bunny CDN का review नहीं है। इस experiment में मैंने Bunny CDN इस्तेमाल नहीं किया। Storage Zone + Pull Zone/CDN अलग delivery architecture है, और Bunny CDN को मैं अभी भी बहुत अच्छा product मानता हूँ। मैंने खास तौर पर Bunny Storage को अपने Nginx cache के runtime origin की तरह इस्तेमाल किया।
शुरुआती architecture समझदार क्यों लगा
Workload में बहुत सारे immutable media objects थे: AVIF, JPEG, PNG, MP4, WebM, audio और दूसरे static files—छोटी images से लेकर बड़े videos तक। पूरा dataset application server पर नहीं रखना चाहता था, लेकिन public URLs, cache policy, redirects, missing-file behavior, Range और fallback अपने Nginx से control करना चाहता था।
┌── HIT ── local SSD
│
Browser → Nginx cache
│
└── MISS ── Bunny Storage
HIT पर design बहुत अच्छा था। Nginx local disk से object दे देता और Bunny उस request में शामिल ही नहीं होता। इसी वजह से cold MISS का असली behavior लंबे समय तक छिपा रहा।
Popular files origin की असली latency छिपा देते थे
बार-बार मांगी जाने वाली image cache में बनी रहती है। पुरानी और कम खोली जाने वाली file के evict होने की संभावना ज्यादा है। इसलिए पुराने pages अनजाने में origin benchmark बन गए।
जुलाई के एक snapshot में लगभग 36 GB media cache, 355,000 से अधिक cache files, लगभग 35 GB की configured limit और root filesystem करीब 93% भरा हुआ था। पास के कुछ snapshots में entries 400,000 के करीब थीं। इतना बड़ा cache भी finite है।
मुख्य सबक: fast HIT, fast origin साबित नहीं करता। वह सिर्फ local cache की speed साबित करता है।
Production logs में file body आने से पहले failures दिखे
upstream timed out
while connecting to upstream
और:
upstream timed out ... while SSL handshaking to upstream
यह बड़े file का धीरे download होना नहीं है। इन मामलों में Nginx upstream connection या TLS handshake पूरा करने की कोशिश ही कर रहा था।
30 जुलाई के diagnostic snapshot में media error log की आखिरी 5,000 lines में 317 upstream-timeout matches थे। पिछले दिन के snapshot में 578 थे। ये unique users नहीं, log matches हैं, लेकिन यह एक isolated request नहीं था।
Bad cases सिर्फ एक storage IP से जुड़े नहीं थे
109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193
इस set के अलग-अलग addresses पर failures दिखे। इसलिए मेरे server की नजर से समस्या external origin path के behavior में थी, किसी एक हमेशा खराब IP में नहीं।
Logs से यह साबित नहीं किया जा सकता कि root cause storage backend, routing, peering, provider path, balancing या कुछ और था। मैं इतना साबित कर सकता हूँ कि कुछ cold MISS अपेक्षित समय में Bunny Storage upstream connection स्थापित नहीं कर पाए।
एक measurement ने 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
एक measured path बिल्कुल अलग था:
109.61.89.55
TCP 1.017782 s
TLS 1.048306 s
TOTAL 1.054474 s
इसका मतलब Bunny Storage हमेशा एक सेकंड slow नहीं था। ज्यादातर measurements कुछ दर्जन ms थे। असली मुद्दा variance था: वही origin hostname कभी useful file transfer शुरू होने से पहले एक सेकंड से अधिक connection delay दे सकता था।
Media pages में average से ज्यादा tail latency महसूस होती है
अगर 79 images 30–50 ms में आ जाएँ और एक image एक सेकंड ले, average फिर भी अच्छा दिख सकता है। User average नहीं देखता; वह एक खाली image box देखता है। इसलिए galleries में p95, p99 और slowest cold MISS मेरे लिए mean से ज्यादा जरूरी हो गए।
Video ने दूसरी, बिल्कुल अलग समस्या दिखाई
Browser video के लिए byte ranges मांग सकता है:
Range: bytes=0-1048575
और seek के बाद:
Range: bytes=50000000-51048575
206 Partial Content के साथ playback और seek पूरे पिछले bytes download किए बिना काम कर सकते हैं।
पहला Range cache एक physical video को कई 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 arbitrary होते हैं। bytes=0-1048575, bytes=0-999999 और seek के offsets एक ही MP4 के लिए अलग cache keys बनाते थे। मैंने यह नहीं मापा कि इससे कितने GB fragmentation हुई, इसलिए कोई संख्या नहीं बना रहा।
फिर fixed 1 MB slices अपनाए
Nginx Slice module बड़े resource को fixed chunks में normalize कर सकता है:
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 के लिए यह बेहतर था। फिर production में यह error आने लगा:
etag mismatch in slice response while reading response header from upstream
MP4 slices ETag consistency पर fail हो रहे थे
30 जुलाई को एक MP4 के repeated slice subrequests में etag mismatch in slice response आया, जबकि requests 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 जैसे addresses तक जा रहे थे। 31 जुलाई को दूसरे MP4 पर वही class का error दोबारा आया।
मैंने पहले इसे informal रूप से अलग “signatures” कहा था। सही term ETag है—यह AccessKey, signed URL या cryptographic signature नहीं है।
Nginx mismatched ETag वाले slices क्यों नहीं जोड़ता
slice 1 → bytes 0–1 MB → ETag A
slice 2 → bytes 1–2 MB → ETag A
consistent है। लेकिन:
slice 1 → ETag A
slice 2 → ETag B
होने पर Nginx यह सुरक्षित रूप से नहीं मान सकता कि दोनों एक ही representation के हैं। Blind assembly दो file versions के bytes मिला सकता है। Nginx का byte-range caching article इसी validator check को समझाता है।
Logs यह साबित करते हैं कि एक ही MP4 के slice responses में incompatible ETags दिखे। वे exact internal cause साबित नहीं करते, क्योंकि मैंने हर subrequest का literal ETag record नहीं किया था। Upstream address बदलना backend inconsistency को plausible बनाता है, लेकिन वह inference है।
इसका मतलब Bunny Storage Range support नहीं करता—ऐसा नहीं है
Range requests काम कर रहे थे और मैं 206 Partial Content cache कर रहा था। Specific problem Nginx Slice, multiple storage responses और safe assembly के लिए जरूरी ETag consistency का combination था।
Video उसी समय normal origin timeout में भी जा सकता था। इसलिए दो अलग failure modes थे: origin connection/latency और Slice/ETag consistency।
आखिर MP4 के लिए Slice हटा दिया
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 की तरह cache नहीं होता था। इससे MP4 slice assembly issue गया, लेकिन cold Range फिर external origin पर ज्यादा निर्भर हो गया।
मुझे एहसास हुआ कि Storage के आगे खुद CDN का हिस्सा बना रहा हूँ
Nginx में cache lock, stale, background update, Range, 206, Slice, normalized chunks, keepalive, TLS reuse, retries, timeout tuning और custom cache keys जमा होते गए। हर चीज अलग से reasonable थी, लेकिन origin से जरूरत बहुत सरल थी: immutable file रखना और bytes देना।
नया origin जानबूझकर सरल है
Media dataset को dedicated server पर ले गया जिसका मुख्य काम Nginx + SSD है। Pure static origin के लिए लगभग 1 vCPU, करीब 1 GB RAM और कुछ सौ GB SSD जैसी छोटी machine एक reasonable starting point हो सकती है, अगर network और disk पर्याप्त हों।
यह universal sizing rule नहीं है। Bandwidth, object size, concurrency, IOPS और MISS rate जरूरी हैं। मेरे current measurements वाला origin इससे ज्यादा resource रखता है, इसलिए मैं वर्तमान peaks को exact 1-vCPU/1-GB benchmark नहीं कहता।
नई architecture
Browser
↓
working server
↓
Nginx + local media cache
│
├── HIT
└── MISS
↓
WireGuard
↓
media origin
↓
Nginx
↓
SSD
Origin private है। Working server fixed private IP से बात करता है। Public HTTPS working server पर terminate होता है; origin traffic पहले से encrypted WireGuard tunnel के अंदर plain HTTP हो सकता है।
Cold path से क्या हट गया
working server
↓
WireGuard
↓
fixed private IP
↓
Nginx
↓
SSD
इस direct server-to-server relation के लिए public DNS, कई storage addresses में selection, public Internet path और अलग Storage TLS handshake जरूरी नहीं रहे।
Cold MISS इतना सस्ता हो गया कि HIT और MISS visually लगभग समान लगते हैं
Real-file के छोटे Range tests आम तौर पर:
CONNECT ≈ 9.5–12.5 ms
TTFB ≈ 19–23 ms
TOTAL ≈ 19–23 ms
रहे। 10/10, 20/20 sequential और 20/20 parallel checks सफल थे। Origin पर उसी छोटे request में करीब 0.5–0.9 ms लगे।
ये छोटे Range/TTFB tests हैं, full video download time नहीं। Full transfer अब भी size और throughput पर निर्भर है।
पुराना bad case और नया cold path बहुत अलग हैं
पुराने normal measurements लगभग 29–44 ms थे। Bad observed path 1.054 s था। नया tiny cold request करीब 20 ms है। उस specific outlier और 20 ms का ratio लगभग 52× है, लेकिन इसका मतलब “मेरा server Bunny Storage से 52× fast है” नहीं है।
MP4 अब सरल है
Cold fill में Nginx private origin से पूरा MP4 ला सकता है, एक cache entry रख सकता है और browser के arbitrary byte ranges local cache से serve कर सकता है। Fill के समय upstream Range हटाया जा सकता है और proxy_force_ranges local partial response दे सकता है।
cold MISS
↓
private origin से पूरा MP4
↓
one cached file
↓
client Range local 206
Full-object caching की कीमत भी है
अगर cold MP4 500 MB है और पहले user को सिर्फ दस सेकंड चाहिए, cache fill पूरे 500 MB transfer कर सकता है। मेरे size और pattern में यह acceptable है। Mostly-cold multi-GB video library के लिए stable sliced cache, HLS/DASH, video CDN या Bunny Stream बेहतर विकल्प हो सकते हैं।
दूसरे बड़े media के लिए fixed slices अब भी उपयोगी हैं
Nginx Slice खराब feature नहीं है। Immutable object और stable origin के साथ यह useful है। फर्क यह है कि अब सारे chunks एक controlled origin और एक controlled file representation से आते हैं।
MISS सस्ता हो तो छोटा cache भी तेज महसूस हो सकता है
पुराना cache लगभग 35–36 GB था। नया cache करीब 16 GB पर स्थिर हुआ क्योंकि min_free से करीब 8 GB खाली रखता हूँ।
पहले:
HIT = fast
MISS = कभी painful
अब:
HIT = fast
MISS = sufficiently fast
बड़ा cache MISS की संख्या कम करता है। अच्छा origin हर MISS की cost कम करता है। इस workload में दूसरा ज्यादा जरूरी निकला।
Real production traffic ने origin की भूमिका confirm की
60-second snapshot में working server users को लगभग 96.84 Mbit/s भेज रहा था और media origin से लगभग 7.07 Mbit/s ले रहा था। उस window में नए 502, 503, 504 या upstream timeout नहीं थे।
एक बाद के 30-second snapshot में public TX 61.36 Mbit/s और origin से WireGuard RX 1.40 Mbit/s था। Origin bytes/public TX ratio 2.28% था।
मैं इसे exact cache-hit rate नहीं कहता, क्योंकि public traffic सिर्फ media नहीं और byte ratio request ratio नहीं। लेकिन यह दिखाता है कि warm-up के बाद origin मुख्यतः cold fills करता है।
अब क्या monitor करूँगा
$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status
HIT और MISS अलग, खासकर p95/p99 MISS। Video के लिए first Range, middle Range, suffix Range, seek के कई non-sequential Range, cold/warm, और 206, Content-Range, Content-Length, Accept-Ranges, ETag। Slice होने पर multiple slices में validator consistency भी।
एक cold URL और 20 अलग cold URL एक test नहीं हैं
proxy_cache_lock एक ही cold cache key पर concurrent requests में मदद करता है। यह 20 अलग files को एक origin request नहीं बनाता। 50 अलग cold images वाला page एक URL बार-बार hit करने वाले benchmark से अधिक realistic origin test हो सकता है।
Self-hosting control देता है, free reliability नहीं
Private media server critical path समझना आसान करता है, लेकिन backup, disk health, free space, updates, firewall, monitoring, restore और redundancy मेरी जिम्मेदारी बनते हैं। एक origin uncached objects के लिए single point of failure भी है।
कब Bunny फिर चुनूँगा
Global delivery, managed redundancy, तेजी से बढ़ता storage या कम administration चाहिए तो Bunny को फिर evaluate करूँगा, और तब Storage + Pull Zone/CDN architecture को भी सीधे test करूँगा।
यह experiment यह साबित नहीं करता कि एक VPS Bunny CDN से बेहतर है, Bunny Storage हमेशा slow है या Bunny Range नहीं दे सकता। यह सिर्फ दिखाता है कि मेरे workload में Bunny Storage को direct runtime origin बनाने पर cold-path और sliced-video behavior ऐसा था जिसे मैं आगे manage नहीं करना चाहता था।
अब मेरा नियम
दो महीने में cache size, cache key, Range, 206, Slice, 1 MB chunks, keepalive, TLS reuse, retries, stale और timeouts tune किए। सबसे बड़ा improvement अधिक सरल था: origin बदलना।
User की मांगी file cache में न हो तो क्या होता है?
अगर जवाब stable origin, predictable TTFB, correct Range, consistent validators और समझ आने वाले failure modes है, cache optimization है। अगर अच्छी UX इस पर निर्भर है कि user कभी MISS न देखे, cache deeper architecture problem छिपा रहा है।
मेरे workload के लिए छोटा, private और boring media origin बेहतर fit रहा। यह CDN नहीं, magic नहीं और operations खत्म नहीं करता। इसने बस cold path को boring बना दिया। दो महीने images, connection timeout, video Range और etag mismatch in slice response debug करने के बाद मुझे यही चाहिए था।