لمدة قاربت شهرين، كان مسار توزيع الوسائط لدي يبدو هكذا:
Browser
↓
خادم العمل
↓
Nginx
↓
proxy_cache محلي
↓ cache MISS
Bunny Storage
كانت الصور والفيديوهات والملفات الصوتية مخزنة في Bunny Storage. لم يكن المستخدم يتصل بـBunny مباشرة؛ كان Nginx يطلب الملف من [https://storage.bunnycdn.com](https://storage.bunnycdn.com)، يضيف AccessKey الموجود على الخادم، يحفظ الاستجابة على SSD محلي ثم يقدّم الرابط العام بنفسه.
أوضح نطاق التجربة منذ البداية: هذه ليست مراجعة لـBunny CDN. لم أستخدم Bunny CDN في هذا الاختبار. بنية Storage Zone مع Pull Zone/CDN هي نموذج توزيع مختلف، وما زلت أرى Bunny CDN منتجًا قويًا جدًا. ما اختبرته تحديدًا هو استخدام Bunny Storage كمصدر runtime لذاكرة Nginx المؤقتة الخاصة بي.
لماذا بدت البنية الأصلية منطقية
الحمل يتكون من عدد كبير من ملفات الوسائط غير المتغيرة: AVIF وJPEG وPNG وMP4 وWebM وملفات صوتية وملفات ثابتة أخرى، من صور صغيرة إلى فيديوهات أكبر بكثير. لم أرد وضع كامل مجموعة البيانات على خادم التطبيق، لكنني أردت أن يتحكم Nginx لدي في الروابط العامة وسياسة التخزين المؤقت والتحويلات وسلوك الملفات المفقودة وRange وعمليات fallback.
┌── HIT ── SSD محلي
│
Browser → Nginx cache
│
└── MISS ── Bunny Storage
عند حدوث HIT كانت البنية ممتازة: يرسل Nginx الملف مباشرة من القرص المحلي ولا يشارك Bunny في الطلب أصلًا. وهذا النجاح أخفى عني فترة طويلة الجزء الأهم: ماذا يحدث عند MISS بارد.
الملفات الشائعة أخفت زمن المصدر الحقيقي
الصورة كثيرة الطلب غالبًا تبقى في الذاكرة المؤقتة. أما الملف القديم الذي نادرًا ما يفتح فاحتمال طرده أكبر. لهذا تحولت الصفحات القديمة دون قصد إلى اختبار للمصدر، لأنها كانت تستدعي Bunny Storage بوتيرة أعلى.
أظهر snapshot في يوليو نحو 36 GB من cache الوسائط، وأكثر من 355,000 ملف cache، وحدًا مضبوطًا قرب 35 GB، بينما كان نظام الملفات الجذري مستخدمًا بنحو 93%. وفي snapshots قريبة اقترب عدد الإدخالات من 400,000. ومع ذلك بقيت الذاكرة المؤقتة محدودة، وبالتالي لا بد من وجود محتوى بارد.
الدرس الأساسي: HIT سريع لا يثبت أن المصدر سريع؛ بل يثبت أن cache المحلي سريع.
السجلات أظهرت فشلًا قبل بدء نقل جسم الملف
أكثر الأدلة فائدة جاءت من سجل أخطاء Nginx. ظهرت مرارًا رسائل مثل:
upstream timed out
while connecting to upstream
وأيضًا:
upstream timed out ... while SSL handshaking to upstream
هذه ليست حالة ملف كبير يستغرق وقتًا في التنزيل. هنا كان Nginx لا يزال يحاول إنشاء اتصال upstream أو إكمال TLS قبل بدء نقل الملف نفسه بشكل طبيعي.
في snapshot تشخيصي يوم 30 يوليو، احتوت آخر 5,000 سطر من سجل أخطاء الوسائط على 317 تطابقًا لـupstream timeout. وفي snapshot من اليوم السابق كان العدد 578. هذه أعداد أسطر سجل وليست 317 أو 578 مستخدمًا فريدًا، لكنها تؤكد أن المشكلة لم تكن طلبًا منفردًا.
الحالات السيئة لم ترتبط بعنوان تخزين واحد
في تلك الفترة كان اسم storage يُحل إلى عناوين منها:
109.61.89.53
109.61.89.54
109.61.89.55
109.61.89.57
79.127.226.193
ورأيت حالات فشل على أكثر من عنوان من هذه المجموعة. لذلك، من وجهة نظر خادمي، كانت المشكلة في مسار الوصول الخارجي إلى origin ككل، لا في عنوان واحد سيئ دائمًا.
لا أستطيع من سجلاتي إثبات إن كان السبب داخل backend التخزين أو التوجيه أو peering أو مسار مزود خادمي إلى Bunny أو الموازنة أو عامل شبكي آخر. ما يمكنني إثباته أضيق: بعض طلبات MISS الباردة لم تتمكن من إنشاء اتصال Bunny Storage ضمن الزمن المتوقع.
قياس واحد كشف الـlong tail بوضوح
قست عناوين storage كلًا على حدة. أربعة مسارات بدت طبيعية:
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 كان دائمًا بطيئًا ثانية كاملة؛ معظم القياسات كانت عشرات الميلي ثانية. المهم كان التفاوت: اسم origin نفسه كان قد يقود إلى مسار اتصال أبطأ بأكثر من ثانية قبل التفكير أصلًا في نقل جسم الملف.
في صفحات الوسائط، tail latency أهم من المتوسط
إذا حملت 79 صورة خلال 30–50 ms واستغرقت صورة واحدة ثانية، فقد يظل المتوسط جيدًا. المستخدم لا يرى المتوسط؛ يرى مستطيلًا فارغًا لم يكتمل بعد.
لهذا أصبحت أهتم في صفحات المعارض بـp95 وp99 وبأبطأ MISS بارد أكثر من اهتمامي بمتوسط استجابة جميل.
الفيديو كشف مشكلة ثانية مختلفة تمامًا
الصور غالبًا تُطلب كملف كامل. الفيديو مختلف لأن المتصفح يستطيع استخدام byte ranges. قد يطلب:
Range: bytes=0-1048575
ثم بعد seek:
Range: bytes=50000000-51048575
ويرد الخادم الصحيح بـ206 Partial Content، ما يسمح ببدء التشغيل والتنقل دون تنزيل كل البايتات السابقة.
أول تصميم للـRange كان يجزئ فيديو واحدًا إلى مفاتيح 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;
كان هذا يمنع partial responses المختلفة من استبدال بعضها، لكنه يعني أن الطلبات العشوائية مثل bytes=0-1048575 وbytes=0-999999 وranges الناتجة عن seek يمكن أن تنشئ إدخالات مختلفة لفيديو واحد. لم أقس بدقة حجم المساحة التي استهلكها هذا التجزؤ، لذلك لا أضع رقمًا غير موجود.
انتقلت إلى slices ثابتة بحجم 1 MB
يوفر Nginx Slice module لهذا النوع من الحالات. بدل تخزين Range عشوائي من المتصفح، يمكن تقسيم الملف إلى أجزاء ثابتة:
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. ثم بدأ الإنتاج يعرض خطأ شديد التحديد:
etag mismatch in slice response while reading response header from upstream
كانت slices الخاصة بـMP4 تفشل بسبب اتساق ETag
في 30 يوليو ظهرت أخطاء etag mismatch in slice response المتكررة لفيديو MP4 واحد بينما كانت subrequests تصل إلى عناوين منها 109.61.89.53 و109.61.89.57 و79.127.226.193 و109.61.89.55. وفي 31 يوليو ظهر النوع نفسه من الأخطاء على MP4 آخر مع انتقال subrequests بين عدة عناوين من pool التخزين.
كنت أصف هذا وقتها بشكل غير دقيق بأنه “توقيعات مختلفة” للقطع. الصحيح أنه كان ETag، لا AccessKey ولا signed URL ولا توقيعًا تشفيريًا.
لماذا يرفض Nginx دمج slices ذات ETag مختلف
ETag هو HTTP validator لتمثيل محدد من المورد. مثلًا:
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 لم يعد يستطيع افتراض أن القطعتين من التمثيل نفسه. دمجهما عميانيًا قد ينتج ملفًا مركبًا من نسختين مختلفتين. لذلك هذا السلوك حماية، لا خطأً عشوائيًا. تشرح مقالة Nginx حول byte-range caching هذا النوع من التحقق.
سجلاتي تثبت أن Nginx رأى ETag غير متوافق بين slice responses لنفس MP4. لكنها لا تثبت السبب الداخلي الدقيق لاختلافه، لأنني لم أسجل القيمة الحرفية لكل ETag في كل subrequest. تغير عناوين upstream يجعل اختلاف استجابات backend تفسيرًا معقولًا، لكنه يظل استنتاجًا لا حقيقة مثبتة.
هذا لا يعني أن Bunny Storage لا يدعم Range
طلبات Range نفسها كانت تعمل، وكنت أخزن 206 Partial Content. المشكلة الأضيق كانت في الجمع بين Nginx Slice واستجابات تخزين متعددة واشتراط Nginx لاتساق ETag.
وفي الوقت نفسه كان الفيديو قد يتأثر بالمشكلة الأولى أيضًا: لدي سجلات MP4 تحمل upstream timed out while connecting to upstream. لذلك كانت لدي فئتان منفصلتان من أعطال الفيديو:
- latency/الاتصال بالـorigin؛
- اتساق Slice وETag.
توقفت لاحقًا عن استخدام Slice مع MP4
عدت في إعداد لاحق إلى byte ranges العادية للمتصفح:
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 responses كأنها الملف الكامل. أزال ذلك مشكلة تجميع slices في MP4، لكنه أعاد trade-off آخر: Range بارد قد يعتمد مباشرة على origin الخارجي.
أدركت أنني أبني جزءًا من CDN أمام Storage
بمرور الوقت أصبح Nginx عندي مسؤولًا عن cache lock وstale وbackground update وRange و206 وSlice وchunks ثابتة وkeepalive وإعادة استخدام TLS وretries وtimeouts ومفاتيح cache مخصصة.
كل أداة منها كانت منطقية بمفردها. لكن السؤال الأبسط كان: ماذا أحتاج فعليًا من origin؟ الجواب: حفظ ملفات immutable وإرسال بايتاتها.
الـorigin البديل بسيط عمدًا
نقلت مجموعة الوسائط إلى خادم مستقل مهمته عمليًا Nginx وSSD. في workload ثابت فقط، من المعقول التفكير كنقطة بداية في آلة صغيرة بنحو vCPU واحد وقرابة 1 GB RAM ومئات الجيجابايت من SSD إذا كانت الشبكة والقرص مناسبين.
هذا ليس قانون sizing عالميًا. المتطلبات الحقيقية تعتمد على bandwidth وأحجام الملفات والتزامن وIOPS ونسبة MISS. كما أن الخادم الذي استخدمته في القياسات الحالية يملك موارد أكثر، لذلك لا أقدم أرقام الذروة الحالية كـbenchmark لجهاز 1-vCPU/1-GB بالضبط.
البنية الجديدة
Browser
↓
خادم العمل
↓
Nginx + media cache محلي
│
├── HIT
│
└── MISS
↓
WireGuard
↓
media origin
↓
Nginx
↓
SSD
الـorigin خاص. يتصل به خادم العمل عبر عنوان private واحد ثابت. يبقى HTTPS العام على خادم العمل، بينما يمكن استخدام HTTP العادي داخل نفق WireGuard المشفر أصلًا.
ما الذي اختفى من cold path
المسار القديم كان يحتوي DNS عامًا ومسار إنترنت عامًا واختيار أحد عناوين storage وTCP وTLS وطلب Storage API. الجديد أقرب إلى:
خادم العمل
↓
WireGuard
↓
private IP ثابت
↓
Nginx
↓
SSD
الشبكة لم تختفِ، لكنني أزلت متغيرات لم تكن ضرورية لعلاقة server-to-server واحدة.
أصبح MISS البارد رخيصًا لدرجة أن الفرق البصري بينه وبين HIT تقلص كثيرًا
بعد النقل فتحت عمدًا صفحات نادرة لإجبار النظام على cold media. اختبارات 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 نفسه كانت الاستجابة للطلب الصغير نفسه نحو 0.5–0.9 ms.
هذه اختبارات Range/TTFB صغيرة وليست وقت تنزيل فيديو كامل. زمن النقل الكامل ما زال يعتمد على حجم الملف والـthroughput. النتيجة المفيدة هي أن cold path أصبح قصيرًا ومستقرًا.
الحالة السيئة القديمة والمسار الجديد يختلفان بأكثر من رتبة حجم
المسارات القديمة الطبيعية كانت غالبًا 29–44 ms. الحالة السيئة التي قستها كانت نحو 1.054 ثانية. الطلب البارد الصغير الجديد قرب 20 ms.
مقارنة ذلك outlier المحدد بـ20 ms تعطي نحو 52× في latency. هذا لا يعني أن “خادمي أسرع من Bunny Storage بـ52 مرة”، بل هو مقارنة بين مسارين مقاسين بعينهما. المهم أن الـlong tail الذي كنت أراه لم يظهر في الاختبارات الحالية.
أصبح MP4 أبسط أيضًا
مع MP4 أستطيع الآن اتباع استراتيجية أخرى: عند cold fill يطلب Nginx العامل الملف كاملًا من origin، يخزنه كمدخل cache واحد، ثم يخدم أي byte ranges للمتصفح محليًا. يمكن إزالة Range من upstream أثناء fill، بينما يسمح proxy_force_ranges بإجابات Range محلية.
cold MISS
↓
إحضار MP4 كاملًا من private origin
↓
ملف cached واحد
↓
طلبات Range للمستخدم تُخدم محليًا كـ206
لكن full-object caching له ثمن
إذا كان MP4 البارد 500 MB وأراد أول مستخدم عشر ثوان فقط، فقد ينقل ملء cache الملف كاملًا بين الخادمين بدل Range صغير. بالنسبة لأحجامي وأنماط الاستخدام لدي، أقبل هذا trade-off لأن كل طلبات Range التالية تعيد استخدام cache محليًا بسيطًا. أما مكتبة فيديوهات باردة متعددة الجيجابايت فسأفكر فيها في sliced cache مستقر أو HLS/DASH أو video CDN أو Bunny Stream.
السlices الثابتة ما زالت مفيدة لوسائط كبيرة أخرى
لا أعتبر Nginx Slice سيئًا. مع object immutable وorigin مستقر يظل مفيدًا. الفرق أن كل قطعة الآن تأتي من origin واحد أتحكم فيه ومن تمثيل ملف واحد أتحكم فيه.
Cache أصغر قد يعطي تجربة أسرع إذا كان MISS رخيصًا
الـcache القديم وصل إلى 35–36 GB ومئات آلاف الإدخالات. بعد النقل استقر cache الجديد قرب 16 GB لأنني أبقي عمدًا نحو 8 GB فارغة عبر min_free.
قديم:
HIT = سريع
MISS = أحيانًا مؤلم
جديد:
HIT = سريع
MISS = سريع بما يكفي أيضًا
cache كبير يقلل عدد MISS. origin جيد يقلل تكلفة كل MISS. في هذا workload اتضح أن الخاصية الثانية أهم مما توقعت.
حركة الإنتاج الحقيقية أكدت أن origin يؤدي دور origin فقط
في snapshot مدته 60 ثانية أرسل خادم العمل للمستخدمين نحو 96.84 Mbit/s بينما استقبل من media origin نحو 7.07 Mbit/s. لم تظهر خلال النافذة أخطاء جديدة من نوع 502 أو 503 أو 504 أو upstream timeout.
وفي snapshot لاحق لمدة 30 ثانية كان public TX 61.36 Mbit/s وWireGuard RX من origin 1.40 Mbit/s. كانت نسبة بايتات origin إلى public TX تساوي 2.28% في تلك النافذة.
لا أسمي معكوس هذه النسبة cache-hit rate دقيقًا، لأن public traffic يحتوي أشياء غير media ولأن نسبة البايتات ليست نسبة الطلبات. لكنها توضح أن origin بعد warm-up يخدم أساسًا cold fills بدل تكرار كامل حركة المستخدمين.
ما الذي سأراقبه الآن
$upstream_addr
$upstream_connect_time
$upstream_header_time
$upstream_response_time
$upstream_cache_status
$request_time
$status
وسأقارن HIT وMISS بصورة منفصلة، خصوصًا p95 وp99 للـMISS.
وللفيديو سأختبر Range الأول، Range من المنتصف، suffix Range من النهاية، عدة ranges غير متسلسلة للـseek، cold مقابل warm، وأتحقق من 206 وContent-Range وContent-Length وAccept-Ranges وETag. وإذا استخدمت Slice فسأتحقق صراحة من ثبات validators بين عدة slices.
طلب cold واحد و20 ملف cold مختلف ليست الاختبار نفسه
proxy_cache_lock مفيد عندما تريد طلبات كثيرة cache key باردًا واحدًا. لكنه لا يحول عشرين ملفًا مختلفًا إلى طلب origin واحد. صفحة تحتوي خمسين صورة cold مختلفة قد تكون اختبارًا أكثر واقعية بكثير من benchmark يكرر URL واحدًا.
الاستضافة الذاتية تمنحني التحكم، لا الاعتمادية مجانًا
الخادم الخاص يجعل critical path أوضح لي، لكنه يجعل النسخ الاحتياطي وصحة القرص والمساحة والتحديثات وfirewall والمراقبة والاستعادة وربما التكرار مسؤوليتي.
والـorigin الواحد نقطة فشل واحدة للملفات غير الموجودة في cache. قد تستمر الملفات cached في العمل، لكن MISS الجديد يحتاج origin سليمًا. الـHA يحتاج هندسة إضافية.
متى سأختار Bunny مرة أخرى
إذا احتجت توزيعًا عالميًا للمستخدمين أو redundancy مُدارة أو storage يتوسع بسرعة أو أقل قدر من إدارة البنية التحتية، فسأفكر في Bunny مرة أخرى دون تردد. وسأقيّم تحديدًا بنية Storage + Pull Zone/CDN التي توثقها Bunny بدل افتراض أن تصميمي القديم، Storage API خلف Nginx، هو الطريقة الوحيدة.
اختباري لا يثبت أن VPS واحد أفضل من Bunny CDN، ولا أن Bunny Storage بطيء دائمًا، ولا أن Bunny لا يدعم Range. ما يثبته هو أن استخدام Bunny Storage مباشرة كـruntime origin لذاكرة Nginx الخاصة بي أنتج في workload هذا سلوكًا للـcold path والفيديو sliced لم أعد أرغب في إدارته.
القاعدة التي أستخدمها الآن
خلال الشهرين حسنت cache size وcache keys وRange و206 وSlice وchunks بحجم 1 MB وkeepalive وإعادة استخدام TLS وretries وstale وtimeouts. كلها أدوات هندسية صحيحة.
أقوى تحسين كان أبسط: غيرت الـorigin.
ماذا يحدث عندما لا يكون الملف الذي يريده المستخدم موجودًا في cache؟
إذا كانت الإجابة origin مستقرًا وTTFB متوقعًا وRange صحيحًا وvalidators متسقة وfailure modes مفهومة، يصبح cache مجرد optimization. أما إذا كان UX الجيد يعتمد على ألا يرى المستخدم MISS أبدًا، فالـcache يخفي مشكلة معمارية أعمق.
بالنسبة إلى workload لدي، كان media origin صغير وخاص وممل هو الخيار الأنسب. ليس CDN، وليس سحرًا، ولا يعفيني من التشغيل. لكنه جعل cold path مملًا. وبعد شهرين من تتبع الصور وtimeouts والـRange في الفيديو وetag mismatch in slice response، كان هذا بالضبط ما أريده.