دانيلا (⁦Dayfing⁩)
العودة إلى المقالات
2,616 كلمة13 د

التخزين المؤقت لموقع ثابت عبر Nginx وCloudflare: الترويسات والإفراغ

أرسل الأصول التي تحمل بصمة في اسمها مع Cache-Control: public, max-age=31536000, immutable، وأرسل HTML إلى المتصفحات مع no-cache إضافة إلى مدة TTL قصيرة ومنفصلة على الحافة (edge) لدى Cloudflare. اجعل HTML مؤهلًا للتخزين المؤقت عبر Cache Rule، وأبقِ Browser Cache TTL على وضع Respect Existing Headers، وأفرغ HTML في الخطوة الأخيرة من كل عملية نشر. عندها يعيد المتصفح التحقق من مستند صغير في كل زيارة، وتجيب الحافة بنفسها عن معظم هذه التحققات، ويظهر الإصدار الجديد فور عودة استجابة طلب الإفراغ.

سياستان للتخزين المؤقت لنوعين من الملفات

ينتج البناء الثابت نوعين من الملفات. الملفات ذات البصمة، مثل /_astro/index.3f9c1a.js، تحمل تجزئة المحتوى في اسمها. عندما يتغير المحتوى يتغير عنوان URL، لذا يمكن أن تبقى نسخة العنوان القديم في أي ذاكرة مؤقتة لمدة عام. أما HTML والخلاصات وملفات sitemap وrobots.txt فتحتفظ بعناوين ثابتة، ولذلك قد تتقادم أي نسخة مخزنة منها.

لهذا تحصل الملفات المجزأة على أطول عمر ولا تحتاج إلى إفراغ أبدًا. وتحصل العناوين الثابتة على سياسة متصفح تعيد التحقق وسياسة حافة يمكن إفراغها. يأتي معظم التسريع من المجموعة الأولى: الزائر العائد يحمّل مستند HTML صغيرًا، غالبًا في صورة استجابة 304، ويعيد استخدام كل السكربتات وأوراق الأنماط والخطوط من ذاكرته المحلية. لذلك يُعد التخزين المؤقت من أرخص الطرق لتحسين Time to First Byte وLargest Contentful Paint، كما يشرح دليل Core Web Vitals.

تجعل قاعدتان للنشر هذا المخطط آمنًا. ارفع الملفات المجزأة الجديدة قبل أن ينتقل HTML إلى الإصدار الجديد. واحتفظ بالملفات المجزأة للإصدار السابق ما دام HTML الذي يشير إليها قد يبقى في أي ذاكرة مؤقتة، لأن تبويبًا فُتح قبل النشر سيطلب الأسماء القديمة.

مدة المتصفح ومدة الحافة ساعتان منفصلتان

ذاكرة المتصفح ملك للزائر، ولا يستطيع أي إجراء تتخذه بعد إرسال الاستجابة أن يحذف منها مدخلًا. أما ذاكرة الحافة فملك لـ Cloudflare، وواجهة purge البرمجية تفرغها خلال ثوانٍ. لذلك اجعل مدة المتصفح طويلة فقط للعناوين التي لا يتغير محتواها أبدًا. وبالنسبة إلى HTML، يسمح no-cache للمتصفح بتخزين الصفحة لكنه يفرض طلبًا شرطيًا قبل كل إعادة استخدام.

يمكن أن تأتي مدة الحافة من s-maxage، أو من Cloudflare-CDN-Cache-Control أو CDN-Cache-Control، أو من Cache Rule. وبما أنك تستطيع إفراغها، فإن مدة الحافة لـ HTML شبكة أمان وليست آلية التحديث. إذا فشل الإفراغ، فإن أسوأ تقادم على الحافة يساوي مدة الصلاحية مضافًا إليها نافذة stale-while-revalidate: مع max-age=300, stale-while-revalidate=60 يكون ذلك 360 ثانية.

افحص أولًا إعدادًا واحدًا في المنطقة. قيمة Browser Cache TTL الافتراضية أربع ساعات في كل الخطط، وتستبدل Cloudflare مدد الخادم الأصلي الأقل من هذه القيمة. عندها قد يبقى HTML المفترض أن يُعاد التحقق منه في المتصفحات لساعات، بعيدًا عن متناول أي إفراغ. اضبط Browser Cache TTL على Respect Existing Headers كما يصف توثيق مدد الحافة والمتصفح.

كيف تقرأ Cloudflare التوجيهات s-maxage وstale-while-revalidate وstale-if-error

تتجاهل المتصفحات s-maxage، لذا يبدو Cache-Control: public, max-age=0, s-maxage=300 الترويسة البديهية لـ HTML، وتستخدمها Cloudflare فعلًا مدةً للحافة. لكن المشكلة موضحة في RFC 9111: يحمل s-maxage أيضًا دلالة proxy-revalidate، أي أن الذاكرة المشتركة يجب ألا تقدم الاستجابة المنتهية قبل إعادة التحقق منها.

في خطط Free وPro وBusiness تكون ميزة Origin Cache Control مفعلة دائمًا، وتطبق Cloudflare هذه القاعدة. ويذكر توثيق إعادة التحقق أن s-maxage وmust-revalidate وproxy-revalidate وno-cache توجيهات تعطل تقديم النسخ المنتهية. فإذا ظهرت بجانب stale-while-revalidate تحولت الحالة UPDATING إلى EXPIRED، وانتظر الزائر الخادم الأصلي. والتوجيهات نفسها تجعل Cloudflare تتجاهل stale-if-error. لذلك فإن ترويسة مثل max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 لا تمنح إلا مدة حافة قدرها 300 ثانية.

عندما يعمل stale-while-revalidate تكون إعادة التحقق غير متزامنة: يتلقى أول طلب بعد انتهاء المدة النسخة المنتهية مع cf-cache-status: UPDATING بينما تحدّثها Cloudflare في الخلفية. ولا يعمل stale-if-error إلا مع استجابات 5xx من الخادم الأصلي. تعطل ميزة Always Online كلا التوجيهين، ويجب أن تكون قيم TTL أعدادًا صحيحة.

لإعطاء المتصفحات والحافة سياستين مختلفتين من دون s-maxage، استخدم ترويسة موجهة. تقيّم Cloudflare أولًا Cloudflare-CDN-Cache-Control ثم CDN-Cache-Control ثم Cache-Control. وعند وجود ترويسة CDN تصل Cache-Control إلى المتصفح دون تعديل ولا تؤثر في الحافة، ولا تمرر Cloudflare الترويسة Cloudflare-CDN-Cache-Control إلى ما بعدها. بالنسبة إلى HTML:

Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400

يعيد المتصفح التحقق في كل مرة. وتحتفظ Cloudflare بالصفحة خمس دقائق، وتقدمها منتهية حتى دقيقة واحدة أثناء التحديث، وتقدم آخر نسخة سليمة مدة يوم إذا تعطل الخادم الأصلي. وتشرح وثائق CDN-Cache-Control قواعد الأولوية بالتفصيل.

قواعد Cache Rules: احترام الخادم الأصلي أو تجاوزه

تحدد Cloudflare أهلية التخزين افتراضيًا حسب امتداد الملف، ولا تخزن HTML ولا JSON افتراضيًا. العنوان /writing/post/ بلا امتداد، لذا يحصل كل طلب صفحة بدون قاعدة على DYNAMIC ويذهب إلى الخادم الأصلي.

تقدم Cache Rule المضبوطة على Eligible for cache ثلاثة أوضاع لـ Edge TTL. يتبع respect_origin ترويساتك، وعند غيابها يطبق القيم الافتراضية، مثل 120 دقيقة لاستجابة 200. ويتبع bypass_by_default الترويسات ولا يخزن شيئًا عند غيابها. أما override_origin فيتجاهل الترويسات ويفرض مدة، وحدها الأدنى يتوقف على الخطة: ساعتان في Free، وساعة في Pro، وثانية واحدة في Business وEnterprise. ويمكن لـ Browser TTL أن يحترم الخادم الأصلي أو يتجاوزه أو يتخطى التخزين.

إذا كان الخادم الأصلي يرسل ترويسات مدروسة فاحترمها. مجموعة القواعد التالية للمرحلة http_request_cache_settings تجعل المضيف مؤهلًا ثم تستثني المسارات الديناميكية. في إعدادات التخزين تفوز آخر قاعدة مطابقة، لذا تأتي قاعدة التخطي في النهاية.

{
  "rules": [
    {
      "description": "Cache the site according to origin headers",
      "expression": "http.host eq \"example.com\"",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "respect_origin" },
        "browser_ttl": { "mode": "respect_origin" }
      }
    },
    {
      "description": "Never cache API routes and previews",
      "expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
      "action": "set_cache_settings",
      "action_parameters": { "cache": false }
    }
  ]
}

لا تضف http.request.method eq "GET" إلى قاعدة التخزين. تحذر Cloudflare من أن إفراغ عنوان واحد قد يفشل إذا طابقت القاعدة GET فقط، لأن طلبات الإفراغ تستخدم داخليًا طريقة أخرى. وتجد القائمة الكاملة للخيارات في إعدادات Cache Rules.

ترويسات Nginx من دون فخ وراثة add_header

يرث Nginx توجيهات add_header من المستوى الأعلى فقط إذا لم يعرّف المستوى الحالي أيًا منها. فبمجرد أن يضيف location ترويسة Cache-Control خاصة به، تختفي فيه بصمت كل ترويسات مستوى server، بما فيها ترويسات الأمان.

server {
    add_header X-Content-Type-Options "nosniff" always;

    location ^~ /_astro/ {
        # X-Content-Type-Options is no longer sent for this location.
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

الحل القابل للنقل هو وضع الترويسات المشتركة في ملف snippet وتضمينه عبر include في كل كتلة تملك add_header خاصًا بها. وفي Nginx 1.29.3 وما بعده، ومنه الفرع المستقر 1.30، يضيف التوجيه add_header_inherit merge الترويسات الموروثة بدلًا من ذلك. ومع merge أبقِ Cache-Control خارج كتلة server، وإلا أرسلتها كتل location مرتين.

بحسب توثيق وحدة headers، لا ينطبق add_header من دون always إلا على الاستجابات 200 و201 و204 و206 و301 و302 و303 و304 و307 و308. استخدم always لترويسات الأمان ولـ no-store في صفحات الأخطاء، ولا تستخدمه أبدًا مع ترويسات التخزين طويلة الأمد، وإلا صار خطأ 404 الناتج عن خطأ إملائي في اسم أصل ما immutable لمدة عام. وتجنب كذلك الجمع بين expires وadd_header Cache-Control لأن ذلك ينتج حقلي ترويسة. هذه كتلة server لبناء ثابت، من دون إعدادات TLS والسجلات:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /srv/example.com/current;
    index index.html;

    gzip on;
    gzip_vary on;
    gzip_static on;
    gzip_types text/css application/javascript application/json
               application/xml application/rss+xml image/svg+xml text/plain;

    location ^~ /_astro/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=86400";
        try_files $uri =404;
    }

    location ~* \.(?:xml|txt)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=300";
        add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri =404;
    }

    location / {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache";
        add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
    location = /404.html {
        internal;
        include snippets/security-headers.conf;
        add_header Cache-Control "no-store" always;
    }
}

يتطلب gzip_static نسخة مبنية مع --with-http_gzip_static_module، ويظهر ذلك في مخرجات nginx -V. ويجب ألا يقبل الخادم الأصلي الاتصالات إلا من Cloudflare حتى لا يتجاوز أحد الذاكرة المؤقتة. ويغطي دليل تحصين خادم Linux VPS إعداد الجدار الناري لذلك.

ETag وLast-Modified وإعادة التحقق

مع etag on، وهو الوضع الافتراضي، يرسل Nginx الترويستين ETag وLast-Modified للملفات الثابتة، ويجيب الطلبات الشرطية بالرمز 304. وتُبنى قيمة ETag لديه من وقت التعديل والحجم، لا من تجزئة المحتوى. لذلك فإن النشر الذي يعيد كتابة كل الملفات يغيّر كل قيم ETag، وتعيد أول عملية تحقق بعد الإصدار استجابة 200 كاملة. وإذا كانت عدة خوادم أصلية تقدم الإصدار نفسه فيجب أن تتطابق أوقات الملفات عليها، مثلًا بالنسخ عبر rsync -a، وإلا توقفت نتيجة إعادة التحقق على الخادم الذي أجاب.

ما دامت نسختها صالحة، تجيب Cloudflare بنفسها عن طلبات إعادة التحقق من المتصفحات. وبعد انتهاء المدة ترسل طلبًا شرطيًا إلى Nginx، فتجدد استجابة 304 مدة TTL من دون نقل جسم الاستجابة. وعندما تغيّر Cloudflare الترميز تحوّل ETag القوية إلى ضعيفة بصيغة W/"..."، وهذا لا يضر لأن If-None-Match يستخدم المقارنة الضعيفة. فعّل Respect Strong ETags فقط إذا احتاج عميل فعلًا إلى مدققات مطابقة بايتًا ببايت.

الضغط: gzip على الخادم الأصلي وBrotli وZstandard على الحافة

تطلب Cloudflare من الخادم الأصلي accept-encoding: br, gzip ويمكنها إعادة ترميز ما تستلمه. وتقدم للزوار gzip أو Brotli أو Zstandard بحسب Accept-Encoding والخطة وقواعد Compression Rules. افتراضيًا تفضل مناطق Free خوارزمية Zstandard، وتفضل Pro وBusiness خوارزمية Brotli، وتستخدم Enterprise خوارزمية gzip. ولا تُضغط إلا الاستجابات 200 و403 و404، كما يوضح توثيق الضغط.

لخادم أصلي خلف Cloudflare يكفي gzip. اضغط الملفات النصية مسبقًا أثناء البناء لأجل gzip_static، أو دع gzip on يضغط أثناء التقديم. تحتوي gzip_types افتراضيًا على text/html فقط، فاذكر بقية الأنواع النصية وأبقِ gzip_vary on. ولا تنتمي Brotli وZstandard إلى مجموعة وحدات nginx.org وتحتاجان إلى وحدات من أطراف ثالثة. وتجنب Cache-Control: no-transform في النصوص، لأنه يمنع Cloudflare من ضغط استجابة غير مضغوطة.

ملفات تعريف الارتباط وسلاسل الاستعلام ومفتاح التخزين

يحتوي مفتاح التخزين الافتراضي في Cloudflare على المخطط والمضيف والمسار وسلسلة الاستعلام كاملة، إضافة إلى بضع ترويسات طلب مثل Origin. ولا تدخل فيه ملفات تعريف الارتباط، لذا يجب ألا يختلف أي عنوان مخزن باختلاف cookie. ضع صفحات المستخدمين المسجلين وسلال الشراء وواجهات API وخلفية الوكيل خلف قاعدة تخطٍّ مع private أو no-store، أو على مضيف منفصل. ويبين دليل بنية وكيل الذكاء الاصطناعي في الإنتاج ما يحتويه عادة مسار الطلب الديناميكي هذا.

ترويسة الاستجابة Set-Cookie تفسد التخزين. فمع Eligible for cache تحتفظ Cloudflare بملف تعريف الارتباط ولا تخزن الاستجابة، فيصبح كل طلب MISS. تأكد من أن أي وحدة أو موازن حمل لا يضيف ملفات تعريف ارتباط إلى الملفات الثابتة، أو احذفها عبر Cache Response Rule.

كل سلسلة استعلام مختلفة تنشئ مدخلًا منفصلًا، لذا تبدأ الروابط التي تحمل ?utm_source= بإخفاق في الذاكرة. وهذا يخفض نسبة الإصابة لكنه لا يمس صحة المحتوى. ولا ينطبق مستوى التخزين Ignore Query String إلا على امتدادات الملفات الثابتة، أما المفاتيح المخصصة حسب معاملات سلسلة الاستعلام فتتوقف على الخطة. وخيار Sort query string متاح في كل الخطط.

استراتيجيات الإفراغ والإفراغ من CI

منذ أبريل 2025 أصبحت كل طرق الإفراغ متاحة في كل الخطط:

  • يحذف Single URL عناوين محددة بدقة، حتى 100 في الطلب الواحد (500 في Enterprise)، ويناسب الحالة التي يعرف فيها البناء ما تغير.
  • يحذف Prefix كل ما يقع تحت مسار مثل example.com/writing/، بما في ذلك صيغ سلاسل الاستعلام.
  • يحذف Tag كل كائن حملت استجابته ترويسة Cache-Tag مطابقة، وتزيل Cloudflare هذه الترويسة قبل أن يراها الزائر. ضع الوسم html على HTML والخلاصات وملفات sitemap، فيفرغ استدعاء واحد كل العناوين الثابتة بينما تبقى الأصول المجزأة في الذاكرة.
  • يحذف Hostname كل ما يخص مضيفًا واحدًا.
  • يفرغ Everything المنطقة كلها ويوجه كل الحركة إلى الخادم الأصلي حتى تمتلئ الذاكرة من جديد، لذا احتفظ به ملاذًا أخيرًا.

تتقاسم طلبات الإفراغ حسب hostname وtag وprefix وeverything حدًا على مستوى الحساب: 5 طلبات في الدقيقة في Free، و5 في الثانية في Pro، و10 في الثانية في Business، و50 في الثانية في Enterprise، وفق توثيق الإفراغ. استخدم رمز API مقصورًا على صلاحية Cache Purge لمنطقة واحدة، واحفظه سرًّا في CI:

#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"

# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"

# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"

# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags":["html"]}' </dev/null \
  | jq -e '.success == true' >/dev/null

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

التحقق عبر curl -I وcf-cache-status وAge

curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
  | grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'

تحوّل Cloudflare الطلب HEAD إلى GET في الطلبات القابلة للتخزين، لذا يملأ curl -I الذاكرة أيضًا. يجب أن تُظهر الاستجابة الأولى MISS، والثانية HIT مع Age، أي عدد الثواني منذ تخزين الكائن أو إعادة التحقق منه. ويعرّف توثيق استجابات الذاكرة المؤقتة كل حالة:

  • DYNAMIC: الطلب غير مؤهل، غالبًا لأن أي قاعدة لا تغطي HTML أو لأن Development Mode مفعل.
  • BYPASS: الطلب مؤهل، لكن no-store أو private أو Set-Cookie أو Vary: * جعلت الاستجابة غير قابلة للتخزين.
  • UPDATING: قُدمت نسخة منتهية أثناء إعادة تحقق في الخلفية.
  • EXPIRED: أُعيد جلب النسخة المنتهية بشكل متزامن. إذا كنت تتوقع UPDATING فابحث عن s-maxage أو must-revalidate أو no-cache.
  • REVALIDATED: أكد الخادم الأصلي النسخة باستجابة 304 بينما كان الطلب ينتظر.
  • STALE: لم يستجب الخادم الأصلي فقُدمت النسخة القديمة.

بعد الإفراغ توقع MISS، أو EXPIRED مع Tiered Cache وبعد purge everything. ولاختبار Nginx وحده، استعلم الخادم الأصلي من مضيف مسموح له عبر curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/، ثم أضف -H 'If-None-Match: "<etag>"' وتوقع 304.

الترويسات الموصى بها حسب نوع الملف

نوع الملف مثال Cache-Control سياسة الحافة بعد النشر
ملفات JS وCSS وخطوط وصور ذات بصمة /_astro/app.3f9c1a.js public, max-age=31536000, immutable احترام الخادم الأصلي لا شيء
صفحات HTML /writing/post/ no-cache max-age=300, stale-while-revalidate=60, stale-if-error=86400 إفراغ الوسم html
الخلاصات وsitemap وrobots.txt /rss.xml public, max-age=300 max-age=3600, stale-if-error=86400 إفراغ الوسم html
صور وأيقونات بلا تجزئة /favicon.ico public, max-age=86400 احترام الخادم الأصلي إفراغ العنوان أو تغيير الاسم
JSON يتغير بين عمليات النشر /data/stats.json public, max-age=60 احترام الخادم الأصلي أو التخطي إفراغ العنوان
صفحة 404 أي عنوان غير موجود no-store مع always لا تخزين لا شيء
API والمعاينات ولوحة الإدارة /api/ private, no-store قاعدة تخطٍّ لا شيء

توضع قيم الحافة التي تحتوي على max-age في Cloudflare-CDN-Cache-Control. وإذا خزنت استجابات 404 على الحافة فضع عليها الوسم html، وإلا بقيت صفحة تُنشر لاحقًا مخفية خلف استجابة 404 مخزنة.

قائمة التحقق للنشر

  • تحتوي أسماء الأصول على تجزئة المحتوى، ولا يحمل أي عنوان ثابت الوسم immutable.
  • كل كتلة Nginx فيها add_header تتضمن ترويسات الأمان، ولا يظهر always إلا مع ترويسات الأمان واستجابات الأخطاء.
  • يرسل HTML الترويسة no-cache مع Cloudflare-CDN-Cache-Control، من دون s-maxage بجانب stale-while-revalidate.
  • تغطي gzip_types الأنواع النصية، ولا تحمل الاستجابات الثابتة Set-Cookie.
  • يحترم Browser Cache TTL الترويسات الموجودة، وتأتي قاعدة التخطي في النهاية من دون شرط يقتصر على GET.
  • ترفع CI الأصول قبل HTML، وتحتفظ بالملفات المجزأة القديمة، وتفرغ الوسم html، وتفشل إذا لم تكن قيمة success هي true.
  • تُظهر الصفحة MISS ثم HIT مع Age ثم UPDATING بعد انتهاء مدة الحافة.
  • لا يقبل الخادم الأصلي الحركة إلا من Cloudflare.

مقالات أخرى