← All posts

نقلنا صورنا إلى شبكة CDN وظل الخادم الأصلي يقدّمها

Authagonal·July 30, 2026

توثيقنا مثقل بلقطات الشاشة. كل ميزة في البوابة، ملتقطة في الوضعين الفاتح والداكن، وبعرضين، وعبر عشر لغات، وهو ما يتجاوز في مجموعه مئة ميغابايت من الصور. كان كل ذلك موجودًا في الحزمة الثابتة الخاصة بالتطبيق، ويُقدَّم من الخادم الأصلي، وهو المكان الخطأ لمئة ميغابايت من الصور التي لا تتغير أبدًا. لذا نقلناها إلى Cloudflare R2 ووضعنا شبكة CDN أمامها، واحدة لكل بيئة: cdn.authagonal.io للإنتاج، ومضيف CDN مماثل لموقع التطوير لدينا.

يختار التطبيق مضيف CDN الصحيح وقت التشغيل بالنظر إلى اسم المضيف الذي يُقدَّم منه. إذا كانت الصفحة على authagonal.io، فإن الصور تأتي من cdn.authagonal.io. بسيط، بلا أي إعداد وقت البناء، ومرجع صورة واحد يُحلّ بشكل صحيح في أي من البيئتين. أطلقناه، وشاهدنا المتصفح يسحب كل صورة بنظافة من الـ CDN، ثم نظرنا إلى حركة الخادم الأصلي. كان الخادم الأصلي لا يزال يقدّم الصور. كل واحدة منها، عند أول تحميل، تمامًا كما كان من قبل.

الصفحة التي يراها الزاحف ليست الصفحة التي يبنيها المتصفح

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

وها هي المشكلة المختبئة في ذلك الوصف. التصيير المسبق يُشغّل الموقع على 127.0.0.1، وهو عنوان استرجاعي على آلة البناء. لذا حين يسأل التطبيق «على أي اسم مضيف أنا، كي أختار CDN»، يكون الجواب أثناء التصيير المسبق «عنوان IP محلي»، وهو لا يطابق authagonal.io ولا يطابق مضيف التطوير. اشتقاق المضيف وقت التشغيل، ذلك الجزء الذكي، يتراجع بهدوء إلى خياره الوحيد المتبقي: المسار النسبي إلى الأصل. وهذا المسار الأصلي بالذات هو ما يتجمّد داخل الـ HTML المُصيَّر مسبقًا.

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

الإصلاح الذي زاد الأمور سوءًا لإحدى عشرة دقيقة

كان الإصلاح الأول هو المُغري. إذا كانت المشكلة أن عنوان URL خاطئًا يتجمّد داخل الـ HTML المُصيَّر مسبقًا، فلا تُصدر أي عنوان URL أثناء التصيير المسبق إطلاقًا. اترك مصدر الصورة فارغًا في اللقطة، ودع الـ JavaScript يملأ عنوان الـ CDN الصحيح بمجرد أن يعمل ويعرف المضيف الحقيقي. لا عنوان URL خاطئ، ولا جلب من الأصل.

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

الدرس من تلك الإحدى عشرة دقيقة: يجب أن يحمل الـ HTML المُصيَّر مسبقًا مرجع صورة حقيقيًا وقابلًا للعمل. لم يكن الخلل قط في وجود عنوان URL. بل كان في أن العنوان الموجود هو العنوان الخاطئ، وتفريغه لا يعدو أن يستبدل عيبًا بعيب آخر. ما كنا نحتاجه فعلًا هو أن يكون العنوان الصحيح معلومًا وقت التصيير المسبق، وهو ليس كذلك، لأنه وقت التصيير المسبق لا أحد يعرف أي بيئة ستقدّم الصفحة.

اتخاذ القرار في آخر لحظة ممكنة

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

لذا لم يعد التصيير المسبق يكتب عنوان URL ولا فراغًا. صار يكتب عنصرًا نائبًا، سلسلة رقيب حرفية، https://cdn.authagonal.io، مكان مضيف الـ CDN. الـ HTML المُصيَّر مسبقًا الذي يغادر البناء يقول https://cdn.authagonal.io/screenshots/whatever.webp، وهو ليس عنوان أصل ولا مصدرًا فارغًا. إنه عنوان URL فيه ثقب.

يملأ nginx الثقب في طريق الخروج. فهو يربط مضيف الطلب بقاعدة الـ CDN الصحيحة، ويربط المضيف غير الإنتاجي بشبكة CDN التطوير الخاصة به، ويربط authagonal.io بـ https://cdn.authagonal.io، وأي شيء آخر بالفراغ حتى يعود العنوان نسبيًا إلى الأصل كملاذ محلي آمن. ثم يُعيد توجيه واحد كتابة الرقيب إلى تلك القيمة بينما يتدفق الـ HTML إلى العميل:

map $host $asset_cdn_base {
    default              "";
    "~*example\.dev$"    "https://cdn.example.dev";
    "~*authagonal\.io$"  "https://cdn.authagonal.io";
}

sub_filter 'https://cdn.authagonal.io' $asset_cdn_base;

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

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

لماذا لا نعيد كتابة الملف مرة واحدة عند الإقلاع فحسب

الاعتراض البديهي هو أن هذا عمل كثير لكل طلب من أجل قيمة ليس لها سوى احتمالين. لماذا لا نعيد كتابة الـ HTML مرة واحدة حين يُقلع الحاوي، ونخبز فيه المضيف الصحيح، ونقدّم ملفًا ثابتًا بلا أي مرشِّح؟

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

كذلك لم نُرد أن نخبز مضيف الـ CDN وقت البناء وننتج حزمتي صور مختلفتين، واحدة لكل بيئة، لأن ذلك يعيد إدخال البناء الخاص بالبيئة الذي كنا قد تخلصنا منه للتو. كان الغرض من اشتقاق المضيف هو أثر واحد لكلتا البيئتين. والرقيب يفي بهذا الوعد: بناء واحد، ملف واحد، والبيئة تُحلّ عند الحافة في آخر لحظة ممكنة.

التصيير المسبق يجمّد قيم البيئة، فحُلّها عند الحافة

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

الإصلاح العام هو ألّا تحلّ قيم البيئة إطلاقًا أثناء التصيير المسبق. أصدر عنصرًا نائبًا، واحمله سليمًا عبر الأثر الثابت، وحُلّه في الطبقة التي ترى البيئة فعلًا، وهي بالنسبة لقيمة تخص كل طلب: الحافة. إنه ربط متأخر، مطبَّق على سلسلة نصية في ملف HTML: اترك الثقب مفتوحًا عبر كل مرحلة لا تعرف الجواب، واملأه في المرحلة الوحيدة التي تعرفه.

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