بازگشت به بلاگ
۶ شهریور ۱۴۰۵Sergei Solod11 دقیقه مطالعه

دو ماه استفاده از Bunny Storage پشت Nginx: چرا تصویر، ویدئو و صوت را به یک سرور رسانه‌ای جدا منتقل کردم

به‌مدت دو ماه Bunny Storage را نه از طریق Bunny CDN، بلکه به‌عنوان origin خصوصی پشت کش Nginx خودم استفاده کردم. لاگ‌های production نشان دادند درخواست‌های cold media گاهی گرفتار تأخیرهای طولانی اتصال می‌شوند و MP4 نیز مشکل جداگانه‌ای با Nginx Slice و ETag ناسازگار دارد؛ در نهایت به یک media origin خصوصی و ساده مهاجرت کردم.

Bunny StorageNginxکش ویدئوCache missسرور رسانه

نزدیک به دو ماه مسیر تحویل فایل‌های رسانه‌ای من این‌گونه بود:

Browser
  ↓
سرور کاری
  ↓
Nginx
  ↓
proxy_cache محلی
  ↓ cache MISS
Bunny Storage

تصاویر، ویدئوها و فایل‌های صوتی در Bunny Storage قرار داشتند. کاربر مستقیماً با Bunny ارتباط نداشت. Nginx من فایل را از [https://storage.bunnycdn.com](https://storage.bunnycdn.com) می‌گرفت، AccessKey سمت سرور را اضافه می‌کرد، پاسخ را روی SSD محلی کش می‌کرد و URL عمومی را خودش تحویل می‌داد.

دامنه این تجربه را باید دقیق مشخص کنم: این بررسی Bunny CDN نیست. در این آزمایش از Bunny CDN استفاده نکردم. معماری Storage Zone همراه Pull Zone/CDN مدل توزیع دیگری است و Bunny CDN را همچنان محصول بسیار خوبی می‌دانم. چیزی که آزمایش کردم Bunny Storage به‌عنوان runtime origin برای کش Nginx خودم بود.

چرا معماری اولیه منطقی به‌نظر می‌رسید

بار کاری شامل تعداد زیادی فایل immutable بود: AVIF، JPEG، PNG، MP4، WebM، صوت و فایل‌های static دیگر؛ از تصاویر کوچک تا ویدئوهای بزرگ‌تر. نمی‌خواستم کل dataset روی application server باشد، اما کنترل URL عمومی، cache policy، redirect، فایل گمشده، Range و fallback را در Nginx خودم می‌خواستم.

                    ┌── HIT ── SSD محلی
                    │
Browser → Nginx cache
                    │
                    └── MISS ── Bunny Storage

در حالت HIT معماری عالی بود. Nginx مستقیماً از دیسک محلی پاسخ می‌داد و Bunny اصلاً در آن درخواست حضور نداشت. همین موفقیت برای مدتی طولانی رفتار مهم‌تر یعنی cold MISS را پنهان کرد.

فایل‌های محبوب latency واقعی origin را پنهان می‌کردند

فایل پرتقاضا احتمال زیادی دارد در cache بماند؛ فایل قدیمی و کم‌تقاضا بیشتر در معرض eviction است. به همین دلیل صفحات قدیمی ناخواسته به آزمون origin تبدیل شدند.

در یکی از snapshotهای ژوئیه حدود 36 GB cache رسانه، بیش از 355,000 فایل cache، سقف تقریباً 35 GB و root filesystem با حدود 93% استفاده وجود داشت. snapshotهای نزدیک به 400,000 entry هم می‌رسیدند. حتی چنین cache بزرگی محدود است.

نتیجه مهم: HIT سریع، origin سریع را ثابت نمی‌کند؛ فقط cache محلی سریع را ثابت می‌کند.

لاگ‌ها شکست را پیش از انتقال بدنه فایل نشان می‌دادند

upstream timed out
while connecting to upstream

و:

upstream timed out ... while SSL handshaking to upstream

این با دانلود آهسته یک فایل بزرگ فرق دارد. در این وضعیت Nginx هنوز مشغول ایجاد اتصال upstream یا تکمیل TLS بود.

در snapshot تشخیصی 30 ژوئیه، میان آخرین 5,000 خط media error log تعداد 317 تطابق upstream timeout وجود داشت. روز قبل 578 تطابق دیده شد. این‌ها تعداد خط لاگ هستند، نه 317 یا 578 کاربر یکتا، اما مسئله را از حالت تک‌رخداد خارج می‌کنند.

موارد بد به یک IP محدود نبودند

109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193

روی چند آدرس از این مجموعه failure دیدم. بنابراین از دید سرور من، مسئله رفتار مسیر خارجی origin بود، نه یک IP دائماً خراب.

از لاگ‌ها نمی‌توانم ثابت کنم علت دقیق storage backend، routing، peering، مسیر provider من به Bunny، balancing یا عامل دیگری بوده است. چیزی که می‌توانم ثابت کنم این است که بعضی cold MISSها در زمان انتظار نتوانستند اتصال Bunny Storage را برقرار کنند.

یک اندازه‌گیری 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

ولی یک مسیر اندازه‌گیری‌شده:

109.61.89.55
TCP    1.017782 s
TLS    1.048306 s
TOTAL  1.054474 s

این به‌معنای یک‌ثانیه‌ای بودن همیشگی Bunny Storage نیست؛ بیشتر نتایج چند ده میلی‌ثانیه بودند. نکته مهم variance بود: همان hostname گاهی مسیری می‌داد که پیش از شروع انتقال مفید فایل، بیش از یک ثانیه هزینه اتصال داشت.

در صفحات رسانه‌ای tail latency از میانگین مهم‌تر است

اگر 79 تصویر طی 30–50 ms بیایند و یکی یک ثانیه طول بکشد، میانگین هنوز خوب به‌نظر می‌رسد. کاربر میانگین را نمی‌بیند؛ جای خالی یک تصویر را می‌بیند. به همین دلیل p95، p99 و کندترین cold MISS برای من مهم‌تر شدند.

ویدئو مشکل دوم و کاملاً متفاوتی را نشان داد

مرورگر برای ویدئو می‌تواند byte Range بخواهد:

Range: bytes=0-1048575

و پس از seek:

Range: bytes=50000000-51048575

پاسخ 206 Partial Content اجازه پخش و seek را بدون دانلود همه بایت‌های قبلی می‌دهد.

اولین cache مبتنی بر Range یک ویدئو را به 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;

Range مرورگر arbitrary است. bytes=0-1048575، bytes=0-999999 و offsetهای seek می‌توانند cache keyهای مختلفی برای یک MP4 بسازند. مقدار دقیق فضای مصرف‌شده توسط این fragmentation را اندازه نگرفتم و عددی برایش نمی‌سازم.

بعد به sliceهای ثابت 1 MB رفتم

Nginx Slice module می‌تواند object بزرگ را به بخش‌های ثابت تبدیل کند:

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

sliceهای MP4 در consistency مربوط به ETag شکست می‌خوردند

30 ژوئیه subrequestهای تکراری یک MP4 خطای etag mismatch in slice response تولید کردند، در حالی که درخواست‌ها به آدرس‌هایی مانند 109.61.89.53، 109.61.89.57، 79.127.226.193 و 109.61.89.55 می‌رفتند. 31 ژوئیه همین کلاس خطا برای MP4 دیگری تکرار شد.

من ابتدا آن را غیررسمی «امضای متفاوت قطعات» می‌نامیدم، اما اصطلاح درست ETag بود؛ نه AccessKey، نه signed URL و نه امضای رمزنگاری.

چرا Nginx sliceهای دارای ETag متفاوت را به هم نمی‌چسباند

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

سازگار است. اما:

slice 1 → ETag A
slice 2 → ETag B

یعنی Nginx دیگر نمی‌تواند مطمئن باشد هر دو بخش متعلق به یک representation هستند. ترکیب کورکورانه می‌تواند بایت‌های نسخه‌های مختلف را مخلوط کند. مقاله Nginx درباره byte-range caching همین بررسی validator را توضیح می‌دهد.

لاگ‌ها ثابت می‌کنند Nginx برای sliceهای همان MP4 ETag ناسازگار دیده است. اما علت داخلی دقیق را ثابت نمی‌کنند، چون مقدار literal هر ETag را برای هر subrequest ذخیره نکرده بودم. تغییر upstream address، ناسازگاری responseهای backend را محتمل می‌کند، اما این بخش inference است.

این یعنی Bunny Storage از Range پشتیبانی نمی‌کند؟ خیر

Range کار می‌کرد و من 206 Partial Content را cache می‌کردم. مشکل محدودتر، ترکیب Nginx Slice با responseهای مختلف Storage و نیاز Nginx به ETag سازگار برای assembly امن بود.

ویدئو همزمان می‌توانست به timeout معمول origin هم بخورد. بنابراین دو failure mode مستقل داشتم: connection/latency origin و Slice/ETag consistency.

در نهایت Slice را برای 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;

partial response دیگر به‌عنوان فایل کامل cache نمی‌شد. مشکل assembly حل شد، اما cold Range دوباره بیشتر به origin خارجی وابسته شد.

فهمیدم دارم بخشی از CDN را جلوی Storage خودم می‌سازم

Nginx من به‌تدریج cache lock، stale، background update، Range، 206، Slice، chunk ثابت، keepalive، TLS reuse، retry، timeout tuning و cache key سفارشی داشت. هرکدام مفید بودند، اما نیاز واقعی origin بسیار ساده بود: فایل immutable را نگه دارد و بایت‌ها را تحویل دهد.

origin جایگزین عمداً ساده است

فایل‌ها را به یک سرور اختصاصی انتقال دادم که وظیفه اصلی آن Nginx + SSD است. برای static origin خالص، ماشینی در حد یک vCPU، حدود 1 GB RAM و چند صد GB SSD می‌تواند نقطه شروع قابل‌بررسی باشد، اگر network و disk کافی باشند.

این قانون عمومی sizing نیست. bandwidth، اندازه object، concurrency، IOPS و MISS rate تعیین‌کننده‌اند. origin فعلی اندازه‌گیری‌شده منابع بیشتری دارد، پس peak فعلی را benchmark دقیق 1-vCPU/1-GB نمی‌نامم.

معماری جدید

Browser
   ↓
سرور کاری
   ↓
Nginx + media cache محلی
   │
   ├── HIT
   └── MISS
          ↓
      WireGuard
          ↓
     media origin
          ↓
        Nginx
          ↓
         SSD

origin خصوصی است. سرور کاری با یک private IP ثابت صحبت می‌کند. HTTPS عمومی همچنان روی سرور کاری terminate می‌شود و ارتباط origin می‌تواند داخل WireGuard رمزگذاری‌شده از HTTP معمولی استفاده کند.

چه چیزهایی از cold path حذف شد

سرور کاری
↓
WireGuard
↓
private IP ثابت
↓
Nginx
↓
SSD

DNS عمومی، انتخاب یکی از چند storage address، مسیر public و TLS اضافی برای این رابطه مستقیم دیگر لازم نیست.

cold MISS آن‌قدر ارزان شد که از نظر بصری تقریباً شبیه HIT است

آزمایش‌های کوچک Range روی فایل واقعی از سرور کاری به origin معمولاً:

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

بودند. تست‌های 10/10، 20/20 sequential و 20/20 parallel موفق شدند. روی خود origin همان request کوچک حدود 0.5–0.9 ms بود.

این‌ها تست کوچک Range/TTFB هستند، نه زمان دانلود کامل یک ویدئوی بزرگ.

bad case قدیمی با cold path جدید اختلاف بزرگی دارد

measurementهای عادی قدیمی اغلب 29–44 ms بودند؛ bad path مشاهده‌شده 1.054 s و tiny cold request جدید نزدیک 20 ms است. مقایسه آن outlier با 20 ms تقریباً 52× اختلاف latency می‌دهد، اما این به‌معنای «سرور من 52 برابر سریع‌تر از Bunny Storage» نیست.

MP4 حالا ساده‌تر است

در cold fill، Nginx می‌تواند MP4 کامل را از private origin دریافت و به‌عنوان یک cache entry نگه دارد، سپس Rangeهای دلخواه browser را از cache محلی پاسخ دهد. با proxy_force_ranges می‌توان پاسخ Range محلی ارائه کرد.

cold MISS
↓
MP4 کامل از private origin
↓
یک فایل cached
↓
Range کاربر به‌صورت local 206

full-object caching هزینه خودش را دارد

اگر MP4 سرد 500 MB باشد و اولین کاربر فقط ده ثانیه بخواهد، fill کامل می‌تواند تمام 500 MB را منتقل کند. برای pattern من قابل قبول است، ولی برای ویدئوهای چندگیگابایتی عمدتاً سرد، stable slice cache، HLS/DASH، video CDN یا Bunny Stream را بررسی می‌کنم.

fixed slice برای media بزرگ دیگر همچنان مفید است

Nginx Slice بد نیست. با object immutable و origin پایدار مفید است. تفاوت این است که اکنون همه قطعات از origin و representation تحت کنترل خودم می‌آیند.

وقتی MISS ارزان باشد cache کوچک‌تر هم می‌تواند سریع‌تر حس شود

cache قدیمی حدود 35–36 GB بود. cache جدید حدود 16 GB پایدار شد، چون با min_free تقریباً 8 GB آزاد نگه می‌دارم.

قدیم:
HIT  = سریع
MISS = گاهی دردناک

جدید:
HIT  = سریع
MISS = به‌اندازه کافی سریع

cache بزرگ تعداد MISS را کم می‌کند؛ origin خوب هزینه هر MISS را. در این workload دومی مهم‌تر بود.

ترافیک واقعی production نقش origin را تأیید کرد

در snapshot شصت‌ثانیه‌ای، سرور کاری حدود 96.84 Mbit/s به کاربران می‌فرستاد و حدود 7.07 Mbit/s از media origin می‌گرفت. در آن window خطای جدید 502، 503، 504 یا upstream timeout نبود.

در snapshot سی‌ثانیه‌ای بعدی، public TX برابر 61.36 Mbit/s و WireGuard RX از origin برابر 1.40 Mbit/s بود. نسبت origin bytes به public TX در آن window برابر 2.28% شد.

این را cache-hit rate دقیق نمی‌نامم؛ public traffic فقط media نیست و نسبت byte برابر نسبت request نیست. اما نشان می‌دهد پس از warm-up، origin عمدتاً cold fillها را پاسخ می‌دهد.

اکنون چه چیزهایی را مانیتور می‌کنم

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

HIT و MISS را جدا و p95/p99 MISS را جدی بررسی می‌کنم.

برای ویدئو Range اول، وسط، suffix range، چند Range غیرپیوسته برای seek، cold و warm و همچنین 206، Content-Range، Content-Length، Accept-Ranges و ETag را تست می‌کنم. در صورت استفاده از Slice، consistency validator میان چند slice را مستقیماً بررسی می‌کنم.

یک cold URL با 20 cold URL متفاوت یک تست نیست

proxy_cache_lock برای چند درخواست به یک cold cache key مفید است، اما بیست فایل متفاوت را به یک origin request تبدیل نمی‌کند. صفحه‌ای با پنجاه تصویر cold متفاوت می‌تواند آزمون واقع‌بینانه‌تری از تکرار یک URL باشد.

self-hosting کنترل می‌دهد، reliability رایگان نه

سرور خصوصی مسیر را برایم قابل‌فهم‌تر کرده، اما backup، سلامت دیسک، فضای آزاد، update، firewall، monitoring، restore و redundancy مسئولیت خودم است. یک origin برای objectهای uncached می‌تواند single point of failure باشد.

چه زمانی دوباره Bunny را انتخاب می‌کنم

برای توزیع جهانی، redundancy مدیریت‌شده، storage سریع‌الرشد یا حداقل administration، Bunny را دوباره جدی بررسی می‌کنم و این بار معماری Storage + Pull Zone/CDN را هم مستقیماً ارزیابی می‌کنم.

این آزمایش ثابت نمی‌کند یک VPS از Bunny CDN بهتر است، Bunny Storage همیشه کند است یا Bunny Range ندارد. فقط نشان می‌دهد در workload من، Bunny Storage به‌عنوان runtime origin مستقیم Nginx cache، cold-path و sliced-video behaviorهایی ایجاد کرد که دیگر نمی‌خواستم مدیریت کنم.

قاعده‌ای که اکنون استفاده می‌کنم

در این دو ماه cache size، cache key، Range، 206، Slice، chunkهای 1 MB، keepalive، TLS reuse، retry، stale و timeout را تنظیم کردم. قوی‌ترین تغییر ساده‌تر بود: origin را عوض کردم.

اگر فایل موردنظر کاربر در cache نباشد، دقیقاً چه اتفاقی می‌افتد؟

اگر پاسخ origin پایدار، TTFB قابل‌پیش‌بینی، Range درست، validator سازگار و failure mode قابل‌فهم باشد، cache یک optimization است. اگر UX خوب به این وابسته باشد که کاربر هیچ‌وقت MISS نبیند، cache یک مشکل معماری عمیق‌تر را پنهان می‌کند.

برای workload من یک media origin کوچک، خصوصی و کسل‌کننده انتخاب بهتری بود. CDN نیست، جادو نیست و مسئولیت عملیاتی را حذف نمی‌کند؛ فقط cold path را کسل‌کننده کرده است. پس از دو ماه debug کردن تصویر، timeout اتصال، Range ویدئو و etag mismatch in slice response دقیقاً همین را می‌خواستم.