> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظه
- URL: https://arabroot.io/articles/شبكة-توصيل-المحتوى-cdn-للمبتدئين/
- Published: 2026-10-05T17:00:00.000Z
- Updated: 2026-10-09T09:42:38.000Z
- Description: لماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, الشبكات والوصول, الاستضافة الذاتية

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

وقد شرحنا في [خدمات Cloudflare](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#cdn-cache) كيف تعمل خدمة ال 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، كما شرحنا في [لماذا يتأخر الرد؟](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B5%D9%84-%D8%A7%D9%84%D8%A5%D9%86%D8%AA%D8%B1%D9%86%D8%AA-%D8%A5%D9%84%D9%89-%D8%A8%D9%8A%D8%AA%D9%83/#latency)، وهذا حد فيزيائي لا يتغير مهما زادت قوة السيرفر أو سرعة اشتراك الزائر. والصفحة الواحدة لا تحتاج ذهاباً وإياباً مرة واحدة، وإنما تحتاج اتصال TCP ثم مصافحة TLS ثم طلب HTML ثم طلبات الملفات التي يذكرها، فيتضاعف أثر المسافة.
- **الطلبات المتكررة للملفات نفسها:** شعار الموقع وملف CSS وملفات JavaScript لا تتغير بين زائر وآخر، ومع ذلك يقرؤها السيرفر من القرص ويرسلها آلاف المرات في اليوم، وهذا وقت معالج وحركة شبكة كان يمكن توفيرهما.
- **ذروة الزيارات Traffic Spike:** رابط ينتشر في الشبكات الاجتماعية أو إعلان في وقت محدد يرفع الزيارات أضعافاً خلال دقائق، والسيرفر الواحد له حد لعدد الاتصالات وسعة الشبكة، فإذا تجاوزته بطؤ الموقع على الجميع أو توقف.
- **تكلفة حركة البيانات Egress:** كثير من مزودي السيرفرات والتخزين السحابي يحاسبونك على البيانات الخارجة من السيرفر، أو يعطونك حداً شهرياً ثم يحاسبونك على ما بعده، والصور والفيديو هي أكبر بند فيها.

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

![مخطط لسيرفر أصلي واحد في فرانكفورت يتصل به زوار من الرياض وجاكرتا وساو باولو بخطوط طويلة على بعد 4 آلاف و11 ألفاً و10 آلاف كيلومتر، وبجانبه أربعة أرقام: المسافة، والملفات نفسها تنزل عشرة آلاف مرة، وذروة زيارات مفاجئة، وفاتورة حركة البيانات الخارجة](https://arabroot.io/content/images/2026/10/cdn-basics-01-problem.webp)

المسافة والطلبات المتكررة والذروة والفاتورة كلها تقع على جهاز واحد

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

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

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

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

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

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

- **التوجيه عبر DNS:** تجعل اسم الموقع، أو النطاق الفرعي للملفات مثل `cdn.example.com`، يشير بسجل CNAME إلى اسم يملكه مزود ال CDN، كما شرحنا في [سجل CNAME](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-dns-%D9%88%D8%B3%D8%AC%D9%84%D8%A7%D8%AA%D9%87-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#cname)، ثم يجيب سيرفر DNS الخاص بالمزود بعنوان نقطة التواجد الأقرب إلى من يسأل، وهذا ما يسمى GeoDNS. ولاحظ أن هذا الجواب يحفظ في ذاكرة ال Resolver طوال مدة TTL، فإذا تعطلت نقطة تواجد فإن من حفظ عنوانها يحتاج أن تنتهي هذه المدة حتى ينتقل إلى غيرها، كما شرحنا في [التخزين المؤقت ومدة بقاء السجل TTL](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-dns-%D9%88%D8%B3%D8%AC%D9%84%D8%A7%D8%AA%D9%87-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#cache-ttl).
- **Anycast:** تعلن الشبكة عنوان IP واحداً من كل نقاط التواجد في الوقت نفسه عبر BGP، فتوصل شبكات الإنترنت كل زائر إلى أقرب نقطة تعلن هذا العنوان دون أي تدخل من DNS، وقد شرحنا هذه الفكرة ونقاط تبادل الإنترنت IXP التي تقف فيها هذه النقاط في [مراكز البيانات وشبكات CDN](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B5%D9%84-%D8%A7%D9%84%D8%A5%D9%86%D8%AA%D8%B1%D9%86%D8%AA-%D8%A5%D9%84%D9%89-%D8%A8%D9%8A%D8%AA%D9%83/#datacenters-cdn).

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

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

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

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

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

![مخطط لسيرفر أصلي في الأعلى وثلاث نقاط تواجد في الرياض وجاكرتا وساو باولو، الزائر الأول في الرياض يحصل على MISS فتطلب النقطة الملف من السيرفر الأصلي وتحفظه، والزوار التالون يحصلون على HIT، وفي ساو باولو نسخة منتهية تتحقق منها النقطة بطلب If-None-Match فيرد السيرفر 304](https://arabroot.io/content/images/2026/10/cdn-basics-02-topology.webp)

الطلب الأول MISS يصل إلى سيرفرك، وما بعده HIT من النقطة القريبة، والنسخة المنتهية يتحقق منها دون إعادة تنزيلها

### Pull أم Push؟

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

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

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

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

كل Cache في طريق الطلب، سواء كان متصفح الزائر أو Reverse Proxy أمام تطبيقك أو شبكة CDN، يتبع قواعد واحدة يحددها معيار [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html?ref=arabroot.io) الخاص بالتخزين المؤقت في 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](https://www.rfc-editor.org/rfc/rfc8246.html?ref=arabroot.io).
- `stale-while-revalidate=N`: بعد انتهاء الصلاحية يجوز إرسال النسخة القديمة لمدة N ثانية بينما يجدد ال Cache نسخته في الخلفية، فلا ينتظر الزائر، ومعها `stale-if-error=N` التي تسمح بالنسخة القديمة إذا كان السيرفر الأصلي معطلاً، وكلتاهما من [RFC 5861](https://www.rfc-editor.org/rfc/rfc5861.html?ref=arabroot.io).

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

وهذا هو الخطأ الأشهر في هذه الترويسة: كثيرون يكتبون `no-cache` على صفحة خاصة ظناً أنها تمنع الحفظ، والصحيح أن المعيار يسمح بحفظ الرد معها ويشترط فقط أن يتحقق ال Cache من السيرفر قبل استخدامه، أما المنع الحقيقي فهو `no-store`، وأما منع ال Cache المشترك وحده مع السماح للمتصفح فهو `private`، وقد شرحنا القيمة المناسبة للصفحات الخاصة في [Cache-Control للصفحات الخاصة](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/#cache-control). ولكي ترى كيف يبدو ذلك في موقع حقيقي، فالأمر التالي يطلب ملف CSS من موقعنا هذا، ثم يطلب واجهة برمجة لوحة الإدارة في Ghost:

```bash
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"
```

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

```text
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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/304?ref=arabroot.io) دون أي محتوى، فيجدد ال Cache صلاحية نسخته ويستخدمها. والنتيجة أن الطلب إلى السيرفر ما زال موجوداً، ولكن حجم الرد بضع مئات من البايتات بدل الملف كاملاً، والتفاصيل في [توثيق MDN للطلبات المشروطة](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Conditional%5Frequests?ref=arabroot.io).

وقد يتساءل البعض: ماذا يحدث إذا لم يرسل سيرفري 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](https://arabroot.io/content/images/2026/10/cdn-basics-03-cache-control.webp)

ثلاثة أسئلة تحدد قيمة 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](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fcache%5Fkey).

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

ثم تأتي ترويسة [Vary](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary?ref=arabroot.io)، وهي التي يقول بها سيرفرك إن الرد يختلف بحسب ترويسة معينة في الطلب، فيضيف ال Cache قيمة تلك الترويسة إلى المفتاح. والمثال الأشهر `Vary: Accept-Encoding`، فالملف نفسه يرسل مضغوطاً ب Brotli لمتصفح يفهمه وب gzip لغيره، فيحفظ ال Cache نسخة لكل طريقة ضغط، وهذا متوقع وسليم. أما `Vary: Cookie` فيعني نسخة لكل زائر، لأن كل زائر له Cookie مختلف، فيعمل ال Cache بلا فائدة ونسبة HIT تقترب من الصفر، وإذا كان الرد يختلف فعلاً بحسب ال Cookie فالأصح ألا يحفظ في ال Cache المشترك إطلاقاً. ولاحظ أن Nginx مثلاً لا يحفظ أي رد يحمل `Vary: *`، ويحفظ ما سواه مع مراعاة الترويسات المذكورة فيه بحسب [توثيق proxy\_cache\_valid](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fcache%5Fvalid).

![مخطط يقسم عنواناً إلى البروتوكول والنطاق والمسار وسلسلة الاستعلام ويضيف إليها الترويسات المذكورة في Vary ليكون مفتاح ال Cache، ثم ثلاث حالات: معاملات تسويقية تصنع ثلاث نسخ لنفس الملف وثلاث MISS، وVary: Accept-Encoding تصنع نسختين br وgzip وهذا سليم، وVary: Cookie تصنع نسخة لكل زائر ونسبة HIT قرابة الصفر](https://arabroot.io/content/images/2026/10/cdn-basics-04-cache-key.webp)

كل اختلاف في المفتاح نسخة جديدة وطلب جديد إلى السيرفر الأصلي

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

هنا تواجه تعارضاً واضحاً: تريد أن تبقى ملفات 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 يصل إلى نقاط التواجد ولا يصل إلى المتصفحات التي تحتفظ بالنسخة القديمة فتنكسر الصفحة](https://arabroot.io/content/images/2026/10/cdn-basics-05-fingerprint.webp)

الملف الذي يتغير محتواه يأخذ اسماً جديداً، فلا يبقى في أي 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](https://www.rfc-editor.org/rfc/rfc9111.html?ref=arabroot.io#section-7.3) إن الرد القابل للحفظ الذي يحمل Set-Cookie يمكن أن يستخدم، وغالباً ما يستخدم، لطلبات أخرى، ويطلب من السيرفرات التي لا تريد ذلك أن ترسل Cache-Control مناسبة. صحيح أن بعض الأدوات تحتاط بنفسها، فNginx مثلاً لا يحفظ أي رد يحمل Set-Cookie، لكن لا تعتمد على احتياط الأداة. أما ترويسة `Authorization` في الطلب فالمعيار يمنع ال Cache المشترك من استخدام رده لطلبات أخرى إلا إذا سمح الرد بذلك صراحة بقيمة مثل `public` أو `s-maxage`، لذلك لا تضع `public` على ردود API الخاصة.

وقد شرحنا في [ملفات تعريف الارتباط Cookies](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#cookies) كيف يحمل كل طلب بعد تسجيل الدخول ما يثبت هوية صاحبه، وأي Cache يحفظ رداً من هذه الردود ثم يقدمه لغيره يسلم صفحة شخص لشخص آخر، وتفاصيل هذا الخطأ مع قواعد Cloudflare في [لا تحفظ في الذاكرة المؤقتة ما يخص مستخدماً واحداً](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#cache-pitfalls)، وسوف نصنعه بأيدينا في التجربة العملية حتى تعرف كيف يبدو.

📌

في فبراير 2017 نشر الباحث الأمني Omer Gil نوعاً من الهجمات سماه [Web Cache Deception](https://omergil.blogspot.com/2017/02/web-cache-deception-attack.html?ref=arabroot.io)، وفكرته أن يرسل المهاجم للضحية رابطاً مثل `/myaccount/home/stylesheet.css`، فيتجاهل التطبيق الجزء الأخير ويعرض صفحة الحساب الخاصة بالضحية، بينما يرى ال Cache الذي أمامه امتداد `.css` فيحفظ الصفحة على أنها ملف ثابت، ثم يفتح المهاجم الرابط نفسه فيحصل على النسخة المحفوظة. وذكر الباحث أن PayPal كان معرضاً لهذا الخطأ قبل إصلاحه، وأن الصفحات التي كان يمكن تسريبها تشمل اسم المستخدم ورصيد حسابه وآخر أربعة أرقام من بطاقته. والدرس أن يحفظ ال Cache بحسب ترويسات الرد وليس بحسب امتداد الملف في الرابط، وأن يرد التطبيق على المسارات غير الموجودة ب 404 بدل أن يعرض أقرب صفحة إليها.

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

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

المسح Purge، ويسميه بعض المزودين Invalidation، هو أن تطلب من ال CDN حذف نسخة قبل انتهاء صلاحيتها، وكل المزودين يقدمونه من لوحة التحكم ومن واجهة برمجية API، فCloudflare مثلاً تتيح في كل خططها المسح بالرابط والنطاق والوسم Tag والبادئة Prefix ومسح كل شيء بحسب [توثيق المسح](https://developers.cloudflare.com/cache/how-to/purge-cache/?ref=arabroot.io)، وفي CloudFront اسمه [Invalidation](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html?ref=arabroot.io). لكن المسح يصل إلى ال CDN فقط ولا يصل إلى متصفحات الزوار، ويأخذ وقتاً حتى ينتشر في كل نقاط التواجد، وله حدود في عدد الطلبات، لذلك اجعله للحالات الاستثنائية، مثل صورة نشرت بالخطأ أو صفحة HTML تحتاج أن تتجدد قبل موعدها، واجعل الطريقة الأساسية للتحديث هي تغيير اسم الملف. ولاحظ أن خادم Nginx مفتوح المصدر لا يتضمن المسح أصلاً، فتعليمة `proxy_cache_purge` بحسب [توثيقها](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fcache%5Fpurge) متاحة في الاشتراك التجاري فقط، وهذا سبب آخر لأن تعتمد على الأسماء الجديدة.

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

إذا كان لدى ال CDN مئات من نقاط التواجد، فقد يصل إلى سيرفرك طلب MISS من كل واحدة منها للملف نفسه، والحل الذي يقدمه أغلب المزودين هو طبقة وسطى: نقطة تواجد واحدة قريبة من سيرفرك، أو عدد قليل منها، تمر بها كل طلبات MISS، فتطلب من سيرفرك مرة واحدة وتخدم منها بقية النقاط. وتسميها Fastly [Shielding](https://www.fastly.com/documentation/guides/concepts/shielding/?ref=arabroot.io)، وتسميها Cloudflare [Tiered Cache](https://developers.cloudflare.com/cache/how-to/tiered-cache/?ref=arabroot.io)، وتسميها CloudFront [Origin Shield](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/origin-shield.html?ref=arabroot.io). ويمكنك أن ترى ذلك في ملف من شبكة jsDelivr، فالرد الذي وصلنا منها جاء عبر Fastly التي تضيف ترويسات `X-Served-By` و`X-Cache` الموثقة في صفحة Shielding، والأمر التالي يطلب الملف نفسه مرتين:

```bash
curl -sI https://cdn.jsdelivr.net/npm/vue@3.5.13/dist/vue.global.prod.js | grep -i -E "^HTTP|cache-control|^age:|^etag|vary|x-cache|x-served"
```

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

```text
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.
- الملف يحمل رقم نسخة `vue@3.5.13` في مساره، لذلك يحفظ سنة كاملة مع `immutable`، وعمره في ال Cache قرابة سبعة أيام `Age: 591830`.

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

الصور هي أكبر جزء من حجم أغلب الصفحات، وبعض المزودين يقدمون تحويلها على الحافة: تطلب الصورة الأصلية بعرض محدد وصيغة حديثة مثل WebP أو AVIF من خلال معاملات في الرابط، فتصغرها نقطة التواجد وتحفظ النتيجة، مثل [Cloudflare Images](https://developers.cloudflare.com/images/transform-images/?ref=arabroot.io) و[Bunny Optimizer](https://bunny.net/optimizer/?ref=arabroot.io)، وقد ذكرنا خدمات Cloudflare للصور في [Images وStream](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#images-stream). ومن يريد أن يفعل ذلك على سيرفره يستطيع أن يشغل أداة مثل [imgproxy](https://imgproxy.net/?ref=arabroot.io) تصغر الصور وتحول صيغتها عند الطلب، ثم يضع أمامها Cache حتى لا تتكرر المعالجة لكل زائر. وإذا كانت الصور قليلة ومعروفة مسبقاً فالأبسط أن تجهز أحجامها وصيغها مرة واحدة عند الرفع.

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

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

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

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

- **Nginx:** تعليمات [proxy\_cache](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fcache) موجودة في النسخة المفتوحة، وهي ما نستخدمه في التجربة العملية بعد قليل.
- **Vinyl Cache:** أداة مخصصة للتخزين المؤقت أمام أي سيرفر HTTP، وكانت تسمى Varnish Cache، ثم انتقلت نسخة المجتمع إلى الاسم الجديد كما يذكر [موقع المشروع](https://vinyl-cache.org/?ref=arabroot.io)، بينما يستمر مشروع آخر باسم [Varnish Cache](https://www.varnish.org/?ref=arabroot.io) تطوره شركة Varnish Software، فانتبه لأي منهما تقصد عندما تقرأ توثيقاً. وقوتها في لغة الإعداد VCL التي تتحكم بها في كل خطوة من خطوات الطلب.
- **Caddy:** وحدة [cache-handler](https://github.com/caddyserver/cache-handler?ref=arabroot.io) المبنية على مشروع Souin، وهي ليست في النسخة الافتراضية من Caddy، فتضيفها عند بناء Caddy بأداة xcaddy.

وهذا الإعداد يخفف الحمل عن تطبيقك وقاعدة بياناتك، ويحمي السيرفر من الطلبات المتكررة، لكنه لا يقرب موقعك من الزائر، فالزائر في جاكرتا ما زال يتصل بسيرفرك في أوروبا، وقد ذكرنا ذلك أيضاً عند شرح [بدائل التخزين المؤقت مفتوحة المصدر](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#cdn-cache). والحالة الأشيع لهذا الإعداد اليوم هي الصور والملفات التي يرفعها المستخدمون، فتحفظها في تخزين كائنات Object Storage على سيرفرك أو عند مزود، وقد شرحنا أنواع التخزين والفرق بينها في [أنواع التخزين Block وFile وObject](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%AA%D8%AE%D8%B2%D9%8A%D9%86-block-%D9%88-file-%D9%88-object-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، ثم تضع أمامه Cache أو CDN تكون هي العنوان العام للملفات، فيبقى التخزين خلفها ولا يرى إلا طلبات MISS.

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

الجزء الثاني هو أن تقلد نقاط التواجد بنفسك: تستأجر عدة سيرفرات VPS صغيرة في مناطق مختلفة، في الخليج وأوروبا وآسيا مثلاً، وتشغل على كل منها Cache مثل الذي في التجربة، يطلب من سيرفرك الأصلي عند MISS، ثم تستخدم DNS يجيب كل زائر بعنوان السيرفر الأقرب إليه، مثل [GeoIP backend في PowerDNS](https://doc.powerdns.com/authoritative/backends/geoip.html?ref=arabroot.io) الذي يحدد منطقة السائل من قاعدة بيانات للعناوين مثل قواعد MaxMind، ويجيبه بالسجل المناسب لمنطقته.

![مخطط من جزأين: يمين، سيرفر VPS واحد فيه Nginx proxy_cache أمام التطبيق وتخزين الكائنات، ويصل إليه كل الزوار، والنتيجة حمل أقل على التطبيق والمسافة كما هي. يسار، زائر يسأل GeoDNS في PowerDNS فيجيبه بعنوان أقرب سيرفر Cache من ثلاثة في الرياض وفرانكفورت وسنغافورة، وكلها تطلب من السيرفر الأصلي عند MISS، وتحتها التكلفة: شهادات TLS والمسح والتحديثات على كل سيرفر، والفحص والتحويل عند العطل في DNS، والمراقبة](https://arabroot.io/content/images/2026/10/cdn-basics-06-self-hosted.webp)

الأول يخفف الحمل عن تطبيقك، والثاني يقرب المحتوى من الزائر ولكنه يضاعف ما تديره

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

- **الشهادات TLS Certificates:** كل سيرفر حافة ينهي اتصال HTTPS بنفسه، فيحتاج شهادة لنطاقك ومفتاحها الخاص، أي أن مفتاحك يصبح موجوداً في عدة أماكن، وقد شرحنا الشهادات وملفاتها في [الشهادات الرقمية TLS](https://arabroot.io/articles/%D8%A7%D9%84%D8%B4%D9%87%D8%A7%D8%AF%D8%A7%D8%AA-%D8%A7%D9%84%D8%B1%D9%82%D9%85%D9%8A%D8%A9-tls-%D9%88-x509-%D9%88%D8%B5%D9%8A%D8%BA-pem-%D9%88-der/).
- **التعطل والتحويل Failover:** إذا توقف سيرفر منطقة فإن GeoDNS يجب أن يعرف ذلك بفحوص دورية ويوقف إرسال الزوار إليه، ومع ذلك يبقى من حفظ عنوانه في ذاكرة DNS يحاول الوصول إليه حتى تنتهي مدة TTL.
- **المسح والتحديث:** أي مسح يجب أن يصل إلى كل السيرفرات، وكل تحديث أمني يجب أن يطبق على كل السيرفرات.
- **عنوان الزائر الحقيقي:** تطبيقك يرى عناوين سيرفرات الحافة بدل عناوين الزوار، فيجب أن تضبط ترويسات X-Forwarded-For وتثق بها من سيرفراتك فقط، كما شرحنا في [لماذا يهمك عنوان الزائر الحقيقي؟](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#real-ip).
- **التكلفة:** ثلاثة سيرفرات صغيرة ومراقبتها وحركة بياناتها، مقابل خدمة تجارية قد تكون خطتها المجانية أو الرخيصة كافية لموقعك.

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

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

من أشهر مزودي CDN [Cloudflare](https://www.cloudflare.com/application-services/products/cdn/?ref=arabroot.io) و[Bunny](https://bunny.net/cdn/?ref=arabroot.io) و[Fastly](https://www.fastly.com/products/cdn?ref=arabroot.io) و[Amazon CloudFront](https://aws.amazon.com/cloudfront/?ref=arabroot.io)، وكلها تتبع قواعد HTTP نفسها التي شرحناها، وتختلف في طريقة التسعير، وعدد نقاط التواجد ومواقعها، وما تحفظه بشكل افتراضي، وقدرتها على تشغيل الكود على الحافة، فاقرأ في توثيق المزود الذي تختاره ما يحفظه افتراضياً، وكيف يكون مفتاح ال Cache، وكيف يتعامل مع ترويسة Set-Cookie، قبل أن تفعله على موقع فيه تسجيل دخول. وتذكر أن ال CDN التي تنهي اتصال TLS ترى محتوى كل طلب، وهذا ما ناقشناه في [الخصوصية والسيادة على البيانات](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#privacy-sovereignty)، وأن الاتصال بينها وبين سيرفرك يجب أن يكون مشفراً بشهادة صحيحة أيضاً كما في [وضع Full (strict)](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#full-strict).

📌

في 8 يونيو 2021 أصبحت المواقع التي تعتمد على شبكة Fastly ترد بأخطاء في أنحاء العالم، فيما وصفته الشركة بأنه انقطاع عالمي Global Outage. وشرحت Fastly في [تقريرها عن الحادثة](https://www.fastly.com/blog/summary-of-june-8-outage?ref=arabroot.io) أن تحديثاً برمجياً نشرته في 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` التالي:

```python
# 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`:

```nginx
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` يحمل حالة كل طلب، وقيمه بحسب [التوثيق](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fupstream%5Fmodule.html?ref=arabroot.io#var%5Fupstream%5Fcache%5Fstatus) هي MISS وBYPASS وEXPIRED وSTALE وUPDATING وREVALIDATED وHIT، ونضعه في ترويسة الرد وفي سجل خاص.

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

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

```bash
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 ثلاث مرات، ثم نعرض ترويسات الرد:

```bash
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
```

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

```text
/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 ثانية حتى تنتهي صلاحيتها، ثم نطلبها مرتين:

```bash
f; f; sleep 11; f; sleep 1; f
```

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

```text
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 بدل إعادة التنزيل

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

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

```text
/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:

```bash
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
```

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

```text
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: سلسلة الاستعلام تصنع نسخاً جديدة

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

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

```text
/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`:

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

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

```text
/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 وصفحة لم يطلبها أحد من قبل:

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

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

```text
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 سجلناه مع حالته، فنعرض آخر الأسطر ثم نحسب النسبة:

```bash
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
```

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

```text
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` ونضيف سطرين، وهذا هو الفرق بين الملفين:

```diff
--- 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:

```bash
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
```

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

```text
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 والشبكة التي أنشأناها فقط:

```bash
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 من موقعنا مرتين من اتصالنا:

```bash
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"
```

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

```text
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 وما تقيسه اختبارات السرعة في [قس الطريق بنفسك](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B5%D9%84-%D8%A7%D9%84%D8%A5%D9%86%D8%AA%D8%B1%D9%86%D8%AA-%D8%A5%D9%84%D9%89-%D8%A8%D9%8A%D8%AA%D9%83/#measure).

## أخطاء شائعة

- **حفظ الصفحات الخاصة:** قاعدة «احفظ كل شيء» أو قيمة افتراضية مثل `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](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#cloudflare).
- **ترك السيرفر الأصلي مفتوحاً للجميع:** من يعرف عنوان سيرفرك يتجاوز ال CDN وحمايتها، فاقصر الوصول إليه على عناوين المزود أو استخدم نفقاً كما في [أغلق سيرفرك على Cloudflare](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/#lock-origin).
- **حفظ صفحات الخطأ:** رد 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 ممكن لكنه يضاعف ما تديره.

[![](https://arabroot.io/content/images/2026/10/storage-types-cover-cover.webp)دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%AA%D8%AE%D8%B2%D9%8A%D9%86-block-%D9%88-file-%D9%88-object-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/cloudflare-services-cover-cover.webp)دليلخدمات Cloudflare بكلمات بسيطة وبدائلها مفتوحة المصدرما الذي تقدمه كل خدمة من Cloudflare، وما المتاح منها مجاناً، وما البديل مفتوح المصدر الذي تشغله على سيرفرك.](https://arabroot.io/articles/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA-cloudflare-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87%D8%A7-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/)[![](https://arabroot.io/content/images/2026/10/internet-connection-cover-cover.webp)دليلكيف يصل الإنترنت إلى بيتك؟ من ال Router إلى الكابلات البحريةنتتبع طلباً واحداً من حاسوبك إلى سيرفر في بلد آخر ثم عودته، فتعرف من يحمله في كل محطة ولماذا يتأخر الرد.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B5%D9%84-%D8%A7%D9%84%D8%A5%D9%86%D8%AA%D8%B1%D9%86%D8%AA-%D8%A5%D9%84%D9%89-%D8%A8%D9%8A%D8%AA%D9%83/)[![](https://arabroot.io/content/images/2026/10/security-headers-cover-cover.webp)دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.](https://arabroot.io/articles/%D8%AA%D8%B1%D9%88%D9%8A%D8%B3%D8%A7%D8%AA-%D8%A7%D9%84%D8%A3%D9%85%D8%A7%D9%86-hsts-%D9%88-csp/)[![](https://arabroot.io/content/images/2026/10/behind-proxy-cover-cover.webp)دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة Nginx 1.31.6 مع proxy\_cache أمام سيرفر Python 3.13 في Docker، وقياس ترويسات موقعنا وشبكة jsDelivr.