عندما يفتح زائر في الرياض موقعك الذي يعمل على سيرفر في فرانكفورت، فإن كل ملف تطلبه الصفحة، من ملف 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: كثير من مزودي السيرفرات والتخزين السحابي يحاسبونك على البيانات الخارجة من السيرفر، أو يعطونك حداً شهرياً ثم يحاسبونك على ما بعده، والصور والفيديو هي أكبر بند فيها.
والصورة التالية تبين هذه المشكلات الأربع مجتمعة على سيرفر واحد:

وقد يتساءل البعض: لماذا لا أشتري سيرفراً أقوى؟ والإجابة أن السيرفر الأقوى يحل جزءاً من المشكلة الثالثة فقط، فهو يتحمل زيارات أكثر، لكنه لا يقرب المسافة من الزائر في جاكرتا، ولا يمنع تكرار الطلبات نفسها، ولا يخفض حركة البيانات الخارجة. والحل الذي يعالج المشكلات الأربع معاً هو أن توضع نسخ من الملفات في أماكن قريبة من الزوار، وهذه هي فكرة ال 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 التي نشرحها بعد قليل، وبعض المزودين يسمحون لك بأن تتجاوزها بقواعد من لوحة التحكم. والصورة التالية تبين الطريق كاملاً:

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 صريحة مع كل نوع من الردود.
والصورة التالية تختصر القرار في ثلاثة أسئلة:

مفتاح ال 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.

غير اسم الملف بدل أن تنتظر انتهاء صلاحيته
هنا تواجه تعارضاً واضحاً: تريد أن تبقى ملفات CSS وJavaScript في ال Cache أطول مدة ممكنة حتى لا تنزل مرة أخرى، وتريد في الوقت نفسه أن يرى الزوار النسخة الجديدة فور نشرها. والحل البسيط أن تعطيها صلاحية قصيرة، ساعة مثلاً، فتخسر أغلب فائدة ال Cache وتبقى ساعة كاملة يرى فيها بعض الزوار القديم وبعضهم الجديد. والحل الثاني أن تعطيها صلاحية طويلة ثم تمسح ال Cache عند كل نشر، وهذا يصل إلى ال CDN فقط، أما الملف المحفوظ في متصفحات الزوار فلا يستطيع أحد أن يمسحه، فيبقى الزائر بملف JavaScript قديم مع صفحة HTML جديدة، وقد تتعطل الصفحة.
والحل الصحيح هو بصمة في اسم الملف Fingerprinting: أداة البناء تحسب هاش لمحتوى الملف وتضعه في اسمه، فيصبح app.3f9c1a.js، وتكتب صفحة HTML هذا الاسم، فإذا تغير سطر واحد في الملف تغير الهاش وأصبح الاسم app.7b2e90.js، وهو عنوان جديد لم يحفظه أي Cache من قبل. وبهذا الشكل تعطي الملفات صلاحية سنة مع immutable، ولا تمسح أي شيء، والشيء الوحيد الذي يجب أن يتجدد بسرعة هو صفحة HTML نفسها، وهي صغيرة وصلاحيتها قصيرة. وأغلب أدوات البناء الحديثة تفعل ذلك بشكل افتراضي في وضع الإنتاج Production، ومن لا يستطيع تغيير اسم الملف يضع رقم النسخة في سلسلة الاستعلام كما رأيت في رابط screen.css?v=… من موقعنا، وهذا يعمل لأن سلسلة الاستعلام جزء من مفتاح ال 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 في لا تحفظ في الذاكرة المؤقتة ما يخص مستخدماً واحداً، وسوف نصنعه بأيدينا في التجربة العملية حتى تعرف كيف يبدو.
/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، ويجيبه بالسجل المناسب لمنطقته.

وقد تبدو العملية من الوهلة الأولى بسيطة، لكن في الواقع فهي غير كذلك، فكل ما تديره على سيرفر واحد يصبح مضروباً في عدد المناطق:
- الشهادات 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).
تجربة عملية: 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 المفتوحة.
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.