ব্লগে ফিরে যান
২৮ আগস্ট, ২০২৬Sergei Solod11 মিনিট পড়া

Nginx-এর পেছনে Bunny Storage ব্যবহার করে দুই মাস: কেন ছবি, ভিডিও ও অডিও আলাদা মিডিয়া সার্ভারে সরালাম

দুই মাস Bunny CDN ব্যবহার না করে Bunny Storage-কে নিজের Nginx cache-এর private origin হিসেবে চালিয়েছিলাম। Production log-এ cold media request-এর long-tail connection timeout এবং MP4-তে Nginx Slice ও ETag-এর আলাদা সমস্যা ধরা পড়ে; শেষ পর্যন্ত আমি একটি সরল private media origin-এ চলে যাই।

Bunny StorageNginxভিডিও ক্যাশিংCache missমিডিয়া সার্ভার

প্রায় দুই মাস আমার media delivery path ছিল এমন:

Browser
  ↓
working server
  ↓
Nginx
  ↓
local proxy_cache
  ↓ cache MISS
Bunny Storage

ছবি, ভিডিও ও অডিও Bunny Storage-এ থাকত। ব্যবহারকারী সরাসরি Bunny-তে যেত না। আমার Nginx [https://storage.bunnycdn.com](https://storage.bunnycdn.com) থেকে object চাইত, server-side AccessKey যোগ করত, response local SSD-তে cache করত এবং public URL নিজেই serve করত।

শুরুতেই scope পরিষ্কার করা দরকার: এটি Bunny CDN-এর review নয়। এই পরীক্ষায় আমি Bunny CDN ব্যবহার করিনি। Storage Zone + Pull Zone/CDN আলাদা delivery architecture, এবং Bunny CDN-কে আমি এখনও শক্তিশালী product মনে করি। আমি পরীক্ষা করেছি Bunny Storage-কে নিজের Nginx cache-এর runtime origin হিসেবে।

শুরুর architecture কেন যুক্তিসঙ্গত মনে হয়েছিল

Workload-এ অনেক immutable media object ছিল: AVIF, JPEG, PNG, MP4, WebM, audio এবং অন্যান্য static file—ছোট image থেকে বড় video পর্যন্ত। পুরো dataset application server-এ রাখতে চাইনি, কিন্তু public URL, cache policy, redirect, missing-file behavior, Range এবং fallback নিজের Nginx দিয়ে নিয়ন্ত্রণ করতে চেয়েছিলাম।

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

HIT হলে design খুব ভালো কাজ করত। Nginx local disk থেকেই object দিত, তাই Bunny সেই request-এ অংশ নিত না। আর এই সাফল্যই দীর্ঘ সময় cold MISS-এর আসল behavior ঢেকে রেখেছিল।

জনপ্রিয় file origin-এর আসল latency ঢেকে দিত

বারবার চাওয়া image cache-এ থেকে যাওয়ার সম্ভাবনা বেশি। পুরোনো, কম খোলা file eviction-এর শিকার হওয়ার সম্ভাবনা বেশি। তাই পুরোনো page-গুলো অনিচ্ছাকৃতভাবে origin benchmark হয়ে উঠেছিল।

জুলাইয়ের একটি snapshot-এ প্রায় 36 GB Bunny media cache, 355,000-এর বেশি cache file, প্রায় 35 GB configured max এবং root filesystem প্রায় 93% পূর্ণ ছিল। কাছাকাছি অন্য snapshot-এ cache entry 400,000-এর কাছাকাছি পৌঁছেছিল। এত বড় cache-ও finite, তাই cold content থাকবেই।

মূল শিক্ষা: দ্রুত HIT দ্রুত origin প্রমাণ করে না; এটি শুধু local cache দ্রুত—এটাই প্রমাণ করে।

Production log দেখিয়েছিল file body আসার আগেই কিছু request ব্যর্থ হচ্ছিল

Nginx error log-এ বারবার দেখা গেছে:

upstream timed out
while connecting to upstream

এবং:

upstream timed out ... while SSL handshaking to upstream

এগুলো বড় file ধীরে download হওয়ার ঘটনা নয়। এখানে Nginx তখনও upstream connection বা TLS setup শেষ করার চেষ্টা করছিল।

30 জুলাইয়ের একটি diagnostic snapshot-এ media error log-এর শেষ 5,000 line-এ 317টি upstream-timeout match ছিল। আগের দিনের snapshot-এ ছিল 578। এগুলো unique user নয়, log match; কিন্তু ঘটনাটি যে একক request ছিল না, তা স্পষ্ট।

সমস্যা একটি storage IP-তেই সীমাবদ্ধ ছিল না

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

এই set-এর একাধিক address-এ failure দেখেছি। তাই আমার server-এর দৃষ্টিতে সমস্যা ছিল external origin path-এর behavior, স্থায়ীভাবে খারাপ একটি IP নয়।

Log থেকে আমি প্রমাণ করতে পারি না 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 ছিল না। বেশিরভাগ path ছিল কয়েক দশ ms। গুরুত্বপূর্ণ ছিল variance: একই origin hostname কখনও useful file transfer শুরু হওয়ার আগেই এক সেকেন্ডের বেশি connection delay দিতে পারত।

Media page-এ average-এর চেয়ে tail latency বেশি দৃশ্যমান

79টি image 30–50 ms-এ load হলেও একটি যদি এক সেকেন্ড নেয়, average এখনও ভালো দেখাতে পারে। ব্যবহারকারী average দেখে না; সে একটি ফাঁকা image slot দেখে। তাই gallery workload-এ p95, p99 এবং slowest cold MISS আমার কাছে mean response time-এর চেয়ে বেশি গুরুত্বপূর্ণ হয়ে গেছে।

Video দ্বিতীয়, সম্পূর্ণ আলাদা সমস্যা দেখিয়েছিল

Browser video-তে byte Range ব্যবহার করতে পারে:

Range: bytes=0-1048575

পরে seek করলে:

Range: bytes=50000000-51048575

Server 206 Partial Content দিলে পুরো আগের অংশ download না করেও playback ও seek করা যায়। আমি এই behavior বজায় রেখে Nginx cache কার্যকর করতে চেয়েছিলাম।

প্রথম Range cache একটি physical video-কে বহু cache key-তে ভেঙে দিচ্ছিল

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 arbitrary হতে পারে। bytes=0-1048575, bytes=0-999999 বা seek-এর অন্য offset—সবই আলাদা cache key। ফলে একটি MP4 বহু cache entry তৈরি করতে পারে। এই fragmentation কত GB নিয়েছিল তা আমি মাপিনি, তাই সংখ্যা বানাচ্ছি না।

তারপর fixed 1 MB slice ব্যবহার করলাম

Nginx-এর Slice module arbitrary Range-এর বদলে fixed chunk ব্যবহার করতে পারে:

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-এ এল:

etag mismatch in slice response while reading response header from upstream

MP4 slice ETag consistency-তে ব্যর্থ হচ্ছিল

30 জুলাই একটি MP4-এর repeated slice subrequest-এ etag mismatch in slice response দেখা যায়, এবং request-গুলো 109.61.89.53, 109.61.89.57, 79.127.226.193, 109.61.89.55 ইত্যাদি storage address-এ যাচ্ছিল। 31 জুলাই অন্য MP4-তেও একই class-এর error হয়।

আমি প্রথমে একে “ভিন্ন signature” বলেছিলাম। সঠিক technical term ছিল ETag—এটি AccessKey, signed URL বা cryptographic signature নয়।

Nginx mismatched ETag-এর slice কেন জোড়ে না

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-এর। Blindly জোড়া দিলে দুই version-এর byte মিশে যেতে পারে। Nginx-এর byte-range caching বিষয়ক লেখায় এই validator check ব্যাখ্যা করা হয়েছে।

আমার log প্রমাণ করে একই MP4-এর slice response-এ incompatible ETag দেখা গিয়েছিল। কেন ETag বদলেছিল, তা log একা প্রমাণ করে না, কারণ প্রতিটি subrequest-এর literal ETag value আলাদাভাবে সংরক্ষণ করিনি। Upstream address বদলানো backend-response inconsistency-কে plausible করে, কিন্তু সেটি inference।

এর মানে Bunny Storage Range support করে না—এমন নয়

Range request কাজ করত, এবং আমি 206 Partial Content cache করছিলাম। নির্দিষ্ট সমস্যা ছিল Nginx Slice, একাধিক storage response এবং একই representation assemble করার জন্য প্রয়োজনীয় ETag consistency-এর সমন্বয়ে।

Video একই সঙ্গে origin timeout-এও পড়তে পারত। তাই দুটি আলাদা failure mode ছিল: 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 file হিসেবে cache হতো না। এতে MP4 slice assembly সমস্যা গেল, কিন্তু cold Range আবার external origin-এর ওপর বেশি নির্ভরশীল হলো।

একসময় বুঝলাম Storage-এর সামনে নিজেই CDN-এর একটি অংশ বানাচ্ছি

Nginx-এ cache lock, stale, background update, Range, 206, Slice, normalized chunk, keepalive, TLS reuse, retry, timeout tuning ও custom key জমতে থাকে। প্রতিটি আলাদাভাবে যুক্তিসঙ্গত, কিন্তু origin থেকে আমার আসলে দরকার ছিল খুব সাধারণ কিছু: immutable file রাখা এবং byte পাঠানো।

নতুন origin ইচ্ছাকৃতভাবে সাধারণ

Media dataset একটি dedicated server-এ সরালাম, যার মূল কাজ Nginx + SSD। Pure static origin-এর জন্য প্রায় 1 vCPU, প্রায় 1 GB RAM এবং কয়েকশ GB SSD-র ছোট machine বিবেচনা করা যায়, যদি network ও disk যথেষ্ট হয়। এটি universal sizing rule নয়; bandwidth, object size, concurrency, IOPS ও MISS rate গুরুত্বপূর্ণ। আমার বর্তমান measurement server-এ আরও resource আছে, তাই measured peak-কে ঠিক 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-তে কথা বলে। User-facing HTTPS working server-এ থাকে, আর origin traffic আগে থেকেই encrypted WireGuard tunnel-এর ভেতর plain HTTP হতে পারে।

Cold path থেকে কী বাদ গেল

আগে public DNS, public route, একাধিক storage address-এর একটি, TCP, TLS এবং Storage API request ছিল। এখন মূল path:

working server
↓
WireGuard
↓
fixed private IP
↓
Nginx
↓
SSD

Cold request এত সস্তা হলো যে HIT ও MISS চোখে প্রায় একই লাগতে শুরু করল

Rare page খুলে cold media trigger করার পর working server থেকে origin-এর real-file Range test সাধারণত ছিল:

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

10/10, 20/20 sequential এবং 20/20 parallel check সফল হয়েছিল। Origin-এর ভেতরে একই ছোট request প্রায় 0.5–0.9 ms লাগত। এগুলো small Range/TTFB test; full video 20 ms-এ download হয়—এমন দাবি নয়।

পুরোনো bad case আর নতুন cold path-এর ফারাক বড়

Normal পুরোনো measurement ছিল 29–44 ms-এর মতো; observed bad path ছিল 1.054 s-এর কাছাকাছি; নতুন tiny cold request প্রায় 20 ms। ঐ নির্দিষ্ট outlier বনাম 20 ms প্রায় 52× latency difference, কিন্তু এটিকে “আমার server Bunny-এর চেয়ে 52× fast” বলা ভুল হবে। এটি দুই নির্দিষ্ট observed path-এর তুলনা।

MP4-ও এখন সহজ

Cold fill-এ working Nginx private origin থেকে complete MP4 আনতে পারে, একটিমাত্র cache entry রাখতে পারে, তারপর browser-এর arbitrary byte Range local cache থেকে দিতে পারে। Fill-এর সময় upstream Range clear করা যায় এবং proxy_force_ranges local 206 response দিতে সাহায্য করে।

cold MISS
↓
private origin থেকে complete MP4
↓
one cached file
↓
client Range local cache থেকে 206

Full-object MP4 caching-এরও খরচ আছে

Cold MP4 যদি 500 MB হয় এবং প্রথম user কেবল দশ সেকেন্ড দেখতে চায়, cache fill পুরো 500 MB transfer করতে পারে। আমার file size ও access pattern-এ trade-off গ্রহণযোগ্য, কারণ পরের Range request-গুলো একই local file reuse করে। Multi-gigabyte, mostly cold library হলে stable slice cache, HLS/DASH, video CDN বা Bunny Stream বেশি উপযোগী হতে পারে।

অন্য বড় media-র জন্য fixed slice এখনও যুক্তিসঙ্গত

Nginx Slice খারাপ feature নয়। Immutable object ও stable origin-এর সঙ্গে এটি useful। এখন পার্থক্য হলো সব chunk আসে আমার নিয়ন্ত্রিত এক origin এবং এক filesystem representation থেকে।

MISS সস্তা হলে ছোট cache-ও দ্রুত অনুভূত হতে পারে

পুরোনো cache ছিল প্রায় 35–36 GB। নতুন cache প্রায় 16 GB-এর কাছে স্থির হয়, কারণ min_free দিয়ে প্রায় 8 GB free রাখি।

পুরোনো:
HIT  = fast
MISS = কখনও painful

নতুন:
HIT  = fast
MISS = যথেষ্ট fast

বড় cache MISS-এর সংখ্যা কমায়; ভালো origin প্রতিটি MISS-এর cost কমায়। এই workload-এ দ্বিতীয়টি বেশি গুরুত্বপূর্ণ হয়ে উঠেছে।

Real production traffic origin-এর ভূমিকা নিশ্চিত করেছে

একটি 60-second snapshot-এ working server user-দের দিকে 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 bytes শুধু media নয় এবং byte ratio request ratio নয়।

এখন কী monitor করব

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

HIT ও MISS আলাদা করে, বিশেষত p95/p99 MISS latency দেখব। Video-তে প্রথম Range, middle Range, suffix Range, seek-এর জন্য multiple non-sequential Range, cold বনাম warm এবং 206, Content-Range, Content-Length, Accept-Ranges, ETag যাচাই করব। Slice থাকলে একাধিক slice-এর validator consistency আলাদা করে পরীক্ষা করব।

একটি cold URL আর 20টি আলাদা cold URL একই test নয়

proxy_cache_lock একই cold cache key-এ concurrent request কমাতে সাহায্য করে। এটি 20টি আলাদা file-কে এক origin request বানায় না। তাই 50টি আলাদা cold image-এর page এক URL বারবার benchmark করার চেয়ে origin-এর জন্য কঠিন ও বাস্তব test হতে পারে।

Self-hosting control দেয়, free reliability নয়

Private media server path বুঝতে সহজ করেছে, কিন্তু backup, disk health, free space, update, firewall, monitoring, restore এবং redundancy এখন আমার দায়িত্ব। এক origin uncached object-এর single point of failure-ও হতে পারে।

কখন Bunny আবার বেছে নেব

Global user delivery, managed redundancy, দ্রুত বাড়তে থাকা storage বা কম infrastructure administration দরকার হলে Bunny আবার বিবেচনা করব। তখন Bunny-এর documented Storage + Pull Zone/CDN architecture-ও পরীক্ষা করব।

এই experiment প্রমাণ করে না যে একটি VPS Bunny CDN-এর চেয়ে ভালো, Bunny Storage সবসময় slow, বা Bunny Range serve করতে পারে না। এটি দেখায় যে আমার workload-এ Bunny Storage-কে সরাসরি নিজের Nginx cache-এর runtime origin হিসেবে ব্যবহার করলে cold-path এবং sliced-video behavior এমন হয়েছিল যা আর manage করতে চাইনি।

এখন আমার নিয়ম

দুই মাসে cache size, cache key, Range, 206, Slice, 1 MB chunk, keepalive, TLS reuse, retry, stale এবং timeout—সবই tune করেছি। সবচেয়ে শক্তিশালী পরিবর্তন ছিল আরও সহজ: origin বদলানো।

ব্যবহারকারী যে file চায়, সেটি cache-এ না থাকলে কী ঘটে?

যদি উত্তর হয় stable origin, predictable TTFB, correct Range, consistent validator এবং বোঝা যায় এমন failure mode, cache তখন optimization। যদি ভালো UX-এর শর্ত হয় ব্যবহারকারী যেন কখনও MISS না দেখে, cache গভীর architecture problem ঢাকছে।

আমার workload-এ ছোট, private, boring media origin-ই ভালো fit। এটি CDN নয়, magic নয়, operational responsibility সরায় না। এটি শুধু cold path-কে boring করেছে। দুই মাস image, timeout, video Range আর etag mismatch in slice response debug করার পর boring-ই আমার দরকার ছিল।