الأدلة التقنية

كيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظه

لماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.

كيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظه

عندما يفتح زائر في الرياض موقعك الذي يعمل على سيرفر في فرانكفورت، فإن كل ملف تطلبه الصفحة، من ملف CSS إلى الخطوط والصور وملفات JavaScript، يقطع قرابة أربعة آلاف كيلومتر ذهاباً ومثلها إياباً، والسيرفر نفسه يرسل الملفات نفسها لكل زائر جديد كأنه يرسلها لأول مرة، وعندما ينتشر رابط من موقعك فجأة فإن هذا السيرفر الواحد هو الذي يتحمل الزيارات كلها، ثم تأتي في آخر الشهر فاتورة حركة البيانات الخارجة منه. والحل الذي تستخدمه أغلب المواقع الكبيرة لهذه المشكلات مجتمعة هو شبكة توصيل المحتوى Content Delivery Network، واختصاراً CDN.

وقد شرحنا في خدمات Cloudflare كيف تعمل خدمة ال CDN عند Cloudflare تحديداً وما تحفظه بشكل افتراضي، أما هذا المقال فيشرح الفكرة نفسها بشكل عام، أي القواعد التي تتبعها كل شبكة CDN وكل Cache على الطريق، حتى تعرف ماذا ترسل من سيرفرك وماذا تتوقع، أياً كان المزود أو الأداة، وسوف نناقش في هذا المقال ما يلي:

  • المشكلة التي تحلها ال CDN، ولماذا لا يحلها سيرفر أقوى.
  • ما هي ال CDN: السيرفر الأصلي Origin ونقاط التواجد PoP، وكيف يصل الزائر إلى أقربها، ومعنى HIT وMISS وTTL، والفرق بين Pull وPush.
  • قواعد HTTP التي تتحكم في كل شيء: ترويسة Cache-Control بقيمها، والتحقق عبر ETag والرد 304، ومفتاح ال Cache وترويسة Vary، وتغيير اسم الملف عند كل نسخة جديدة.
  • ما الذي تحفظه في ال Cache، وما الذي لا يجب أن تحفظه أبداً.
  • مسح ال Cache Purge، وطبقة الحماية Origin Shield، وتحسين الصور على الحافة.
  • ما تستطيع أن تبنيه بنفسك، وحدوده بصراحة، ثم الخدمات التجارية.
  • تجربة عملية ب Nginx أمام سيرفر بطيء، ثم قياس الأداء وأخطاء شائعة وقائمة تحقق.

لماذا يبطؤ موقعك على الزائر البعيد؟

لنفرض أن لديك موقعاً يعمل على VPS واحد في أوروبا، وزواره في الخليج وآسيا وأمريكا الجنوبية، فأمامك أربع مشكلات منفصلة يجمعها سبب واحد هو أن كل طلب يذهب إلى الجهاز نفسه في المكان نفسه:

  • المسافة Latency: الضوء داخل الألياف الضوئية يقطع قرابة 200 ألف كيلومتر في الثانية، فكل 100 كيلومتر تضيف قرابة مللي ثانية واحدة إلى زمن الذهاب والعودة RTT، كما شرحنا في لماذا يتأخر الرد؟، وهذا حد فيزيائي لا يتغير مهما زادت قوة السيرفر أو سرعة اشتراك الزائر. والصفحة الواحدة لا تحتاج ذهاباً وإياباً مرة واحدة، وإنما تحتاج اتصال TCP ثم مصافحة TLS ثم طلب HTML ثم طلبات الملفات التي يذكرها، فيتضاعف أثر المسافة.
  • الطلبات المتكررة للملفات نفسها: شعار الموقع وملف CSS وملفات JavaScript لا تتغير بين زائر وآخر، ومع ذلك يقرؤها السيرفر من القرص ويرسلها آلاف المرات في اليوم، وهذا وقت معالج وحركة شبكة كان يمكن توفيرهما.
  • ذروة الزيارات Traffic Spike: رابط ينتشر في الشبكات الاجتماعية أو إعلان في وقت محدد يرفع الزيارات أضعافاً خلال دقائق، والسيرفر الواحد له حد لعدد الاتصالات وسعة الشبكة، فإذا تجاوزته بطؤ الموقع على الجميع أو توقف.
  • تكلفة حركة البيانات Egress: كثير من مزودي السيرفرات والتخزين السحابي يحاسبونك على البيانات الخارجة من السيرفر، أو يعطونك حداً شهرياً ثم يحاسبونك على ما بعده، والصور والفيديو هي أكبر بند فيها.

والصورة التالية تبين هذه المشكلات الأربع مجتمعة على سيرفر واحد:

مخطط لسيرفر أصلي واحد في فرانكفورت يتصل به زوار من الرياض وجاكرتا وساو باولو بخطوط طويلة على بعد 4 آلاف و11 ألفاً و10 آلاف كيلومتر، وبجانبه أربعة أرقام: المسافة، والملفات نفسها تنزل عشرة آلاف مرة، وذروة زيارات مفاجئة، وفاتورة حركة البيانات الخارجة
المسافة والطلبات المتكررة والذروة والفاتورة كلها تقع على جهاز واحد

وقد يتساءل البعض: لماذا لا أشتري سيرفراً أقوى؟ والإجابة أن السيرفر الأقوى يحل جزءاً من المشكلة الثالثة فقط، فهو يتحمل زيارات أكثر، لكنه لا يقرب المسافة من الزائر في جاكرتا، ولا يمنع تكرار الطلبات نفسها، ولا يخفض حركة البيانات الخارجة. والحل الذي يعالج المشكلات الأربع معاً هو أن توضع نسخ من الملفات في أماكن قريبة من الزوار، وهذه هي فكرة ال CDN.

ما هي شبكة توصيل المحتوى CDN؟

شبكة توصيل المحتوى هي مجموعة من السيرفرات موزعة في مدن كثيرة حول العالم، تقف بين الزائر وسيرفرك وتحفظ نسخاً مؤقتة من ملفاتك، فإذا طلب الزائر ملفاً موجوداً عند أقرب سيرفر إليه حصل عليه من هناك دون أن يصل طلبه إلى سيرفرك. وسيرفرك هنا يسمى السيرفر الأصلي Origin لأنه مصدر النسخة الأصلية، وكل موقع من مواقع الشبكة يسمى نقطة التواجد Point of Presence، واختصاراً PoP، وفيه عادة عدد من السيرفرات التي تسمى سيرفرات الحافة Edge Servers لأنها على حافة الشبكة قرب المستخدمين.

والتشبيه الأقرب هو المستودع المركزي وفروعه: الشركة لا ترسل كل طلب من المستودع الرئيسي في مدينة واحدة إلى كل عميل في البلد، وإنما تضع في كل فرع البضائع الأكثر طلباً، فيستلمها العميل من الفرع القريب في اليوم نفسه، وإذا طلب شيئاً غير موجود في الفرع طلبه الفرع من المستودع مرة واحدة واحتفظ بنسخة منه لمن يأتي بعده.

كيف يصل الزائر إلى أقرب نقطة؟

الزائر يكتب اسم موقعك نفسه، ولا يعرف شيئاً عن نقاط التواجد، والذي يوجهه إلى أقربها هو واحدة من طريقتين، وأغلب الشبكات الكبيرة تجمع بينهما:

  • التوجيه عبر DNS: تجعل اسم الموقع، أو النطاق الفرعي للملفات مثل cdn.example.com، يشير بسجل CNAME إلى اسم يملكه مزود ال CDN، كما شرحنا في سجل CNAME، ثم يجيب سيرفر DNS الخاص بالمزود بعنوان نقطة التواجد الأقرب إلى من يسأل، وهذا ما يسمى GeoDNS. ولاحظ أن هذا الجواب يحفظ في ذاكرة ال Resolver طوال مدة TTL، فإذا تعطلت نقطة تواجد فإن من حفظ عنوانها يحتاج أن تنتهي هذه المدة حتى ينتقل إلى غيرها، كما شرحنا في التخزين المؤقت ومدة بقاء السجل TTL.
  • Anycast: تعلن الشبكة عنوان IP واحداً من كل نقاط التواجد في الوقت نفسه عبر BGP، فتوصل شبكات الإنترنت كل زائر إلى أقرب نقطة تعلن هذا العنوان دون أي تدخل من DNS، وقد شرحنا هذه الفكرة ونقاط تبادل الإنترنت IXP التي تقف فيها هذه النقاط في مراكز البيانات وشبكات CDN.

و«أقرب» هنا تعني الأقرب من ناحية الشبكة وليس الخريطة، فقد يصل زائر في مدينة ما إلى نقطة في مدينة أبعد لأن مزوده يتبادل الحركة هناك مع شبكة ال CDN.

HIT وMISS وانتهاء الصلاحية

عندما يصل الطلب إلى سيرفر الحافة فإنه يبحث عن نسخة محفوظة من الملف، والنتيجة واحدة من ثلاث حالات سوف تراها في ترويسات الرد عند كل مزود بأسماء متقاربة:

  • HIT: النسخة موجودة وما زالت صالحة Fresh، فيرد سيرفر الحافة مباشرة، وهذا هو الهدف.
  • MISS: لا توجد نسخة، فيطلب سيرفر الحافة الملف من السيرفر الأصلي، ثم يرسله للزائر ويحفظ نسخة منه إذا سمحت ترويسات الرد بذلك.
  • EXPIRED أو STALE: النسخة موجودة لكن مدة صلاحيتها انتهت، فيسأل سيرفر الحافة السيرفر الأصلي هل تغير الملف، أو يرسل النسخة القديمة مؤقتاً إذا سمح الإعداد بذلك، كما سنرى في التجربة العملية.

ومدة الصلاحية هذه تسمى TTL، أي Time To Live، وهي عدد الثواني التي تبقى فيها النسخة صالحة دون أن يسأل سيرفر الحافة عنها، ومن الذي يحددها؟ الأصل أن سيرفرك يحددها بترويسة Cache-Control التي نشرحها بعد قليل، وبعض المزودين يسمحون لك بأن تتجاوزها بقواعد من لوحة التحكم. والصورة التالية تبين الطريق كاملاً:

مخطط لسيرفر أصلي في الأعلى وثلاث نقاط تواجد في الرياض وجاكرتا وساو باولو، الزائر الأول في الرياض يحصل على MISS فتطلب النقطة الملف من السيرفر الأصلي وتحفظه، والزوار التالون يحصلون على HIT، وفي ساو باولو نسخة منتهية تتحقق منها النقطة بطلب If-None-Match فيرد السيرفر 304
الطلب الأول MISS يصل إلى سيرفرك، وما بعده HIT من النقطة القريبة، والنسخة المنتهية يتحقق منها دون إعادة تنزيلها

Pull أم Push؟

هناك طريقتان لوصول ملفاتك إلى ال CDN:

  • Pull CDN: لا ترفع شيئاً، وإنما تعطي ال CDN عنوان سيرفرك الأصلي، فتطلب منه كل ملف عند أول MISS ثم تحفظه، وهي الطريقة الأشيع والأسهل لأن سيرفرك يبقى المصدر الوحيد للحقيقة، وعيبها أن الزائر الأول في كل نقطة تواجد ينتظر الطريق كاملاً إلى السيرفر الأصلي.
  • Push CDN: ترفع الملفات بنفسك إلى تخزين المزود، فيصبح هو المصدر، وهذه تناسب الملفات الكبيرة التي لا تتغير ويطلبها كثيرون، مثل ملفات التثبيت والفيديو، وعيبها أنك مسؤول عن رفع كل نسخة جديدة وحذف القديمة.

وفي الواقع فإن الحد بين الطريقتين أصبح أقل وضوحاً، فالشائع اليوم أن تضع الملفات الكبيرة في تخزين الكائنات Object Storage ثم تجعله هو السيرفر الأصلي لشبكة Pull، كما سيأتي.

Cache-Control: الترويسة التي تقرر كل شيء

كل Cache في طريق الطلب، سواء كان متصفح الزائر أو Reverse Proxy أمام تطبيقك أو شبكة CDN، يتبع قواعد واحدة يحددها معيار RFC 9111 الخاص بالتخزين المؤقت في HTTP، وأهم ما في هذه القواعد ترويسة Cache-Control التي يرسلها سيرفرك مع كل رد. ويفرق المعيار بين نوعين من ال Cache: Cache خاص Private Cache وهو متصفح الزائر الذي يخدم شخصاً واحداً، وCache مشترك Shared Cache وهو كل ما يخدم أكثر من شخص، مثل ال CDN وال Reverse Proxy، وهذا الفرق هو أساس كل ما يلي.

والقيم التي سوف تستخدمها هي:

  • max-age=N: الرد صالح لمدة N ثانية من وقت إنشائه، ويطبقه المتصفح وال CDN معاً.
  • s-maxage=N: مثل max-age لكنه لل Cache المشترك فقط، ويتقدم على max-age عنده، فتستطيع أن تقول للمتصفح «تحقق كل مرة» ولل CDN «احفظه دقيقة».
  • public: يسمح صراحة لأي Cache بأن يحفظ الرد، حتى لو كان الطلب يحمل ترويسة Authorization، لذلك لا تضعه إلا على ما هو عام فعلاً.
  • private: الرد لمستخدم واحد، فيحفظه المتصفح فقط، ولا يجوز لأي Cache مشترك أن يحفظه.
  • no-cache: يجوز حفظ الرد، لكن لا يجوز استخدامه قبل التحقق من السيرفر في كل مرة.
  • no-store: لا يحفظ الرد في أي مكان، لا في المتصفح ولا في ال CDN.
  • immutable: الملف لن يتغير أبداً خلال مدة صلاحيته، فلا يحتاج المتصفح أن يتحقق منه حتى عندما يضغط الزائر زر التحديث، وهي من معيار منفصل هو RFC 8246.
  • stale-while-revalidate=N: بعد انتهاء الصلاحية يجوز إرسال النسخة القديمة لمدة N ثانية بينما يجدد ال Cache نسخته في الخلفية، فلا ينتظر الزائر، ومعها stale-if-error=N التي تسمح بالنسخة القديمة إذا كان السيرفر الأصلي معطلاً، وكلتاهما من RFC 5861.

no-cache لا تعني «لا تحفظ»

وهذا هو الخطأ الأشهر في هذه الترويسة: كثيرون يكتبون no-cache على صفحة خاصة ظناً أنها تمنع الحفظ، والصحيح أن المعيار يسمح بحفظ الرد معها ويشترط فقط أن يتحقق ال Cache من السيرفر قبل استخدامه، أما المنع الحقيقي فهو no-store، وأما منع ال Cache المشترك وحده مع السماح للمتصفح فهو private، وقد شرحنا القيمة المناسبة للصفحات الخاصة في Cache-Control للصفحات الخاصة. ولكي ترى كيف يبدو ذلك في موقع حقيقي، فالأمر التالي يطلب ملف CSS من موقعنا هذا، ثم يطلب واجهة برمجة لوحة الإدارة في Ghost:

curl -sI "https://arabroot.io/assets/css/screen.css?v=XVE0lbl24sMme7M1" | grep -i -E "^HTTP|cache-control|^age:|^etag|last-modified|vary|cf-cache"
curl -sI "https://arabroot.io/ghost/api/admin/site/" | grep -i -E "^HTTP|cache-control|cf-cache"

والمخرج سوف يكون كما يلي:

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000
etag: W/"17fdb-1a10d980508"
last-modified: Mon, 05 Oct 2026 19:43:49 GMT
vary: Accept-Encoding
Age: 26471
cf-cache-status: HIT

HTTP/1.1 200 OK
Cache-Control: no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0
cf-cache-status: DYNAMIC

في المخرج أعلاه لاحظ التالي:

  • ملف CSS صالح لمدة سنة كاملة max-age=31536000، وهذا آمن لأن رابطه يحمل رقم نسخة في ?v= يتغير عند كل تحديث للقالب، كما سنشرح في قسم تغيير اسم الملف.
  • الترويسة Age: 26471 تعني أن هذه النسخة محفوظة في ال CDN منذ قرابة سبع ساعات، وcf-cache-status: HIT تعني أن الرد جاء من نقطة التواجد دون أن يصل الطلب إلى سيرفرنا.
  • واجهة الإدارة ترسل كل القيم التي تمنع الحفظ معاً، بما فيها قيم مثل post-check وpre-check لا يعرفها المعيار الحالي أصلاً، وهذا أسلوب شائع في الأطر البرمجية للاحتياط مع أي Cache قديم، والقيمة التي تقوم بالعمل فعلاً هنا هي no-store.

ETag وLast-Modified والرد 304

عندما تنتهي صلاحية النسخة فإن إعادة تنزيل الملف كاملاً هدر إذا لم يتغير، لذلك يرسل السيرفر مع الرد ما يسمى المدقق Validator، وهو واحد من اثنين: ETag، وهو معرف للنسخة يختاره السيرفر وغالباً هو هاش للمحتوى، أو Last-Modified، وهو وقت آخر تعديل. وعند انتهاء الصلاحية يرسل ال Cache طلباً مشروطاً Conditional Request يحمل If-None-Match مع قيمة ETag، أو If-Modified-Since مع الوقت، فإذا لم يتغير الملف رد السيرفر 304 Not Modified دون أي محتوى، فيجدد ال Cache صلاحية نسخته ويستخدمها. والنتيجة أن الطلب إلى السيرفر ما زال موجوداً، ولكن حجم الرد بضع مئات من البايتات بدل الملف كاملاً، والتفاصيل في توثيق MDN للطلبات المشروطة.

وقد يتساءل البعض: ماذا يحدث إذا لم يرسل سيرفري Cache-Control إطلاقاً؟ والإجابة أن المعيار يسمح لل Cache بأن يقدر مدة صلاحية من عنده Heuristic Freshness لبعض الردود، ويقترح مثلاً 10% من المدة منذ Last-Modified، فملف عدل قبل عشرة أيام قد يحفظ يوماً كاملاً دون أن تطلب ذلك، وكل مزود وكل أداة لها سلوكها الافتراضي المختلف. لذلك لا تترك هذا القرار للتخمين، وأرسل Cache-Control صريحة مع كل نوع من الردود.

والصورة التالية تختصر القرار في ثلاثة أسئلة:

مخطط قرار يبدأ برد من التطبيق: هل يختلف لكل مستخدم؟ إذا نعم: هل هو حساس؟ نعم يعني no-store ولا يعني private, no-cache. وإذا لم يختلف: هل يتغير اسمه مع محتواه؟ نعم يعني public, max-age=31536000, immutable، ولا يعني public, max-age=0, s-maxage=60 مع ETag
ثلاثة أسئلة تحدد قيمة Cache-Control لأي رد، واليسار ما تحفظه ال CDN واليمين ما لا تحفظه

مفتاح ال Cache وترويسة Vary

كيف يعرف ال Cache أن الطلب الجديد هو الطلب نفسه الذي حفظ رده من قبل؟ عن طريق مفتاح ال Cache Cache Key، وهو بحسب RFC 9111 يتكون على الأقل من طريقة الطلب Method والعنوان الكامل Target URI، وأغلب ال Caches لا تحفظ إلا طلبات GET، فيصبح المفتاح عملياً هو العنوان كاملاً: البروتوكول والنطاق والمسار وسلسلة الاستعلام Query String. وفي Nginx مثلاً القيمة الافتراضية للمفتاح هي $scheme$proxy_host$request_uri كما في توثيق proxy_cache_key.

وهذا يعني أن /hero.webp?utm_source=x و/hero.webp?utm_source=y مفتاحان مختلفان لنفس الملف، فكل حملة تسويقية تضيف معاملاتها إلى الرابط تصنع نسخاً جديدة وطلبات MISS جديدة إلى سيرفرك، والحل أن تحذف هذه المعاملات من المفتاح أو ترتبها، وأغلب مزودي CDN يسمحون بذلك من الإعدادات.

ثم تأتي ترويسة Vary، وهي التي يقول بها سيرفرك إن الرد يختلف بحسب ترويسة معينة في الطلب، فيضيف ال Cache قيمة تلك الترويسة إلى المفتاح. والمثال الأشهر Vary: Accept-Encoding، فالملف نفسه يرسل مضغوطاً ب Brotli لمتصفح يفهمه وب gzip لغيره، فيحفظ ال Cache نسخة لكل طريقة ضغط، وهذا متوقع وسليم. أما Vary: Cookie فيعني نسخة لكل زائر، لأن كل زائر له Cookie مختلف، فيعمل ال Cache بلا فائدة ونسبة HIT تقترب من الصفر، وإذا كان الرد يختلف فعلاً بحسب ال Cookie فالأصح ألا يحفظ في ال Cache المشترك إطلاقاً. ولاحظ أن Nginx مثلاً لا يحفظ أي رد يحمل Vary: *، ويحفظ ما سواه مع مراعاة الترويسات المذكورة فيه بحسب توثيق proxy_cache_valid.

مخطط يقسم عنواناً إلى البروتوكول والنطاق والمسار وسلسلة الاستعلام ويضيف إليها الترويسات المذكورة في Vary ليكون مفتاح ال Cache، ثم ثلاث حالات: معاملات تسويقية تصنع ثلاث نسخ لنفس الملف وثلاث MISS، وVary: Accept-Encoding تصنع نسختين br وgzip وهذا سليم، وVary: Cookie تصنع نسخة لكل زائر ونسبة HIT قرابة الصفر
كل اختلاف في المفتاح نسخة جديدة وطلب جديد إلى السيرفر الأصلي

غير اسم الملف بدل أن تنتظر انتهاء صلاحيته

هنا تواجه تعارضاً واضحاً: تريد أن تبقى ملفات CSS وJavaScript في ال Cache أطول مدة ممكنة حتى لا تنزل مرة أخرى، وتريد في الوقت نفسه أن يرى الزوار النسخة الجديدة فور نشرها. والحل البسيط أن تعطيها صلاحية قصيرة، ساعة مثلاً، فتخسر أغلب فائدة ال Cache وتبقى ساعة كاملة يرى فيها بعض الزوار القديم وبعضهم الجديد. والحل الثاني أن تعطيها صلاحية طويلة ثم تمسح ال Cache عند كل نشر، وهذا يصل إلى ال CDN فقط، أما الملف المحفوظ في متصفحات الزوار فلا يستطيع أحد أن يمسحه، فيبقى الزائر بملف JavaScript قديم مع صفحة HTML جديدة، وقد تتعطل الصفحة.

والحل الصحيح هو بصمة في اسم الملف Fingerprinting: أداة البناء تحسب هاش لمحتوى الملف وتضعه في اسمه، فيصبح app.3f9c1a.js، وتكتب صفحة HTML هذا الاسم، فإذا تغير سطر واحد في الملف تغير الهاش وأصبح الاسم app.7b2e90.js، وهو عنوان جديد لم يحفظه أي Cache من قبل. وبهذا الشكل تعطي الملفات صلاحية سنة مع immutable، ولا تمسح أي شيء، والشيء الوحيد الذي يجب أن يتجدد بسرعة هو صفحة HTML نفسها، وهي صغيرة وصلاحيتها قصيرة. وأغلب أدوات البناء الحديثة تفعل ذلك بشكل افتراضي في وضع الإنتاج Production، ومن لا يستطيع تغيير اسم الملف يضع رقم النسخة في سلسلة الاستعلام كما رأيت في رابط screen.css?v=… من موقعنا، وهذا يعمل لأن سلسلة الاستعلام جزء من مفتاح ال Cache، بشرط ألا يكون مزودك قد ضبط حذفها من المفتاح.

مخطط يقارن طريقتين: يسار، أداة البناء تكتب app.7b2e90.js باسم جديد وتتغير صفحة index.html وحدها فتحصل النقطة على MISS واحد ثم HIT لمدة سنة دون أي مسح. يمين، الاسم نفسه app.js، ومسح ال CDN يصل إلى نقاط التواجد ولا يصل إلى المتصفحات التي تحتفظ بالنسخة القديمة فتنكسر الصفحة
الملف الذي يتغير محتواه يأخذ اسماً جديداً، فلا يبقى في أي Cache نسخة قديمة تحت الاسم نفسه

ما الذي تضعه في ال Cache، وما الذي لا يجب أن تضعه أبداً

القاعدة العامة أن ما هو واحد لكل الزوار يحفظ، وما يخص زائراً واحداً لا يحفظ في أي Cache مشترك، والجدول التالي يفصل ذلك:

النوعهل تحفظه ال CDN؟القيمة المقترحة
ملفات CSS وJavaScript والخطوط ببصمة في الاسمنعم، أطول مدةpublic, max-age=31536000, immutable
الصور والفيديو وأجزاء البث HLS/DASHنعممدة طويلة مع بصمة، أو أيام مع ETag
صفحات HTML العامة مثل المقالاتنعم بحذر، مدة قصيرةpublic, max-age=0, s-maxage=60 مع ETag
ردود API عامة لا تعتمد على المستخدمممكن، مدة قصيرةpublic, s-maxage=30
صفحات بعد تسجيل الدخول ولوحات الإدارةلاprivate, no-cache أو no-store
ردود API تعتمد على Cookie أو Authorizationلاprivate, no-store
الردود التي تحمل Set-Cookieلاprivate, no-store

والنقطة التي يغفل عنها الكثيرون هي أن المعيار نفسه لا يعتبر Set-Cookie مانعاً للحفظ، فهو يقول صراحة في قسم الخصوصية من RFC 9111 إن الرد القابل للحفظ الذي يحمل Set-Cookie يمكن أن يستخدم، وغالباً ما يستخدم، لطلبات أخرى، ويطلب من السيرفرات التي لا تريد ذلك أن ترسل Cache-Control مناسبة. صحيح أن بعض الأدوات تحتاط بنفسها، فNginx مثلاً لا يحفظ أي رد يحمل Set-Cookie، لكن لا تعتمد على احتياط الأداة. أما ترويسة Authorization في الطلب فالمعيار يمنع ال Cache المشترك من استخدام رده لطلبات أخرى إلا إذا سمح الرد بذلك صراحة بقيمة مثل public أو s-maxage، لذلك لا تضع public على ردود API الخاصة.

وقد شرحنا في ملفات تعريف الارتباط Cookies كيف يحمل كل طلب بعد تسجيل الدخول ما يثبت هوية صاحبه، وأي Cache يحفظ رداً من هذه الردود ثم يقدمه لغيره يسلم صفحة شخص لشخص آخر، وتفاصيل هذا الخطأ مع قواعد Cloudflare في لا تحفظ في الذاكرة المؤقتة ما يخص مستخدماً واحداً، وسوف نصنعه بأيدينا في التجربة العملية حتى تعرف كيف يبدو.

📌
في فبراير 2017 نشر الباحث الأمني Omer Gil نوعاً من الهجمات سماه Web Cache Deception، وفكرته أن يرسل المهاجم للضحية رابطاً مثل /myaccount/home/stylesheet.css، فيتجاهل التطبيق الجزء الأخير ويعرض صفحة الحساب الخاصة بالضحية، بينما يرى ال Cache الذي أمامه امتداد .css فيحفظ الصفحة على أنها ملف ثابت، ثم يفتح المهاجم الرابط نفسه فيحصل على النسخة المحفوظة. وذكر الباحث أن PayPal كان معرضاً لهذا الخطأ قبل إصلاحه، وأن الصفحات التي كان يمكن تسريبها تشمل اسم المستخدم ورصيد حسابه وآخر أربعة أرقام من بطاقته. والدرس أن يحفظ ال Cache بحسب ترويسات الرد وليس بحسب امتداد الملف في الرابط، وأن يرد التطبيق على المسارات غير الموجودة ب 404 بدل أن يعرض أقرب صفحة إليها.

المسح Purge وطبقة الحماية Origin Shield وتحسين الصور

متى تحتاج المسح؟

المسح Purge، ويسميه بعض المزودين Invalidation، هو أن تطلب من ال CDN حذف نسخة قبل انتهاء صلاحيتها، وكل المزودين يقدمونه من لوحة التحكم ومن واجهة برمجية API، فCloudflare مثلاً تتيح في كل خططها المسح بالرابط والنطاق والوسم Tag والبادئة Prefix ومسح كل شيء بحسب توثيق المسح، وفي CloudFront اسمه Invalidation. لكن المسح يصل إلى ال CDN فقط ولا يصل إلى متصفحات الزوار، ويأخذ وقتاً حتى ينتشر في كل نقاط التواجد، وله حدود في عدد الطلبات، لذلك اجعله للحالات الاستثنائية، مثل صورة نشرت بالخطأ أو صفحة HTML تحتاج أن تتجدد قبل موعدها، واجعل الطريقة الأساسية للتحديث هي تغيير اسم الملف. ولاحظ أن خادم Nginx مفتوح المصدر لا يتضمن المسح أصلاً، فتعليمة proxy_cache_purge بحسب توثيقها متاحة في الاشتراك التجاري فقط، وهذا سبب آخر لأن تعتمد على الأسماء الجديدة.

طبقة الحماية Origin Shield

إذا كان لدى ال CDN مئات من نقاط التواجد، فقد يصل إلى سيرفرك طلب MISS من كل واحدة منها للملف نفسه، والحل الذي يقدمه أغلب المزودين هو طبقة وسطى: نقطة تواجد واحدة قريبة من سيرفرك، أو عدد قليل منها، تمر بها كل طلبات MISS، فتطلب من سيرفرك مرة واحدة وتخدم منها بقية النقاط. وتسميها Fastly Shielding، وتسميها Cloudflare Tiered Cache، وتسميها CloudFront Origin Shield. ويمكنك أن ترى ذلك في ملف من شبكة jsDelivr، فالرد الذي وصلنا منها جاء عبر Fastly التي تضيف ترويسات X-Served-By وX-Cache الموثقة في صفحة Shielding، والأمر التالي يطلب الملف نفسه مرتين:

curl -sI https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.prod.js | grep -i -E "^HTTP|cache-control|^age:|^etag|vary|x-cache|x-served"

والمخرج في المرتين كان كما يلي:

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
ETag: W/"268e4-ppH3G4jvWcqIWhaIGyTzpxblZRM"
Age: 591830
X-Served-By: cache-fra-eddf8230130-FRA, cache-mrs1530060-MRS
X-Cache: HIT, MISS
Vary: Accept-Encoding

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
ETag: W/"268e4-ppH3G4jvWcqIWhaIGyTzpxblZRM"
Age: 591830
X-Served-By: cache-fra-eddf8230130-FRA, cache-mrs1530047-MRS
X-Cache: HIT, HIT
Vary: Accept-Encoding

في المخرج أعلاه لاحظ التالي:

  • الترويسة X-Served-By فيها سيرفران، الأول في فرانكفورت FRA وهو طبقة الحماية، والثاني في مرسيليا MRS وهو نقطة التواجد التي وصل إليها طلبنا، وبحسب توثيق Fastly فإن هذه الترويسات تحمل قيمة لكل نقطة مر بها الطلب عند تفعيل Shielding.
  • في الطلب الأول X-Cache: HIT, MISS، أي أن نقطة مرسيليا لم تكن تملك الملف، فطلبته من طبقة الحماية التي كانت تملكه، ولم يصل الطلب إلى سيرفر jsDelivr الأصلي. وفي الطلب الثاني وصلنا إلى سيرفر آخر في مرسيليا رقمه مختلف، فوجد الملف ورد ب HIT.
  • الملف يحمل رقم نسخة [email protected] في مساره، لذلك يحفظ سنة كاملة مع immutable، وعمره في ال Cache قرابة سبعة أيام Age: 591830.

تحسين الصور على الحافة

الصور هي أكبر جزء من حجم أغلب الصفحات، وبعض المزودين يقدمون تحويلها على الحافة: تطلب الصورة الأصلية بعرض محدد وصيغة حديثة مثل WebP أو AVIF من خلال معاملات في الرابط، فتصغرها نقطة التواجد وتحفظ النتيجة، مثل Cloudflare Images وBunny Optimizer، وقد ذكرنا خدمات Cloudflare للصور في Images وStream. ومن يريد أن يفعل ذلك على سيرفره يستطيع أن يشغل أداة مثل imgproxy تصغر الصور وتحول صيغتها عند الطلب، ثم يضع أمامها Cache حتى لا تتكرر المعالجة لكل زائر. وإذا كانت الصور قليلة ومعروفة مسبقاً فالأبسط أن تجهز أحجامها وصيغها مرة واحدة عند الرفع.

هل تستطيع أن تبني CDN بنفسك؟

وهنا يجب أن نكون صريحين: قيمة ال CDN الحقيقية في نقاط التواجد المنتشرة في مدن كثيرة والمتصلة مباشرة بشبكات المزودين، وهذه لا يمكن أن تستضيفها على سيرفر واحد مهما كان قوياً، لأن السيرفر الواحد في مكان واحد. لكن هناك جزأين من الفكرة تستطيع أن تبنيهما بنفسك، ولكل منهما تكلفته.

Cache أمام تطبيقك على السيرفر نفسه

الجزء الأول والأسهل هو أن تضع Cache في ال Reverse Proxy الذي أمام تطبيقك، فيرد من الذاكرة أو القرص بدل أن ينفذ التطبيق الكود ويسأل قاعدة البيانات في كل طلب، والخيارات الشائعة هي:

  • Nginx: تعليمات proxy_cache موجودة في النسخة المفتوحة، وهي ما نستخدمه في التجربة العملية بعد قليل.
  • Vinyl Cache: أداة مخصصة للتخزين المؤقت أمام أي سيرفر HTTP، وكانت تسمى Varnish Cache، ثم انتقلت نسخة المجتمع إلى الاسم الجديد كما يذكر موقع المشروع، بينما يستمر مشروع آخر باسم Varnish Cache تطوره شركة Varnish Software، فانتبه لأي منهما تقصد عندما تقرأ توثيقاً. وقوتها في لغة الإعداد VCL التي تتحكم بها في كل خطوة من خطوات الطلب.
  • Caddy: وحدة cache-handler المبنية على مشروع Souin، وهي ليست في النسخة الافتراضية من Caddy، فتضيفها عند بناء Caddy بأداة xcaddy.

وهذا الإعداد يخفف الحمل عن تطبيقك وقاعدة بياناتك، ويحمي السيرفر من الطلبات المتكررة، لكنه لا يقرب موقعك من الزائر، فالزائر في جاكرتا ما زال يتصل بسيرفرك في أوروبا، وقد ذكرنا ذلك أيضاً عند شرح بدائل التخزين المؤقت مفتوحة المصدر. والحالة الأشيع لهذا الإعداد اليوم هي الصور والملفات التي يرفعها المستخدمون، فتحفظها في تخزين كائنات Object Storage على سيرفرك أو عند مزود، وقد شرحنا أنواع التخزين والفرق بينها في أنواع التخزين Block وFile وObject، ثم تضع أمامه Cache أو CDN تكون هي العنوان العام للملفات، فيبقى التخزين خلفها ولا يرى إلا طلبات MISS.

CDN صغيرة من عدة سيرفرات مع GeoDNS

الجزء الثاني هو أن تقلد نقاط التواجد بنفسك: تستأجر عدة سيرفرات VPS صغيرة في مناطق مختلفة، في الخليج وأوروبا وآسيا مثلاً، وتشغل على كل منها Cache مثل الذي في التجربة، يطلب من سيرفرك الأصلي عند MISS، ثم تستخدم DNS يجيب كل زائر بعنوان السيرفر الأقرب إليه، مثل GeoIP backend في PowerDNS الذي يحدد منطقة السائل من قاعدة بيانات للعناوين مثل قواعد MaxMind، ويجيبه بالسجل المناسب لمنطقته.

مخطط من جزأين: يمين، سيرفر VPS واحد فيه Nginx proxy_cache أمام التطبيق وتخزين الكائنات، ويصل إليه كل الزوار، والنتيجة حمل أقل على التطبيق والمسافة كما هي. يسار، زائر يسأل GeoDNS في PowerDNS فيجيبه بعنوان أقرب سيرفر Cache من ثلاثة في الرياض وفرانكفورت وسنغافورة، وكلها تطلب من السيرفر الأصلي عند MISS، وتحتها التكلفة: شهادات TLS والمسح والتحديثات على كل سيرفر، والفحص والتحويل عند العطل في DNS، والمراقبة
الأول يخفف الحمل عن تطبيقك، والثاني يقرب المحتوى من الزائر ولكنه يضاعف ما تديره

وقد تبدو العملية من الوهلة الأولى بسيطة، لكن في الواقع فهي غير كذلك، فكل ما تديره على سيرفر واحد يصبح مضروباً في عدد المناطق:

  • الشهادات TLS Certificates: كل سيرفر حافة ينهي اتصال HTTPS بنفسه، فيحتاج شهادة لنطاقك ومفتاحها الخاص، أي أن مفتاحك يصبح موجوداً في عدة أماكن، وقد شرحنا الشهادات وملفاتها في الشهادات الرقمية TLS.
  • التعطل والتحويل Failover: إذا توقف سيرفر منطقة فإن GeoDNS يجب أن يعرف ذلك بفحوص دورية ويوقف إرسال الزوار إليه، ومع ذلك يبقى من حفظ عنوانه في ذاكرة DNS يحاول الوصول إليه حتى تنتهي مدة TTL.
  • المسح والتحديث: أي مسح يجب أن يصل إلى كل السيرفرات، وكل تحديث أمني يجب أن يطبق على كل السيرفرات.
  • عنوان الزائر الحقيقي: تطبيقك يرى عناوين سيرفرات الحافة بدل عناوين الزوار، فيجب أن تضبط ترويسات X-Forwarded-For وتثق بها من سيرفراتك فقط، كما شرحنا في لماذا يهمك عنوان الزائر الحقيقي؟.
  • التكلفة: ثلاثة سيرفرات صغيرة ومراقبتها وحركة بياناتها، مقابل خدمة تجارية قد تكون خطتها المجانية أو الرخيصة كافية لموقعك.

لذلك فهذا الحل يناسب من لديه سبب واضح، مثل اشتراط أن تبقى البيانات داخل بلد معين أو داخل شبكات يملكها، أو حجم حركة يجعل التكلفة التجارية مرتفعة، أو جمهور في منطقتين أو ثلاث فقط. وأما لأغلب المواقع فالأنسب أن تبدأ ب Cache على سيرفرك مع ترويسات صحيحة، ثم تضيف CDN تجارية أمامه عندما تحتاج القرب من الزوار.

الخدمات التجارية

من أشهر مزودي CDN Cloudflare وBunny وFastly وAmazon CloudFront، وكلها تتبع قواعد HTTP نفسها التي شرحناها، وتختلف في طريقة التسعير، وعدد نقاط التواجد ومواقعها، وما تحفظه بشكل افتراضي، وقدرتها على تشغيل الكود على الحافة، فاقرأ في توثيق المزود الذي تختاره ما يحفظه افتراضياً، وكيف يكون مفتاح ال Cache، وكيف يتعامل مع ترويسة Set-Cookie، قبل أن تفعله على موقع فيه تسجيل دخول. وتذكر أن ال CDN التي تنهي اتصال TLS ترى محتوى كل طلب، وهذا ما ناقشناه في الخصوصية والسيادة على البيانات، وأن الاتصال بينها وبين سيرفرك يجب أن يكون مشفراً بشهادة صحيحة أيضاً كما في وضع Full (strict).

📌
في 8 يونيو 2021 أصبحت المواقع التي تعتمد على شبكة Fastly ترد بأخطاء في أنحاء العالم، فيما وصفته الشركة بأنه انقطاع عالمي Global Outage. وشرحت Fastly في تقريرها عن الحادثة أن تحديثاً برمجياً نشرته في 12 مايو كان يحمل خطأ لم يكتشف، وأن عميلاً أرسل في صباح 8 يونيو تعديلاً صحيحاً على إعداداته فتحقق الشرط الذي يظهر فيه الخطأ، فأصبح 85% من شبكتها يرد بأخطاء، واكتشفت المشكلة خلال دقيقة، وعاد 95% من الشبكة إلى العمل خلال 49 دقيقة. والدرس أن ال CDN تصبح جزءاً من موقعك، فإذا تعطلت تعطل موقعك معها حتى لو كان سيرفرك سليماً، لذلك اعرف مسبقاً كيف تعيد توجيه الزوار إلى سيرفرك مباشرة أو إلى مزود آخر عند الحاجة، واعرف هل يتحمل سيرفرك الزيارات وحده دون ال Cache.

تجربة عملية: Cache أمام سيرفر بطيء

الآن سوف نقوم ببناء نسخة صغيرة من سيرفر حافة على جهازك: سيرفر أصلي صغير بلغة Python يتأخر 400 مللي ثانية عن قصد في كل طلب حتى يشبه سيرفراً بعيداً، وأمامه Nginx مع proxy_cache يضيف ترويسة X-Cache-Status لكل رد حتى نرى ما يحدث، ثم نرى HIT وMISS، وانتهاء الصلاحية، والتحقق ب ETag، وصفحة خاصة تتسرب من مستخدم إلى آخر ثم نصلحها، ثم نوقف السيرفر الأصلي ونرى ماذا يبقى. والتجربة تحتاج Docker فقط، وتعمل على عناوين 127.0.0.1 فلا يصل إليها أحد من خارج جهازك.

تجهيز السيرفر الأصلي وال Cache

قم بإنشاء مجلد للتجربة، ثم أنشئ فيه الملف origin.py التالي:

# A tiny origin: a slow server far away (we add 400 ms on purpose to every request)
import time, hashlib
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

JS = b"console.log('app v1');\n" * 2000
LOGO = b"<svg xmlns='http://www.w3.org/2000/svg'/>"
LOGO_ETAG = '"' + hashlib.sha256(LOGO).hexdigest()[:16] + '"'

class H(BaseHTTPRequestHandler):
    def send(self, code, body, headers):
        self.send_response(code)
        for k, v in headers.items():
            self.send_header(k, v)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def do_HEAD(self):
        self.do_GET()

    def do_GET(self):
        time.sleep(0.4)                      # pretend the origin is far away
        p = self.path
        cookie = self.headers.get("Cookie", "")
        user = cookie.split("session=")[1].split(";")[0] if "session=" in cookie else None
        if p.startswith("/assets/app."):     # fingerprinted file: cache for a year
            self.send(200, JS, {"Content-Type": "text/javascript",
                                "Cache-Control": "public, max-age=31536000, immutable"})
        elif p == "/news.html":              # HTML: short life on the edge, none in the browser
            body = f"<h1>News</h1><p>built at {time.strftime('%H:%M:%S')}</p><script src='/assets/app.3f9c1a.js'></script>".encode()
            self.send(200, body, {"Content-Type": "text/html",
                                  "Cache-Control": "public, max-age=0, s-maxage=10"})
        elif p == "/logo.svg":               # validator: answer 304 when the ETag matches
            if self.headers.get("If-None-Match") == LOGO_ETAG:
                self.send(304, b"", {"ETag": LOGO_ETAG, "Cache-Control": "public, max-age=5"})
            else:
                self.send(200, LOGO, {"Content-Type": "image/svg+xml", "ETag": LOGO_ETAG,
                                      "Cache-Control": "public, max-age=5"})
        elif p.startswith("/login?user="):   # sets the session cookie
            name = p.split("=", 1)[1]
            self.send(200, b"welcome\n", {"Content-Type": "text/plain",
                                          "Set-Cookie": f"session={name}; HttpOnly; Path=/"})
        elif p == "/account":                # the right way: private page says so
            self.send(200, f"account page of {user}\n".encode(), {"Content-Type": "text/plain",
                                                                  "Cache-Control": "private, no-store"})
        elif p == "/dashboard":              # the mistake: private page with no Cache-Control
            self.send(200, f"dashboard of {user}\n".encode(), {"Content-Type": "text/plain"})
        else:
            self.send(404, b"not found\n", {"Content-Type": "text/plain", "Cache-Control": "no-store"})

    def log_message(self, fmt, *args):
        inm = self.headers.get("If-None-Match")
        print(f"origin: {self.command} {self.path} -> {args[1]}" + (f" (If-None-Match {inm})" if inm else ""), flush=True)

ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever()

في الملف أعلاه لاحظ التالي:

  • ملف JavaScript ببصمة في اسمه يرسل مع public, max-age=31536000, immutable، وأي اسم يبدأ ب /assets/app. يعيد المحتوى نفسه حتى نجرب تغيير الاسم.
  • الصفحة /news.html تكتب وقت إنشائها، وصلاحيتها في ال Cache المشترك 10 ثوان s-maxage=10، وفي المتصفح صفر.
  • الملف /logo.svg يحمل ETag وصلاحية 5 ثوان، ويرد ب 304 إذا جاءه الطلب بقيمة ETag نفسها.
  • الصفحة /account تخص المستخدم وتقول ذلك ب private, no-store، أما /dashboard فتخص المستخدم أيضاً لكنها لا ترسل أي Cache-Control، وهذا هو الخطأ الذي سوف نكشفه.

ثم أنشئ ملف إعداد Nginx باسم edge.conf:

proxy_cache_path /var/cache/nginx/edge levels=1:2 keys_zone=edge:10m max_size=1g inactive=7d use_temp_path=off;

log_format cache '$remote_addr "$request" $status $upstream_cache_status $request_time';

server {
    listen 80;
    access_log /var/log/nginx/cache.log cache;

    location / {
        proxy_pass http://lab-cdn-origin:8000;
        proxy_connect_timeout 5s;
        proxy_cache edge;
        proxy_cache_key $scheme$host$request_uri;
        proxy_cache_valid 200 10m;
        proxy_cache_revalidate on;
        proxy_cache_lock on;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;
        add_header X-Cache-Status $upstream_cache_status always;
    }
}

في الإعداد أعلاه لاحظ التالي:

  • proxy_cache_path يحدد مجلد ال Cache على القرص ومنطقة في الذاكرة اسمها edge للمفاتيح، وinactive=7d تحذف ما لم يطلب منذ سبعة أيام حتى لو كان صالحاً.
  • proxy_cache_valid 200 10m تحفظ كل رد ناجح لا يحمل Cache-Control عشر دقائق، وهذا السطر موجود في كثير من الأمثلة المنتشرة، وهو سبب التسرب الذي سوف نراه بعد قليل.
  • proxy_cache_revalidate on تجعل Nginx يتحقق من النسخة المنتهية بطلب مشروط فيه If-None-Match بدل تنزيلها كاملة.
  • proxy_cache_lock on تجعل الطلبات المتزامنة على ملف غير موجود تنتظر طلباً واحداً إلى السيرفر الأصلي بدل أن يذهب كل منها بنفسه.
  • proxy_cache_use_stale مع proxy_cache_background_update ترسل النسخة القديمة فوراً وتجدد في الخلفية، وترسلها أيضاً إذا تعطل السيرفر الأصلي، وهو سلوك قريب من stale-while-revalidate وstale-if-error اللتين يفهمهما Nginx أيضاً إذا جاءتا في ترويسة الرد.
  • $upstream_cache_status يحمل حالة كل طلب، وقيمه بحسب التوثيق هي MISS وBYPASS وEXPIRED وSTALE وUPDATING وREVALIDATED وHIT، ونضعه في ترويسة الرد وفي سجل خاص.

الآن قم بتشغيل الاثنين على شبكة Docker خاصة بالتجربة:

docker network create lab-cdn-net
docker run -d --name lab-cdn-origin --network lab-cdn-net \
  -v "$PWD/origin.py:/app/origin.py:ro" python:3.13-alpine python -u /app/origin.py
docker run -d --name lab-cdn-edge --network lab-cdn-net -p 127.0.0.1:18480:80 \
  -v "$PWD/edge.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.31-alpine

وحتى نختصر الأوامر في الخطوات التالية نعرف دالة صغيرة في ال Shell تطلب مساراً وتطبع حالته وزمنه، ودالة ثانية تطلب صفحة الأخبار وتطبع حالتها ووقت إنشائها:

st() { curl -s -o /dev/null -w "$1 -> %{http_code} X-Cache-Status: %header{x-cache-status}  time: %{time_total}s\n" "http://127.0.0.1:18480$1"; }
f() { curl -s -D /tmp/h -o /tmp/b http://127.0.0.1:18480/news.html; grep -i x-cache-status /tmp/h | tr -d '\r'; grep -o "built at [0-9:]*" /tmp/b; }

الخطوة 1: MISS ثم HIT

نطلب ملف JavaScript ثلاث مرات، ثم نعرض ترويسات الرد:

for i in 1 2 3; do st /assets/app.3f9c1a.js; done
curl -sI http://127.0.0.1:18480/assets/app.3f9c1a.js

والمخرج سوف يكون كما يلي:

/assets/app.3f9c1a.js -> 200 X-Cache-Status: MISS  time: 0.407631s
/assets/app.3f9c1a.js -> 200 X-Cache-Status: HIT  time: 0.003813s
/assets/app.3f9c1a.js -> 200 X-Cache-Status: HIT  time: 0.003113s
HTTP/1.1 200 OK
Server: nginx/1.31.6
Date: Tue, 06 Oct 2026 03:19:42 GMT
Content-Type: text/javascript
Content-Length: 46000
Connection: keep-alive
Cache-Control: public, max-age=31536000, immutable
X-Cache-Status: HIT

الطلب الأول MISS وصل إلى السيرفر الأصلي فأخذ 0.4 ثانية، والطلبان التاليان HIT من القرص في قرابة 3 مللي ثوان. ولاحظ أن هذه الأرقام للتوضيح فقط، فقد قسناها على الجهاز نفسه، والتأخير في السيرفر الأصلي صنعناه بأنفسنا، لكن الفكرة هي نفسها في الواقع: الفرق بين HIT وMISS هو زمن الذهاب إلى السيرفر الأصلي والعودة منه وزمن معالجته للطلب.

الخطوة 2: صفحة HTML بصلاحية قصيرة

نطلب صفحة الأخبار مرتين، ثم ننتظر 11 ثانية حتى تنتهي صلاحيتها، ثم نطلبها مرتين:

f; f; sleep 11; f; sleep 1; f

والمخرج سوف يكون كما يلي:

X-Cache-Status: MISS
built at 03:19:43
X-Cache-Status: HIT
built at 03:19:43
X-Cache-Status: STALE
built at 03:19:43
X-Cache-Status: HIT
built at 03:19:54

بعد انتهاء الصلاحية لم ينتظر الطلب الثالث السيرفر الأصلي، وإنما حصل فوراً على النسخة القديمة بحالة STALE، وفي الوقت نفسه طلب Nginx نسخة جديدة في الخلفية، فجاء الطلب الرابع HIT بوقت إنشاء جديد. وهذا هو السلوك الذي تريده لصفحات HTML العامة: لا ينتظر أي زائر، والمحتوى لا يتأخر عن الأصل إلا ثوان معدودة.

الخطوة 3: التحقق ب ETag بدل إعادة التنزيل

st /logo.svg; sleep 6; st /logo.svg; sleep 1; st /logo.svg
docker logs lab-cdn-origin 2>&1 | grep logo

والمخرج سوف يكون كما يلي:

/logo.svg -> 200 X-Cache-Status: MISS  time: 0.405770s
/logo.svg -> 200 X-Cache-Status: STALE  time: 0.004475s
/logo.svg -> 200 X-Cache-Status: HIT  time: 0.003580s
origin: GET /logo.svg -> 200
origin: GET /logo.svg -> 304 (If-None-Match "a87cba1d08bc5397")

لاحظ في سجل السيرفر الأصلي أن الطلب الثاني الذي وصله لم يكن طلباً عادياً، وإنما طلب مشروط يحمل ETag النسخة المحفوظة، فرد ب 304 دون أي محتوى، وجدد Nginx صلاحية نسخته واستخدمها، وهذا ما تفعله نقطة التواجد في ساو باولو في الصورة الثانية أعلاه.

الخطوة 4: صفحة خاصة تتسرب

الآن نسجل الدخول، ثم نطلب صفحة الحساب الصحيحة باسم مستخدمين مختلفين، ثم الصفحة التي نسيت ترويسة Cache-Control:

curl -s -i "http://127.0.0.1:18480/login?user=alice" | grep -i -E "^set-cookie|x-cache"
for u in alice bob; do curl -s -i -H "Cookie: session=$u" http://127.0.0.1:18480/account | grep -i -E "^cache-control|x-cache|account"; done
for u in alice bob; do curl -s -i -H "Cookie: session=$u" http://127.0.0.1:18480/dashboard | grep -i -E "^cache-control|x-cache|dashboard"; done

والمخرج سوف يكون كما يلي:

Set-Cookie: session=alice; HttpOnly; Path=/
X-Cache-Status: MISS
Cache-Control: private, no-store
X-Cache-Status: MISS
account page of alice
Cache-Control: private, no-store
X-Cache-Status: MISS
account page of bob
X-Cache-Status: MISS
dashboard of alice
X-Cache-Status: HIT
dashboard of alice

في المخرج أعلاه لاحظ التالي:

  • رد تسجيل الدخول يحمل Set-Cookie فلم يحفظه Nginx، وصفحة الحساب تحمل private, no-store فلم يحفظها، فحصل كل مستخدم على صفحته.
  • صفحة /dashboard لا تحمل أي ترويسة تمنع الحفظ، فطبق عليها Nginx السطر proxy_cache_valid 200 10m وحفظها، ولم يدخل ال Cookie في مفتاح ال Cache، فحصل Bob على لوحة Alice بحالة HIT. وفي موقع حقيقي يعني هذا أن كل من يفتح الصفحة خلال عشر دقائق يرى بيانات أول من فتحها.

وسوف نصلح هذا الخطأ في الخطوة الأخيرة، لأن الإصلاح يغير إعداد Nginx، وما زلنا نحتاج الإعداد الحالي للخطوات التالية.

الخطوة 5: سلسلة الاستعلام تصنع نسخاً جديدة

st '/assets/app.3f9c1a.js?utm_source=x'
st '/assets/app.3f9c1a.js?utm_source=y'

والمخرج سوف يكون كما يلي:

/assets/app.3f9c1a.js?utm_source=x -> 200 X-Cache-Status: MISS  time: 0.406677s
/assets/app.3f9c1a.js?utm_source=y -> 200 X-Cache-Status: MISS  time: 0.412441s

الملف نفسه محفوظ منذ الخطوة الأولى، ومع ذلك كان كل طلب MISS ووصل إلى السيرفر الأصلي، لأن المفتاح $scheme$host$request_uri يشمل سلسلة الاستعلام، وهذا ما رسمناه في الصورة الرابعة.

الخطوة 6: نسخة جديدة باسم جديد

لنفرض أننا عدلنا ملف JavaScript فأصبح اسمه بعد البناء app.7b2e90.js:

st /assets/app.7b2e90.js; st /assets/app.7b2e90.js

والمخرج سوف يكون كما يلي:

/assets/app.7b2e90.js -> 200 X-Cache-Status: MISS  time: 0.407009s
/assets/app.7b2e90.js -> 200 X-Cache-Status: HIT  time: 0.003218s

الاسم الجديد MISS مرة واحدة ثم HIT، دون أي مسح ودون أن نلمس النسخة القديمة، والنسخة القديمة تبقى في ال Cache حتى لا يطلبها أحد سبعة أيام بحسب inactive=7d فيحذفها Nginx.

الخطوة 7: السيرفر الأصلي يتوقف

نوقف السيرفر الأصلي، وننتظر حتى تنتهي صلاحية صفحة الأخبار، ثم نطلب الصفحة وملف JavaScript وصفحة لم يطلبها أحد من قبل:

docker stop lab-cdn-origin; sleep 9
f; st /assets/app.3f9c1a.js; st /about.html
docker start lab-cdn-origin

والمخرج سوف يكون كما يلي:

X-Cache-Status: STALE
built at 03:19:54
/assets/app.3f9c1a.js -> 200 X-Cache-Status: HIT  time: 0.006232s
/about.html -> 504 X-Cache-Status: MISS  time: 5.004893s

الصفحة المنتهية وصلت بحالة STALE من النسخة القديمة لأن السيرفر الأصلي لا يرد، والملف الصالح وصل HIT كأن شيئاً لم يحدث، أما الصفحة التي لم تحفظ من قبل فانتظرت خمس ثوان حتى انتهت مهلة proxy_connect_timeout ثم فشلت ب 504. وهذا يبين حدود ال Cache بوضوح: يحميك من عطل قصير في السيرفر الأصلي لما هو محفوظ فقط.

الخطوة 8: نسبة HIT من السجل

كل طلب مر على Nginx سجلناه مع حالته، فنعرض آخر الأسطر ثم نحسب النسبة:

docker exec lab-cdn-edge tail -n 6 /var/log/nginx/cache.log
docker exec lab-cdn-edge awk '{n[$(NF-1)]++; t++} END {for (s in n) printf "%-8s %3d\n", s, n[s]; printf "hit ratio: %.0f%%\n", 100*(n["HIT"]+n["STALE"]+n["REVALIDATED"])/t}' /var/log/nginx/cache.log

والمخرج سوف يكون كما يلي:

192.168.96.1 "GET /assets/app.3f9c1a.js?utm_source=y HTTP/1.1" 200 MISS 0.405
192.168.96.1 "GET /assets/app.7b2e90.js HTTP/1.1" 200 MISS 0.401
192.168.96.1 "GET /assets/app.7b2e90.js HTTP/1.1" 200 HIT 0.000
192.168.96.1 "GET /assets/app.3f9c1a.js HTTP/1.1" 200 HIT 0.000
192.168.96.1 "GET /news.html HTTP/1.1" 200 STALE 5.005
192.168.96.1 "GET /about.html HTTP/1.1" 504 MISS 5.001
HIT        9
STALE      3
MISS      11
hit ratio: 52%

النسبة هنا منخفضة لأن التجربة صنعت حالات MISS عن قصد، وفي موقع حقيقي تريدها للملفات الثابتة قريبة من 90% أو أكثر. ولاحظ سطر news.html بحالة STALE وزمن 5 ثوان، فهذا الزمن يشمل محاولة التجديد في الخلفية التي انتظرت السيرفر المتوقف، أما الزائر فحصل على الرد فوراً، وعندما قسنا الطلب نفسه من curl في تشغيل آخر كان زمنه 0.004 ثانية تقريباً.

الخطوة 9: إصلاح التسرب

الإصلاح الصحيح له جانبان: التطبيق يجب أن يرسل private أو no-store مع كل صفحة خاصة كما تفعل /account، وال Cache يجب ألا يحفظ ما لم يسمح به التطبيق صراحة، وألا يستخدم ال Cache أصلاً لأي طلب يحمل Cookie الجلسة أو Authorization. والثاني هو ما نضبطه الآن، فننسخ edge.conf إلى ملف جديد باسم edge-fixed.conf، ونحذف منه proxy_cache_valid ونضيف سطرين، وهذا هو الفرق بين الملفين:

--- edge.conf
+++ edge-fixed.conf
@@ -11,7 +11,9 @@
         proxy_connect_timeout 5s;
         proxy_cache edge;
         proxy_cache_key $scheme$host$request_uri;
-        proxy_cache_valid 200 10m;
+        # no proxy_cache_valid: cache only what the origin allows explicitly
+        proxy_cache_bypass $cookie_session $http_authorization;
+        proxy_no_cache     $cookie_session $http_authorization;
         proxy_cache_revalidate on;
         proxy_cache_lock on;
         proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

ثم نعيد تشغيل Nginx بالإعداد الجديد، ونطلب اللوحة باسم المستخدمين ثم دون أي Cookie:

docker rm -f lab-cdn-edge
docker run -d --name lab-cdn-edge --network lab-cdn-net -p 127.0.0.1:18480:80 \
  -v "$PWD/edge-fixed.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.31-alpine
for u in alice bob; do curl -s -i -H "Cookie: session=$u" http://127.0.0.1:18480/dashboard | grep -i -E "x-cache|dashboard"; done
curl -s -i http://127.0.0.1:18480/dashboard | grep -i -E "x-cache|dashboard"; curl -s -i http://127.0.0.1:18480/dashboard | grep -i -E "x-cache|dashboard"
st /assets/app.3f9c1a.js; st /assets/app.3f9c1a.js

والمخرج سوف يكون كما يلي:

X-Cache-Status: BYPASS
dashboard of alice
X-Cache-Status: BYPASS
dashboard of bob
X-Cache-Status: MISS
dashboard of None
X-Cache-Status: MISS
dashboard of None
/assets/app.3f9c1a.js -> 200 X-Cache-Status: MISS  time: 0.405703s
/assets/app.3f9c1a.js -> 200 X-Cache-Status: HIT  time: 0.003306s

في المخرج أعلاه لاحظ التالي:

  • كل طلب يحمل Cookie الجلسة أصبح BYPASS، أي أنه ذهب إلى السيرفر الأصلي مباشرة ولم يحفظ، فحصل كل مستخدم على لوحته.
  • الطلب دون Cookie أصبح MISS في كل مرة، لأن الرد لا يحمل Cache-Control ولم يعد هناك proxy_cache_valid يحفظه افتراضياً، فلا يحفظ إلا ما يسمح به التطبيق.
  • ملف JavaScript ما زال يحفظ بشكل طبيعي، لأن السيرفر الأصلي يسمح بذلك صراحة. ولاحظ أنه بدأ ب MISS لأننا أنشأنا Container جديداً دون Volume، فبدأ ال Cache فارغاً، وهذه أيضاً طريقة بسيطة لمسح كل شيء في نسخة Nginx المفتوحة.
⚠️
اسم Cookie الجلسة في الإعداد أعلاه هو session لأن تطبيق التجربة يستخدمه، وفي تطبيقك الحقيقي قد يكون اسمه مختلفاً، مثل sessionid الذي يستخدمه Django بشكل افتراضي، فاكتب الاسم الصحيح في $cookie_…، والسبب أن Nginx لا يعرف أي Cookie يدل على تسجيل الدخول، وإذا كتبت اسماً خطأ فلن يحدث أي تجاوز وسوف يعود التسرب.

إزالة التجربة

بعد الانتهاء احذف ال Containers والشبكة التي أنشأناها فقط:

docker rm -f lab-cdn-edge lab-cdn-origin
docker network rm lab-cdn-net

كيف تقيس أثر ال CDN؟

هناك رقمان يكفيان في البداية:

  • نسبة HIT Cache Hit Ratio: نسبة الطلبات التي ردت من ال Cache إلى كل الطلبات، وتجدها في لوحة أي مزود، أو تحسبها من سجلك كما فعلنا في الخطوة 8. واحسبها لكل نوع على حدة، فنسبة منخفضة على الملفات الثابتة تعني أن هناك خطأ في الترويسات أو في مفتاح ال Cache، أما على HTML فهي طبيعية.
  • زمن أول بايت Time To First Byte: الوقت من إرسال الطلب حتى وصول أول بايت من الرد، وهو الذي يتأثر مباشرة بالمسافة وبحالة HIT أو MISS.

والأمر التالي يقسم زمن الطلب إلى مراحله، وقد طلبنا به ملف CSS من موقعنا مرتين من اتصالنا:

curl -s -o /dev/null -w "dns: %{time_namelookup}s  connect: %{time_connect}s  tls: %{time_appconnect}s  ttfb: %{time_starttransfer}s  total: %{time_total}s  cache: %header{cf-cache-status}\n" "https://arabroot.io/assets/css/screen.css?v=XVE0lbl24sMme7M1"

والمخرج كان كما يلي:

dns: 0.630188s  connect: 0.958284s  tls: 1.592554s  ttfb: 2.049220s  total: 2.223206s  cache: HIT
dns: 0.005998s  connect: 0.095406s  tls: 0.197790s  ttfb: 0.295912s  total: 0.411439s  cache: HIT

لاحظ أن القيم تراكمية من بداية الطلب، فقيمة ttfb تشمل DNS والاتصال وTLS. والطلب الأول كان بطيئاً في كل مراحله لأنه بدأ دون أي ذاكرة، بما فيها البحث في DNS، أما الثاني فهو الصورة الأقرب لما يراه زائر عائد، وكلاهما HIT من نقطة التواجد، لذلك لا تحكم من قياس واحد، وكرر القياس عدة مرات وخذ الوسيط. والأهم أن القياس من جهازك يخبرك بما يراه زائر في مدينتك فقط، فإذا كان جمهورك في عدة دول فقس من سيرفرات أو أجهزة فيها، وقد شرحنا أدوات قياس الطريق ping وtraceroute وما تقيسه اختبارات السرعة في قس الطريق بنفسك.

أخطاء شائعة

  • حفظ الصفحات الخاصة: قاعدة «احفظ كل شيء» أو قيمة افتراضية مثل proxy_cache_valid تحفظ صفحة مستخدم وتقدمها لغيره، كما رأيت في الخطوة 4.
  • الاعتماد على no-cache لمنع الحفظ: القيمة التي تمنع الحفظ هي no-store، أو private لل Cache المشترك.
  • صلاحية طويلة لملف لا يتغير اسمه: max-age لمدة سنة على app.js بدون بصمة يعني أن الزوار يبقون على النسخة القديمة حتى بعد المسح.
  • Vary: Cookie أو Vary: User-Agent على ملفات عامة: نسخة لكل زائر أو لكل متصفح، ونسبة HIT قرابة الصفر.
  • معاملات التتبع في المفتاح: utm_source وأمثالها تصنع نسخاً لا نهاية لها للملف نفسه.
  • نسيان عنوان الزائر الحقيقي: بعد تفعيل ال CDN يرى تطبيقك عناوين نقاط التواجد، فيحد معدل الطلبات Rate Limiting للجميع معاً أو يحظر ال CDN نفسها، والحل في إذا كان Cloudflare أمام ال Proxy.
  • ترك السيرفر الأصلي مفتوحاً للجميع: من يعرف عنوان سيرفرك يتجاوز ال CDN وحمايتها، فاقصر الوصول إليه على عناوين المزود أو استخدم نفقاً كما في أغلق سيرفرك على Cloudflare.
  • حفظ صفحات الخطأ: رد 404 أو 500 يحفظ مدة طويلة فيبقى الخطأ ظاهراً بعد إصلاحه، فاجعل صلاحية الأخطاء قصيرة جداً أو صفراً.

قائمة تحقق قبل أن تفعل ال CDN

  • كل نوع من الردود يرسل Cache-Control صريحة: الملفات الثابتة سنة مع immutable، وHTML العامة مدة قصيرة على ال CDN مع ETag، والصفحات الخاصة private أو no-store.
  • أداة البناء تضع بصمة في أسماء ملفات CSS وJavaScript، أو رقم نسخة في الرابط.
  • الطلبات التي تحمل Cookie الجلسة أو Authorization لا تحفظ، ومسارات مثل /admin و/account و/api مستثناة صراحة.
  • افتح صفحة خاصة بحسابين مختلفين بعد التفعيل وتأكد أن حالتها في الترويسات ليست HIT.
  • معاملات التتبع محذوفة من مفتاح ال Cache، وVary لا تذكر إلا ما يغير الرد فعلاً.
  • تطبيقك يقرأ عنوان الزائر الحقيقي من الترويسة الصحيحة، ويثق بها من عناوين المزود فقط.
  • الاتصال بين ال CDN وسيرفرك مشفر بشهادة صحيحة، والسيرفر الأصلي لا يقبل الاتصال إلا من ال CDN.
  • تعرف كيف تمسح ملفاً عند الحاجة، وكيف تعيد توجيه الزوار إلى سيرفرك مباشرة إذا تعطلت ال CDN.
  • تراقب نسبة HIT وزمن أول بايت من المدن التي فيها جمهورك.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • ال CDN تضع نسخاً من ملفاتك في نقاط تواجد قريبة من الزوار، فتقلل المسافة والحمل على سيرفرك وحركة البيانات الخارجة منه، ويصل الزائر إلى أقرب نقطة عبر GeoDNS أو Anycast.
  • كل Cache يتبع ترويسة Cache-Control التي يرسلها سيرفرك: max-age للجميع وs-maxage لل Cache المشترك، وprivate للمتصفح وحده، وno-store لمنع الحفظ في أي مكان، وno-cache لا تمنع الحفظ.
  • مفتاح ال Cache هو العنوان كاملاً مع ما تذكره Vary، فانتبه لمعاملات التتبع ولا تستخدم Vary: Cookie لملفات عامة.
  • غير اسم الملف عند كل نسخة جديدة واجعل صلاحيته سنة، واجعل HTML وحدها قصيرة الصلاحية، واترك المسح للحالات الاستثنائية.
  • لا تحفظ في أي Cache مشترك ما يخص مستخدماً واحداً، ولا تعتمد على أن الأداة تتجاهل Set-Cookie، فالمعيار نفسه لا يمنع حفظها.
  • تستطيع أن تبني Cache أمام تطبيقك أو أمام تخزين الكائنات بنفسك بسهولة، أما نقاط التواجد حول العالم فتقليدها بعدة سيرفرات وGeoDNS ممكن لكنه يضاعف ما تديره.
دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.دليلخدمات Cloudflare بكلمات بسيطة وبدائلها مفتوحة المصدرما الذي تقدمه كل خدمة من Cloudflare، وما المتاح منها مجاناً، وما البديل مفتوح المصدر الذي تشغله على سيرفرك.دليلكيف يصل الإنترنت إلى بيتك؟ من ال Router إلى الكابلات البحريةنتتبع طلباً واحداً من حاسوبك إلى سيرفر في بلد آخر ثم عودته، فتعرف من يحمله في كل محطة ولماذا يتأخر الرد.دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ تجربة Nginx 1.31.6 مع proxy_cache أمام سيرفر Python 3.13 في Docker، وقياس ترويسات موقعنا وشبكة jsDelivr.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.